mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-04-30 16:00:38 +08:00
Compare commits
5 Commits
v0.6.0
...
lunny/rena
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dafb880cae | ||
|
|
ded278da71 | ||
|
|
fe73bf9a96 | ||
|
|
f9bfeb85d9 | ||
|
|
81f3d3ef3f |
@@ -40,7 +40,7 @@ cpu.out
|
||||
*.db
|
||||
*.log
|
||||
|
||||
/act_runner
|
||||
/runner
|
||||
/debug
|
||||
|
||||
/bin
|
||||
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Echo the tag
|
||||
run: echo "${{ env.DOCKER_ORG }}/act_runner:nightly${{ matrix.variant.tag_suffix }}"
|
||||
run: echo "${{ env.DOCKER_ORG }}/runner:nightly${{ matrix.variant.tag_suffix }}"
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -82,4 +82,4 @@ jobs:
|
||||
linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.DOCKER_ORG }}/act_runner:nightly${{ matrix.variant.tag_suffix }}
|
||||
${{ env.DOCKER_ORG }}/runner:nightly${{ matrix.variant.tag_suffix }}
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,4 +1,4 @@
|
||||
/act_runner
|
||||
/runner
|
||||
.env
|
||||
.runner
|
||||
coverage.txt
|
||||
|
||||
@@ -114,7 +114,7 @@ formatters:
|
||||
custom-order: true
|
||||
sections:
|
||||
- standard
|
||||
- prefix(gitea.com/gitea/act_runner)
|
||||
- prefix(gitea.com/gitea/runner)
|
||||
- blank
|
||||
- default
|
||||
gofumpt:
|
||||
|
||||
@@ -63,7 +63,7 @@ builds:
|
||||
flags:
|
||||
- -trimpath
|
||||
ldflags:
|
||||
- -s -w -X gitea.com/gitea/act_runner/internal/pkg/ver.version={{ .Summary }}
|
||||
- -s -w -X gitea.com/gitea/runner/internal/pkg/ver.version={{ .Summary }}
|
||||
binary: >-
|
||||
{{ .ProjectName }}-
|
||||
{{- .Version }}-
|
||||
@@ -86,7 +86,7 @@ blobs:
|
||||
provider: s3
|
||||
bucket: "{{ .Env.S3_BUCKET }}"
|
||||
region: "{{ .Env.S3_REGION }}"
|
||||
directory: "act_runner/{{.Version}}"
|
||||
directory: "runner/{{.Version}}"
|
||||
extra_files:
|
||||
- glob: ./**.xz
|
||||
- glob: ./**.sha256
|
||||
|
||||
10
Dockerfile
10
Dockerfile
@@ -9,8 +9,8 @@ RUN apk add --no-cache make git
|
||||
ARG GOPROXY
|
||||
ENV GOPROXY=${GOPROXY:-}
|
||||
|
||||
COPY . /opt/src/act_runner
|
||||
WORKDIR /opt/src/act_runner
|
||||
COPY . /opt/src/runner
|
||||
WORKDIR /opt/src/runner
|
||||
|
||||
RUN make clean && make build
|
||||
|
||||
@@ -21,7 +21,7 @@ FROM docker:28-dind AS dind
|
||||
|
||||
RUN apk add --no-cache s6 bash git tzdata
|
||||
|
||||
COPY --from=builder /opt/src/act_runner/act_runner /usr/local/bin/act_runner
|
||||
COPY --from=builder /opt/src/runner/runner /usr/local/bin/runner
|
||||
COPY scripts/run.sh /usr/local/bin/run.sh
|
||||
COPY scripts/s6 /etc/s6
|
||||
|
||||
@@ -37,7 +37,7 @@ FROM docker:28-dind-rootless AS dind-rootless
|
||||
USER root
|
||||
RUN apk add --no-cache s6 bash git tzdata
|
||||
|
||||
COPY --from=builder /opt/src/act_runner/act_runner /usr/local/bin/act_runner
|
||||
COPY --from=builder /opt/src/runner/runner /usr/local/bin/runner
|
||||
COPY scripts/run.sh /usr/local/bin/run.sh
|
||||
COPY scripts/s6 /etc/s6
|
||||
|
||||
@@ -56,7 +56,7 @@ ENTRYPOINT ["s6-svscan","/etc/s6"]
|
||||
FROM alpine AS basic
|
||||
RUN apk add --no-cache tini bash git tzdata
|
||||
|
||||
COPY --from=builder /opt/src/act_runner/act_runner /usr/local/bin/act_runner
|
||||
COPY --from=builder /opt/src/runner/runner /usr/local/bin/runner
|
||||
COPY scripts/run.sh /usr/local/bin/run.sh
|
||||
|
||||
VOLUME /data
|
||||
|
||||
12
Makefile
12
Makefile
@@ -1,5 +1,5 @@
|
||||
DIST := dist
|
||||
EXECUTABLE := act_runner
|
||||
EXECUTABLE := runner
|
||||
DIST_DIRS := $(DIST)/binaries $(DIST)/release
|
||||
GO ?= go
|
||||
SHASUM ?= shasum -a 256
|
||||
@@ -13,7 +13,7 @@ DARWIN_ARCHS ?= darwin-12/amd64,darwin-12/arm64
|
||||
WINDOWS_ARCHS ?= windows/amd64
|
||||
GOFILES := $(shell find . -type f -name "*.go" -o -name "go.mod" ! -name "generated.*")
|
||||
|
||||
DOCKER_IMAGE ?= gitea/act_runner
|
||||
DOCKER_IMAGE ?= gitea/runner
|
||||
DOCKER_TAG ?= nightly
|
||||
DOCKER_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)
|
||||
DOCKER_ROOTLESS_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)-dind-rootless
|
||||
@@ -67,7 +67,7 @@ else
|
||||
endif
|
||||
|
||||
TAGS ?=
|
||||
LDFLAGS ?= -X "gitea.com/gitea/act_runner/internal/pkg/ver.version=v$(RELASE_VERSION)"
|
||||
LDFLAGS ?= -X "gitea.com/gitea/runner/internal/pkg/ver.version=v$(RELASE_VERSION)"
|
||||
|
||||
.PHONY: all
|
||||
all: build
|
||||
@@ -86,7 +86,7 @@ go-check:
|
||||
$(eval MIN_GO_VERSION := $(shell printf "%03d%03d" $(shell echo '$(MIN_GO_VERSION_STR)' | tr '.' ' ')))
|
||||
$(eval GO_VERSION := $(shell printf "%03d%03d" $(shell $(GO) version | grep -Eo '[0-9]+\.[0-9]+' | tr '.' ' ');))
|
||||
@if [ "$(GO_VERSION)" -lt "$(MIN_GO_VERSION)" ]; then \
|
||||
echo "Act Runner requires Go $(MIN_GO_VERSION_STR) or greater to build. You can get it at https://go.dev/dl/"; \
|
||||
echo "Gitea Runner requires Go $(MIN_GO_VERSION_STR) or greater to build. You can get it at https://go.dev/dl/"; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
@@ -140,11 +140,11 @@ test: fmt-check security-check ## test everything
|
||||
@$(GO) test -race -short -v -cover -coverprofile coverage.txt ./... && echo "\n==>\033[32m Ok\033[m\n" || exit 1
|
||||
|
||||
.PHONY: install
|
||||
install: $(GOFILES) ## install the act_runner binary via `go install`
|
||||
install: $(GOFILES) ## install the runner binary via `go install`
|
||||
$(GO) install -v -tags '$(TAGS)' -ldflags '-s -w $(EXTLDFLAGS) $(LDFLAGS)'
|
||||
|
||||
.PHONY: build
|
||||
build: go-check $(EXECUTABLE) ## build the act_runner binary
|
||||
build: go-check $(EXECUTABLE) ## build the runner binary
|
||||
|
||||
$(EXECUTABLE): $(GOFILES)
|
||||
$(GO) build -v -tags '$(TAGS)' -ldflags '-s -w $(EXTLDFLAGS) $(LDFLAGS)' -o $@
|
||||
|
||||
26
README.md
26
README.md
@@ -1,6 +1,4 @@
|
||||
# act runner
|
||||
|
||||
Act runner is a runner for Gitea.
|
||||
# Gitea Runner
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -10,7 +8,7 @@ Docker Engine Community version is required for docker mode. To install Docker C
|
||||
|
||||
### Download pre-built binary
|
||||
|
||||
Visit [here](https://dl.gitea.com/act_runner/) and download the right version for your platform.
|
||||
Visit [here](https://dl.gitea.com/runner/) and download the right version for your platform.
|
||||
|
||||
### Build from source
|
||||
|
||||
@@ -26,8 +24,8 @@ make docker
|
||||
|
||||
## Quickstart
|
||||
|
||||
Actions are disabled by default, so you need to add the following to the configuration file of your Gitea instance to enable it:
|
||||
|
||||
Actions are disabled by default, so you need to add the following to the configuration file of your Gitea instance to enable it:
|
||||
|
||||
```ini
|
||||
[actions]
|
||||
ENABLED=true
|
||||
@@ -36,7 +34,7 @@ ENABLED=true
|
||||
### Register
|
||||
|
||||
```bash
|
||||
./act_runner register
|
||||
./runner register
|
||||
```
|
||||
|
||||
And you will be asked to input:
|
||||
@@ -68,7 +66,7 @@ INFO Runner registered successfully.
|
||||
You can also register with command line arguments.
|
||||
|
||||
```bash
|
||||
./act_runner register --instance http://192.168.8.8:3000 --token <my_runner_token> --no-interactive
|
||||
./runner register --instance http://192.168.8.8:3000 --token <my_runner_token> --no-interactive
|
||||
```
|
||||
|
||||
If the registry succeed, it will run immediately. Next time, you could run the runner directly.
|
||||
@@ -76,29 +74,29 @@ If the registry succeed, it will run immediately. Next time, you could run the r
|
||||
### Run
|
||||
|
||||
```bash
|
||||
./act_runner daemon
|
||||
./runner daemon
|
||||
```
|
||||
|
||||
### Run with docker
|
||||
|
||||
```bash
|
||||
docker run -e GITEA_INSTANCE_URL=https://your_gitea.com -e GITEA_RUNNER_REGISTRATION_TOKEN=<your_token> -v /var/run/docker.sock:/var/run/docker.sock --name my_runner gitea/act_runner:nightly
|
||||
docker run -e GITEA_INSTANCE_URL=https://your_gitea.com -e GITEA_RUNNER_REGISTRATION_TOKEN=<your_token> -v /var/run/docker.sock:/var/run/docker.sock --name my_runner gitea/runner:nightly
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
You can also configure the runner with a configuration file.
|
||||
The configuration file is a YAML file, you can generate a sample configuration file with `./act_runner generate-config`.
|
||||
The configuration file is a YAML file, you can generate a sample configuration file with `./runner generate-config`.
|
||||
|
||||
```bash
|
||||
./act_runner generate-config > config.yaml
|
||||
./runner generate-config > config.yaml
|
||||
```
|
||||
|
||||
You can specify the configuration file path with `-c`/`--config` argument.
|
||||
|
||||
```bash
|
||||
./act_runner -c config.yaml register # register with config file
|
||||
./act_runner -c config.yaml daemon # run with config file
|
||||
./runner -c config.yaml register # register with config file
|
||||
./runner -c config.yaml daemon # run with config file
|
||||
```
|
||||
|
||||
You can read the latest version of the configuration file online at [config.example.yaml](internal/pkg/config/config.example.yaml).
|
||||
|
||||
@@ -5,28 +5,21 @@
|
||||
package artifactcache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/act_runner/act/common"
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
|
||||
"github.com/julienschmidt/httprouter"
|
||||
"github.com/sirupsen/logrus"
|
||||
@@ -35,36 +28,9 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
apiPath = "/_apis/artifactcache"
|
||||
internalPath = "/_internal"
|
||||
|
||||
// artifactURLTTL bounds how long a signed artifactLocation URL stays valid.
|
||||
// Short enough that a leaked URL is near-worthless; long enough to let the
|
||||
// @actions/cache client download a big blob that was returned from /cache.
|
||||
artifactURLTTL = 10 * time.Minute
|
||||
urlBase = "/_apis/artifactcache"
|
||||
)
|
||||
|
||||
type credKey struct{}
|
||||
|
||||
// JobCredential ties a per-job bearer token (ACTIONS_RUNTIME_TOKEN) to the
|
||||
// repository that owns it. Every cache entry is stamped with Repo on
|
||||
// reserve/commit and checked on read/write so one repo can never observe or
|
||||
// poison another repo's cache, even from inside a container that reaches the
|
||||
// cache server over the docker bridge network.
|
||||
type JobCredential struct {
|
||||
Repo string
|
||||
}
|
||||
|
||||
// credEntry holds a registered job's credential along with an active
|
||||
// registration count. RegisterJob is reference-counted so that if two tasks
|
||||
// briefly share an ACTIONS_RUNTIME_TOKEN — e.g. a runner that retries a task
|
||||
// after a crash before the old registration is revoked — the first task's
|
||||
// revoker does not cut the second task's auth out from under it.
|
||||
type credEntry struct {
|
||||
cred JobCredential
|
||||
refs int
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
dir string
|
||||
storage *Storage
|
||||
@@ -77,36 +43,10 @@ type Handler struct {
|
||||
gcAt time.Time
|
||||
|
||||
outboundIP string
|
||||
|
||||
// internalSecret guards /_internal/{register,revoke}. When set, a remote
|
||||
// runner can use these endpoints to pre-register per-job
|
||||
// ACTIONS_RUNTIME_TOKENs against this server, enabling the same
|
||||
// per-job auth and repo scoping as the embedded handler over the
|
||||
// network. Empty disables the control-plane entirely.
|
||||
internalSecret string
|
||||
|
||||
// secret signs short-lived artifact download URLs. The @actions/cache
|
||||
// toolkit does not send Authorization on the download request, so blob
|
||||
// GETs authenticate via a per-URL HMAC signature with expiry rather than
|
||||
// via the bearer token used for management endpoints.
|
||||
secret []byte
|
||||
|
||||
credMu sync.RWMutex
|
||||
creds map[string]*credEntry
|
||||
}
|
||||
|
||||
// StartHandler opens the on-disk cache store and starts the HTTP server.
|
||||
//
|
||||
// internalSecret, when non-empty, enables a control-plane API at
|
||||
// /_internal/{register,revoke} that lets a remote runner pre-register the
|
||||
// per-job ACTIONS_RUNTIME_TOKENs it expects this server to honor. The
|
||||
// embedded in-process handler leaves it empty and registers tokens via the
|
||||
// in-process RegisterJob method directly.
|
||||
func StartHandler(dir, outboundIP string, port uint16, internalSecret string, logger logrus.FieldLogger) (*Handler, error) {
|
||||
h := &Handler{
|
||||
creds: make(map[string]*credEntry),
|
||||
internalSecret: internalSecret,
|
||||
}
|
||||
func StartHandler(dir, outboundIP string, port uint16, logger logrus.FieldLogger) (*Handler, error) {
|
||||
h := &Handler{}
|
||||
|
||||
if logger == nil {
|
||||
discard := logrus.New()
|
||||
@@ -143,37 +83,19 @@ func StartHandler(dir, outboundIP string, port uint16, internalSecret string, lo
|
||||
h.outboundIP = ip.String()
|
||||
}
|
||||
|
||||
secret, err := loadOrCreateSecret(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h.secret = secret
|
||||
|
||||
router := httprouter.New()
|
||||
router.GET(apiPath+"/cache", h.bearerAuth(h.find))
|
||||
router.POST(apiPath+"/caches", h.bearerAuth(h.reserve))
|
||||
router.PATCH(apiPath+"/caches/:id", h.bearerAuth(h.upload))
|
||||
router.POST(apiPath+"/caches/:id", h.bearerAuth(h.commit))
|
||||
router.POST(apiPath+"/clean", h.bearerAuth(h.clean))
|
||||
// Artifact GET is signed via query-string HMAC because @actions/cache
|
||||
// does not attach Authorization when downloading archiveLocation.
|
||||
router.GET(apiPath+"/artifacts/:id", h.signedURLAuth(h.get))
|
||||
// Control-plane: a remote runner registers/revokes per-job tokens so the
|
||||
// cache API can authenticate them. Always wired so the routes exist; the
|
||||
// handlers themselves 401 when internalSecret is unset.
|
||||
router.POST(internalPath+"/register", h.internalAuth(h.internalRegister))
|
||||
router.POST(internalPath+"/revoke", h.internalAuth(h.internalRevoke))
|
||||
router.GET(urlBase+"/cache", h.middleware(h.find))
|
||||
router.POST(urlBase+"/caches", h.middleware(h.reserve))
|
||||
router.PATCH(urlBase+"/caches/:id", h.middleware(h.upload))
|
||||
router.POST(urlBase+"/caches/:id", h.middleware(h.commit))
|
||||
router.GET(urlBase+"/artifacts/:id", h.middleware(h.get))
|
||||
router.POST(urlBase+"/clean", h.middleware(h.clean))
|
||||
|
||||
h.router = router
|
||||
|
||||
h.gcCache()
|
||||
|
||||
// Listen on all interfaces. Binding to outboundIP only would give no real
|
||||
// security benefit (it is the LAN/internet-facing address either way) and
|
||||
// can break Docker Desktop variants where the host's outbound IP is not
|
||||
// routable from inside the container network. Authentication is enforced
|
||||
// by the bearer middleware and per-repo scoping, not by reachability.
|
||||
listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
|
||||
listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) // listen on all interfaces
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -199,91 +121,6 @@ func (h *Handler) ExternalURL() string {
|
||||
h.listener.Addr().(*net.TCPAddr).Port)
|
||||
}
|
||||
|
||||
// RegisterJob makes token a valid bearer credential for cache requests from
|
||||
// the given repository and returns a function that removes it. The runner
|
||||
// calls this at job start and defers the returned func so that the credential
|
||||
// is only accepted while the job is running.
|
||||
//
|
||||
// Registrations are reference-counted: if a token is already registered, the
|
||||
// existing repo is kept and the refcount is incremented. The entry is
|
||||
// removed only when every revoker returned by RegisterJob has been called.
|
||||
// This keeps a stray re-registration from silently revoking a live job.
|
||||
func (h *Handler) RegisterJob(token, repo string) func() {
|
||||
if h == nil || token == "" {
|
||||
return func() {}
|
||||
}
|
||||
h.credMu.Lock()
|
||||
if existing, ok := h.creds[token]; ok {
|
||||
existing.refs++
|
||||
} else {
|
||||
h.creds[token] = &credEntry{
|
||||
cred: JobCredential{Repo: repo},
|
||||
refs: 1,
|
||||
}
|
||||
}
|
||||
h.credMu.Unlock()
|
||||
return func() {
|
||||
h.credMu.Lock()
|
||||
if entry, ok := h.creds[token]; ok {
|
||||
entry.refs--
|
||||
if entry.refs <= 0 {
|
||||
delete(h.creds, token)
|
||||
}
|
||||
}
|
||||
h.credMu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// RevokeJob explicitly revokes one registration of token, mirroring one call
|
||||
// of the closure returned by RegisterJob. Used by the control-plane endpoint
|
||||
// so a remote runner can revoke without holding the closure.
|
||||
func (h *Handler) RevokeJob(token string) {
|
||||
if h == nil || token == "" {
|
||||
return
|
||||
}
|
||||
h.credMu.Lock()
|
||||
if entry, ok := h.creds[token]; ok {
|
||||
entry.refs--
|
||||
if entry.refs <= 0 {
|
||||
delete(h.creds, token)
|
||||
}
|
||||
}
|
||||
h.credMu.Unlock()
|
||||
}
|
||||
|
||||
func (h *Handler) lookupCredential(token string) (JobCredential, bool) {
|
||||
h.credMu.RLock()
|
||||
entry, ok := h.creds[token]
|
||||
h.credMu.RUnlock()
|
||||
if !ok {
|
||||
return JobCredential{}, false
|
||||
}
|
||||
return entry.cred, true
|
||||
}
|
||||
|
||||
// loadOrCreateSecret returns the 32-byte HMAC signing key for artifact URLs,
|
||||
// persisted in dir/.secret so signed URLs handed out before a restart stay
|
||||
// valid across the restart and so the standalone cache-server can be pointed
|
||||
// at by config.Cache.ExternalServer without the URL rotating.
|
||||
func loadOrCreateSecret(dir string) ([]byte, error) {
|
||||
path := filepath.Join(dir, ".secret")
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
if secret, err := hex.DecodeString(strings.TrimSpace(string(data))); err == nil && len(secret) >= 32 {
|
||||
return secret, nil
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("read cache secret: %w", err)
|
||||
}
|
||||
secret := make([]byte, 32)
|
||||
if _, err := rand.Read(secret); err != nil {
|
||||
return nil, fmt.Errorf("generate cache secret: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(hex.EncodeToString(secret)), 0o600); err != nil {
|
||||
return nil, fmt.Errorf("write cache secret: %w", err)
|
||||
}
|
||||
return secret, nil
|
||||
}
|
||||
|
||||
func (h *Handler) Close() error {
|
||||
if h == nil {
|
||||
return nil
|
||||
@@ -323,7 +160,6 @@ func (h *Handler) openDB() (*bolthold.Store, error) {
|
||||
|
||||
// GET /_apis/artifactcache/cache
|
||||
func (h *Handler) find(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
||||
cred := credFromContext(r.Context())
|
||||
keys := strings.Split(r.URL.Query().Get("keys"), ",")
|
||||
// cache keys are case insensitive
|
||||
for i, key := range keys {
|
||||
@@ -338,7 +174,7 @@ func (h *Handler) find(w http.ResponseWriter, r *http.Request, _ httprouter.Para
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
cache, err := findCache(db, cred.Repo, keys, version)
|
||||
cache, err := findCache(db, keys, version)
|
||||
if err != nil {
|
||||
h.responseJSON(w, r, 500, err)
|
||||
return
|
||||
@@ -358,14 +194,13 @@ func (h *Handler) find(w http.ResponseWriter, r *http.Request, _ httprouter.Para
|
||||
}
|
||||
h.responseJSON(w, r, 200, map[string]any{
|
||||
"result": "hit",
|
||||
"archiveLocation": h.signedArtifactURL(cache.ID, time.Now().Add(artifactURLTTL)),
|
||||
"archiveLocation": fmt.Sprintf("%s%s/artifacts/%d", h.ExternalURL(), urlBase, cache.ID),
|
||||
"cacheKey": cache.Key,
|
||||
})
|
||||
}
|
||||
|
||||
// POST /_apis/artifactcache/caches
|
||||
func (h *Handler) reserve(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
||||
cred := credFromContext(r.Context())
|
||||
api := &Request{}
|
||||
if err := json.NewDecoder(r.Body).Decode(api); err != nil {
|
||||
h.responseJSON(w, r, 400, err)
|
||||
@@ -375,7 +210,6 @@ func (h *Handler) reserve(w http.ResponseWriter, r *http.Request, _ httprouter.P
|
||||
api.Key = strings.ToLower(api.Key)
|
||||
|
||||
cache := api.ToCache()
|
||||
cache.Repo = cred.Repo
|
||||
db, err := h.openDB()
|
||||
if err != nil {
|
||||
h.responseJSON(w, r, 500, err)
|
||||
@@ -397,7 +231,6 @@ func (h *Handler) reserve(w http.ResponseWriter, r *http.Request, _ httprouter.P
|
||||
|
||||
// PATCH /_apis/artifactcache/caches/:id
|
||||
func (h *Handler) upload(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
||||
cred := credFromContext(r.Context())
|
||||
id, err := strconv.ParseInt(params.ByName("id"), 10, 64)
|
||||
if err != nil {
|
||||
h.responseJSON(w, r, 400, err)
|
||||
@@ -420,11 +253,6 @@ func (h *Handler) upload(w http.ResponseWriter, r *http.Request, params httprout
|
||||
return
|
||||
}
|
||||
|
||||
if cache.Repo != cred.Repo {
|
||||
h.responseJSON(w, r, 403, fmt.Errorf("cache %d: forbidden", id))
|
||||
return
|
||||
}
|
||||
|
||||
if cache.Complete {
|
||||
h.responseJSON(w, r, 400, fmt.Errorf("cache %v %q: already complete", cache.ID, cache.Key))
|
||||
return
|
||||
@@ -444,7 +272,6 @@ func (h *Handler) upload(w http.ResponseWriter, r *http.Request, params httprout
|
||||
|
||||
// POST /_apis/artifactcache/caches/:id
|
||||
func (h *Handler) commit(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
||||
cred := credFromContext(r.Context())
|
||||
id, err := strconv.ParseInt(params.ByName("id"), 10, 64)
|
||||
if err != nil {
|
||||
h.responseJSON(w, r, 400, err)
|
||||
@@ -467,11 +294,6 @@ func (h *Handler) commit(w http.ResponseWriter, r *http.Request, params httprout
|
||||
return
|
||||
}
|
||||
|
||||
if cache.Repo != cred.Repo {
|
||||
h.responseJSON(w, r, 403, fmt.Errorf("cache %d: forbidden", id))
|
||||
return
|
||||
}
|
||||
|
||||
if cache.Complete {
|
||||
h.responseJSON(w, r, 400, fmt.Errorf("cache %v %q: already complete", cache.ID, cache.Key))
|
||||
return
|
||||
@@ -504,10 +326,6 @@ func (h *Handler) commit(w http.ResponseWriter, r *http.Request, params httprout
|
||||
}
|
||||
|
||||
// GET /_apis/artifactcache/artifacts/:id
|
||||
// Authenticated via signed URL (see signedURLAuth), not bearer, because the
|
||||
// @actions/cache toolkit downloads archiveLocation without Authorization.
|
||||
// Repository scoping is already enforced at find() time; the signature binds
|
||||
// the URL to the specific cache ID and an expiry.
|
||||
func (h *Handler) get(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
||||
id, err := strconv.ParseInt(params.ByName("id"), 10, 64)
|
||||
if err != nil {
|
||||
@@ -526,158 +344,21 @@ func (h *Handler) clean(w http.ResponseWriter, r *http.Request, _ httprouter.Par
|
||||
h.responseJSON(w, r, 200)
|
||||
}
|
||||
|
||||
// bearerAuth resolves ACTIONS_RUNTIME_TOKEN against the set of currently
|
||||
// registered jobs. A match attaches the job's JobCredential to the request
|
||||
// context; a miss returns 401 before the handler body runs.
|
||||
func (h *Handler) bearerAuth(handler httprouter.Handle) httprouter.Handle {
|
||||
func (h *Handler) middleware(handler httprouter.Handle) httprouter.Handle {
|
||||
return func(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
||||
h.logger.Debugf("%s %s", r.Method, r.URL.Path)
|
||||
token := bearerToken(r)
|
||||
if token == "" {
|
||||
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("missing bearer token"))
|
||||
return
|
||||
}
|
||||
cred, ok := h.lookupCredential(token)
|
||||
if !ok {
|
||||
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("unknown bearer token"))
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), credKey{}, cred)
|
||||
handler(w, r.WithContext(ctx), params)
|
||||
go h.gcCache()
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) signedURLAuth(handler httprouter.Handle) httprouter.Handle {
|
||||
return func(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
||||
h.logger.Debugf("%s %s", r.Method, r.URL.Path)
|
||||
id, err := strconv.ParseInt(params.ByName("id"), 10, 64)
|
||||
if err != nil {
|
||||
h.responseJSON(w, r, 400, err)
|
||||
return
|
||||
}
|
||||
expStr := r.URL.Query().Get("exp")
|
||||
sig := r.URL.Query().Get("sig")
|
||||
if expStr == "" || sig == "" {
|
||||
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("missing signature"))
|
||||
return
|
||||
}
|
||||
exp, err := strconv.ParseInt(expStr, 10, 64)
|
||||
if err != nil {
|
||||
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("invalid expiry"))
|
||||
return
|
||||
}
|
||||
if time.Now().Unix() > exp {
|
||||
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("signature expired"))
|
||||
return
|
||||
}
|
||||
expected := h.computeSignature(id, exp)
|
||||
if !hmac.Equal([]byte(sig), []byte(expected)) {
|
||||
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("bad signature"))
|
||||
return
|
||||
}
|
||||
h.logger.Debugf("%s %s", r.Method, r.RequestURI)
|
||||
handler(w, r, params)
|
||||
go h.gcCache()
|
||||
}
|
||||
}
|
||||
|
||||
// internalAuth gates the control-plane endpoints. The bearer must
|
||||
// constant-time-equal the configured internalSecret. If the secret is empty,
|
||||
// the control-plane is disabled and every request gets 404 — which matches
|
||||
// the upstream nektos/act behavior of "the route does not exist".
|
||||
func (h *Handler) internalAuth(handler httprouter.Handle) httprouter.Handle {
|
||||
return func(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
||||
if h.internalSecret == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
token := bearerToken(r)
|
||||
if token == "" || !hmac.Equal([]byte(token), []byte(h.internalSecret)) {
|
||||
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("internal: bad secret"))
|
||||
return
|
||||
}
|
||||
handler(w, r, params)
|
||||
}
|
||||
}
|
||||
|
||||
type internalRegisterBody struct {
|
||||
Token string `json:"token"`
|
||||
Repo string `json:"repo"`
|
||||
}
|
||||
|
||||
type internalRevokeBody struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// POST /_internal/register
|
||||
func (h *Handler) internalRegister(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
||||
var body internalRegisterBody
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
h.responseJSON(w, r, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if body.Token == "" {
|
||||
h.responseJSON(w, r, http.StatusBadRequest, errors.New("token is required"))
|
||||
return
|
||||
}
|
||||
h.RegisterJob(body.Token, body.Repo)
|
||||
h.responseJSON(w, r, http.StatusOK)
|
||||
}
|
||||
|
||||
// POST /_internal/revoke
|
||||
func (h *Handler) internalRevoke(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
||||
var body internalRevokeBody
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
h.responseJSON(w, r, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if body.Token == "" {
|
||||
h.responseJSON(w, r, http.StatusBadRequest, errors.New("token is required"))
|
||||
return
|
||||
}
|
||||
h.RevokeJob(body.Token)
|
||||
h.responseJSON(w, r, http.StatusOK)
|
||||
}
|
||||
|
||||
func bearerToken(r *http.Request) string {
|
||||
auth := r.Header.Get("Authorization")
|
||||
const prefix = "Bearer "
|
||||
if len(auth) > len(prefix) && strings.EqualFold(auth[:len(prefix)], prefix) {
|
||||
return auth[len(prefix):]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func credFromContext(ctx context.Context) JobCredential {
|
||||
if cred, ok := ctx.Value(credKey{}).(JobCredential); ok {
|
||||
return cred
|
||||
}
|
||||
return JobCredential{}
|
||||
}
|
||||
|
||||
func (h *Handler) computeSignature(cacheID, exp int64) string {
|
||||
mac := hmac.New(sha256.New, h.secret)
|
||||
fmt.Fprintf(mac, "%d:%d", cacheID, exp)
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func (h *Handler) signedArtifactURL(cacheID uint64, exp time.Time) string {
|
||||
expUnix := exp.Unix()
|
||||
sig := h.computeSignature(int64(cacheID), expUnix)
|
||||
q := url.Values{}
|
||||
q.Set("exp", strconv.FormatInt(expUnix, 10))
|
||||
q.Set("sig", sig)
|
||||
return fmt.Sprintf("%s%s/artifacts/%d?%s", h.ExternalURL(), apiPath, cacheID, q.Encode())
|
||||
}
|
||||
|
||||
// if not found, return (nil, nil) instead of an error.
|
||||
func findCache(db *bolthold.Store, repo string, keys []string, version string) (*Cache, error) {
|
||||
func findCache(db *bolthold.Store, keys []string, version string) (*Cache, error) {
|
||||
cache := &Cache{}
|
||||
for _, prefix := range keys {
|
||||
// if a key in the list matches exactly, don't return partial matches
|
||||
if err := db.FindOne(cache,
|
||||
bolthold.Where("Repo").Eq(repo).
|
||||
And("Key").Eq(prefix).
|
||||
bolthold.Where("Key").Eq(prefix).
|
||||
And("Version").Eq(version).
|
||||
And("Complete").Eq(true).
|
||||
SortBy("CreatedAt").Reverse()); err == nil || !errors.Is(err, bolthold.ErrNotFound) {
|
||||
@@ -692,8 +373,7 @@ func findCache(db *bolthold.Store, repo string, keys []string, version string) (
|
||||
continue
|
||||
}
|
||||
if err := db.FindOne(cache,
|
||||
bolthold.Where("Repo").Eq(repo).
|
||||
And("Key").RegExp(re).
|
||||
bolthold.Where("Key").RegExp(re).
|
||||
And("Version").Eq(version).
|
||||
And("Complete").Eq(true).
|
||||
SortBy("CreatedAt").Reverse()); err != nil {
|
||||
@@ -814,16 +494,12 @@ func (h *Handler) gcCache() {
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the old caches with the same key and version within the same
|
||||
// repository, keep the latest one. Aggregation must include Repo so two
|
||||
// repos that happen to share a (key, version) do not evict each other —
|
||||
// otherwise per-repo scoping holds for reads but one repo can age
|
||||
// another out after keepOld.
|
||||
// Remove the old caches with the same key and version, keep the latest one.
|
||||
// Also keep the olds which have been used recently for a while in case of the cache is still in use.
|
||||
if results, err := db.FindAggregate(
|
||||
&Cache{},
|
||||
bolthold.Where("Complete").Eq(true),
|
||||
"Repo", "Key", "Version",
|
||||
"Key", "Version",
|
||||
); err != nil {
|
||||
h.logger.Warnf("find aggregate caches: %v", err)
|
||||
} else {
|
||||
@@ -857,7 +533,7 @@ func (h *Handler) responseJSON(w http.ResponseWriter, r *http.Request, code int,
|
||||
if len(v) == 0 || v[0] == nil {
|
||||
data, _ = json.Marshal(struct{}{})
|
||||
} else if err, ok := v[0].(error); ok {
|
||||
h.logger.Errorf("%v %v: %v", r.Method, r.URL.Path, err)
|
||||
h.logger.Errorf("%v %v: %v", r.Method, r.RequestURI, err)
|
||||
data, _ = json.Marshal(map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
|
||||
@@ -22,38 +22,12 @@ import (
|
||||
"go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
// testToken is registered with the cache server in every test that needs to
|
||||
// make authenticated requests; testClient then attaches it as the
|
||||
// Authorization: Bearer header. testRepo is the repository scope used when
|
||||
// registering it; cross-repo isolation is exercised in its own test.
|
||||
const (
|
||||
testToken = "test-runtime-token"
|
||||
testRepo = "owner/repo"
|
||||
)
|
||||
|
||||
type bearerTransport struct{ token string }
|
||||
|
||||
func (b *bearerTransport) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
r.Header.Set("Authorization", "Bearer "+b.token)
|
||||
return http.DefaultTransport.RoundTrip(r)
|
||||
}
|
||||
|
||||
var testClient = &http.Client{Transport: &bearerTransport{token: testToken}}
|
||||
|
||||
// signArtifactURL builds a signed download URL the same way the server does;
|
||||
// tests use it to reach the get handler directly without going through a
|
||||
// find/cache-hit round trip.
|
||||
func signArtifactURL(h *Handler, id int64) string {
|
||||
return h.signedArtifactURL(uint64(id), time.Now().Add(artifactURLTTL))
|
||||
}
|
||||
|
||||
func TestHandler(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
||||
handler, err := StartHandler(dir, "", 0, "", nil)
|
||||
handler, err := StartHandler(dir, "", 0, nil)
|
||||
require.NoError(t, err)
|
||||
handler.RegisterJob(testToken, testRepo)
|
||||
|
||||
base := fmt.Sprintf("%s%s", handler.ExternalURL(), apiPath)
|
||||
base := fmt.Sprintf("%s%s", handler.ExternalURL(), urlBase)
|
||||
|
||||
defer func() {
|
||||
t.Run("inpect db", func(t *testing.T) {
|
||||
@@ -71,7 +45,7 @@ func TestHandler(t *testing.T) {
|
||||
require.NoError(t, handler.Close())
|
||||
assert.Nil(t, handler.server)
|
||||
assert.Nil(t, handler.listener)
|
||||
_, err := testClient.Post(fmt.Sprintf("%s/caches/%d", base, 1), "", nil) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
_, err := http.Post(fmt.Sprintf("%s/caches/%d", base, 1), "", nil) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}()
|
||||
@@ -79,7 +53,7 @@ func TestHandler(t *testing.T) {
|
||||
t.Run("get not exist", func(t *testing.T) {
|
||||
key := strings.ToLower(t.Name())
|
||||
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
|
||||
resp, err := testClient.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version)) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
resp, err := http.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version)) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 204, resp.StatusCode)
|
||||
})
|
||||
@@ -94,7 +68,7 @@ func TestHandler(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("clean", func(t *testing.T) {
|
||||
resp, err := testClient.Post(base+"/clean", "", nil) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
resp, err := http.Post(base+"/clean", "", nil) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
})
|
||||
@@ -102,7 +76,7 @@ func TestHandler(t *testing.T) {
|
||||
t.Run("reserve with bad request", func(t *testing.T) {
|
||||
body := []byte(`invalid json`)
|
||||
require.NoError(t, err)
|
||||
resp, err := testClient.Post(base+"/caches", "application/json", bytes.NewReader(body)) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
resp, err := http.Post(base+"/caches", "application/json", bytes.NewReader(body)) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 400, resp.StatusCode)
|
||||
})
|
||||
@@ -120,7 +94,7 @@ func TestHandler(t *testing.T) {
|
||||
Size: 100,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
resp, err := testClient.Post(base+"/caches", "application/json", bytes.NewReader(body)) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
resp, err := http.Post(base+"/caches", "application/json", bytes.NewReader(body)) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
@@ -134,7 +108,7 @@ func TestHandler(t *testing.T) {
|
||||
Size: 100,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
resp, err := testClient.Post(base+"/caches", "application/json", bytes.NewReader(body)) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
resp, err := http.Post(base+"/caches", "application/json", bytes.NewReader(body)) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
@@ -151,7 +125,7 @@ func TestHandler(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Content-Type", "application/octet-stream")
|
||||
req.Header.Set("Content-Range", "bytes 0-99/*")
|
||||
resp, err := testClient.Do(req) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
resp, err := http.DefaultClient.Do(req) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 400, resp.StatusCode)
|
||||
})
|
||||
@@ -162,7 +136,7 @@ func TestHandler(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Content-Type", "application/octet-stream")
|
||||
req.Header.Set("Content-Range", "bytes 0-99/*")
|
||||
resp, err := testClient.Do(req) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
resp, err := http.DefaultClient.Do(req) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 400, resp.StatusCode)
|
||||
})
|
||||
@@ -181,7 +155,7 @@ func TestHandler(t *testing.T) {
|
||||
Size: 100,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
resp, err := testClient.Post(base+"/caches", "application/json", bytes.NewReader(body)) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
resp, err := http.Post(base+"/caches", "application/json", bytes.NewReader(body)) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
@@ -197,12 +171,12 @@ func TestHandler(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Content-Type", "application/octet-stream")
|
||||
req.Header.Set("Content-Range", "bytes 0-99/*")
|
||||
resp, err := testClient.Do(req) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
resp, err := http.DefaultClient.Do(req) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
}
|
||||
{
|
||||
resp, err := testClient.Post(fmt.Sprintf("%s/caches/%d", base, id), "", nil) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
resp, err := http.Post(fmt.Sprintf("%s/caches/%d", base, id), "", nil) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
}
|
||||
@@ -212,7 +186,7 @@ func TestHandler(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Content-Type", "application/octet-stream")
|
||||
req.Header.Set("Content-Range", "bytes 0-99/*")
|
||||
resp, err := testClient.Do(req) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
resp, err := http.DefaultClient.Do(req) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 400, resp.StatusCode)
|
||||
}
|
||||
@@ -232,7 +206,7 @@ func TestHandler(t *testing.T) {
|
||||
Size: 100,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
resp, err := testClient.Post(base+"/caches", "application/json", bytes.NewReader(body)) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
resp, err := http.Post(base+"/caches", "application/json", bytes.NewReader(body)) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
@@ -248,7 +222,7 @@ func TestHandler(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Content-Type", "application/octet-stream")
|
||||
req.Header.Set("Content-Range", "bytes xx-99/*")
|
||||
resp, err := testClient.Do(req) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
resp, err := http.DefaultClient.Do(req) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 400, resp.StatusCode)
|
||||
}
|
||||
@@ -256,7 +230,7 @@ func TestHandler(t *testing.T) {
|
||||
|
||||
t.Run("commit with bad id", func(t *testing.T) {
|
||||
{
|
||||
resp, err := testClient.Post(base+"/caches/invalid_id", "", nil) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
resp, err := http.Post(base+"/caches/invalid_id", "", nil) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 400, resp.StatusCode)
|
||||
}
|
||||
@@ -264,7 +238,7 @@ func TestHandler(t *testing.T) {
|
||||
|
||||
t.Run("commit with not exist id", func(t *testing.T) {
|
||||
{
|
||||
resp, err := testClient.Post(fmt.Sprintf("%s/caches/%d", base, 100), "", nil) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
resp, err := http.Post(fmt.Sprintf("%s/caches/%d", base, 100), "", nil) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 400, resp.StatusCode)
|
||||
}
|
||||
@@ -284,7 +258,7 @@ func TestHandler(t *testing.T) {
|
||||
Size: 100,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
resp, err := testClient.Post(base+"/caches", "application/json", bytes.NewReader(body)) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
resp, err := http.Post(base+"/caches", "application/json", bytes.NewReader(body)) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
@@ -300,17 +274,17 @@ func TestHandler(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Content-Type", "application/octet-stream")
|
||||
req.Header.Set("Content-Range", "bytes 0-99/*")
|
||||
resp, err := testClient.Do(req) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
resp, err := http.DefaultClient.Do(req) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
}
|
||||
{
|
||||
resp, err := testClient.Post(fmt.Sprintf("%s/caches/%d", base, id), "", nil) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
resp, err := http.Post(fmt.Sprintf("%s/caches/%d", base, id), "", nil) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
}
|
||||
{
|
||||
resp, err := testClient.Post(fmt.Sprintf("%s/caches/%d", base, id), "", nil) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
resp, err := http.Post(fmt.Sprintf("%s/caches/%d", base, id), "", nil) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 400, resp.StatusCode)
|
||||
}
|
||||
@@ -330,7 +304,7 @@ func TestHandler(t *testing.T) {
|
||||
Size: 100,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
resp, err := testClient.Post(base+"/caches", "application/json", bytes.NewReader(body)) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
resp, err := http.Post(base+"/caches", "application/json", bytes.NewReader(body)) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
@@ -346,31 +320,31 @@ func TestHandler(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Content-Type", "application/octet-stream")
|
||||
req.Header.Set("Content-Range", "bytes 0-59/*")
|
||||
resp, err := testClient.Do(req) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
resp, err := http.DefaultClient.Do(req) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
}
|
||||
{
|
||||
resp, err := testClient.Post(fmt.Sprintf("%s/caches/%d", base, id), "", nil) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
resp, err := http.Post(fmt.Sprintf("%s/caches/%d", base, id), "", nil) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 500, resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get with bad id", func(t *testing.T) {
|
||||
resp, err := testClient.Get(base + "/artifacts/invalid_id") //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
resp, err := http.Get(base + "/artifacts/invalid_id") //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 400, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("get with not exist id", func(t *testing.T) {
|
||||
resp, err := testClient.Get(signArtifactURL(handler, 100)) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
resp, err := http.Get(fmt.Sprintf("%s/artifacts/%d", base, 100)) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 404, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("get with not exist id", func(t *testing.T) {
|
||||
resp, err := testClient.Get(signArtifactURL(handler, 100)) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
resp, err := http.Get(fmt.Sprintf("%s/artifacts/%d", base, 100)) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 404, resp.StatusCode)
|
||||
})
|
||||
@@ -401,7 +375,7 @@ func TestHandler(t *testing.T) {
|
||||
key + "_a",
|
||||
}, ",")
|
||||
|
||||
resp, err := testClient.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, reqKeys, version)) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
resp, err := http.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, reqKeys, version)) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
@@ -421,7 +395,7 @@ func TestHandler(t *testing.T) {
|
||||
assert.Equal(t, "hit", got.Result)
|
||||
assert.Equal(t, keys[except], got.CacheKey)
|
||||
|
||||
contentResp, err := testClient.Get(got.ArchiveLocation) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
contentResp, err := http.Get(got.ArchiveLocation) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 200, contentResp.StatusCode)
|
||||
content, err := io.ReadAll(contentResp.Body)
|
||||
@@ -439,7 +413,7 @@ func TestHandler(t *testing.T) {
|
||||
|
||||
{
|
||||
reqKey := key + "_aBc"
|
||||
resp, err := testClient.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, reqKey, version)) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
resp, err := http.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, reqKey, version)) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 200, resp.StatusCode)
|
||||
got := struct {
|
||||
@@ -478,7 +452,7 @@ func TestHandler(t *testing.T) {
|
||||
key + "_a_b",
|
||||
}, ",")
|
||||
|
||||
resp, err := testClient.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, reqKeys, version)) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
resp, err := http.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, reqKeys, version)) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
@@ -496,7 +470,7 @@ func TestHandler(t *testing.T) {
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
|
||||
assert.Equal(t, keys[expect], got.CacheKey)
|
||||
|
||||
contentResp, err := testClient.Get(got.ArchiveLocation) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
contentResp, err := http.Get(got.ArchiveLocation) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 200, contentResp.StatusCode)
|
||||
content, err := io.ReadAll(contentResp.Body)
|
||||
@@ -530,7 +504,7 @@ func TestHandler(t *testing.T) {
|
||||
key + "_a_b",
|
||||
}, ",")
|
||||
|
||||
resp, err := testClient.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, reqKeys, version)) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
resp, err := http.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, reqKeys, version)) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
@@ -549,7 +523,7 @@ func TestHandler(t *testing.T) {
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
|
||||
assert.Equal(t, keys[expect], got.CacheKey)
|
||||
|
||||
contentResp, err := testClient.Get(got.ArchiveLocation) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
contentResp, err := http.Get(got.ArchiveLocation) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 200, contentResp.StatusCode)
|
||||
content, err := io.ReadAll(contentResp.Body)
|
||||
@@ -567,7 +541,7 @@ func uploadCacheNormally(t *testing.T, base, key, version string, content []byte
|
||||
Size: int64(len(content)),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
resp, err := testClient.Post(base+"/caches", "application/json", bytes.NewReader(body)) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
resp, err := http.Post(base+"/caches", "application/json", bytes.NewReader(body)) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
@@ -583,18 +557,18 @@ func uploadCacheNormally(t *testing.T, base, key, version string, content []byte
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Content-Type", "application/octet-stream")
|
||||
req.Header.Set("Content-Range", "bytes 0-99/*")
|
||||
resp, err := testClient.Do(req) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
resp, err := http.DefaultClient.Do(req) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
}
|
||||
{
|
||||
resp, err := testClient.Post(fmt.Sprintf("%s/caches/%d", base, id), "", nil) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
resp, err := http.Post(fmt.Sprintf("%s/caches/%d", base, id), "", nil) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
}
|
||||
var archiveLocation string
|
||||
{
|
||||
resp, err := testClient.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version)) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
resp, err := http.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version)) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 200, resp.StatusCode)
|
||||
got := struct {
|
||||
@@ -608,7 +582,7 @@ func uploadCacheNormally(t *testing.T, base, key, version string, content []byte
|
||||
archiveLocation = got.ArchiveLocation
|
||||
}
|
||||
{
|
||||
resp, err := testClient.Get(archiveLocation) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
resp, err := http.Get(archiveLocation) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 200, resp.StatusCode)
|
||||
got, err := io.ReadAll(resp.Body)
|
||||
@@ -619,7 +593,7 @@ func uploadCacheNormally(t *testing.T, base, key, version string, content []byte
|
||||
|
||||
func TestHandler_gcCache(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
||||
handler, err := StartHandler(dir, "", 0, "", nil)
|
||||
handler, err := StartHandler(dir, "", 0, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() {
|
||||
@@ -725,421 +699,3 @@ func TestHandler_gcCache(t *testing.T) {
|
||||
}
|
||||
require.NoError(t, db.Close())
|
||||
}
|
||||
|
||||
// TestHandler_RejectsMissingBearer covers the advisory's root cause:
|
||||
// unauthenticated access to management endpoints is now refused with 401.
|
||||
func TestHandler_RejectsMissingBearer(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
||||
handler, err := StartHandler(dir, "", 0, "", nil)
|
||||
require.NoError(t, err)
|
||||
defer handler.Close()
|
||||
|
||||
base := handler.ExternalURL() + apiPath
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
body string
|
||||
}{
|
||||
{"find", http.MethodGet, "/cache?keys=x&version=y", ""},
|
||||
{"reserve", http.MethodPost, "/caches", "{}"},
|
||||
{"upload", http.MethodPatch, "/caches/1", ""},
|
||||
{"commit", http.MethodPost, "/caches/1", ""},
|
||||
{"clean", http.MethodPost, "/clean", ""},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req, err := http.NewRequest(tc.method, base+tc.path, strings.NewReader(tc.body))
|
||||
require.NoError(t, err)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandler_RejectsUnknownBearer verifies that a bearer token is only
|
||||
// accepted after RegisterJob; stale/forged tokens cannot be replayed.
|
||||
func TestHandler_RejectsUnknownBearer(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
||||
handler, err := StartHandler(dir, "", 0, "", nil)
|
||||
require.NoError(t, err)
|
||||
defer handler.Close()
|
||||
|
||||
base := handler.ExternalURL() + apiPath
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, base+"/cache?keys=x&version=y", nil)
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Authorization", "Bearer not-a-registered-token")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
}
|
||||
|
||||
// TestHandler_UnregisterRevokes ensures that the function returned by
|
||||
// RegisterJob invalidates the credential, so a token leaked at job time stops
|
||||
// working the moment the job ends instead of living for the runner's lifetime.
|
||||
func TestHandler_UnregisterRevokes(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
||||
handler, err := StartHandler(dir, "", 0, "", nil)
|
||||
require.NoError(t, err)
|
||||
defer handler.Close()
|
||||
|
||||
unregister := handler.RegisterJob("tmp-token", testRepo)
|
||||
|
||||
base := handler.ExternalURL() + apiPath
|
||||
req, err := http.NewRequest(http.MethodGet, base+"/cache?keys=x&version=y", nil)
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Authorization", "Bearer tmp-token")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
assert.NotEqual(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
|
||||
unregister()
|
||||
|
||||
resp, err = http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
}
|
||||
|
||||
// TestHandler_CrossRepoIsolation addresses the intra-runner poisoning vector
|
||||
// raised in GHSA-82g9-637c-2fx2: job containers can reach the cache server
|
||||
// over the docker bridge, so IP allowlisting alone does not stop a malicious
|
||||
// PR run from another repo. A cache entry created under repoA must be
|
||||
// invisible to queries scoped to repoB.
|
||||
func TestHandler_CrossRepoIsolation(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
||||
handler, err := StartHandler(dir, "", 0, "", nil)
|
||||
require.NoError(t, err)
|
||||
defer handler.Close()
|
||||
handler.RegisterJob("token-a", "owner/repoA")
|
||||
handler.RegisterJob("token-b", "owner/repoB")
|
||||
|
||||
base := handler.ExternalURL() + apiPath
|
||||
key := "shared-key"
|
||||
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
|
||||
content := []byte("repoA-payload")
|
||||
|
||||
clientA := &http.Client{Transport: &bearerTransport{token: "token-a"}}
|
||||
clientB := &http.Client{Transport: &bearerTransport{token: "token-b"}}
|
||||
|
||||
// repoA reserves + uploads + commits.
|
||||
reserveBody, err := json.Marshal(&Request{Key: key, Version: version, Size: int64(len(content))})
|
||||
require.NoError(t, err)
|
||||
resp, err := clientA.Post(base+"/caches", "application/json", bytes.NewReader(reserveBody))
|
||||
require.NoError(t, err)
|
||||
var reserved struct {
|
||||
CacheID uint64 `json:"cacheId"`
|
||||
}
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&reserved))
|
||||
resp.Body.Close()
|
||||
require.NotZero(t, reserved.CacheID)
|
||||
|
||||
req, err := http.NewRequest(http.MethodPatch, fmt.Sprintf("%s/caches/%d", base, reserved.CacheID), bytes.NewReader(content))
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Content-Range", fmt.Sprintf("bytes 0-%d/*", len(content)-1))
|
||||
resp, err = clientA.Do(req)
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
resp, err = clientA.Post(fmt.Sprintf("%s/caches/%d", base, reserved.CacheID), "", nil)
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
// repoB with a matching key and version must NOT see repoA's cache.
|
||||
resp, err = clientB.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version))
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
assert.Equal(t, http.StatusNoContent, resp.StatusCode)
|
||||
|
||||
// repoA still sees its own cache.
|
||||
resp, err = clientA.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version))
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
// repoB cannot upload to repoA's reserved id either (forbidden, not 401).
|
||||
req, err = http.NewRequest(http.MethodPatch, fmt.Sprintf("%s/caches/%d", base, reserved.CacheID), bytes.NewReader([]byte("poison")))
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Content-Range", "bytes 0-5/*")
|
||||
resp, err = clientB.Do(req)
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
}
|
||||
|
||||
// TestHandler_ArtifactSignature verifies that archive downloads reject
|
||||
// missing / tampered / expired signatures, so a leaked archiveLocation stops
|
||||
// working after artifactURLTTL even if the bearer token is still registered.
|
||||
func TestHandler_ArtifactSignature(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
||||
handler, err := StartHandler(dir, "", 0, "", nil)
|
||||
require.NoError(t, err)
|
||||
defer handler.Close()
|
||||
handler.RegisterJob(testToken, testRepo)
|
||||
|
||||
base := handler.ExternalURL() + apiPath
|
||||
|
||||
t.Run("missing signature", func(t *testing.T) {
|
||||
resp, err := testClient.Get(fmt.Sprintf("%s/artifacts/%d", base, 1))
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("tampered signature", func(t *testing.T) {
|
||||
good := handler.signedArtifactURL(1, time.Now().Add(artifactURLTTL))
|
||||
bad := good[:len(good)-4] + "dead"
|
||||
resp, err := testClient.Get(bad)
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("expired signature", func(t *testing.T) {
|
||||
expired := handler.signedArtifactURL(1, time.Now().Add(-time.Second))
|
||||
resp, err := testClient.Get(expired)
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("signature from a different server", func(t *testing.T) {
|
||||
dir2 := filepath.Join(t.TempDir(), "artifactcache2")
|
||||
other, err := StartHandler(dir2, "", 0, "", nil)
|
||||
require.NoError(t, err)
|
||||
defer other.Close()
|
||||
otherURL := other.signedArtifactURL(1, time.Now().Add(artifactURLTTL))
|
||||
// Rewrite the host so the request still lands on our handler, but
|
||||
// the signature was computed with a different secret.
|
||||
parts := strings.SplitN(otherURL, apiPath, 2)
|
||||
forged := base + parts[1]
|
||||
resp, err := testClient.Get(forged)
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
// TestHandler_SecretPersistsAcrossRestarts is the property that lets
|
||||
// act_runner cache-server be pointed at via cfg.Cache.ExternalServer: a
|
||||
// restart must not invalidate signed URLs the handler has already issued
|
||||
// (within their expiry window).
|
||||
func TestHandler_SecretPersistsAcrossRestarts(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
||||
|
||||
first, err := StartHandler(dir, "127.0.0.1", 0, "", nil)
|
||||
require.NoError(t, err)
|
||||
exp := time.Now().Add(artifactURLTTL).Unix()
|
||||
sig := first.computeSignature(42, exp)
|
||||
require.NoError(t, first.Close())
|
||||
|
||||
second, err := StartHandler(dir, "127.0.0.1", 0, "", nil)
|
||||
require.NoError(t, err)
|
||||
defer second.Close()
|
||||
|
||||
assert.Equal(t, sig, second.computeSignature(42, exp))
|
||||
}
|
||||
|
||||
// TestHandler_ArtifactSignatureDownload is a happy-path round trip that
|
||||
// ensures a real reserve/upload/commit/find/download flow still works after
|
||||
// the auth refactor.
|
||||
func TestHandler_ArtifactSignatureDownload(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
||||
handler, err := StartHandler(dir, "", 0, "", nil)
|
||||
require.NoError(t, err)
|
||||
defer handler.Close()
|
||||
handler.RegisterJob(testToken, testRepo)
|
||||
|
||||
base := handler.ExternalURL() + apiPath
|
||||
key := "download-key"
|
||||
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
|
||||
content := []byte("hello")
|
||||
uploadCacheNormally(t, base, key, version, content)
|
||||
|
||||
resp, err := testClient.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var hit struct {
|
||||
ArchiveLocation string `json:"archiveLocation"`
|
||||
}
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&hit))
|
||||
resp.Body.Close()
|
||||
|
||||
require.Contains(t, hit.ArchiveLocation, "sig=")
|
||||
require.Contains(t, hit.ArchiveLocation, "exp=")
|
||||
|
||||
// Download without any Authorization header — the signature alone must
|
||||
// be enough, because @actions/cache downloads archiveLocation unauth'd.
|
||||
dl, err := http.Get(hit.ArchiveLocation)
|
||||
require.NoError(t, err)
|
||||
body, err := io.ReadAll(dl.Body)
|
||||
dl.Body.Close()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, dl.StatusCode)
|
||||
assert.Equal(t, content, body)
|
||||
}
|
||||
|
||||
// TestHandler_RegisterJob_RefCounted verifies that a duplicate RegisterJob
|
||||
// for the same token does not silently revoke the first registration on the
|
||||
// first revoker call. This matters if a runner ever re-registers a token
|
||||
// (restart mid-task, retry), which must not kill the live job's auth.
|
||||
func TestHandler_RegisterJob_RefCounted(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
||||
handler, err := StartHandler(dir, "", 0, "", nil)
|
||||
require.NoError(t, err)
|
||||
defer handler.Close()
|
||||
|
||||
first := handler.RegisterJob("shared", testRepo)
|
||||
second := handler.RegisterJob("shared", testRepo)
|
||||
|
||||
base := handler.ExternalURL() + apiPath
|
||||
probe := func() int {
|
||||
req, err := http.NewRequest(http.MethodGet, base+"/cache?keys=x&version=v", nil)
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Authorization", "Bearer shared")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
return resp.StatusCode
|
||||
}
|
||||
|
||||
require.NotEqual(t, http.StatusUnauthorized, probe())
|
||||
first()
|
||||
assert.NotEqual(t, http.StatusUnauthorized, probe(),
|
||||
"token must stay valid while another registration holds the refcount")
|
||||
second()
|
||||
assert.Equal(t, http.StatusUnauthorized, probe(),
|
||||
"token is revoked only after every revoker has run")
|
||||
}
|
||||
|
||||
// TestHandler_GC_PerRepoDedup ensures duplicate-pruning does not evict
|
||||
// another repo's entry. Two repos reserve the same (key, version); after the
|
||||
// keepOld window, GC must keep the one from each repo.
|
||||
func TestHandler_GC_PerRepoDedup(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
||||
handler, err := StartHandler(dir, "", 0, "", nil)
|
||||
require.NoError(t, err)
|
||||
defer handler.Close()
|
||||
handler.RegisterJob("tok-a", "owner/repoA")
|
||||
handler.RegisterJob("tok-b", "owner/repoB")
|
||||
|
||||
key := "shared-dedup-key"
|
||||
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
|
||||
|
||||
// Seed one completed cache per repo directly via the DB, bypassing the
|
||||
// HTTP round trip so we can precisely control UsedAt.
|
||||
db, err := handler.openDB()
|
||||
require.NoError(t, err)
|
||||
now := time.Now().Unix()
|
||||
stale := time.Now().Add(-keepOld - time.Minute).Unix()
|
||||
a := &Cache{Repo: "owner/repoA", Key: key, Version: version, Complete: true, CreatedAt: stale, UsedAt: stale, Size: 1}
|
||||
b := &Cache{Repo: "owner/repoB", Key: key, Version: version, Complete: true, CreatedAt: now, UsedAt: now, Size: 1}
|
||||
require.NoError(t, insertCache(db, a))
|
||||
require.NoError(t, insertCache(db, b))
|
||||
// Write the backing blobs so the dedup deletion has something to remove.
|
||||
require.NoError(t, handler.storage.Write(a.ID, 0, strings.NewReader("a")))
|
||||
_, err = handler.storage.Commit(a.ID, 1)
|
||||
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())
|
||||
|
||||
// Force GC to run regardless of the cooldown.
|
||||
handler.gcAt = time.Time{}
|
||||
handler.gcCache()
|
||||
|
||||
db, err = handler.openDB()
|
||||
require.NoError(t, err)
|
||||
defer db.Close()
|
||||
var after []Cache
|
||||
require.NoError(t, db.Find(&after, bolthold.Where("Key").Eq(key).And("Version").Eq(version)))
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
// TestHandler_InternalAPI_Disabled verifies that without an internalSecret
|
||||
// the control-plane routes are 404 — operators can't accidentally hit
|
||||
// register/revoke when the feature is off.
|
||||
func TestHandler_InternalAPI_Disabled(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
||||
handler, err := StartHandler(dir, "", 0, "", nil)
|
||||
require.NoError(t, err)
|
||||
defer handler.Close()
|
||||
|
||||
for _, ep := range []string{"/_internal/register", "/_internal/revoke"} {
|
||||
resp, err := http.Post(handler.ExternalURL()+ep, "application/json", strings.NewReader(`{}`))
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
assert.Equal(t, http.StatusNotFound, resp.StatusCode, ep)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandler_InternalAPI_AuthAndUsage covers the control-plane: bad/missing
|
||||
// secret → 401, malformed body → 400, happy path round-trips a token through
|
||||
// register → cache-API accepts it → revoke → cache-API rejects it.
|
||||
func TestHandler_InternalAPI_AuthAndUsage(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
||||
const secret = "internal-secret"
|
||||
handler, err := StartHandler(dir, "", 0, secret, nil)
|
||||
require.NoError(t, err)
|
||||
defer handler.Close()
|
||||
|
||||
base := handler.ExternalURL()
|
||||
|
||||
post := func(path, bearer, body string) int {
|
||||
req, err := http.NewRequest(http.MethodPost, base+path, strings.NewReader(body))
|
||||
require.NoError(t, err)
|
||||
if bearer != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+bearer)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
return resp.StatusCode
|
||||
}
|
||||
|
||||
t.Run("missing secret 401", func(t *testing.T) {
|
||||
assert.Equal(t, http.StatusUnauthorized, post("/_internal/register", "", `{"token":"x","repo":"r"}`))
|
||||
})
|
||||
t.Run("wrong secret 401", func(t *testing.T) {
|
||||
assert.Equal(t, http.StatusUnauthorized, post("/_internal/register", "wrong", `{"token":"x","repo":"r"}`))
|
||||
})
|
||||
t.Run("malformed body 400", func(t *testing.T) {
|
||||
assert.Equal(t, http.StatusBadRequest, post("/_internal/register", secret, `not json`))
|
||||
})
|
||||
t.Run("missing token 400", func(t *testing.T) {
|
||||
assert.Equal(t, http.StatusBadRequest, post("/_internal/register", secret, `{"repo":"r"}`))
|
||||
})
|
||||
|
||||
t.Run("register then revoke round-trip", func(t *testing.T) {
|
||||
probe := func(token string) int {
|
||||
req, _ := http.NewRequest(http.MethodGet, base+apiPath+"/cache?keys=k&version=v", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
return resp.StatusCode
|
||||
}
|
||||
|
||||
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"}`))
|
||||
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.StatusUnauthorized, probe("via-internal-api"))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ func (c *Request) ToCache() *Cache {
|
||||
|
||||
type Cache struct {
|
||||
ID uint64 `json:"id" boltholdKey:"ID"`
|
||||
Repo string `json:"repo" boltholdIndex:"Repo"`
|
||||
Key string `json:"key" boltholdIndex:"Key"`
|
||||
Version string `json:"version" boltholdIndex:"Version"`
|
||||
Size int64 `json:"cacheSize"`
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/act_runner/act/common"
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
|
||||
"github.com/julienschmidt/httprouter"
|
||||
)
|
||||
|
||||
@@ -17,8 +17,8 @@ import (
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"gitea.com/gitea/act_runner/act/model"
|
||||
"gitea.com/gitea/act_runner/act/runner"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
"gitea.com/gitea/runner/act/runner"
|
||||
|
||||
"github.com/julienschmidt/httprouter"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
@@ -117,7 +117,7 @@ func NewParallelExecutor(parallel int, executors ...Executor) Executor {
|
||||
log.Debugf("Worker %d executing task %d", workerID, taskCount)
|
||||
// Recover from panics in executors to avoid crashing the worker
|
||||
// goroutine which would leave the runner process hung.
|
||||
// https://gitea.com/gitea/act_runner/issues/371
|
||||
// https://gitea.com/gitea/runner/issues/371
|
||||
errs <- func() (err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gitea.com/gitea/act_runner/act/common"
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/config"
|
||||
@@ -32,21 +32,12 @@ var (
|
||||
githubHTTPRegex = regexp.MustCompile(`^https?://.*github.com.*/(.+)/(.+?)(?:.git)?$`)
|
||||
githubSSHRegex = regexp.MustCompile(`github.com[:/](.+)/(.+?)(?:.git)?$`)
|
||||
|
||||
cloneLocks sync.Map // key: clone target directory; value: *sync.Mutex
|
||||
cloneLock sync.Mutex
|
||||
|
||||
ErrShortRef = errors.New("short SHA references are not supported")
|
||||
ErrNoRepo = errors.New("unable to find git repo")
|
||||
)
|
||||
|
||||
// acquireCloneLock returns an unlock function after locking the per-directory mutex for dir.
|
||||
// Only concurrent operations targeting the same directory are erialized; clones into different directories run in parallel.
|
||||
func acquireCloneLock(dir string) func() {
|
||||
v, _ := cloneLocks.LoadOrStore(dir, &sync.Mutex{})
|
||||
mu := v.(*sync.Mutex)
|
||||
mu.Lock()
|
||||
return mu.Unlock
|
||||
}
|
||||
|
||||
type Error struct {
|
||||
err error
|
||||
commit string
|
||||
@@ -310,7 +301,8 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
||||
logger.Infof(" \u2601 git clone '%s' # ref=%s", input.URL, input.Ref)
|
||||
logger.Debugf(" cloning %s to %s", input.URL, input.Dir)
|
||||
|
||||
defer acquireCloneLock(input.Dir)()
|
||||
cloneLock.Lock()
|
||||
defer cloneLock.Unlock()
|
||||
|
||||
refName := plumbing.ReferenceName("refs/heads/" + input.Ref)
|
||||
r, err := CloneIfRequired(ctx, refName, input, logger)
|
||||
|
||||
@@ -11,10 +11,8 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -305,61 +303,3 @@ func gitCmd(args ...string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestAcquireCloneLock(t *testing.T) {
|
||||
t.Run("same directory serializes", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
unlock1 := acquireCloneLock(dir)
|
||||
|
||||
secondAcquired := make(chan struct{})
|
||||
go func() {
|
||||
unlock := acquireCloneLock(dir)
|
||||
close(secondAcquired)
|
||||
unlock()
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-secondAcquired:
|
||||
t.Fatal("second acquire should block while first holds the lock")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
|
||||
unlock1()
|
||||
|
||||
select {
|
||||
case <-secondAcquired:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("second acquire should proceed after first releases the lock")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("different directories do not block", func(t *testing.T) {
|
||||
dirA := t.TempDir()
|
||||
dirB := t.TempDir()
|
||||
|
||||
unlockA := acquireCloneLock(dirA)
|
||||
defer unlockA()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
unlock := acquireCloneLock(dirB)
|
||||
unlock()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("acquire on a different directory must not block")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("same directory reuses the same mutex", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
v1, _ := cloneLocks.LoadOrStore(dir, &sync.Mutex{})
|
||||
v2, _ := cloneLocks.LoadOrStore(dir, &sync.Mutex{})
|
||||
require.Same(t, v1, v2)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"context"
|
||||
"io"
|
||||
|
||||
"gitea.com/gitea/act_runner/act/common"
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
|
||||
"github.com/docker/go-connections/nat"
|
||||
)
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/act_runner/act/common"
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
|
||||
"github.com/docker/cli/cli/config"
|
||||
"github.com/docker/cli/cli/config/credentials"
|
||||
|
||||
@@ -12,11 +12,10 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gitea.com/gitea/act_runner/act/common"
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/pkg/archive"
|
||||
// github.com/docker/docker/builder/dockerignore is deprecated
|
||||
"github.com/moby/buildkit/frontend/dockerfile/dockerignore"
|
||||
"github.com/moby/patternmatcher"
|
||||
)
|
||||
|
||||
@@ -9,7 +9,7 @@ package container
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gitea.com/gitea/act_runner/act/common"
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
)
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/act_runner/act/common"
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
|
||||
"github.com/distribution/reference"
|
||||
"github.com/docker/docker/api/types"
|
||||
|
||||
@@ -20,8 +20,8 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/act_runner/act/common"
|
||||
"gitea.com/gitea/act_runner/act/filecollector"
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/filecollector"
|
||||
|
||||
"github.com/Masterminds/semver"
|
||||
"github.com/docker/cli/cli/compose/loader"
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/act_runner/act/common"
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"context"
|
||||
"runtime"
|
||||
|
||||
"gitea.com/gitea/act_runner/act/common"
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
@@ -9,7 +9,7 @@ package container
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gitea.com/gitea/act_runner/act/common"
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/docker/docker/api/types/volume"
|
||||
|
||||
@@ -19,9 +19,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/act_runner/act/common"
|
||||
"gitea.com/gitea/act_runner/act/filecollector"
|
||||
"gitea.com/gitea/act_runner/act/lookpath"
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/filecollector"
|
||||
"gitea.com/gitea/runner/act/lookpath"
|
||||
|
||||
"github.com/go-git/go-billy/v5/helper/polyfill"
|
||||
"github.com/go-git/go-billy/v5/osfs"
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/act_runner/act/common"
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
)
|
||||
|
||||
func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Executor {
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/act_runner/act/model"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
|
||||
"github.com/go-git/go-git/v5/plumbing/format/gitignore"
|
||||
"github.com/rhysd/actionlint"
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.com/gitea/act_runner/act/model"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/act_runner/act/model"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
|
||||
"github.com/rhysd/actionlint"
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"gitea.com/gitea/act_runner/act/model"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/act_runner/act/common"
|
||||
"gitea.com/gitea/act_runner/act/common/git"
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/common/git"
|
||||
)
|
||||
|
||||
type GithubContext struct {
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/act_runner/act/common"
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"go.yaml.in/yaml/v4"
|
||||
|
||||
@@ -18,9 +18,9 @@ import (
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/act_runner/act/common"
|
||||
"gitea.com/gitea/act_runner/act/container"
|
||||
"gitea.com/gitea/act_runner/act/model"
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/container"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
|
||||
"github.com/kballard/go-shellquote"
|
||||
)
|
||||
@@ -429,7 +429,7 @@ func newStepContainer(ctx context.Context, step step, image string, cmd, entrypo
|
||||
Image: image,
|
||||
Username: rc.Config.Secrets["DOCKER_USERNAME"],
|
||||
Password: rc.Config.Secrets["DOCKER_PASSWORD"],
|
||||
Name: createContainerName(rc.jobContainerName(), "STEP-"+stepModel.ID),
|
||||
Name: createSimpleContainerName(rc.jobContainerName(), "STEP-"+stepModel.ID),
|
||||
Env: envList,
|
||||
Mounts: mounts,
|
||||
NetworkMode: networkMode,
|
||||
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/act_runner/act/common"
|
||||
"gitea.com/gitea/act_runner/act/model"
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
)
|
||||
|
||||
func evaluateCompositeInputAndEnv(ctx context.Context, parent *RunContext, step actionStep) map[string]string {
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.com/gitea/act_runner/act/model"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/act_runner/act/common"
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
)
|
||||
|
||||
var commandPatternGA *regexp.Regexp
|
||||
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"gitea.com/gitea/act_runner/act/common"
|
||||
"gitea.com/gitea/act_runner/act/model"
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
|
||||
"github.com/sirupsen/logrus/hooks/test"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"context"
|
||||
"io"
|
||||
|
||||
"gitea.com/gitea/act_runner/act/common"
|
||||
"gitea.com/gitea/act_runner/act/container"
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/container"
|
||||
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
@@ -15,10 +15,10 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/act_runner/act/common"
|
||||
"gitea.com/gitea/act_runner/act/container"
|
||||
"gitea.com/gitea/act_runner/act/exprparser"
|
||||
"gitea.com/gitea/act_runner/act/model"
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/container"
|
||||
"gitea.com/gitea/runner/act/exprparser"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
|
||||
_ "embed"
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"gitea.com/gitea/act_runner/act/exprparser"
|
||||
"gitea.com/gitea/act_runner/act/model"
|
||||
"gitea.com/gitea/runner/act/exprparser"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
|
||||
assert "github.com/stretchr/testify/assert"
|
||||
yaml "go.yaml.in/yaml/v4"
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/act_runner/act/common"
|
||||
"gitea.com/gitea/act_runner/act/model"
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
)
|
||||
|
||||
type jobInfo interface {
|
||||
@@ -137,7 +137,7 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
||||
// if !rc.IsHostEnv(ctx) && rc.Config.ContainerNetworkMode == "" {
|
||||
// // clean network in docker mode only
|
||||
// // if the value of `ContainerNetworkMode` is empty string,
|
||||
// // it means that the network to which containers are connecting is created by `act_runner`,
|
||||
// // it means that the network to which containers are connecting is created by `runner`,
|
||||
// // so, we should remove the network at last.
|
||||
// networkName, _ := rc.networkName()
|
||||
// logger.Infof("Cleaning up network for job %s, and network name is: %s", rc.JobName, networkName)
|
||||
|
||||
@@ -12,9 +12,9 @@ import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"gitea.com/gitea/act_runner/act/common"
|
||||
"gitea.com/gitea/act_runner/act/container"
|
||||
"gitea.com/gitea/act_runner/act/model"
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/container"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/act_runner/act/filecollector"
|
||||
"gitea.com/gitea/runner/act/filecollector"
|
||||
)
|
||||
|
||||
type LocalRepositoryCache struct {
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gitea.com/gitea/act_runner/act/common"
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"golang.org/x/term"
|
||||
@@ -30,11 +30,6 @@ const (
|
||||
gray = 37
|
||||
)
|
||||
|
||||
const (
|
||||
rawOutputField = "raw_output"
|
||||
scriptLineCyanField = "script_line_cyan"
|
||||
)
|
||||
|
||||
var (
|
||||
colors []int
|
||||
nextColor int
|
||||
@@ -166,8 +161,6 @@ func withStepLogger(ctx context.Context, stepNumber int, stepID, stepName, stage
|
||||
|
||||
type entryProcessor func(entry *logrus.Entry) *logrus.Entry
|
||||
|
||||
// valueMasker applies secrets and ::add-mask:: patterns to every log entry, including
|
||||
// raw_output (command/stream) lines; there is no bypass by field.
|
||||
func valueMasker(insecureSecrets bool, secrets map[string]string) entryProcessor {
|
||||
return func(entry *logrus.Entry) *logrus.Entry {
|
||||
if insecureSecrets {
|
||||
@@ -234,12 +227,8 @@ func (f *jobLogFormatter) printColored(b *bytes.Buffer, entry *logrus.Entry) {
|
||||
debugFlag = "[DEBUG] "
|
||||
}
|
||||
|
||||
if entry.Data[rawOutputField] == true {
|
||||
if entry.Data[scriptLineCyanField] == true {
|
||||
fmt.Fprintf(b, "\x1b[%dm|\x1b[0m \x1b[36;1m%s\x1b[0m", f.color, entry.Message)
|
||||
} else {
|
||||
fmt.Fprintf(b, "\x1b[%dm|\x1b[0m %s", f.color, entry.Message)
|
||||
}
|
||||
if entry.Data["raw_output"] == true {
|
||||
fmt.Fprintf(b, "\x1b[%dm|\x1b[0m %s", f.color, entry.Message)
|
||||
} else if entry.Data["dryrun"] == true {
|
||||
fmt.Fprintf(b, "\x1b[1m\x1b[%dm\x1b[7m*DRYRUN*\x1b[0m \x1b[%dm[%s] \x1b[0m%s%s", gray, f.color, job, debugFlag, entry.Message)
|
||||
} else {
|
||||
@@ -262,7 +251,7 @@ func (f *jobLogFormatter) print(b *bytes.Buffer, entry *logrus.Entry) {
|
||||
debugFlag = "[DEBUG] "
|
||||
}
|
||||
|
||||
if entry.Data[rawOutputField] == true {
|
||||
if entry.Data["raw_output"] == true {
|
||||
fmt.Fprintf(b, "[%s] | %s", job, entry.Message)
|
||||
} else if entry.Data["dryrun"] == true {
|
||||
fmt.Fprintf(b, "*DRYRUN* [%s] %s%s", job, debugFlag, entry.Message)
|
||||
|
||||
@@ -6,7 +6,7 @@ package runner
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.com/gitea/act_runner/act/model"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"go.yaml.in/yaml/v4"
|
||||
|
||||
@@ -17,9 +17,9 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gitea.com/gitea/act_runner/act/common"
|
||||
"gitea.com/gitea/act_runner/act/common/git"
|
||||
"gitea.com/gitea/act_runner/act/model"
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/common/git"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
)
|
||||
|
||||
func newLocalReusableWorkflowExecutor(rc *RunContext) common.Executor {
|
||||
|
||||
@@ -23,10 +23,10 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/act_runner/act/common"
|
||||
"gitea.com/gitea/act_runner/act/container"
|
||||
"gitea.com/gitea/act_runner/act/exprparser"
|
||||
"gitea.com/gitea/act_runner/act/model"
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/container"
|
||||
"gitea.com/gitea/runner/act/exprparser"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
|
||||
"github.com/docker/go-connections/nat"
|
||||
"github.com/opencontainers/selinux/go-selinux"
|
||||
@@ -101,7 +101,8 @@ func (rc *RunContext) jobContainerName() string {
|
||||
if rc.caller != nil {
|
||||
nameParts = append(nameParts, "CALLED-BY-"+rc.caller.runContext.JobName)
|
||||
}
|
||||
return createContainerName(nameParts...) // For Gitea
|
||||
// return createSimpleContainerName(rc.Config.ContainerNamePrefix, "WORKFLOW-"+rc.Run.Workflow.Name, "JOB-"+rc.Name)
|
||||
return createSimpleContainerName(nameParts...) // For Gitea
|
||||
}
|
||||
|
||||
// networkNameForGitea return the name of the network
|
||||
@@ -382,7 +383,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
||||
if createAndDeleteNetwork {
|
||||
// clean network if it has been created by act
|
||||
// if using service containers
|
||||
// it means that the network to which containers are connecting is created by `act_runner`,
|
||||
// it means that the network to which containers are connecting is created by `runner`,
|
||||
// so, we should remove the network at last.
|
||||
logger.Infof("Cleaning up network for job %s, and network name is: %s", rc.JobName, networkName)
|
||||
if err := container.NewDockerNetworkRemoveExecutor(networkName)(ctx); err != nil {
|
||||
@@ -768,6 +769,7 @@ func mergeMaps(maps ...map[string]string) map[string]string {
|
||||
return rtnMap
|
||||
}
|
||||
|
||||
// Deprecated: use createSimpleContainerName
|
||||
func createContainerName(parts ...string) string {
|
||||
name := strings.Join(parts, "-")
|
||||
pattern := regexp.MustCompile("[^a-zA-Z0-9]")
|
||||
@@ -781,6 +783,22 @@ func createContainerName(parts ...string) string {
|
||||
return fmt.Sprintf("%s-%x", trimmedName, hash)
|
||||
}
|
||||
|
||||
func createSimpleContainerName(parts ...string) string {
|
||||
pattern := regexp.MustCompile("[^a-zA-Z0-9-]")
|
||||
name := make([]string, 0, len(parts))
|
||||
for _, v := range parts {
|
||||
v = pattern.ReplaceAllString(v, "-")
|
||||
v = strings.Trim(v, "-")
|
||||
for strings.Contains(v, "--") {
|
||||
v = strings.ReplaceAll(v, "--", "-")
|
||||
}
|
||||
if v != "" {
|
||||
name = append(name, v)
|
||||
}
|
||||
}
|
||||
return strings.Join(name, "_")
|
||||
}
|
||||
|
||||
func trimToLen(s string, l int) string {
|
||||
if l < 0 {
|
||||
l = 0
|
||||
|
||||
@@ -12,8 +12,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.com/gitea/act_runner/act/exprparser"
|
||||
"gitea.com/gitea/act_runner/act/model"
|
||||
"gitea.com/gitea/runner/act/exprparser"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
assert "github.com/stretchr/testify/assert"
|
||||
@@ -622,16 +622,23 @@ func TestRunContextGetEnv(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateContainerNameBoundedForLongMatrixInput(t *testing.T) {
|
||||
longMatrixValue := strings.Repeat("os=ubuntu-latest-go=1.24-node=22-", 20)
|
||||
name := createContainerName(
|
||||
"gitea",
|
||||
"WORKFLOW-super-long-workflow-name",
|
||||
"JOB-build-matrix-"+longMatrixValue,
|
||||
)
|
||||
|
||||
assert.LessOrEqual(t, len(name), 128)
|
||||
assert.LessOrEqual(t, len(name+"-env"), 255)
|
||||
assert.LessOrEqual(t, len(name+"-network"), 255)
|
||||
assert.LessOrEqual(t, len(name+"-job1234567890"), 255)
|
||||
func Test_createSimpleContainerName(t *testing.T) {
|
||||
tests := []struct {
|
||||
parts []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
parts: []string{"a--a", "BB正", "c-C"},
|
||||
want: "a-a_BB_c-C",
|
||||
},
|
||||
{
|
||||
parts: []string{"a-a", "", "-"},
|
||||
want: "a-a",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(strings.Join(tt.parts, " "), func(t *testing.T) {
|
||||
assert.Equalf(t, tt.want, createSimpleContainerName(tt.parts...), "createSimpleContainerName(%v)", tt.parts)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/act_runner/act/common"
|
||||
"gitea.com/gitea/act_runner/act/model"
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
|
||||
docker_container "github.com/docker/docker/api/types/container"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
@@ -16,8 +16,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.com/gitea/act_runner/act/common"
|
||||
"gitea.com/gitea/act_runner/act/model"
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
@@ -13,10 +13,10 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/act_runner/act/common"
|
||||
"gitea.com/gitea/act_runner/act/container"
|
||||
"gitea.com/gitea/act_runner/act/exprparser"
|
||||
"gitea.com/gitea/act_runner/act/model"
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/container"
|
||||
"gitea.com/gitea/runner/act/exprparser"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
)
|
||||
|
||||
type step interface {
|
||||
|
||||
@@ -15,8 +15,8 @@ import (
|
||||
"path"
|
||||
"path/filepath"
|
||||
|
||||
"gitea.com/gitea/act_runner/act/common"
|
||||
"gitea.com/gitea/act_runner/act/model"
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
)
|
||||
|
||||
type stepActionLocal struct {
|
||||
@@ -44,10 +44,6 @@ func (sal *stepActionLocal) main() common.Executor {
|
||||
return nil
|
||||
}
|
||||
|
||||
printRunActionHeader(ctx, sal.Step, sal.env, sal.getRunContext())
|
||||
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
|
||||
defer rawLogger.Infof("::endgroup::")
|
||||
|
||||
actionDir := filepath.Join(sal.getRunContext().Config.Workdir, sal.Step.Uses)
|
||||
|
||||
localReader := func(ctx context.Context) actionYamlReader {
|
||||
|
||||
@@ -12,8 +12,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.com/gitea/act_runner/act/common"
|
||||
"gitea.com/gitea/act_runner/act/model"
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
|
||||
@@ -16,9 +16,9 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/act_runner/act/common"
|
||||
"gitea.com/gitea/act_runner/act/common/git"
|
||||
"gitea.com/gitea/act_runner/act/model"
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/common/git"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
|
||||
gogit "github.com/go-git/go-git/v5"
|
||||
)
|
||||
@@ -166,10 +166,6 @@ func (sar *stepActionRemote) main() common.Executor {
|
||||
return common.NewPipelineExecutor(
|
||||
sar.prepareActionExecutor(),
|
||||
runStepExecutor(sar, stepStageMain, func(ctx context.Context) error {
|
||||
printRunActionHeader(ctx, sar.Step, sar.env, sar.RunContext)
|
||||
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
|
||||
defer rawLogger.Infof("::endgroup::")
|
||||
|
||||
github := sar.getGithubContext(ctx)
|
||||
if sar.remoteAction.IsCheckout() && isLocalCheckout(github, sar.Step) && !sar.RunContext.Config.NoSkipCheckout {
|
||||
if sar.RunContext.Config.BindWorkdir {
|
||||
|
||||
@@ -14,9 +14,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/act_runner/act/common"
|
||||
"gitea.com/gitea/act_runner/act/common/git"
|
||||
"gitea.com/gitea/act_runner/act/model"
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/common/git"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
|
||||
@@ -9,9 +9,9 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/act_runner/act/common"
|
||||
"gitea.com/gitea/act_runner/act/container"
|
||||
"gitea.com/gitea/act_runner/act/model"
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/container"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
|
||||
"github.com/kballard/go-shellquote"
|
||||
)
|
||||
@@ -116,10 +116,6 @@ func (sd *stepDocker) newStepContainer(ctx context.Context, image string, cmd, e
|
||||
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_TEMP", "/tmp"))
|
||||
|
||||
binds, mounts := rc.GetBindsAndMounts()
|
||||
networkMode := "container:" + rc.jobContainerName()
|
||||
if rc.IsHostEnv(ctx) {
|
||||
networkMode = "default"
|
||||
}
|
||||
stepContainer := ContainerNewContainer(&container.NewContainerInput{
|
||||
Cmd: cmd,
|
||||
Entrypoint: entrypoint,
|
||||
@@ -127,10 +123,10 @@ func (sd *stepDocker) newStepContainer(ctx context.Context, image string, cmd, e
|
||||
Image: image,
|
||||
Username: rc.Config.Secrets["DOCKER_USERNAME"],
|
||||
Password: rc.Config.Secrets["DOCKER_PASSWORD"],
|
||||
Name: createContainerName(rc.jobContainerName(), "STEP-"+step.ID),
|
||||
Name: createSimpleContainerName(rc.jobContainerName(), "STEP-"+step.ID),
|
||||
Env: envList,
|
||||
Mounts: mounts,
|
||||
NetworkMode: networkMode,
|
||||
NetworkMode: "container:" + rc.jobContainerName(),
|
||||
Binds: binds,
|
||||
Stdout: logWriter,
|
||||
Stderr: logWriter,
|
||||
|
||||
@@ -8,11 +8,10 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.com/gitea/act_runner/act/container"
|
||||
"gitea.com/gitea/act_runner/act/model"
|
||||
"gitea.com/gitea/runner/act/container"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
@@ -119,81 +118,3 @@ func TestStepDockerPrePost(t *testing.T) {
|
||||
err = sd.post()(ctx)
|
||||
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
}
|
||||
|
||||
func TestStepDockerNewStepContainerNetworkMode(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
platform string
|
||||
expectDefault bool
|
||||
}{
|
||||
{
|
||||
name: "docker mode attaches to job container network",
|
||||
platform: "node:14",
|
||||
expectDefault: false,
|
||||
},
|
||||
{
|
||||
name: "host mode uses default network",
|
||||
platform: "-self-hosted",
|
||||
expectDefault: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cm := &containerMock{}
|
||||
|
||||
var captured *container.NewContainerInput
|
||||
origContainerNewContainer := ContainerNewContainer
|
||||
ContainerNewContainer = func(input *container.NewContainerInput) container.ExecutionsEnvironment {
|
||||
captured = input
|
||||
return cm
|
||||
}
|
||||
defer func() {
|
||||
ContainerNewContainer = origContainerNewContainer
|
||||
}()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
platform := tc.platform
|
||||
sd := &stepDocker{
|
||||
RunContext: &RunContext{
|
||||
StepResults: map[string]*model.StepResult{},
|
||||
Config: &Config{
|
||||
PlatformPicker: func(_ []string) string {
|
||||
return platform
|
||||
},
|
||||
},
|
||||
Run: &model.Run{
|
||||
JobID: "1",
|
||||
Workflow: &model.Workflow{
|
||||
Jobs: map[string]*model.Job{
|
||||
"1": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
JobContainer: cm,
|
||||
},
|
||||
Step: &model.Step{
|
||||
ID: "1",
|
||||
Uses: "docker://alpine:3.20",
|
||||
},
|
||||
}
|
||||
sd.RunContext.ExprEval = sd.RunContext.NewExpressionEvaluator(ctx)
|
||||
|
||||
assert.Equal(t, tc.expectDefault, sd.RunContext.IsHostEnv(ctx),
|
||||
"IsHostEnv mismatch for platform %q", tc.platform)
|
||||
|
||||
_ = sd.newStepContainer(ctx, "alpine:3.20", []string{"echo", "hello"}, nil)
|
||||
|
||||
if tc.expectDefault {
|
||||
assert.Equal(t, "default", captured.NetworkMode,
|
||||
"host-mode step container must use 'default' network, got %q",
|
||||
captured.NetworkMode)
|
||||
} else {
|
||||
assert.True(t, strings.HasPrefix(captured.NetworkMode, "container:"),
|
||||
"docker-mode step container must attach to job container network, got %q",
|
||||
captured.NetworkMode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ package runner
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gitea.com/gitea/act_runner/act/model"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
)
|
||||
|
||||
type stepFactory interface {
|
||||
|
||||
@@ -7,7 +7,7 @@ package runner
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.com/gitea/act_runner/act/model"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -9,27 +9,23 @@ import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/act_runner/act/common"
|
||||
"gitea.com/gitea/act_runner/act/container"
|
||||
"gitea.com/gitea/act_runner/act/lookpath"
|
||||
"gitea.com/gitea/act_runner/act/model"
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/container"
|
||||
"gitea.com/gitea/runner/act/lookpath"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
|
||||
"github.com/kballard/go-shellquote"
|
||||
yaml "go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
type stepRun struct {
|
||||
Step *model.Step
|
||||
RunContext *RunContext
|
||||
cmd []string
|
||||
cmdline string
|
||||
env map[string]string
|
||||
WorkingDirectory string
|
||||
interpolatedScript string
|
||||
shellCommand string
|
||||
Step *model.Step
|
||||
RunContext *RunContext
|
||||
cmd []string
|
||||
cmdline string
|
||||
env map[string]string
|
||||
WorkingDirectory string
|
||||
}
|
||||
|
||||
func (sr *stepRun) pre() common.Executor {
|
||||
@@ -43,154 +39,15 @@ func (sr *stepRun) main() common.Executor {
|
||||
return runStepExecutor(sr, stepStageMain, common.NewPipelineExecutor(
|
||||
sr.setupShellCommandExecutor(),
|
||||
func(ctx context.Context) error {
|
||||
rc := sr.getRunContext()
|
||||
// Apply ::add-path:: effects before printing so PATH is accurate in the env: block.
|
||||
rc.ApplyExtraPath(ctx, &sr.env)
|
||||
sr.printRunScriptActionDetails(ctx)
|
||||
if he, ok := rc.JobContainer.(*container.HostEnvironment); ok && he != nil {
|
||||
sr.getRunContext().ApplyExtraPath(ctx, &sr.env)
|
||||
if he, ok := sr.getRunContext().JobContainer.(*container.HostEnvironment); ok && he != nil {
|
||||
return he.ExecWithCmdLine(sr.cmd, sr.cmdline, sr.env, "", sr.WorkingDirectory)(ctx)
|
||||
}
|
||||
return rc.JobContainer.Exec(sr.cmd, sr.env, "", sr.WorkingDirectory)(ctx)
|
||||
return sr.getRunContext().JobContainer.Exec(sr.cmd, sr.env, "", sr.WorkingDirectory)(ctx)
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
// printRunScriptActionDetails mirrors actions/runner ScriptHandler.PrintActionDetails
|
||||
// for script steps.
|
||||
func (sr *stepRun) printRunScriptActionDetails(ctx context.Context) {
|
||||
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
|
||||
scriptLineLogger := rawLogger.WithField(scriptLineCyanField, true)
|
||||
|
||||
normalized := strings.TrimRight(strings.ReplaceAll(sr.interpolatedScript, "\r\n", "\n"), "\n")
|
||||
|
||||
rawLogger.Infof("::group::Run %s", sr.runScriptGroupTitle(normalized))
|
||||
|
||||
if normalized != "" {
|
||||
for line := range strings.SplitSeq(normalized, "\n") {
|
||||
scriptLineLogger.Info(line)
|
||||
}
|
||||
}
|
||||
|
||||
rawLogger.Infof("shell: %s", sr.shellCommand)
|
||||
|
||||
printStepEnvBlock(ctx, sr.Step, sr.env, sr.getRunContext())
|
||||
rawLogger.Infof("::endgroup::")
|
||||
}
|
||||
|
||||
// printRunActionHeader mirrors actions/runner's "Run <action>" header for `uses:` steps,
|
||||
// including the with: inputs and the step-level env: block. The caller is responsible
|
||||
// for emitting ::endgroup:: after the action finishes.
|
||||
func printRunActionHeader(ctx context.Context, step *model.Step, env map[string]string, rc *RunContext) {
|
||||
if step == nil {
|
||||
return
|
||||
}
|
||||
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
|
||||
|
||||
title := step.Uses
|
||||
if step.Name != "" {
|
||||
title = step.Name
|
||||
}
|
||||
rawLogger.Infof("::group::Run %s", title)
|
||||
|
||||
if len(step.With) > 0 {
|
||||
rawLogger.Infof("with:")
|
||||
for _, k := range slices.Sorted(maps.Keys(step.With)) {
|
||||
rawLogger.Infof(" %s: %s", k, step.With[k])
|
||||
}
|
||||
}
|
||||
|
||||
printStepEnvBlock(ctx, step, env, rc)
|
||||
}
|
||||
|
||||
// printStepEnvBlock emits the declared-env block (YAML order, internal vars filtered)
|
||||
// shared by the run: and uses: "Run" headers.
|
||||
func printStepEnvBlock(ctx context.Context, step *model.Step, env map[string]string, rc *RunContext) {
|
||||
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
|
||||
caseInsensitive := rc != nil && rc.JobContainer != nil && rc.JobContainer.IsEnvironmentCaseInsensitive()
|
||||
var visible []string
|
||||
for _, k := range stepDeclaredEnvKeysInOrder(step) {
|
||||
if !isInternalEnvKey(k, caseInsensitive) {
|
||||
visible = append(visible, k)
|
||||
}
|
||||
}
|
||||
if len(visible) == 0 {
|
||||
return
|
||||
}
|
||||
rawLogger.Infof("env:")
|
||||
envLookup := env
|
||||
if caseInsensitive {
|
||||
envLookup = make(map[string]string, len(env))
|
||||
for k, v := range env {
|
||||
envLookup[strings.ToUpper(k)] = v
|
||||
}
|
||||
}
|
||||
for _, k := range visible {
|
||||
lookupKey := k
|
||||
if caseInsensitive {
|
||||
lookupKey = strings.ToUpper(k)
|
||||
}
|
||||
rawLogger.Infof(" %s: %s", k, envLookup[lookupKey])
|
||||
}
|
||||
}
|
||||
|
||||
// isInternalEnvKey matches actions/runner's filtered set of vars that are hidden
|
||||
// from the "Run" header's env: block because they are injected by the runner itself.
|
||||
func isInternalEnvKey(k string, caseInsensitive bool) bool {
|
||||
upper := k
|
||||
if caseInsensitive {
|
||||
upper = strings.ToUpper(k)
|
||||
}
|
||||
switch upper {
|
||||
case "PATH", "HOME", "CI":
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(upper, "GITHUB_") ||
|
||||
strings.HasPrefix(upper, "GITEA_") ||
|
||||
strings.HasPrefix(upper, "RUNNER_") ||
|
||||
strings.HasPrefix(upper, "INPUT_")
|
||||
}
|
||||
|
||||
func (sr *stepRun) runScriptGroupTitle(normalizedScript string) string {
|
||||
trimmed := strings.TrimLeft(normalizedScript, " \t\r\n")
|
||||
if idx := strings.IndexAny(trimmed, "\r\n"); idx >= 0 {
|
||||
trimmed = trimmed[:idx]
|
||||
}
|
||||
if trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
if sr.Step != nil {
|
||||
if sr.Step.Name != "" {
|
||||
return sr.Step.Name
|
||||
}
|
||||
return sr.Step.ID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// stepDeclaredEnvKeysInOrder walks the raw YAML Env mapping so keys are emitted in
|
||||
// the order the workflow author wrote them; step.Environment() decodes into a Go map
|
||||
// and loses ordering.
|
||||
func stepDeclaredEnvKeysInOrder(step *model.Step) []string {
|
||||
if step == nil || step.Env.Kind != yaml.MappingNode {
|
||||
return nil
|
||||
}
|
||||
content := step.Env.Content
|
||||
keys := make([]string, 0, len(content)/2)
|
||||
seen := make(map[string]struct{}, len(content)/2)
|
||||
for i := 0; i+1 < len(content); i += 2 {
|
||||
k := content[i]
|
||||
if k.Kind != yaml.ScalarNode || k.Tag == "!!merge" || k.Value == "<<" {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[k.Value]; dup {
|
||||
continue
|
||||
}
|
||||
seen[k.Value] = struct{}{}
|
||||
keys = append(keys, k.Value)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func (sr *stepRun) post() common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
return nil
|
||||
@@ -254,10 +111,8 @@ func (sr *stepRun) setupShellCommand(ctx context.Context) (name, script string,
|
||||
step := sr.Step
|
||||
|
||||
script = sr.RunContext.NewStepExpressionEvaluator(ctx, sr).Interpolate(ctx, step.Run)
|
||||
sr.interpolatedScript = script
|
||||
|
||||
scCmd := step.ShellCommand()
|
||||
sr.shellCommand = scCmd
|
||||
|
||||
name = getScriptName(sr.RunContext, step)
|
||||
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2026 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package runner
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.com/gitea/act_runner/act/common"
|
||||
"gitea.com/gitea/act_runner/act/model"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
yaml "go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
func TestRunScriptGroupTitle(t *testing.T) {
|
||||
sr := &stepRun{Step: &model.Step{Name: "Build"}}
|
||||
assert.Equal(t, "make build", sr.runScriptGroupTitle("make build"))
|
||||
assert.Equal(t, "echo one", sr.runScriptGroupTitle(" \techo one\necho two"))
|
||||
assert.Equal(t, "Build", sr.runScriptGroupTitle(""))
|
||||
|
||||
sr = &stepRun{Step: &model.Step{ID: "s1"}}
|
||||
assert.Equal(t, "s1", sr.runScriptGroupTitle("\n \n"))
|
||||
}
|
||||
|
||||
func TestStepDeclaredEnvOrderPreservesYAML(t *testing.T) {
|
||||
raw := `id: s1
|
||||
run: "echo 1"
|
||||
env:
|
||||
GITHUB_TOKEN: tok
|
||||
PATH: /custom/bin
|
||||
MY_VAR: hello
|
||||
`
|
||||
var step model.Step
|
||||
require.NoError(t, yaml.Unmarshal([]byte(raw), &step))
|
||||
assert.Equal(t, []string{"GITHUB_TOKEN", "PATH", "MY_VAR"}, stepDeclaredEnvKeysInOrder(&step))
|
||||
}
|
||||
|
||||
func TestStepDeclaredEnvKeysInOrderEmpty(t *testing.T) {
|
||||
assert.Nil(t, stepDeclaredEnvKeysInOrder(nil))
|
||||
assert.Empty(t, stepDeclaredEnvKeysInOrder(&model.Step{}))
|
||||
}
|
||||
|
||||
func TestStepDeclaredEnvKeysIgnoreYAMLMergeKey(t *testing.T) {
|
||||
doc := `
|
||||
common: &common
|
||||
COMMON_A: a
|
||||
COMMON_B: b
|
||||
step:
|
||||
env:
|
||||
LOCAL_BEFORE: before
|
||||
<<: *common
|
||||
COMMON_B: overridden
|
||||
LOCAL_AFTER: after
|
||||
`
|
||||
var root struct {
|
||||
Step model.Step `yaml:"step"`
|
||||
}
|
||||
require.NoError(t, yaml.Unmarshal([]byte(doc), &root))
|
||||
|
||||
keys := stepDeclaredEnvKeysInOrder(&root.Step)
|
||||
assert.Equal(t, []string{"LOCAL_BEFORE", "COMMON_B", "LOCAL_AFTER"}, keys)
|
||||
}
|
||||
|
||||
func TestPrintRunScriptActionDetailsGolden(t *testing.T) {
|
||||
raw := `id: s1
|
||||
name: Build
|
||||
run: |
|
||||
echo one
|
||||
echo two
|
||||
shell: pwsh
|
||||
env:
|
||||
PATH_PREFIX: /custom/bin
|
||||
GITHUB_TOKEN: tok
|
||||
GREETING: hello
|
||||
`
|
||||
var step model.Step
|
||||
require.NoError(t, yaml.Unmarshal([]byte(raw), &step))
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
logger := logrus.New()
|
||||
logger.SetOutput(buf)
|
||||
logger.SetLevel(logrus.InfoLevel)
|
||||
logger.SetFormatter(&jobLogFormatter{color: cyan})
|
||||
entry := logger.WithFields(logrus.Fields{"job": "j1"})
|
||||
ctx := common.WithLogger(context.Background(), entry)
|
||||
|
||||
sr := &stepRun{
|
||||
Step: &step,
|
||||
RunContext: &RunContext{},
|
||||
shellCommand: "pwsh -command . '{0}'",
|
||||
interpolatedScript: "echo one\necho two\n",
|
||||
env: map[string]string{
|
||||
"PATH_PREFIX": "/custom/bin",
|
||||
"GITHUB_TOKEN": "tok",
|
||||
"GREETING": "hello",
|
||||
},
|
||||
}
|
||||
|
||||
sr.printRunScriptActionDetails(ctx)
|
||||
|
||||
want := strings.Join([]string{
|
||||
"[j1] | ::group::Run echo one",
|
||||
"[j1] | echo one",
|
||||
"[j1] | echo two",
|
||||
"[j1] | shell: pwsh -command . '{0}'",
|
||||
"[j1] | env:",
|
||||
"[j1] | PATH_PREFIX: /custom/bin",
|
||||
"[j1] | GREETING: hello",
|
||||
"[j1] | ::endgroup::",
|
||||
"",
|
||||
}, "\n")
|
||||
assert.Equal(t, want, buf.String())
|
||||
}
|
||||
|
||||
func TestPrintRunActionHeaderGolden(t *testing.T) {
|
||||
raw := `id: s1
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: "0"
|
||||
token: secret
|
||||
env:
|
||||
CUSTOM: value
|
||||
GITHUB_TOKEN: tok
|
||||
`
|
||||
var step model.Step
|
||||
require.NoError(t, yaml.Unmarshal([]byte(raw), &step))
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
logger := logrus.New()
|
||||
logger.SetOutput(buf)
|
||||
logger.SetLevel(logrus.InfoLevel)
|
||||
logger.SetFormatter(&jobLogFormatter{color: cyan})
|
||||
entry := logger.WithFields(logrus.Fields{"job": "j1"})
|
||||
ctx := common.WithLogger(context.Background(), entry)
|
||||
|
||||
printRunActionHeader(ctx, &step, map[string]string{"CUSTOM": "value", "GITHUB_TOKEN": "tok"}, &RunContext{})
|
||||
|
||||
want := strings.Join([]string{
|
||||
"[j1] | ::group::Run actions/checkout@v4",
|
||||
"[j1] | with:",
|
||||
"[j1] | fetch-depth: 0",
|
||||
"[j1] | token: secret",
|
||||
"[j1] | env:",
|
||||
"[j1] | CUSTOM: value",
|
||||
"",
|
||||
}, "\n")
|
||||
assert.Equal(t, want, buf.String())
|
||||
}
|
||||
|
||||
func TestIsInternalEnvKey(t *testing.T) {
|
||||
for _, k := range []string{"PATH", "HOME", "CI", "GITHUB_TOKEN", "GITEA_ACTIONS", "RUNNER_OS", "INPUT_FOO"} {
|
||||
assert.True(t, isInternalEnvKey(k, false), k)
|
||||
}
|
||||
for _, k := range []string{"PATH_PREFIX", "MY_VAR", "GREETING", "HOMEPAGE"} {
|
||||
assert.False(t, isInternalEnvKey(k, false), k)
|
||||
}
|
||||
assert.True(t, isInternalEnvKey("path", true))
|
||||
assert.False(t, isInternalEnvKey("path", false))
|
||||
}
|
||||
|
||||
func TestPrintColoredScriptLineCyan(t *testing.T) {
|
||||
f := &jobLogFormatter{color: cyan}
|
||||
entry := &logrus.Entry{
|
||||
Level: logrus.InfoLevel,
|
||||
Message: "echo one",
|
||||
Data: logrus.Fields{
|
||||
"job": "j1",
|
||||
rawOutputField: true,
|
||||
scriptLineCyanField: true,
|
||||
},
|
||||
}
|
||||
buf := &bytes.Buffer{}
|
||||
f.printColored(buf, entry)
|
||||
assert.Equal(t, "\x1b[36m|\x1b[0m \x1b[36;1mecho one\x1b[0m", buf.String())
|
||||
}
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"gitea.com/gitea/act_runner/act/container"
|
||||
"gitea.com/gitea/act_runner/act/model"
|
||||
"gitea.com/gitea/runner/act/container"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"gitea.com/gitea/act_runner/act/common"
|
||||
"gitea.com/gitea/act_runner/act/model"
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Usage Examples for `act_runner`
|
||||
# Usage Examples for `runner`
|
||||
|
||||
Welcome to our collection of usage and deployment examples specifically designed for Gitea setups. Whether you're a beginner or an experienced user, you'll find practical resources here that you can directly apply to enhance your Gitea experience. We encourage you to contribute your own insights and knowledge to make this collection even more comprehensive and valuable.
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
### Running `act_runner` using `docker-compose`
|
||||
### Running `runner` using `docker-compose`
|
||||
|
||||
```yml
|
||||
...
|
||||
@@ -19,15 +19,15 @@
|
||||
# - GITEA_RUNNER_REGISTRATION_TOKEN=<user-defined registration token>
|
||||
|
||||
runner:
|
||||
image: gitea/act_runner
|
||||
image: gitea/runner
|
||||
restart: always
|
||||
depends_on:
|
||||
gitea:
|
||||
# required so runner can attach to gitea, see "healthcheck"
|
||||
condition: service_healthy
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
volumes:
|
||||
- ./data/act_runner:/data
|
||||
- ./data/runner:/data
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
environment:
|
||||
- GITEA_INSTANCE_URL=<instance url>
|
||||
@@ -38,18 +38,18 @@
|
||||
- GITEA_RUNNER_REGISTRATION_TOKEN=<registration token>
|
||||
```
|
||||
|
||||
### Running `act_runner` using Docker-in-Docker (DIND)
|
||||
### Running `runner` using Docker-in-Docker (DIND)
|
||||
|
||||
```yml
|
||||
...
|
||||
runner:
|
||||
image: gitea/act_runner:latest-dind-rootless
|
||||
image: gitea/runner:latest-dind-rootless
|
||||
restart: always
|
||||
privileged: true
|
||||
depends_on:
|
||||
- gitea
|
||||
volumes:
|
||||
- ./data/act_runner:/data
|
||||
- ./data/runner:/data
|
||||
environment:
|
||||
- GITEA_INSTANCE_URL=<instance url>
|
||||
- DOCKER_HOST=unix:///var/run/user/1000/docker.sock
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
### Run `act_runner` in a Docker Container
|
||||
### Run `runner` in a Docker Container
|
||||
|
||||
```sh
|
||||
docker run -e GITEA_INSTANCE_URL=http://192.168.8.18:3000 -e GITEA_RUNNER_REGISTRATION_TOKEN=<runner_token> -v /var/run/docker.sock:/var/run/docker.sock -v $PWD/data:/data --name my_runner gitea/act_runner:nightly
|
||||
docker run -e GITEA_INSTANCE_URL=http://192.168.8.18:3000 -e GITEA_RUNNER_REGISTRATION_TOKEN=<runner_token> -v /var/run/docker.sock:/var/run/docker.sock -v $PWD/data:/data --name my_runner gitea/runner:nightly
|
||||
```
|
||||
|
||||
The `/data` directory inside the docker container contains the runner API keys after registration.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
## Kubernetes Docker in Docker Deployment with `act_runner`
|
||||
## Kubernetes Docker in Docker Deployment with `runner`
|
||||
|
||||
NOTE: Docker in Docker (dind) requires elevated privileges on Kubernetes. The current way to achieve this is to set the pod `SecurityContext` to `privileged`. Keep in mind that this is a potential security issue that has the potential for a malicious application to break out of the container context.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
kind: PersistentVolumeClaim
|
||||
apiVersion: v1
|
||||
metadata:
|
||||
name: act-runner-vol
|
||||
name: runner-vol
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
@@ -13,7 +13,7 @@ spec:
|
||||
apiVersion: v1
|
||||
data:
|
||||
# The registration token can be obtained from the web UI, API or command-line.
|
||||
# You can also set a pre-defined global runner registration token for the Gitea instance via
|
||||
# You can also set a pre-defined global runner registration token for the Gitea instance via
|
||||
# `GITEA_RUNNER_REGISTRATION_TOKEN`/`GITEA_RUNNER_REGISTRATION_TOKEN_FILE` environment variable.
|
||||
token: << base64 encoded registration token >>
|
||||
kind: Secret
|
||||
@@ -25,19 +25,19 @@ apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
labels:
|
||||
app: act-runner
|
||||
name: act-runner
|
||||
app: runner
|
||||
name: runner
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: act-runner
|
||||
app: runner
|
||||
strategy: {}
|
||||
template:
|
||||
metadata:
|
||||
creationTimestamp: null
|
||||
labels:
|
||||
app: act-runner
|
||||
app: runner
|
||||
spec:
|
||||
restartPolicy: Always
|
||||
volumes:
|
||||
@@ -45,10 +45,10 @@ spec:
|
||||
emptyDir: {}
|
||||
- name: runner-data
|
||||
persistentVolumeClaim:
|
||||
claimName: act-runner-vol
|
||||
claimName: runner-vol
|
||||
containers:
|
||||
- name: runner
|
||||
image: gitea/act_runner:nightly
|
||||
image: gitea/runner:nightly
|
||||
command: ["sh", "-c", "while ! nc -z localhost 2376 </dev/null; do echo 'waiting for docker daemon...'; sleep 5; done; /sbin/tini -- run.sh"]
|
||||
env:
|
||||
- name: DOCKER_HOST
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
kind: PersistentVolumeClaim
|
||||
apiVersion: v1
|
||||
metadata:
|
||||
name: act-runner-vol
|
||||
name: runner-vol
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
@@ -13,7 +13,7 @@ spec:
|
||||
apiVersion: v1
|
||||
data:
|
||||
# The registration token can be obtained from the web UI, API or command-line.
|
||||
# You can also set a pre-defined global runner registration token for the Gitea instance via
|
||||
# You can also set a pre-defined global runner registration token for the Gitea instance via
|
||||
# `GITEA_RUNNER_REGISTRATION_TOKEN`/`GITEA_RUNNER_REGISTRATION_TOKEN_FILE` environment variable.
|
||||
token: << base64 encoded registration token >>
|
||||
kind: Secret
|
||||
@@ -25,32 +25,32 @@ apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
labels:
|
||||
app: act-runner
|
||||
name: act-runner
|
||||
app: runner
|
||||
name: runner
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: act-runner
|
||||
app: runner
|
||||
strategy: {}
|
||||
template:
|
||||
metadata:
|
||||
creationTimestamp: null
|
||||
labels:
|
||||
app: act-runner
|
||||
app: runner
|
||||
spec:
|
||||
restartPolicy: Always
|
||||
volumes:
|
||||
- name: runner-data
|
||||
persistentVolumeClaim:
|
||||
claimName: act-runner-vol
|
||||
claimName: runner-vol
|
||||
securityContext:
|
||||
fsGroup: 1000
|
||||
containers:
|
||||
- name: runner
|
||||
image: gitea/act_runner:nightly-dind-rootless
|
||||
image: gitea/runner:nightly-dind-rootless
|
||||
imagePullPolicy: Always
|
||||
# command: ["sh", "-c", "while ! nc -z localhost 2376 </dev/null; do echo 'waiting for docker daemon...'; sleep 5; done; /sbin/tini -- /opt/act/run.sh"]
|
||||
# command: ["sh", "-c", "while ! nc -z localhost 2376 </dev/null; do echo 'waiting for docker daemon...'; sleep 5; done; /sbin/tini -- run.sh"]
|
||||
env:
|
||||
- name: DOCKER_HOST
|
||||
value: tcp://localhost:2376
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
## `act_runner` on Virtual or Physical Servers
|
||||
## `runner` on Virtual or Physical Servers
|
||||
|
||||
Files in this directory:
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
## Using Rootless Docker with`act_runner`
|
||||
## Using Rootless Docker with`runner`
|
||||
|
||||
Here is a simple example of how to set up `act_runner` with rootless Docker. It has been created with Debian, but other Linux should work the same way.
|
||||
Here is a simple example of how to set up `runner` with rootless Docker. It has been created with Debian, but other Linux should work the same way.
|
||||
|
||||
Note: This procedure needs a real login shell -- using `sudo su` or other method of accessing the account will fail some of the steps below.
|
||||
|
||||
As `root`:
|
||||
|
||||
- Create a user to run both `docker` and `act_runner`. In this example, we use a non-privileged account called `rootless`.
|
||||
- Create a user to run both `docker` and `runner`. In this example, we use a non-privileged account called `rootless`.
|
||||
|
||||
```bash
|
||||
useradd -m rootless
|
||||
@@ -38,36 +38,36 @@ export DOCKER_HOST=unix:///run/user/$(id -u)/docker.sock
|
||||
```
|
||||
|
||||
- Reboot. Ensure that the Docker process is working.
|
||||
- Create a directory for saving `act_runner` data between restarts
|
||||
- Create a directory for saving `runner` data between restarts
|
||||
|
||||
`mkdir /home/rootless/act_runner`
|
||||
`mkdir /home/rootless/runner`
|
||||
|
||||
- Register the runner from the data directory
|
||||
|
||||
```bash
|
||||
cd /home/rootless/act_runner
|
||||
act_runner register
|
||||
cd /home/rootless/runner
|
||||
runner register
|
||||
```
|
||||
|
||||
- Generate a `act_runner` configuration file in the data directory. Edit the file to adjust for the system.
|
||||
- Generate a `runner` configuration file in the data directory. Edit the file to adjust for the system.
|
||||
|
||||
```bash
|
||||
act_runner generate-config >/home/rootless/act_runner/config
|
||||
runner generate-config >/home/rootless/runner/config
|
||||
```
|
||||
|
||||
- Create a new user-level`systemd` unit file as `/home/rootless/.config/systemd/user/act_runner.service` with the following contents:
|
||||
- Create a new user-level`systemd` unit file as `/home/rootless/.config/systemd/user/runner.service` with the following contents:
|
||||
|
||||
```bash
|
||||
Description=Gitea Actions runner
|
||||
Documentation=https://gitea.com/gitea/act_runner
|
||||
Documentation=https://gitea.com/gitea/runner
|
||||
After=docker.service
|
||||
|
||||
[Service]
|
||||
Environment=PATH=/home/rootless/bin:/sbin:/usr/sbin:/home/rootless/bin:/home/rootless/bin:/home/rootless/bin:/usr/local/bin:/usr/bin:/bin:/usr/local/games:/usr/games
|
||||
Environment=DOCKER_HOST=unix:///run/user/1001/docker.sock
|
||||
ExecStart=/usr/bin/act_runner daemon -c /home/rootless/act_runner/config
|
||||
ExecStart=/usr/bin/runner daemon -c /home/rootless/runner/config
|
||||
ExecReload=/bin/kill -s HUP $MAINPID
|
||||
WorkingDirectory=/home/rootless/act_runner
|
||||
WorkingDirectory=/home/rootless/runner
|
||||
TimeoutSec=0
|
||||
RestartSec=2
|
||||
Restart=always
|
||||
@@ -88,8 +88,8 @@ export DOCKER_HOST=unix:///run/user/$(id -u)/docker.sock
|
||||
|
||||
- Reboot
|
||||
|
||||
After the system restarts, check that the`act_runner` is working and that the runner is connected to Gitea.
|
||||
After the system restarts, check that the`runner` is working and that the runner is connected to Gitea.
|
||||
|
||||
````bash
|
||||
systemctl --user status act_runner
|
||||
journalctl --user -xeu act_runner
|
||||
systemctl --user status runner
|
||||
journalctl --user -xeu runner
|
||||
|
||||
14
go.mod
14
go.mod
@@ -1,14 +1,14 @@
|
||||
module gitea.com/gitea/act_runner
|
||||
module gitea.com/gitea/runner
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
code.gitea.io/actions-proto-go v0.4.1
|
||||
connectrpc.com/connect v1.19.2
|
||||
connectrpc.com/connect v1.19.1
|
||||
github.com/avast/retry-go/v4 v4.7.0
|
||||
github.com/docker/docker v25.0.14+incompatible
|
||||
github.com/docker/docker v25.0.13+incompatible
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/mattn/go-isatty v0.0.22
|
||||
github.com/mattn/go-isatty v0.0.20
|
||||
github.com/sirupsen/logrus v1.9.4
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/stretchr/testify v1.11.1
|
||||
@@ -24,7 +24,7 @@ require (
|
||||
github.com/Masterminds/semver v1.5.0
|
||||
github.com/creack/pty v1.1.24
|
||||
github.com/distribution/reference v0.6.0
|
||||
github.com/docker/cli v25.0.7+incompatible
|
||||
github.com/docker/cli v25.0.3+incompatible
|
||||
github.com/docker/go-connections v0.6.0
|
||||
github.com/go-git/go-billy/v5 v5.8.0
|
||||
github.com/go-git/go-git/v5 v5.18.0
|
||||
@@ -114,3 +114,7 @@ require (
|
||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
)
|
||||
|
||||
// Remove after github.com/docker/distribution is updated to support distribution/reference v0.6.0
|
||||
// (pulled in via moby/buildkit, breaks on undefined: reference.SplitHostname)
|
||||
replace github.com/distribution/reference v0.6.0 => github.com/distribution/reference v0.5.0
|
||||
|
||||
16
go.sum
16
go.sum
@@ -1,7 +1,7 @@
|
||||
code.gitea.io/actions-proto-go v0.4.1 h1:l0EYhjsgpUe/1VABo2eK7zcoNX2W44WOnb0MSLrKfls=
|
||||
code.gitea.io/actions-proto-go v0.4.1/go.mod h1:mn7Wkqz6JbnTOHQpot3yDeHx+O5C9EGhMEE+htvHBas=
|
||||
connectrpc.com/connect v1.19.2 h1:McQ83FGdzL+t60peksi0gXC7MQ/iLKgLduAnThbM0mo=
|
||||
connectrpc.com/connect v1.19.2/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w=
|
||||
connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14=
|
||||
connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w=
|
||||
cyphar.com/go-pathrs v0.2.3 h1:0pH8gep37wB0BgaXrEaN1OtZhUMeS7VvaejSr6i822o=
|
||||
cyphar.com/go-pathrs v0.2.3/go.mod h1:y8f1EMG7r+hCuFf/rXsKqMJrJAUoADZGNh5/vZPKcGc=
|
||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||
@@ -49,14 +49,12 @@ github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/cli v25.0.7+incompatible h1:scW/AbGafKmANsonsFckFHTwpz2QypoPA/zpoLnDs/E=
|
||||
github.com/docker/cli v25.0.7+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||
github.com/distribution/reference v0.5.0 h1:/FUIFXtfc/x2gpa5/VGfiGLuOIdYa1t65IKK2OFGvA0=
|
||||
github.com/distribution/reference v0.5.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/cli v25.0.3+incompatible h1:KLeNs7zws74oFuVhgZQ5ONGZiXUUdgsdy6/EsX/6284=
|
||||
github.com/docker/cli v25.0.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||
github.com/docker/docker v25.0.13+incompatible h1:YeBrkUd3q0ZoRDNoEzuopwCLU+uD8GZahDHwBdsTnkU=
|
||||
github.com/docker/docker v25.0.13+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/docker v25.0.14+incompatible h1:+HNue3fKbqiDHYFAriyiMjfS5u25zB0E2/R8f42lOMc=
|
||||
github.com/docker/docker v25.0.14+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/docker-credential-helpers v0.9.5 h1:EFNN8DHvaiK8zVqFA2DT6BjXE0GzfLOZ38ggPTKePkY=
|
||||
github.com/docker/docker-credential-helpers v0.9.5/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c=
|
||||
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
|
||||
@@ -133,8 +131,6 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
|
||||
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk=
|
||||
|
||||
@@ -4,13 +4,12 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
|
||||
"gitea.com/gitea/act_runner/act/artifactcache"
|
||||
"gitea.com/gitea/act_runner/internal/pkg/config"
|
||||
"gitea.com/gitea/runner/act/artifactcache"
|
||||
"gitea.com/gitea/runner/internal/pkg/config"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -48,15 +47,10 @@ func runCacheServer(configFile *string, cacheArgs *cacheServerArgs) func(cmd *co
|
||||
port = cacheArgs.Port
|
||||
}
|
||||
|
||||
secret := cfg.Cache.ExternalSecret
|
||||
if secret == "" {
|
||||
return errors.New("cache.external_secret must be set for cache-server; configure the same value on each runner that points at this server via cache.external_server")
|
||||
}
|
||||
cacheHandler, err := artifactcache.StartHandler(
|
||||
dir,
|
||||
host,
|
||||
port,
|
||||
secret,
|
||||
log.StandardLogger().WithField("module", "cache_request"),
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
@@ -8,17 +8,17 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"gitea.com/gitea/act_runner/internal/pkg/config"
|
||||
"gitea.com/gitea/act_runner/internal/pkg/ver"
|
||||
"gitea.com/gitea/runner/internal/pkg/config"
|
||||
"gitea.com/gitea/runner/internal/pkg/ver"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func Execute(ctx context.Context) {
|
||||
// ./act_runner
|
||||
// ./runner
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "act_runner [event name to run]\nIf no event name passed, will default to \"on: push\"",
|
||||
Short: "Run GitHub actions locally by specifying the event name (e.g. `push`) or an action name directly.",
|
||||
Use: "runner [event name to run]\nIf no event name passed, will default to \"on: push\"",
|
||||
Short: "Gitea Runner",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
Version: ver.Version(),
|
||||
SilenceUsage: true,
|
||||
@@ -26,7 +26,7 @@ func Execute(ctx context.Context) {
|
||||
configFile := ""
|
||||
rootCmd.PersistentFlags().StringVarP(&configFile, "config", "c", "", "Config file path")
|
||||
|
||||
// ./act_runner register
|
||||
// ./runner register
|
||||
var regArgs registerArgs
|
||||
registerCmd := &cobra.Command{
|
||||
Use: "register",
|
||||
@@ -42,7 +42,7 @@ func Execute(ctx context.Context) {
|
||||
registerCmd.Flags().BoolVar(®Args.Ephemeral, "ephemeral", false, "Configure the runner to be ephemeral and only ever be able to pick a single job (stricter than --once)")
|
||||
rootCmd.AddCommand(registerCmd)
|
||||
|
||||
// ./act_runner daemon
|
||||
// ./runner daemon
|
||||
var daemArgs daemonArgs
|
||||
daemonCmd := &cobra.Command{
|
||||
Use: "daemon",
|
||||
@@ -53,10 +53,10 @@ func Execute(ctx context.Context) {
|
||||
daemonCmd.Flags().BoolVar(&daemArgs.Once, "once", false, "Run one job then exit")
|
||||
rootCmd.AddCommand(daemonCmd)
|
||||
|
||||
// ./act_runner exec
|
||||
// ./runner exec
|
||||
rootCmd.AddCommand(loadExecCmd(ctx))
|
||||
|
||||
// ./act_runner config
|
||||
// ./runner config
|
||||
rootCmd.AddCommand(&cobra.Command{
|
||||
Use: "generate-config",
|
||||
Short: "Generate an example config file",
|
||||
@@ -66,7 +66,7 @@ func Execute(ctx context.Context) {
|
||||
},
|
||||
})
|
||||
|
||||
// ./act_runner cache-server
|
||||
// ./runner cache-server
|
||||
var cacheArgs cacheServerArgs
|
||||
cacheCmd := &cobra.Command{
|
||||
Use: "cache-server",
|
||||
|
||||
@@ -16,14 +16,14 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/act_runner/internal/app/poll"
|
||||
"gitea.com/gitea/act_runner/internal/app/run"
|
||||
"gitea.com/gitea/act_runner/internal/pkg/client"
|
||||
"gitea.com/gitea/act_runner/internal/pkg/config"
|
||||
"gitea.com/gitea/act_runner/internal/pkg/envcheck"
|
||||
"gitea.com/gitea/act_runner/internal/pkg/labels"
|
||||
"gitea.com/gitea/act_runner/internal/pkg/metrics"
|
||||
"gitea.com/gitea/act_runner/internal/pkg/ver"
|
||||
"gitea.com/gitea/runner/internal/app/poll"
|
||||
"gitea.com/gitea/runner/internal/app/run"
|
||||
"gitea.com/gitea/runner/internal/pkg/client"
|
||||
"gitea.com/gitea/runner/internal/pkg/config"
|
||||
"gitea.com/gitea/runner/internal/pkg/envcheck"
|
||||
"gitea.com/gitea/runner/internal/pkg/labels"
|
||||
"gitea.com/gitea/runner/internal/pkg/metrics"
|
||||
"gitea.com/gitea/runner/internal/pkg/ver"
|
||||
|
||||
"connectrpc.com/connect"
|
||||
"github.com/mattn/go-isatty"
|
||||
@@ -104,7 +104,7 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu
|
||||
}
|
||||
// if dockerSocketPath passes the check, override DOCKER_HOST with dockerSocketPath
|
||||
os.Setenv("DOCKER_HOST", dockerSocketPath)
|
||||
// empty cfg.Container.DockerHost means act_runner need to find an available docker host automatically
|
||||
// empty cfg.Container.DockerHost means runner need to find an available docker host automatically
|
||||
// and assign the path to cfg.Container.DockerHost
|
||||
if cfg.Container.DockerHost == "" {
|
||||
cfg.Container.DockerHost = dockerSocketPath
|
||||
|
||||
@@ -6,8 +6,6 @@ package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
@@ -17,11 +15,11 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/act_runner/act/artifactcache"
|
||||
"gitea.com/gitea/act_runner/act/artifacts"
|
||||
"gitea.com/gitea/act_runner/act/common"
|
||||
"gitea.com/gitea/act_runner/act/model"
|
||||
"gitea.com/gitea/act_runner/act/runner"
|
||||
"gitea.com/gitea/runner/act/artifactcache"
|
||||
"gitea.com/gitea/runner/act/artifacts"
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
"gitea.com/gitea/runner/act/runner"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/joho/godotenv"
|
||||
@@ -370,7 +368,7 @@ func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command
|
||||
}
|
||||
|
||||
// init a cache server
|
||||
handler, err := artifactcache.StartHandler("", "", 0, "", log.StandardLogger().WithField("module", "cache_request"))
|
||||
handler, err := artifactcache.StartHandler("", "", 0, log.StandardLogger().WithField("module", "cache_request"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -395,25 +393,6 @@ func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command
|
||||
execArgs.artifactServerPath = tempDir
|
||||
}
|
||||
|
||||
// Register ACTIONS_RUNTIME_TOKEN against local cache server
|
||||
env := execArgs.LoadEnvs()
|
||||
const actionsRuntimeTokenEnvName = "ACTIONS_RUNTIME_TOKEN"
|
||||
actionsRuntimeToken := env[actionsRuntimeTokenEnvName]
|
||||
if actionsRuntimeToken == "" {
|
||||
actionsRuntimeToken = os.Getenv(actionsRuntimeTokenEnvName)
|
||||
}
|
||||
if actionsRuntimeToken == "" {
|
||||
tmpBranch := make([]byte, 12)
|
||||
if _, err := rand.Read(tmpBranch); err != nil {
|
||||
actionsRuntimeToken = "token"
|
||||
} else {
|
||||
actionsRuntimeToken = hex.EncodeToString(tmpBranch)
|
||||
}
|
||||
env[actionsRuntimeTokenEnvName] = actionsRuntimeToken
|
||||
os.Setenv(actionsRuntimeTokenEnvName, actionsRuntimeToken)
|
||||
}
|
||||
handler.RegisterJob(actionsRuntimeToken, "__local/__exec")
|
||||
|
||||
// run the plan
|
||||
config := &runner.Config{
|
||||
Workdir: execArgs.Workdir(),
|
||||
@@ -423,7 +402,7 @@ func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command
|
||||
ForceRebuild: execArgs.forceRebuild,
|
||||
LogOutput: true,
|
||||
JSONLogger: execArgs.jsonLogger,
|
||||
Env: env,
|
||||
Env: execArgs.LoadEnvs(),
|
||||
Vars: execArgs.LoadVars(),
|
||||
Secrets: execArgs.LoadSecrets(),
|
||||
InsecureSecrets: execArgs.insecureSecrets,
|
||||
|
||||
@@ -14,10 +14,10 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/act_runner/internal/pkg/client"
|
||||
"gitea.com/gitea/act_runner/internal/pkg/config"
|
||||
"gitea.com/gitea/act_runner/internal/pkg/labels"
|
||||
"gitea.com/gitea/act_runner/internal/pkg/ver"
|
||||
"gitea.com/gitea/runner/internal/pkg/client"
|
||||
"gitea.com/gitea/runner/internal/pkg/config"
|
||||
"gitea.com/gitea/runner/internal/pkg/labels"
|
||||
"gitea.com/gitea/runner/internal/pkg/ver"
|
||||
|
||||
pingv1 "code.gitea.io/actions-proto-go/ping/v1"
|
||||
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
|
||||
|
||||
@@ -12,9 +12,9 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/act_runner/internal/pkg/client"
|
||||
"gitea.com/gitea/act_runner/internal/pkg/config"
|
||||
"gitea.com/gitea/act_runner/internal/pkg/metrics"
|
||||
"gitea.com/gitea/runner/internal/pkg/client"
|
||||
"gitea.com/gitea/runner/internal/pkg/config"
|
||||
"gitea.com/gitea/runner/internal/pkg/metrics"
|
||||
|
||||
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
|
||||
"connectrpc.com/connect"
|
||||
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/act_runner/internal/pkg/client/mocks"
|
||||
"gitea.com/gitea/act_runner/internal/pkg/config"
|
||||
"gitea.com/gitea/runner/internal/pkg/client/mocks"
|
||||
"gitea.com/gitea/runner/internal/pkg/config"
|
||||
|
||||
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
|
||||
connect_go "connectrpc.com/connect"
|
||||
|
||||
@@ -4,12 +4,10 @@
|
||||
package run
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -17,16 +15,16 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/act_runner/act/artifactcache"
|
||||
"gitea.com/gitea/act_runner/act/common"
|
||||
"gitea.com/gitea/act_runner/act/model"
|
||||
"gitea.com/gitea/act_runner/act/runner"
|
||||
"gitea.com/gitea/act_runner/internal/pkg/client"
|
||||
"gitea.com/gitea/act_runner/internal/pkg/config"
|
||||
"gitea.com/gitea/act_runner/internal/pkg/labels"
|
||||
"gitea.com/gitea/act_runner/internal/pkg/metrics"
|
||||
"gitea.com/gitea/act_runner/internal/pkg/report"
|
||||
"gitea.com/gitea/act_runner/internal/pkg/ver"
|
||||
"gitea.com/gitea/runner/act/artifactcache"
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
"gitea.com/gitea/runner/act/runner"
|
||||
"gitea.com/gitea/runner/internal/pkg/client"
|
||||
"gitea.com/gitea/runner/internal/pkg/config"
|
||||
"gitea.com/gitea/runner/internal/pkg/labels"
|
||||
"gitea.com/gitea/runner/internal/pkg/metrics"
|
||||
"gitea.com/gitea/runner/internal/pkg/report"
|
||||
"gitea.com/gitea/runner/internal/pkg/ver"
|
||||
|
||||
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
|
||||
"connectrpc.com/connect"
|
||||
@@ -40,10 +38,9 @@ type Runner struct {
|
||||
|
||||
cfg *config.Config
|
||||
|
||||
client client.Client
|
||||
labels labels.Labels
|
||||
envs map[string]string
|
||||
cacheHandler *artifactcache.Handler
|
||||
client client.Client
|
||||
labels labels.Labels
|
||||
envs map[string]string
|
||||
|
||||
runningTasks sync.Map
|
||||
runningCount atomic.Int64
|
||||
@@ -58,24 +55,21 @@ func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client)
|
||||
}
|
||||
envs := make(map[string]string, len(cfg.Runner.Envs))
|
||||
maps.Copy(envs, cfg.Runner.Envs)
|
||||
var cacheHandler *artifactcache.Handler
|
||||
if cfg.Cache.Enabled == nil || *cfg.Cache.Enabled {
|
||||
if cfg.Cache.ExternalServer != "" {
|
||||
envs["ACTIONS_CACHE_URL"] = cfg.Cache.ExternalServer
|
||||
} else {
|
||||
handler, err := artifactcache.StartHandler(
|
||||
cacheHandler, err := artifactcache.StartHandler(
|
||||
cfg.Cache.Dir,
|
||||
cfg.Cache.Host,
|
||||
cfg.Cache.Port,
|
||||
"",
|
||||
log.StandardLogger().WithField("module", "cache_request"),
|
||||
)
|
||||
if err != nil {
|
||||
log.Errorf("cannot init cache server, it will be disabled: %v", err)
|
||||
// go on
|
||||
} else {
|
||||
cacheHandler = handler
|
||||
envs["ACTIONS_CACHE_URL"] = handler.ExternalURL() + "/"
|
||||
envs["ACTIONS_CACHE_URL"] = cacheHandler.ExternalURL() + "/"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,12 +84,11 @@ func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client)
|
||||
envs["GITEA_ACTIONS_RUNNER_VERSION"] = ver.Version()
|
||||
|
||||
return &Runner{
|
||||
name: reg.Name,
|
||||
cfg: cfg,
|
||||
client: cli,
|
||||
labels: ls,
|
||||
envs: envs,
|
||||
cacheHandler: cacheHandler,
|
||||
name: reg.Name,
|
||||
cfg: cfg,
|
||||
client: cli,
|
||||
labels: ls,
|
||||
envs: envs,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,21 +199,6 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
||||
giteaRuntimeToken = preset.Token
|
||||
}
|
||||
r.envs["ACTIONS_RUNTIME_TOKEN"] = giteaRuntimeToken
|
||||
// Mask the runtime token so it cannot be echoed in user step output; it is
|
||||
// now also the cache server's bearer credential and leaking it would let
|
||||
// any reader of the log impersonate this job against the cache.
|
||||
if giteaRuntimeToken != "" {
|
||||
task.Secrets["ACTIONS_RUNTIME_TOKEN"] = giteaRuntimeToken
|
||||
}
|
||||
|
||||
// Register this job's runtime token with the local cache server so that
|
||||
// cache requests from the job container can authenticate. The credential
|
||||
// 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
|
||||
// lifetime. Only applies to the embedded cache server; when the operator
|
||||
// points the runner at an external cache via cfg.Cache.ExternalServer, it
|
||||
// is that server's responsibility to authenticate requests.
|
||||
defer r.registerCacheForTask(giteaRuntimeToken, preset.Repository, reporter)()
|
||||
|
||||
eventJSON, err := json.Marshal(preset.Event)
|
||||
if err != nil {
|
||||
@@ -300,82 +278,6 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
||||
return execErr
|
||||
}
|
||||
|
||||
// registerCacheForTask tells the cache server to accept requests authenticated
|
||||
// with the given runtime token for the duration of this task. Returns a
|
||||
// function the caller must invoke (typically via defer) to revoke the
|
||||
// credential when the task finishes.
|
||||
//
|
||||
// Three modes:
|
||||
// - Embedded handler: register in-process via RegisterJob.
|
||||
// - external_server + external_secret: POST to the remote server's
|
||||
// /_internal/register, defer a POST to /_internal/revoke. This is what
|
||||
// enables full per-job auth and repo scoping over the network.
|
||||
// - external_server alone (no secret): no-op revoker. The remote server is
|
||||
// in legacy openMode and ignores the runtime token; trust is at the
|
||||
// network layer.
|
||||
//
|
||||
// Safe with an empty token (older Gitea did not issue one).
|
||||
func (r *Runner) registerCacheForTask(token, repo string, reporter *report.Reporter) func() {
|
||||
if token == "" {
|
||||
return func() {}
|
||||
}
|
||||
if r.cacheHandler != nil {
|
||||
return r.cacheHandler.RegisterJob(token, repo)
|
||||
}
|
||||
if r.cfg.Cache.ExternalServer != "" && r.cfg.Cache.ExternalSecret != "" {
|
||||
return r.registerExternalCacheJob(token, repo, reporter)
|
||||
}
|
||||
return func() {}
|
||||
}
|
||||
|
||||
// registerExternalCacheJob POSTs to the remote cache-server's control-plane.
|
||||
// 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
|
||||
// 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.
|
||||
func (r *Runner) registerExternalCacheJob(token, repo string, reporter *report.Reporter) func() {
|
||||
base := strings.TrimRight(r.cfg.Cache.ExternalServer, "/")
|
||||
if err := postInternalCache(base+"/_internal/register", r.cfg.Cache.ExternalSecret,
|
||||
map[string]string{"token": token, "repo": repo}); err != nil {
|
||||
log.Warnf("cache external_server register failed (%s): %v", base, err)
|
||||
if reporter != nil {
|
||||
reporter.Logf("::warning::cache external_server register failed (%s): %v — cache requests from this job will be unauthenticated and likely return 401", base, err)
|
||||
}
|
||||
}
|
||||
return func() {
|
||||
if err := postInternalCache(base+"/_internal/revoke", r.cfg.Cache.ExternalSecret,
|
||||
map[string]string{"token": token}); err != nil {
|
||||
log.Warnf("cache external_server revoke failed (%s): %v", base, err)
|
||||
if reporter != nil {
|
||||
reporter.Logf("::warning::cache external_server revoke failed (%s): %v", base, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func postInternalCache(url, secret string, body map[string]string) error {
|
||||
buf, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(buf))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+secret)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return fmt.Errorf("status %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Runner) RunningCount() int64 {
|
||||
return r.runningCount.Load()
|
||||
}
|
||||
|
||||
@@ -1,239 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package run
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.com/gitea/act_runner/act/artifactcache"
|
||||
"gitea.com/gitea/act_runner/internal/pkg/config"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func emptyCfg() *config.Config { return &config.Config{} }
|
||||
|
||||
func TestRunner_registerCacheForTask(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
||||
handler, err := artifactcache.StartHandler(dir, "127.0.0.1", 0, "", nil)
|
||||
require.NoError(t, err)
|
||||
defer handler.Close()
|
||||
|
||||
r := &Runner{cfg: emptyCfg(), cacheHandler: handler}
|
||||
token := "run-token-123"
|
||||
unregister := r.registerCacheForTask(token, "owner/repo", nil)
|
||||
|
||||
base := handler.ExternalURL() + "/_apis/artifactcache"
|
||||
probe := func() int {
|
||||
req, err := http.NewRequest(http.MethodGet, base+"/cache?keys=x&version=v", nil)
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
return resp.StatusCode
|
||||
}
|
||||
|
||||
assert.NotEqual(t, http.StatusUnauthorized, probe(),
|
||||
"token should be accepted while task is registered")
|
||||
|
||||
unregister()
|
||||
assert.Equal(t, http.StatusUnauthorized, probe(),
|
||||
"token must be rejected after the revoker runs")
|
||||
}
|
||||
|
||||
func TestRunner_registerCacheForTask_NoOps(t *testing.T) {
|
||||
t.Run("nil cacheHandler", func(t *testing.T) {
|
||||
r := &Runner{cfg: emptyCfg()}
|
||||
unregister := r.registerCacheForTask("tok", "owner/repo", nil)
|
||||
require.NotNil(t, unregister)
|
||||
unregister()
|
||||
})
|
||||
|
||||
t.Run("empty token", func(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
||||
handler, err := artifactcache.StartHandler(dir, "127.0.0.1", 0, "", nil)
|
||||
require.NoError(t, err)
|
||||
defer handler.Close()
|
||||
|
||||
r := &Runner{cfg: emptyCfg(), cacheHandler: handler}
|
||||
unregister := r.registerCacheForTask("", "owner/repo", nil)
|
||||
require.NotNil(t, unregister)
|
||||
unregister()
|
||||
})
|
||||
}
|
||||
|
||||
// Locks in @actions/cache's wire protocol: bearer on reserve/upload/commit
|
||||
// /find, no auth on the signed archiveLocation download.
|
||||
func TestRunner_CacheFullFlow_MatchesToolkit(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
||||
handler, err := artifactcache.StartHandler(dir, "127.0.0.1", 0, "", nil)
|
||||
require.NoError(t, err)
|
||||
defer handler.Close()
|
||||
|
||||
r := &Runner{cfg: emptyCfg(), cacheHandler: handler}
|
||||
token := "full-flow-token"
|
||||
unregister := r.registerCacheForTask(token, "owner/repo", nil)
|
||||
defer unregister()
|
||||
|
||||
base := handler.ExternalURL() + "/_apis/artifactcache"
|
||||
do := func(method, url, contentType, contentRange, body string) *http.Response {
|
||||
req, err := http.NewRequest(method, url, strings.NewReader(body))
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
if contentRange != "" {
|
||||
req.Header.Set("Content-Range", contentRange)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
return resp
|
||||
}
|
||||
|
||||
key := "toolkit-flow"
|
||||
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
|
||||
body := `hello-cache-body`
|
||||
|
||||
// reserve
|
||||
resp := do(http.MethodPost, base+"/caches", "application/json", "",
|
||||
fmt.Sprintf(`{"key":"%s","version":"%s","cacheSize":%d}`, key, version, len(body)))
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var reserved struct {
|
||||
CacheID uint64 `json:"cacheId"`
|
||||
}
|
||||
require.NoError(t, decodeJSON(resp, &reserved))
|
||||
require.NotZero(t, reserved.CacheID)
|
||||
|
||||
// upload
|
||||
resp = do(http.MethodPatch, fmt.Sprintf("%s/caches/%d", base, reserved.CacheID),
|
||||
"application/octet-stream", fmt.Sprintf("bytes 0-%d/*", len(body)-1), body)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
resp.Body.Close()
|
||||
|
||||
// commit
|
||||
resp = do(http.MethodPost, fmt.Sprintf("%s/caches/%d", base, reserved.CacheID), "", "", "")
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
resp.Body.Close()
|
||||
|
||||
// find — @actions/cache always sends comma-separated keys here
|
||||
resp = do(http.MethodGet,
|
||||
fmt.Sprintf("%s/cache?keys=%s,fallback&version=%s", base, key, version), "", "", "")
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var hit struct {
|
||||
ArchiveLocation string `json:"archiveLocation"`
|
||||
CacheKey string `json:"cacheKey"`
|
||||
}
|
||||
require.NoError(t, decodeJSON(resp, &hit))
|
||||
require.Equal(t, key, hit.CacheKey)
|
||||
require.NotEmpty(t, hit.ArchiveLocation)
|
||||
|
||||
// download — toolkit does NOT attach Authorization here; the signature
|
||||
// in the URL must be enough.
|
||||
dl, err := http.Get(hit.ArchiveLocation)
|
||||
require.NoError(t, err)
|
||||
defer dl.Body.Close()
|
||||
require.Equal(t, http.StatusOK, dl.StatusCode)
|
||||
got := make([]byte, 64)
|
||||
n, _ := dl.Body.Read(got)
|
||||
assert.Equal(t, body, string(got[:n]))
|
||||
}
|
||||
|
||||
func decodeJSON(resp *http.Response, v any) error {
|
||||
defer resp.Body.Close()
|
||||
return json.NewDecoder(resp.Body).Decode(v)
|
||||
}
|
||||
|
||||
// End-to-end against a remote cache-server: token unknown → 401, register →
|
||||
// reserve/upload/commit/find/download all OK, revoke → 401 again.
|
||||
func TestRunner_ExternalCacheServer_RegisterRevoke(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "remote-cache")
|
||||
const secret = "shared-secret-for-tests"
|
||||
remote, err := artifactcache.StartHandler(dir, "127.0.0.1", 0, secret, nil)
|
||||
require.NoError(t, err)
|
||||
defer remote.Close()
|
||||
|
||||
r := &Runner{cfg: &config.Config{Cache: config.Cache{
|
||||
ExternalServer: remote.ExternalURL(),
|
||||
ExternalSecret: secret,
|
||||
}}}
|
||||
|
||||
token := "external-task-token"
|
||||
repo := "owner/repoX"
|
||||
base := remote.ExternalURL() + "/_apis/artifactcache"
|
||||
probe := func() int {
|
||||
req, _ := http.NewRequest(http.MethodGet, base+"/cache?keys=k&version=v", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
return resp.StatusCode
|
||||
}
|
||||
|
||||
require.Equal(t, http.StatusUnauthorized, probe(),
|
||||
"token must be unknown to the remote server before registration")
|
||||
|
||||
unregister := r.registerCacheForTask(token, repo, nil)
|
||||
require.NotEqual(t, http.StatusUnauthorized, probe(),
|
||||
"token must be accepted after registerCacheForTask")
|
||||
|
||||
// Full reserve→upload→commit→find→download cycle, identical to what
|
||||
// @actions/cache does, against the remote (external) server.
|
||||
body := []byte("payload-from-task")
|
||||
reserveBody, _ := json.Marshal(&artifactcache.Request{Key: "ext-key", Version: "v", Size: int64(len(body))})
|
||||
req, _ := http.NewRequest(http.MethodPost, base+"/caches", bytes.NewReader(reserveBody))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var reserved struct {
|
||||
CacheID uint64 `json:"cacheId"`
|
||||
}
|
||||
require.NoError(t, decodeJSON(resp, &reserved))
|
||||
require.NotZero(t, reserved.CacheID)
|
||||
|
||||
req, _ = http.NewRequest(http.MethodPatch, fmt.Sprintf("%s/caches/%d", base, reserved.CacheID), bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Range", fmt.Sprintf("bytes 0-%d/*", len(body)-1))
|
||||
resp, err = http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
req, _ = http.NewRequest(http.MethodPost, fmt.Sprintf("%s/caches/%d", base, reserved.CacheID), nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err = http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
req, _ = http.NewRequest(http.MethodGet, base+"/cache?keys=ext-key&version=v", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err = http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var hit struct {
|
||||
ArchiveLocation string `json:"archiveLocation"`
|
||||
}
|
||||
require.NoError(t, decodeJSON(resp, &hit))
|
||||
require.NotEmpty(t, hit.ArchiveLocation)
|
||||
|
||||
dl, err := http.Get(hit.ArchiveLocation)
|
||||
require.NoError(t, err)
|
||||
defer dl.Body.Close()
|
||||
require.Equal(t, http.StatusOK, dl.StatusCode)
|
||||
|
||||
unregister()
|
||||
assert.Equal(t, http.StatusUnauthorized, probe(),
|
||||
"token must be rejected after the revoker runs")
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/act_runner/act/model"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
|
||||
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
|
||||
"go.yaml.in/yaml/v4"
|
||||
|
||||
@@ -6,7 +6,7 @@ package run
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.com/gitea/act_runner/act/model"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
|
||||
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Example configuration file, it's safe to copy this as the default config file without any modification.
|
||||
|
||||
# You don't have to copy this file to your instance,
|
||||
# just run `./act_runner generate-config > config.yaml` to generate a config file.
|
||||
# just run `./runner generate-config > config.yaml` to generate a config file.
|
||||
|
||||
log:
|
||||
# The level of logging, can be trace, debug, info, warn, error, fatal
|
||||
@@ -79,26 +79,21 @@ cache:
|
||||
# 0 means to use a random available port.
|
||||
port: 0
|
||||
# The external cache server URL. Valid only when enable is true.
|
||||
# If it's specified, act_runner will use this URL as the ACTIONS_CACHE_URL rather than start a server by itself.
|
||||
# If it's specified, runner will use this URL as the ACTIONS_CACHE_URL rather than start a server by itself.
|
||||
# The URL should generally end with "/".
|
||||
# Requires external_secret below to be set to the same value on both this runner and the cache-server.
|
||||
external_server: ""
|
||||
# Shared secret between this runner and the external `act_runner cache-server`. Required when external_server
|
||||
# (or `act_runner cache-server`) is in use: the runner pre-registers each job's ACTIONS_RUNTIME_TOKEN with the
|
||||
# cache-server, and the cache-server enforces bearer auth + per-repo cache isolation.
|
||||
external_secret: ""
|
||||
|
||||
container:
|
||||
# Specifies the network to which the container will connect.
|
||||
# Could be host, bridge or the name of a custom network.
|
||||
# If it's empty, act_runner will create a network automatically.
|
||||
# If it's empty, runner will create a network automatically.
|
||||
network: ""
|
||||
# Whether to use privileged mode or not when launching task containers (privileged mode is required for Docker-in-Docker).
|
||||
privileged: false
|
||||
# Any other options to be used when the container is started (e.g., --add-host=my.gitea.url:host-gateway).
|
||||
options:
|
||||
# The parent directory of a job's working directory.
|
||||
# NOTE: There is no need to add the first '/' of the path as act_runner will add it automatically.
|
||||
# NOTE: There is no need to add the first '/' of the path as runner will add it automatically.
|
||||
# If the path starts with '/', the '/' will be trimmed.
|
||||
# For example, if the parent directory is /path/to/my/dir, workdir_parent should be path/to/my/dir
|
||||
# If it's empty, /workspace will be used.
|
||||
@@ -114,17 +109,17 @@ container:
|
||||
# - '**'
|
||||
valid_volumes: []
|
||||
# Overrides the docker client host with the specified one.
|
||||
# If it's empty, act_runner will find an available docker host automatically.
|
||||
# If it's "-", act_runner will find an available docker host automatically, but the docker host won't be mounted to the job containers and service containers.
|
||||
# If it's empty, runner will find an available docker host automatically.
|
||||
# If it's "-", runner will find an available docker host automatically, but the docker host won't be mounted to the job containers and service containers.
|
||||
# If it's not empty or "-", the specified docker host will be used. An error will be returned if it doesn't work.
|
||||
docker_host: ""
|
||||
# Pull docker image(s) even if already present
|
||||
force_pull: true
|
||||
# Rebuild docker image(s) even if already present
|
||||
force_rebuild: false
|
||||
# Always require a reachable docker daemon, even if not required by act_runner
|
||||
# Always require a reachable docker daemon, even if not required by runner
|
||||
require_docker: false
|
||||
# Timeout to wait for the docker daemon to be reachable, if docker is required by require_docker or act_runner
|
||||
# Timeout to wait for the docker daemon to be reachable, if docker is required by require_docker or runner
|
||||
docker_timeout: 0s
|
||||
# Bind the workspace to the host filesystem instead of using Docker volumes.
|
||||
# This is required for Docker-in-Docker (DinD) setups when jobs use docker compose
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"os"
|
||||
@@ -48,7 +47,6 @@ type Cache struct {
|
||||
Host string `yaml:"host"` // Host specifies the caching host.
|
||||
Port uint16 `yaml:"port"` // Port specifies the caching port.
|
||||
ExternalServer string `yaml:"external_server"` // ExternalServer specifies the URL of external cache server
|
||||
ExternalSecret string `yaml:"external_secret"` // ExternalSecret is a shared secret between this runner and an external act_runner cache-server, enabling per-job ACTIONS_RUNTIME_TOKEN authentication and repo scoping over the network. Leave empty to keep the legacy unauthenticated behavior.
|
||||
}
|
||||
|
||||
// Container represents the configuration for the container.
|
||||
@@ -62,8 +60,8 @@ type Container struct {
|
||||
DockerHost string `yaml:"docker_host"` // DockerHost specifies the Docker host. It overrides the value specified in environment variable DOCKER_HOST.
|
||||
ForcePull bool `yaml:"force_pull"` // Pull docker image(s) even if already present
|
||||
ForceRebuild bool `yaml:"force_rebuild"` // Rebuild docker image(s) even if already present
|
||||
RequireDocker bool `yaml:"require_docker"` // Always require a reachable docker daemon, even if not required by act_runner
|
||||
DockerTimeout time.Duration `yaml:"docker_timeout"` // Timeout to wait for the docker daemon to be reachable, if docker is required by require_docker or act_runner
|
||||
RequireDocker bool `yaml:"require_docker"` // Always require a reachable docker daemon, even if not required by runner
|
||||
DockerTimeout time.Duration `yaml:"docker_timeout"` // Timeout to wait for the docker daemon to be reachable, if docker is required by require_docker or runner
|
||||
BindWorkdir bool `yaml:"bind_workdir"` // BindWorkdir binds the workspace to the host filesystem instead of using Docker volumes. Required for DinD when jobs use docker compose with bind mounts.
|
||||
}
|
||||
|
||||
@@ -137,9 +135,6 @@ func LoadDefault(file string) (*Config, error) {
|
||||
home, _ := os.UserHomeDir()
|
||||
cfg.Cache.Dir = filepath.Join(home, ".cache", "actcache")
|
||||
}
|
||||
if cfg.Cache.ExternalServer != "" && cfg.Cache.ExternalSecret == "" {
|
||||
return nil, errors.New("cache.external_server is set but cache.external_secret is empty; configure the same external_secret on this runner and the act_runner cache-server")
|
||||
}
|
||||
}
|
||||
if cfg.Container.WorkdirParent == "" {
|
||||
cfg.Container.WorkdirParent = "workspace"
|
||||
@@ -190,7 +185,7 @@ func LoadDefault(file string) (*Config, error) {
|
||||
if cfg.Container.NetworkMode == "bridge" {
|
||||
// Previously, if the value of `container.network_mode` is `bridge`, we will create a new network for job.
|
||||
// But “bridge” is easily confused with the bridge network created by Docker by default.
|
||||
// So we set the value of `container.network` to empty string to make `act_runner` automatically create a new network for job.
|
||||
// So we set the value of `container.network` to empty string to make `runner` automatically create a new network for job.
|
||||
cfg.Container.Network = ""
|
||||
} else {
|
||||
cfg.Container.Network = cfg.Container.NetworkMode
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLoadDefault_RejectsExternalServerWithoutSecret(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
require.NoError(t, os.WriteFile(path, []byte(`
|
||||
cache:
|
||||
enabled: true
|
||||
external_server: "http://cache.invalid/"
|
||||
`), 0o600))
|
||||
|
||||
_, err := LoadDefault(path)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "external_secret")
|
||||
}
|
||||
|
||||
func TestLoadDefault_AcceptsExternalServerWithSecret(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
require.NoError(t, os.WriteFile(path, []byte(`
|
||||
cache:
|
||||
enabled: true
|
||||
external_server: "http://cache.invalid/"
|
||||
external_secret: "shh"
|
||||
`), 0o600))
|
||||
|
||||
_, err := LoadDefault(path)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"os"
|
||||
)
|
||||
|
||||
const registrationWarning = "This file is automatically generated by act-runner. Do not edit it manually unless you know what you are doing. Removing this file will cause act runner to re-register as a new runner."
|
||||
const registrationWarning = "This file is automatically generated by Gitea Runner. Do not edit it manually unless you know what you are doing. Removing this file will cause Gitea Runner to re-register as a new runner."
|
||||
|
||||
// Registration is the registration information for a runner
|
||||
type Registration struct {
|
||||
|
||||
@@ -12,8 +12,8 @@ import (
|
||||
"github.com/prometheus/client_golang/prometheus/collectors"
|
||||
)
|
||||
|
||||
// Namespace is the Prometheus namespace for all act_runner metrics.
|
||||
const Namespace = "act_runner"
|
||||
// Namespace is the Prometheus namespace for all runner metrics.
|
||||
const Namespace = "runner"
|
||||
|
||||
// Label value constants for Prometheus metrics.
|
||||
// Using constants prevents typos from silently creating new time-series.
|
||||
|
||||
@@ -10,12 +10,11 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/act_runner/internal/pkg/client"
|
||||
"gitea.com/gitea/act_runner/internal/pkg/config"
|
||||
"gitea.com/gitea/act_runner/internal/pkg/metrics"
|
||||
"gitea.com/gitea/runner/internal/pkg/client"
|
||||
"gitea.com/gitea/runner/internal/pkg/config"
|
||||
"gitea.com/gitea/runner/internal/pkg/metrics"
|
||||
|
||||
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
|
||||
"connectrpc.com/connect"
|
||||
@@ -49,10 +48,6 @@ type Reporter struct {
|
||||
outputs sync.Map
|
||||
daemon chan struct{}
|
||||
|
||||
// Unix-nanos of the last successful UpdateTask. Atomic so the heartbeat
|
||||
// guard in ReportState reads it without contending stateMu.
|
||||
lastReportedAtNanos atomic.Int64
|
||||
|
||||
// Adaptive batching control
|
||||
logReportInterval time.Duration
|
||||
logReportMaxLatency time.Duration
|
||||
@@ -494,12 +489,8 @@ func (r *Reporter) ReportState(reportResult bool) error {
|
||||
|
||||
// Consume stateChanged atomically with the snapshot; restored on error
|
||||
// below so a concurrent Fire() during UpdateTask isn't silently lost.
|
||||
// Heartbeat at stateReportInterval even when nothing changed, so the server
|
||||
// doesn't time out long-running silent jobs as orphaned (#826).
|
||||
last := r.lastReportedAtNanos.Load()
|
||||
withinHeartbeatInterval := last != 0 && time.Since(time.Unix(0, last)) < r.stateReportInterval
|
||||
r.stateMu.Lock()
|
||||
if !reportResult && !r.stateChanged && len(outputs) == 0 && withinHeartbeatInterval {
|
||||
if !reportResult && !r.stateChanged && len(outputs) == 0 {
|
||||
r.stateMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
@@ -526,7 +517,6 @@ func (r *Reporter) ReportState(reportResult bool) error {
|
||||
return err
|
||||
}
|
||||
metrics.ReportStateTotal.WithLabelValues(metrics.LabelResultSuccess).Inc()
|
||||
r.lastReportedAtNanos.Store(time.Now().UnixNano())
|
||||
|
||||
for _, k := range resp.Msg.SentOutputs {
|
||||
r.outputs.Store(k, struct{}{})
|
||||
|
||||
@@ -12,8 +12,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/act_runner/internal/pkg/client/mocks"
|
||||
"gitea.com/gitea/act_runner/internal/pkg/config"
|
||||
"gitea.com/gitea/runner/internal/pkg/client/mocks"
|
||||
"gitea.com/gitea/runner/internal/pkg/config"
|
||||
|
||||
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
|
||||
connect_go "connectrpc.com/connect"
|
||||
@@ -220,7 +220,7 @@ func TestReporter_Fire(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestReporter_EphemeralRunnerDeletion reproduces the exact scenario from
|
||||
// https://gitea.com/gitea/act_runner/issues/793:
|
||||
// https://gitea.com/gitea/runner/issues/793:
|
||||
//
|
||||
// 1. RunDaemon calls ReportLog(false) — runner is still alive
|
||||
// 2. Close() updates state to Result=FAILURE (between RunDaemon's ReportLog and ReportState)
|
||||
@@ -597,45 +597,3 @@ func TestReporter_StateNotifyFlush(t *testing.T) {
|
||||
}, 500*time.Millisecond, 10*time.Millisecond,
|
||||
"step transition should have triggered immediate state flush via stateNotify")
|
||||
}
|
||||
|
||||
// TestReporter_StateHeartbeat verifies that ReportState sends a heartbeat
|
||||
// UpdateTask once stateReportInterval has elapsed since the last successful
|
||||
// report, even when nothing has changed. Without this, long-running silent
|
||||
// jobs (no log output, no step transitions) cause the server to time the
|
||||
// task out and cancel it (#826).
|
||||
func TestReporter_StateHeartbeat(t *testing.T) {
|
||||
var updateTaskCalls atomic.Int64
|
||||
|
||||
client := mocks.NewClient(t)
|
||||
client.On("UpdateTask", mock.Anything, mock.Anything).Return(
|
||||
func(_ context.Context, _ *connect_go.Request[runnerv1.UpdateTaskRequest]) (*connect_go.Response[runnerv1.UpdateTaskResponse], error) {
|
||||
updateTaskCalls.Add(1)
|
||||
return connect_go.NewResponse(&runnerv1.UpdateTaskResponse{}), nil
|
||||
},
|
||||
)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
taskCtx, err := structpb.NewStruct(map[string]any{})
|
||||
require.NoError(t, err)
|
||||
cfg, _ := config.LoadDefault("")
|
||||
cfg.Runner.StateReportInterval = 50 * time.Millisecond
|
||||
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg)
|
||||
reporter.ResetSteps(1)
|
||||
|
||||
// First call has no prior report — sends to seed lastReportedAt.
|
||||
reporter.stateMu.Lock()
|
||||
reporter.stateChanged = true
|
||||
reporter.stateMu.Unlock()
|
||||
require.NoError(t, reporter.ReportState(false))
|
||||
require.Equal(t, int64(1), updateTaskCalls.Load())
|
||||
|
||||
// Second call immediately after with nothing changed — must skip.
|
||||
require.NoError(t, reporter.ReportState(false))
|
||||
assert.Equal(t, int64(1), updateTaskCalls.Load(), "no-op ReportState within stateReportInterval must skip")
|
||||
|
||||
// After stateReportInterval elapses, a heartbeat must fire even with no changes.
|
||||
time.Sleep(2 * cfg.Runner.StateReportInterval)
|
||||
require.NoError(t, reporter.ReportState(false))
|
||||
assert.Equal(t, int64(2), updateTaskCalls.Load(), "ReportState must heartbeat after stateReportInterval even with no state change")
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
package ver
|
||||
|
||||
// go build -ldflags "-X gitea.com/gitea/act_runner/internal/pkg/ver.version=1.2.3"
|
||||
// go build -ldflags "-X gitea.com/gitea/runner/internal/pkg/ver.version=1.2.3"
|
||||
var version = "dev"
|
||||
|
||||
func Version() string {
|
||||
|
||||
2
main.go
2
main.go
@@ -8,7 +8,7 @@ import (
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"gitea.com/gitea/act_runner/internal/app/cmd"
|
||||
"gitea.com/gitea/runner/internal/app/cmd"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
@@ -36,12 +36,12 @@ if [[ ! -s "$RUNNER_STATE_FILE" ]]; then
|
||||
try=$((try + 1))
|
||||
success=0
|
||||
|
||||
# The point of this loop is to make it simple, when running both act_runner and gitea in docker,
|
||||
# for the act_runner to wait a moment for gitea to become available before erroring out. Within
|
||||
# The point of this loop is to make it simple, when running both runner and gitea in docker,
|
||||
# for the runner to wait a moment for gitea to become available before erroring out. Within
|
||||
# the context of a single docker-compose, something similar could be done via healthchecks, but
|
||||
# this is more flexible.
|
||||
while [[ $success -eq 0 ]] && [[ $try -lt ${GITEA_MAX_REG_ATTEMPTS:-10} ]]; do
|
||||
act_runner register \
|
||||
runner register \
|
||||
--instance "${GITEA_INSTANCE_URL}" \
|
||||
--token "${GITEA_RUNNER_REGISTRATION_TOKEN}" \
|
||||
--name "${GITEA_RUNNER_NAME:-`hostname`}" \
|
||||
@@ -57,8 +57,8 @@ if [[ ! -s "$RUNNER_STATE_FILE" ]]; then
|
||||
fi
|
||||
done
|
||||
fi
|
||||
# Prevent reading the token from the act_runner process
|
||||
# Prevent reading the token from the runner process
|
||||
unset GITEA_RUNNER_REGISTRATION_TOKEN
|
||||
unset GITEA_RUNNER_REGISTRATION_TOKEN_FILE
|
||||
|
||||
exec act_runner daemon ${CONFIG_ARG} ${RUN_ARGS}
|
||||
exec runner daemon ${CONFIG_ARG} ${RUN_ARGS}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
while [ ! -d /etc/s6/docker/supervise ]; do
|
||||
sleep 0.1
|
||||
done
|
||||
|
||||
s6-svwait -U /etc/s6/docker
|
||||
|
||||
exec run.sh
|
||||
5
scripts/s6/runner/run
Executable file
5
scripts/s6/runner/run
Executable file
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
s6-svwait -U /etc/s6/docker
|
||||
|
||||
exec run.sh
|
||||
Reference in New Issue
Block a user