mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-08-06 17:04:22 +02:00
Compare commits
5 Commits
refactor/s
...
3618385b28
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3618385b28 | ||
|
|
55a625f733 | ||
|
|
47366f8f34 | ||
|
|
b7aeda6e7f | ||
|
|
aced51b4d5 |
@@ -85,7 +85,7 @@ jobs:
|
|||||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
|
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
|
||||||
|
|
||||||
- name: Login to DockerHub
|
- name: Login to DockerHub
|
||||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ jobs:
|
|||||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
|
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
|
||||||
|
|
||||||
- name: Login to DockerHub
|
- name: Login to DockerHub
|
||||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|||||||
@@ -37,12 +37,8 @@ linters:
|
|||||||
rules:
|
rules:
|
||||||
main:
|
main:
|
||||||
deny:
|
deny:
|
||||||
- pkg: io/ioutil
|
|
||||||
desc: use os or io instead
|
|
||||||
- pkg: golang.org/x/exp
|
- pkg: golang.org/x/exp
|
||||||
desc: it's experimental and unreliable
|
desc: it's experimental and unreliable
|
||||||
- pkg: github.com/pkg/errors
|
|
||||||
desc: use builtin errors package instead
|
|
||||||
nolintlint:
|
nolintlint:
|
||||||
allow-unused: false
|
allow-unused: false
|
||||||
require-explanation: true
|
require-explanation: true
|
||||||
|
|||||||
22
AGENTS.md
22
AGENTS.md
@@ -1,10 +1,18 @@
|
|||||||
|
- Never assume, verify before claiming
|
||||||
- Use `make help` to find available development targets
|
- Use `make help` to find available development targets
|
||||||
- Run `make fmt` to format `.go` files, and run `make lint-go` to lint them
|
- PR descriptions: minimal, only what and why, no task lists or file listings
|
||||||
- Run `make tidy` after any `go.mod` changes
|
- Reference issues and PRs by full URL, not by number
|
||||||
- Run single go unit tests with `go test -run '^TestName$' ./modulepath/`
|
- Use Conventional Commits for commit messages and PR titles, plus the `enhance` type for user-facing enhancements
|
||||||
|
- Add an `Assisted-by: AGENT_NAME:MODEL_VERSION` trailer to commit messages, never `Co-Authored-By` or `Signed-off-by`
|
||||||
|
- Attribute agent authorship on one trailing line in issue and pull request comments, never as a PR description section
|
||||||
|
- Never force-push, amend, or squash unless asked. Use new commits and normal push for pull request updates
|
||||||
|
- Comments: write almost none, short and preferably same-line, explaining why for a future reader. Never narrate code, the change or the prompt. Preserve existing ones that still apply
|
||||||
- Add the current year into the copyright header of new `.go` files
|
- Add the current year into the copyright header of new `.go` files
|
||||||
- Ensure no trailing whitespace in edited files
|
- Ensure no trailing whitespace in edited files
|
||||||
- Never force-push, amend, or squash unless asked. Use new commits and normal push for pull request updates
|
- Run `make fmt` after `.go` edits, `make tidy` after `go.mod` edits, and `make checks` for the non-lint source checks
|
||||||
- Preserve existing code comments, do not remove or rewrite comments that are still relevant
|
- Lint what changed with `make lint-go`, and `make lint-go-windows` for Windows and platform-split files
|
||||||
- Include authorship attribution in issue and pull request comments
|
- Fix the cause rather than disabling a linter or weakening a test. Where unavoidable, use the narrowest scope with a trailing comment giving the reason
|
||||||
- Add `Co-Authored-By` lines to all commits, indicating name and model used
|
- Run single go tests with `go test -run '^TestName$' ./modulepath/`. `make test` self-skips the integration tests without docker or network, `make test-dind` runs the daemon-facing tests against the built dind image
|
||||||
|
- Write the fewest, fastest tests covering the behavior, extending an existing one where possible. Prefer unit tests where logic is testable in isolation
|
||||||
|
- Wait on a deterministic condition rather than `sleep`
|
||||||
|
- Update the files under `docs/` when behavior documented there changes
|
||||||
|
|||||||
4
Makefile
4
Makefile
@@ -6,7 +6,7 @@ SHASUM ?= shasum -a 256
|
|||||||
HAS_GO = $(shell hash $(GO) > /dev/null 2>&1 && echo "GO" || echo "NOGO" )
|
HAS_GO = $(shell hash $(GO) > /dev/null 2>&1 && echo "GO" || echo "NOGO" )
|
||||||
XGO_PACKAGE ?= src.techknowlogick.com/xgo@v1.9.0 # renovate: datasource=go
|
XGO_PACKAGE ?= src.techknowlogick.com/xgo@v1.9.0 # renovate: datasource=go
|
||||||
XGO_VERSION := go-1.26.x
|
XGO_VERSION := go-1.26.x
|
||||||
GXZ_PACKAGE ?= github.com/ulikunitz/xz/cmd/gxz@v0.5.15 # renovate: datasource=go
|
GXZ_PACKAGE ?= github.com/ulikunitz/xz/cmd/gxz@v0.5.16 # renovate: datasource=go
|
||||||
|
|
||||||
LINUX_ARCHS ?= linux/amd64,linux/arm64
|
LINUX_ARCHS ?= linux/amd64,linux/arm64
|
||||||
DARWIN_ARCHS ?= darwin-12/amd64,darwin-12/arm64
|
DARWIN_ARCHS ?= darwin-12/amd64,darwin-12/arm64
|
||||||
@@ -19,7 +19,7 @@ DOCKER_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)
|
|||||||
DOCKER_ROOTLESS_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)-dind-rootless
|
DOCKER_ROOTLESS_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)-dind-rootless
|
||||||
|
|
||||||
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2 # renovate: datasource=go
|
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2 # renovate: datasource=go
|
||||||
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1.3.0 # renovate: datasource=go
|
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1.6.0 # renovate: datasource=go
|
||||||
|
|
||||||
GOTEST_FLAGS ?= -race -timeout 20m -parallel 8
|
GOTEST_FLAGS ?= -race -timeout 20m -parallel 8
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,13 @@ type credKey struct{}
|
|||||||
// poison another repo's cache, even from inside a container that reaches the
|
// poison another repo's cache, even from inside a container that reaches the
|
||||||
// cache server over the docker bridge network.
|
// cache server over the docker bridge network.
|
||||||
type JobCredential struct {
|
type JobCredential struct {
|
||||||
Repo string
|
Repo string `json:"repo"`
|
||||||
|
|
||||||
|
// Results is the instance whose artifact service this server forwards for the job, and
|
||||||
|
// InsecureTLS how the runner reaches it; see results.go. The tags are the wire format a
|
||||||
|
// remote runner registers with.
|
||||||
|
Results string `json:"results"`
|
||||||
|
InsecureTLS bool `json:"insecure_tls"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// credEntry holds a registered job's credential along with an active
|
// credEntry holds a registered job's credential along with an active
|
||||||
@@ -165,6 +171,7 @@ func StartHandler(dir, outboundIP string, port uint16, internalSecret string, lo
|
|||||||
router.POST(internalPath+"/register", h.internalAuth(h.internalRegister))
|
router.POST(internalPath+"/register", h.internalAuth(h.internalRegister))
|
||||||
router.POST(internalPath+"/revoke", h.internalAuth(h.internalRevoke))
|
router.POST(internalPath+"/revoke", h.internalAuth(h.internalRevoke))
|
||||||
h.registerV2Routes(router)
|
h.registerV2Routes(router)
|
||||||
|
router.NotFound = http.HandlerFunc(h.forwardOrNotFound)
|
||||||
|
|
||||||
h.router = router
|
h.router = router
|
||||||
|
|
||||||
@@ -211,10 +218,11 @@ func (h *Handler) ExternalURL() string {
|
|||||||
// is only accepted while the job is running.
|
// is only accepted while the job is running.
|
||||||
//
|
//
|
||||||
// Registrations are reference-counted: if a token is already registered, the
|
// Registrations are reference-counted: if a token is already registered, the
|
||||||
// existing repo is kept and the refcount is incremented. The entry is
|
// credential it was registered with is kept and the refcount is incremented.
|
||||||
// removed only when every revoker returned by RegisterJob has been called.
|
// 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.
|
// This keeps a stray re-registration from silently revoking a live job.
|
||||||
func (h *Handler) RegisterJob(token, repo string) func() {
|
func (h *Handler) RegisterJob(token string, cred JobCredential) func() {
|
||||||
if h == nil || token == "" {
|
if h == nil || token == "" {
|
||||||
return func() {}
|
return func() {}
|
||||||
}
|
}
|
||||||
@@ -223,7 +231,7 @@ func (h *Handler) RegisterJob(token, repo string) func() {
|
|||||||
existing.refs++
|
existing.refs++
|
||||||
} else {
|
} else {
|
||||||
h.creds[token] = &credEntry{
|
h.creds[token] = &credEntry{
|
||||||
cred: JobCredential{Repo: repo},
|
cred: cred,
|
||||||
refs: 1,
|
refs: 1,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -619,7 +627,7 @@ func (h *Handler) internalAuth(handler httprouter.Handle) httprouter.Handle {
|
|||||||
|
|
||||||
type internalRegisterBody struct {
|
type internalRegisterBody struct {
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
Repo string `json:"repo"`
|
JobCredential
|
||||||
}
|
}
|
||||||
|
|
||||||
type internalRevokeBody struct {
|
type internalRevokeBody struct {
|
||||||
@@ -627,6 +635,15 @@ type internalRevokeBody struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// POST /_internal/register
|
// POST /_internal/register
|
||||||
|
// ResultsURL is what a job registered with cred should be given as ACTIONS_RESULTS_URL, or "" when
|
||||||
|
// the credential names no instance to forward the artifact half to.
|
||||||
|
func (h *Handler) ResultsURL(cred JobCredential) string {
|
||||||
|
if h == nil || cred.Results == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return h.ExternalURL()
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) internalRegister(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
func (h *Handler) internalRegister(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
||||||
var body internalRegisterBody
|
var body internalRegisterBody
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
@@ -637,8 +654,9 @@ func (h *Handler) internalRegister(w http.ResponseWriter, r *http.Request, _ htt
|
|||||||
h.responseJSON(w, r, http.StatusBadRequest, errors.New("token is required"))
|
h.responseJSON(w, r, http.StatusBadRequest, errors.New("token is required"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.RegisterJob(body.Token, body.Repo)
|
h.RegisterJob(body.Token, body.JobCredential)
|
||||||
h.responseJSON(w, r, http.StatusOK)
|
// A server too old to forward answers without this, which is how the caller knows.
|
||||||
|
h.responseJSON(w, r, http.StatusOK, map[string]any{"results_url": h.ResultsURL(body.JobCredential)})
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /_internal/revoke
|
// POST /_internal/revoke
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ func TestHandler(t *testing.T) {
|
|||||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
dir := filepath.Join(t.TempDir(), "artifactcache")
|
||||||
handler, err := StartHandler(dir, "", 0, "", nil)
|
handler, err := StartHandler(dir, "", 0, "", nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
handler.RegisterJob(testToken, testRepo)
|
handler.RegisterJob(testToken, JobCredential{Repo: testRepo})
|
||||||
|
|
||||||
base := fmt.Sprintf("%s%s", handler.ExternalURL(), apiPath)
|
base := fmt.Sprintf("%s%s", handler.ExternalURL(), apiPath)
|
||||||
|
|
||||||
@@ -890,7 +890,7 @@ func TestHandler_UnregisterRevokes(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer handler.Close()
|
defer handler.Close()
|
||||||
|
|
||||||
unregister := handler.RegisterJob("tmp-token", testRepo)
|
unregister := handler.RegisterJob("tmp-token", JobCredential{Repo: testRepo})
|
||||||
|
|
||||||
base := handler.ExternalURL() + apiPath
|
base := handler.ExternalURL() + apiPath
|
||||||
req, err := http.NewRequest(http.MethodGet, base+"/cache?keys=x&version=y", nil)
|
req, err := http.NewRequest(http.MethodGet, base+"/cache?keys=x&version=y", nil)
|
||||||
@@ -920,8 +920,8 @@ func TestHandler_CrossRepoIsolation(t *testing.T) {
|
|||||||
handler, err := StartHandler(dir, "", 0, "", nil)
|
handler, err := StartHandler(dir, "", 0, "", nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer handler.Close()
|
defer handler.Close()
|
||||||
handler.RegisterJob("token-a", "owner/repoA")
|
handler.RegisterJob("token-a", JobCredential{Repo: "owner/repoA"})
|
||||||
handler.RegisterJob("token-b", "owner/repoB")
|
handler.RegisterJob("token-b", JobCredential{Repo: "owner/repoB"})
|
||||||
|
|
||||||
base := handler.ExternalURL() + apiPath
|
base := handler.ExternalURL() + apiPath
|
||||||
key := "shared-key"
|
key := "shared-key"
|
||||||
@@ -986,7 +986,7 @@ func TestHandler_ArtifactSignature(t *testing.T) {
|
|||||||
handler, err := StartHandler(dir, "", 0, "", nil)
|
handler, err := StartHandler(dir, "", 0, "", nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer handler.Close()
|
defer handler.Close()
|
||||||
handler.RegisterJob(testToken, testRepo)
|
handler.RegisterJob(testToken, JobCredential{Repo: testRepo})
|
||||||
|
|
||||||
base := handler.ExternalURL() + apiPath
|
base := handler.ExternalURL() + apiPath
|
||||||
|
|
||||||
@@ -1059,7 +1059,7 @@ func TestHandler_ArtifactSignatureDownload(t *testing.T) {
|
|||||||
handler, err := StartHandler(dir, "", 0, "", nil)
|
handler, err := StartHandler(dir, "", 0, "", nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer handler.Close()
|
defer handler.Close()
|
||||||
handler.RegisterJob(testToken, testRepo)
|
handler.RegisterJob(testToken, JobCredential{Repo: testRepo})
|
||||||
|
|
||||||
base := handler.ExternalURL() + apiPath
|
base := handler.ExternalURL() + apiPath
|
||||||
key := "download-key"
|
key := "download-key"
|
||||||
@@ -1100,8 +1100,8 @@ func TestHandler_RegisterJob_RefCounted(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer handler.Close()
|
defer handler.Close()
|
||||||
|
|
||||||
first := handler.RegisterJob("shared", testRepo)
|
first := handler.RegisterJob("shared", JobCredential{Repo: testRepo})
|
||||||
second := handler.RegisterJob("shared", testRepo)
|
second := handler.RegisterJob("shared", JobCredential{Repo: testRepo})
|
||||||
|
|
||||||
base := handler.ExternalURL() + apiPath
|
base := handler.ExternalURL() + apiPath
|
||||||
probe := func() int {
|
probe := func() int {
|
||||||
@@ -1131,8 +1131,8 @@ func TestHandler_GC_PerRepoDedup(t *testing.T) {
|
|||||||
handler, err := StartHandler(dir, "", 0, "", nil)
|
handler, err := StartHandler(dir, "", 0, "", nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer handler.Close()
|
defer handler.Close()
|
||||||
handler.RegisterJob("tok-a", "owner/repoA")
|
handler.RegisterJob("tok-a", JobCredential{Repo: "owner/repoA"})
|
||||||
handler.RegisterJob("tok-b", "owner/repoB")
|
handler.RegisterJob("tok-b", JobCredential{Repo: "owner/repoB"})
|
||||||
|
|
||||||
key := "shared-dedup-key"
|
key := "shared-dedup-key"
|
||||||
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
|
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ import (
|
|||||||
// endpoints, and uploads the archive to the returned URL with the Azure blob protocol.
|
// endpoints, and uploads the archive to the returned URL with the Azure blob protocol.
|
||||||
// Both API versions are served from the same store, so a repository keeps its cache
|
// Both API versions are served from the same store, so a repository keeps its cache
|
||||||
// when a workflow moves between action versions.
|
// when a workflow moves between action versions.
|
||||||
|
//
|
||||||
|
// Responses carry the proto field names, which is what Gitea's own results API emits and the only
|
||||||
|
// spelling the Go clients parse. The JavaScript toolkit accepts either.
|
||||||
const (
|
const (
|
||||||
cacheServiceV2Path = "/twirp/github.actions.results.api.v1.CacheService"
|
cacheServiceV2Path = "/twirp/github.actions.results.api.v1.CacheService"
|
||||||
|
|
||||||
@@ -94,7 +97,7 @@ func (h *Handler) v2CreateCacheEntry(w http.ResponseWriter, r *http.Request, _ h
|
|||||||
|
|
||||||
h.responseJSON(w, r, http.StatusOK, map[string]any{
|
h.responseJSON(w, r, http.StatusOK, map[string]any{
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"signedUploadUrl": h.signedURL(blobPath, blobUploadPurpose, cache.ID, time.Now().Add(blobUploadURLTTL)),
|
"signed_upload_url": h.signedURL(blobPath, blobUploadPurpose, cache.ID, time.Now().Add(blobUploadURLTTL)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,7 +137,7 @@ func (h *Handler) v2FinalizeCacheEntryUpload(w http.ResponseWriter, r *http.Requ
|
|||||||
h.responseJSON(w, r, http.StatusOK, map[string]any{
|
h.responseJSON(w, r, http.StatusOK, map[string]any{
|
||||||
"ok": true,
|
"ok": true,
|
||||||
// int64 fields travel as strings in the proto JSON mapping.
|
// int64 fields travel as strings in the proto JSON mapping.
|
||||||
"entryId": strconv.FormatUint(cache.ID, 10),
|
"entry_id": strconv.FormatUint(cache.ID, 10),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,8 +168,8 @@ func (h *Handler) v2GetCacheEntryDownloadURL(w http.ResponseWriter, r *http.Requ
|
|||||||
|
|
||||||
h.responseJSON(w, r, http.StatusOK, map[string]any{
|
h.responseJSON(w, r, http.StatusOK, map[string]any{
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"signedDownloadUrl": h.signedArtifactURL(cache.ID, time.Now().Add(artifactURLTTL)),
|
"signed_download_url": h.signedArtifactURL(cache.ID, time.Now().Add(artifactURLTTL)),
|
||||||
"matchedKey": cache.Key,
|
"matched_key": cache.Key,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,6 +212,8 @@ func (h *Handler) v2UploadBlob(w http.ResponseWriter, r *http.Request, params ht
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The Azure SDK client dereferences this without checking, so its absence panics the caller.
|
||||||
|
w.Header().Set("x-ms-request-id", strconv.FormatInt(time.Now().UnixNano(), 10))
|
||||||
w.WriteHeader(http.StatusCreated)
|
w.WriteHeader(http.StatusCreated)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,11 @@ func putBlob(t *testing.T, url string, content []byte) int {
|
|||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := http.DefaultClient.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode == http.StatusCreated {
|
||||||
|
// The Azure SDK client dereferences this header without checking, so a blob upload that
|
||||||
|
// omits it panics the caller rather than failing it.
|
||||||
|
require.NotEmpty(t, resp.Header.Get("x-ms-request-id"))
|
||||||
|
}
|
||||||
return resp.StatusCode
|
return resp.StatusCode
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +72,7 @@ func startTestHandler(t *testing.T) *Handler {
|
|||||||
handler, err := StartHandler(filepath.Join(t.TempDir(), "artifactcache"), "127.0.0.1", 0, "", nil)
|
handler, err := StartHandler(filepath.Join(t.TempDir(), "artifactcache"), "127.0.0.1", 0, "", nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
t.Cleanup(func() { _ = handler.Close() })
|
t.Cleanup(func() { _ = handler.Close() })
|
||||||
handler.RegisterJob(testToken, testRepo)
|
handler.RegisterJob(testToken, JobCredential{Repo: testRepo})
|
||||||
return handler
|
return handler
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,7 +83,7 @@ func saveV2(t *testing.T, handler *Handler, key, version string, content []byte)
|
|||||||
|
|
||||||
created := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": key, "version": version})
|
created := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": key, "version": version})
|
||||||
require.Equal(t, true, created["ok"])
|
require.Equal(t, true, created["ok"])
|
||||||
uploadURL, _ = created["signedUploadUrl"].(string)
|
uploadURL, _ = created["signed_upload_url"].(string)
|
||||||
require.NotEmpty(t, uploadURL)
|
require.NotEmpty(t, uploadURL)
|
||||||
require.Equal(t, http.StatusCreated, putBlob(t, uploadURL, content))
|
require.Equal(t, http.StatusCreated, putBlob(t, uploadURL, content))
|
||||||
|
|
||||||
@@ -100,7 +105,7 @@ func TestCacheServiceV2RoundTrip(t *testing.T) {
|
|||||||
|
|
||||||
finalized, uploadURL := saveV2(t, handler, "deps-v1", "abc123", content)
|
finalized, uploadURL := saveV2(t, handler, "deps-v1", "abc123", content)
|
||||||
require.Equal(t, true, finalized["ok"])
|
require.Equal(t, true, finalized["ok"])
|
||||||
assert.NotEmpty(t, finalized["entryId"])
|
assert.NotEmpty(t, finalized["entry_id"])
|
||||||
|
|
||||||
// The upload URL outlives the finalize call, so replaying it must not poison the entry,
|
// The upload URL outlives the finalize call, so replaying it must not poison the entry,
|
||||||
// and it is an upload URL only: nothing reads a blob back through it.
|
// and it is an upload URL only: nothing reads a blob back through it.
|
||||||
@@ -112,8 +117,8 @@ func TestCacheServiceV2RoundTrip(t *testing.T) {
|
|||||||
|
|
||||||
got := v2Call(t, handler, testClient, "GetCacheEntryDownloadURL", map[string]any{"key": "deps-v1", "version": "abc123"})
|
got := v2Call(t, handler, testClient, "GetCacheEntryDownloadURL", map[string]any{"key": "deps-v1", "version": "abc123"})
|
||||||
require.Equal(t, true, got["ok"])
|
require.Equal(t, true, got["ok"])
|
||||||
assert.Equal(t, "deps-v1", got["matchedKey"])
|
assert.Equal(t, "deps-v1", got["matched_key"])
|
||||||
downloadURL, _ := got["signedDownloadUrl"].(string)
|
downloadURL, _ := got["signed_download_url"].(string)
|
||||||
require.NotEmpty(t, downloadURL)
|
require.NotEmpty(t, downloadURL)
|
||||||
assert.Equal(t, content, getURL(t, downloadURL))
|
assert.Equal(t, content, getURL(t, downloadURL))
|
||||||
}
|
}
|
||||||
@@ -124,7 +129,7 @@ func TestCacheServiceV2BlockUpload(t *testing.T) {
|
|||||||
handler := startTestHandler(t)
|
handler := startTestHandler(t)
|
||||||
|
|
||||||
created := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": "blocks", "version": "v1"})
|
created := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": "blocks", "version": "v1"})
|
||||||
uploadURL, _ := created["signedUploadUrl"].(string)
|
uploadURL, _ := created["signed_upload_url"].(string)
|
||||||
require.NotEmpty(t, uploadURL)
|
require.NotEmpty(t, uploadURL)
|
||||||
|
|
||||||
blocks := map[string][]byte{}
|
blocks := map[string][]byte{}
|
||||||
@@ -154,7 +159,7 @@ func TestCacheServiceV2BlockUpload(t *testing.T) {
|
|||||||
|
|
||||||
got := v2Call(t, handler, testClient, "GetCacheEntryDownloadURL", map[string]any{"key": "blocks", "version": "v1"})
|
got := v2Call(t, handler, testClient, "GetCacheEntryDownloadURL", map[string]any{"key": "blocks", "version": "v1"})
|
||||||
require.Equal(t, true, got["ok"])
|
require.Equal(t, true, got["ok"])
|
||||||
assert.Equal(t, "hello world!", string(getURL(t, got["signedDownloadUrl"].(string))))
|
assert.Equal(t, "hello world!", string(getURL(t, got["signed_download_url"].(string))))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCacheServiceV2Lookups(t *testing.T) {
|
func TestCacheServiceV2Lookups(t *testing.T) {
|
||||||
@@ -175,7 +180,7 @@ func TestCacheServiceV2Lookups(t *testing.T) {
|
|||||||
"key": "deps-zzz", field: []string{"deps-"}, "version": "v1",
|
"key": "deps-zzz", field: []string{"deps-"}, "version": "v1",
|
||||||
})
|
})
|
||||||
require.Equal(t, true, got["ok"])
|
require.Equal(t, true, got["ok"])
|
||||||
assert.Equal(t, "deps-abc", got["matchedKey"])
|
assert.Equal(t, "deps-abc", got["matched_key"])
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,7 +195,7 @@ func TestCacheServiceV2Lookups(t *testing.T) {
|
|||||||
t.Run("a prefix of an existing key is still reserved", func(t *testing.T) {
|
t.Run("a prefix of an existing key is still reserved", func(t *testing.T) {
|
||||||
reserved := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": "deps", "version": "v1"})
|
reserved := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": "deps", "version": "v1"})
|
||||||
require.Equal(t, true, reserved["ok"])
|
require.Equal(t, true, reserved["ok"])
|
||||||
assert.NotEmpty(t, reserved["signedUploadUrl"])
|
assert.NotEmpty(t, reserved["signed_upload_url"])
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("finalizing without a reservation is not ok", func(t *testing.T) {
|
t.Run("finalizing without a reservation is not ok", func(t *testing.T) {
|
||||||
@@ -203,7 +208,7 @@ func TestCacheServiceV2Lookups(t *testing.T) {
|
|||||||
// The size the client declares is what Commit validates the assembled archive against.
|
// The size the client declares is what Commit validates the assembled archive against.
|
||||||
t.Run("finalizing with the wrong size is not ok", func(t *testing.T) {
|
t.Run("finalizing with the wrong size is not ok", func(t *testing.T) {
|
||||||
created := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": "wrong-size", "version": "v1"})
|
created := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": "wrong-size", "version": "v1"})
|
||||||
require.Equal(t, http.StatusCreated, putBlob(t, created["signedUploadUrl"].(string), []byte("four")))
|
require.Equal(t, http.StatusCreated, putBlob(t, created["signed_upload_url"].(string), []byte("four")))
|
||||||
|
|
||||||
got := v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{
|
got := v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{
|
||||||
"key": "wrong-size", "version": "v1", "size_bytes": 99,
|
"key": "wrong-size", "version": "v1", "size_bytes": 99,
|
||||||
@@ -227,7 +232,7 @@ func TestCacheServiceV2Lookups(t *testing.T) {
|
|||||||
|
|
||||||
// The cache of one repository must stay invisible to another, as it does for the v1 API.
|
// The cache of one repository must stay invisible to another, as it does for the v1 API.
|
||||||
t.Run("another repository sees nothing", func(t *testing.T) {
|
t.Run("another repository sees nothing", func(t *testing.T) {
|
||||||
handler.RegisterJob("other-runtime-token", "other/repo")
|
handler.RegisterJob("other-runtime-token", JobCredential{Repo: "other/repo"})
|
||||||
otherClient := &http.Client{Transport: &bearerTransport{token: "other-runtime-token"}}
|
otherClient := &http.Client{Transport: &bearerTransport{token: "other-runtime-token"}}
|
||||||
|
|
||||||
got := v2Call(t, handler, otherClient, "GetCacheEntryDownloadURL", map[string]any{"key": "deps-abc", "version": "v1"})
|
got := v2Call(t, handler, otherClient, "GetCacheEntryDownloadURL", map[string]any{"key": "deps-abc", "version": "v1"})
|
||||||
|
|||||||
59
act/artifactcache/results.go
Normal file
59
act/artifactcache/results.go
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package artifactcache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httputil"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The results service is one origin serving every github.actions.results.api.v1 service, and
|
||||||
|
// Gitea implements only the artifact half of it. Forwarding that half from here makes this origin
|
||||||
|
// the whole service, so ACTIONS_RESULTS_URL can point at it truthfully, which is what the clients
|
||||||
|
// this runner cannot patch need, docker buildx among them.
|
||||||
|
//
|
||||||
|
// The instance to forward to travels with the job registration rather than with configuration, so
|
||||||
|
// a cache server shared between runners serves each of their instances.
|
||||||
|
const artifactServicePath = "/twirp/github.actions.results.api.v1.ArtifactService/"
|
||||||
|
|
||||||
|
// forwardOrNotFound is the router's fallback: the artifact service of the instance the job
|
||||||
|
// registered with, and the 404 the router would have written otherwise.
|
||||||
|
func (h *Handler) forwardOrNotFound(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cred, ok := h.lookupCredential(bearerToken(r))
|
||||||
|
if !ok || cred.Results == "" || !strings.HasPrefix(r.URL.Path, artifactServicePath) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target, err := url.Parse(strings.TrimSuffix(cred.Results, "/"))
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Errorf("artifact service forward to %q: %v", cred.Results, err)
|
||||||
|
w.WriteHeader(http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logger.Debugf("%s %s: forwarding to %s", r.Method, r.URL.Path, target)
|
||||||
|
|
||||||
|
proxy := &httputil.ReverseProxy{
|
||||||
|
Rewrite: func(r *httputil.ProxyRequest) {
|
||||||
|
r.SetURL(target)
|
||||||
|
// Gitea builds the URLs it hands back from this Host, and their scheme from the
|
||||||
|
// connection unless a forwarded header overrides it, so artifact bodies go to Gitea
|
||||||
|
// directly and never through here.
|
||||||
|
r.Out.Host = target.Host
|
||||||
|
},
|
||||||
|
ErrorHandler: func(w http.ResponseWriter, _ *http.Request, err error) {
|
||||||
|
h.logger.Warnf("artifact service forward to %s: %v", target, err)
|
||||||
|
w.WriteHeader(http.StatusBadGateway)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if cred.InsecureTLS {
|
||||||
|
proxy.Transport = insecureTransport
|
||||||
|
}
|
||||||
|
proxy.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// insecureTransport is shared, because a transport per request would pool no connections.
|
||||||
|
var insecureTransport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} //nolint:gosec // the runner reaches its instance on the operator's say-so
|
||||||
56
act/artifactcache/results_test.go
Normal file
56
act/artifactcache/results_test.go
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package artifactcache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The artifact half is forwarded under the Host Gitea knows itself by, so the URLs it hands back
|
||||||
|
// still point at Gitea, and nothing else is proxied.
|
||||||
|
func TestFrontResultsService(t *testing.T) {
|
||||||
|
var gotHost, gotPath, gotProto string
|
||||||
|
gitea := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotHost, gotPath, gotProto = r.Host, r.URL.Path, r.Header.Get("X-Forwarded-Proto")
|
||||||
|
_, _ = io.WriteString(w, `{"ok":true}`)
|
||||||
|
}))
|
||||||
|
defer gitea.Close()
|
||||||
|
|
||||||
|
handler, err := StartHandler(t.TempDir(), "127.0.0.1", 0, "", nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer handler.Close()
|
||||||
|
const token = "forward-token"
|
||||||
|
|
||||||
|
client := &http.Client{Transport: &bearerTransport{token: token}}
|
||||||
|
post := func(path string) int {
|
||||||
|
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, handler.ExternalURL()+path, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
resp.Body.Close()
|
||||||
|
return resp.StatusCode
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusNotFound, post(artifactServicePath+"CreateArtifact"),
|
||||||
|
"an unregistered token is forwarded nowhere")
|
||||||
|
|
||||||
|
defer handler.RegisterJob(token, JobCredential{Repo: "owner/repo", Results: gitea.URL})()
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, post(artifactServicePath+"CreateArtifact"))
|
||||||
|
assert.Equal(t, strings.TrimPrefix(gitea.URL, "http://"), gotHost, "Gitea must see the host it mints its URLs from")
|
||||||
|
assert.Empty(t, gotProto, "a forwarded scheme would make an https Gitea mint http URLs")
|
||||||
|
assert.Equal(t, artifactServicePath+"CreateArtifact", gotPath)
|
||||||
|
|
||||||
|
gotPath = ""
|
||||||
|
assert.Equal(t, http.StatusNotFound, post("/twirp/github.actions.results.api.v1.OtherService/Do"))
|
||||||
|
assert.Equal(t, http.StatusNotFound, post("/api/v1/repos/owner/repo"))
|
||||||
|
assert.Empty(t, gotPath, "only the artifact service is forwarded")
|
||||||
|
}
|
||||||
60
act/common/cartesian.go
Normal file
60
act/common/cartesian.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package common
|
||||||
|
|
||||||
|
import "slices"
|
||||||
|
|
||||||
|
// CartesianProduct takes map of lists and returns list of unique tuples
|
||||||
|
func CartesianProduct(mapOfLists map[string][]any) []map[string]any {
|
||||||
|
listNames := make([]string, 0)
|
||||||
|
lists := make([][]any, 0)
|
||||||
|
for k, v := range mapOfLists {
|
||||||
|
listNames = append(listNames, k)
|
||||||
|
lists = append(lists, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
listCart := cartN(lists...)
|
||||||
|
|
||||||
|
rtn := make([]map[string]any, 0)
|
||||||
|
for _, list := range listCart {
|
||||||
|
vMap := make(map[string]any)
|
||||||
|
for i, v := range list {
|
||||||
|
vMap[listNames[i]] = v
|
||||||
|
}
|
||||||
|
rtn = append(rtn, vMap)
|
||||||
|
}
|
||||||
|
return rtn
|
||||||
|
}
|
||||||
|
|
||||||
|
func cartN(a ...[]any) [][]any {
|
||||||
|
c := 1
|
||||||
|
for _, a := range a {
|
||||||
|
c *= len(a)
|
||||||
|
}
|
||||||
|
if c == 0 || len(a) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
p := make([][]any, c)
|
||||||
|
b := make([]any, c*len(a))
|
||||||
|
n := make([]int, len(a))
|
||||||
|
s := 0
|
||||||
|
for i := range p {
|
||||||
|
e := s + len(a)
|
||||||
|
pi := b[s:e]
|
||||||
|
p[i] = pi
|
||||||
|
s = e
|
||||||
|
for j, n := range n {
|
||||||
|
pi[j] = a[j][n]
|
||||||
|
}
|
||||||
|
for j := range slices.Backward(n) {
|
||||||
|
n[j]++
|
||||||
|
if n[j] < len(a[j]) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
n[j] = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
43
act/common/cartesian_test.go
Normal file
43
act/common/cartesian_test.go
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCartesianProduct(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
input := map[string][]any{
|
||||||
|
"foo": {1, 2, 3, 4},
|
||||||
|
"bar": {"a", "b", "c"},
|
||||||
|
"baz": {false, true},
|
||||||
|
}
|
||||||
|
|
||||||
|
output := CartesianProduct(input)
|
||||||
|
assert.Len(output, 24)
|
||||||
|
|
||||||
|
for _, v := range output {
|
||||||
|
assert.Len(v, 3)
|
||||||
|
|
||||||
|
assert.Contains(v, "foo")
|
||||||
|
assert.Contains(v, "bar")
|
||||||
|
assert.Contains(v, "baz")
|
||||||
|
}
|
||||||
|
|
||||||
|
input = map[string][]any{
|
||||||
|
"foo": {1, 2, 3, 4},
|
||||||
|
"bar": {},
|
||||||
|
"baz": {false, true},
|
||||||
|
}
|
||||||
|
output = CartesianProduct(input)
|
||||||
|
assert.Empty(output)
|
||||||
|
|
||||||
|
input = map[string][]any{}
|
||||||
|
output = CartesianProduct(input)
|
||||||
|
assert.Empty(output)
|
||||||
|
}
|
||||||
@@ -4,8 +4,10 @@
|
|||||||
|
|
||||||
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
||||||
|
|
||||||
// This file is exact copy of https://github.com/docker/cli/blob/9a471180cb7d39c236d090399a9d362c3f5a8ebd/cli/command/container/opts.go
|
// This file is exact copy of https://github.com/docker/cli/blob/dfc4efb1e2ab8c06d70d2a1366ad448d2f917e90/cli/command/container/opts.go with:
|
||||||
// appended with license information.
|
// * appended with license information
|
||||||
|
// * regexp and loader.ParseVolume in place of the import-restricted internal/lazyregexp and internal/volumespec
|
||||||
|
// * invalidParameter from the package's errors.go, and convertPortSet/convertPortMap for the callers in docker_run.go
|
||||||
//
|
//
|
||||||
// docker/cli is licensed under the Apache License, Version 2.0.
|
// docker/cli is licensed under the Apache License, Version 2.0.
|
||||||
// See DOCKER_LICENSE for the full license text.
|
// See DOCKER_LICENSE for the full license text.
|
||||||
@@ -30,6 +32,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
cerrdefs "github.com/containerd/errdefs"
|
||||||
"github.com/docker/cli/cli/compose/loader"
|
"github.com/docker/cli/cli/compose/loader"
|
||||||
"github.com/docker/cli/opts"
|
"github.com/docker/cli/opts"
|
||||||
"github.com/docker/go-connections/nat"
|
"github.com/docker/go-connections/nat"
|
||||||
@@ -380,7 +383,7 @@ func parse(flags *pflag.FlagSet, copts *containerOptions, serverOS string) (*con
|
|||||||
var binds []string
|
var binds []string
|
||||||
volumes := copts.volumes.GetMap()
|
volumes := copts.volumes.GetMap()
|
||||||
// add any bind targets to the list of container volumes
|
// add any bind targets to the list of container volumes
|
||||||
for bind := range copts.volumes.GetMap() {
|
for bind := range volumes {
|
||||||
parsed, err := loader.ParseVolume(bind)
|
parsed, err := loader.ParseVolume(bind)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -515,13 +518,13 @@ func parse(flags *pflag.FlagSet, copts *containerOptions, serverOS string) (*con
|
|||||||
// collect all the environment variables for the container
|
// collect all the environment variables for the container
|
||||||
envVariables, err := opts.ReadKVEnvStrings(copts.envFile.GetSlice(), copts.env.GetSlice())
|
envVariables, err := opts.ReadKVEnvStrings(copts.envFile.GetSlice(), copts.env.GetSlice())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("--env-file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// collect all the labels for the container
|
// collect all the labels for the container
|
||||||
labels, err := opts.ReadKVStrings(copts.labelsFile.GetSlice(), copts.labels.GetSlice())
|
labels, err := opts.ReadKVStrings(copts.labelsFile.GetSlice(), copts.labels.GetSlice())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("--label-file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
pidMode := container.PidMode(copts.pidMode)
|
pidMode := container.PidMode(copts.pidMode)
|
||||||
@@ -1164,16 +1167,17 @@ func toNetipAddrSlice(ips []string) []netip.Addr {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// invalidParameter wraps an error to indicate it was caused by invalid input.
|
// invalidParameter wraps an error to indicate it was caused by invalid input.
|
||||||
// This is a local replacement for docker/docker/errdefs.InvalidParameter.
|
// This is a copy of docker/cli's cli/command/container/errors.go, which is not importable.
|
||||||
type invalidParameterError struct{ error }
|
type invalidParameterErr struct{ error }
|
||||||
|
|
||||||
func (e invalidParameterError) InvalidParameter() {}
|
func (invalidParameterErr) InvalidParameter() {}
|
||||||
|
func (e invalidParameterErr) Unwrap() error { return e.error }
|
||||||
|
|
||||||
func invalidParameter(err error) error {
|
func invalidParameter(err error) error {
|
||||||
if err == nil {
|
if err == nil || cerrdefs.IsInvalidArgument(err) {
|
||||||
return nil
|
return err
|
||||||
}
|
}
|
||||||
return invalidParameterError{err}
|
return invalidParameterErr{err}
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertPortSet(ports nat.PortSet) (network.PortSet, error) {
|
func convertPortSet(ports nat.PortSet) (network.PortSet, error) {
|
||||||
|
|||||||
@@ -2,20 +2,22 @@
|
|||||||
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
// This file is exact copy of https://github.com/docker/cli/blob/9ac8584acfd501c3f4da0e845e3a40ed15c85041/cli/command/container/opts_test.go with:
|
// This file is exact copy of https://github.com/docker/cli/blob/dfc4efb1e2ab8c06d70d2a1366ad448d2f917e90/cli/command/container/opts_test.go with:
|
||||||
// * appended with license information
|
// * appended with license information
|
||||||
// * commented out case 'invalid-mixed-network-types' in test TestParseNetworkConfig
|
// * added tests for the locally changed parseDevice, validateDevice and invalidParameter
|
||||||
//
|
//
|
||||||
// docker/cli is licensed under the Apache License, Version 2.0.
|
// docker/cli is licensed under the Apache License, Version 2.0.
|
||||||
// See DOCKER_LICENSE for the full license text.
|
// See DOCKER_LICENSE for the full license text.
|
||||||
//
|
//
|
||||||
|
|
||||||
//nolint:depguard,gocritic // verbatim copy from docker/cli tests
|
//nolint:gocritic // verbatim copy from docker/cli tests
|
||||||
package container
|
package container
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
@@ -23,18 +25,23 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/docker/go-connections/nat"
|
|
||||||
"github.com/google/go-cmp/cmp"
|
|
||||||
"github.com/google/go-cmp/cmp/cmpopts"
|
"github.com/google/go-cmp/cmp/cmpopts"
|
||||||
"github.com/moby/moby/api/types/container"
|
"github.com/moby/moby/api/types/container"
|
||||||
networktypes "github.com/moby/moby/api/types/network"
|
networktypes "github.com/moby/moby/api/types/network"
|
||||||
"github.com/pkg/errors"
|
|
||||||
"github.com/spf13/pflag"
|
"github.com/spf13/pflag"
|
||||||
"gotest.tools/v3/assert"
|
"gotest.tools/v3/assert"
|
||||||
is "gotest.tools/v3/assert/cmp"
|
is "gotest.tools/v3/assert/cmp"
|
||||||
"gotest.tools/v3/skip"
|
"gotest.tools/v3/skip"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func mustParseMAC(s string) networktypes.HardwareAddr {
|
||||||
|
mac, err := net.ParseMAC(s)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return networktypes.HardwareAddr(mac)
|
||||||
|
}
|
||||||
|
|
||||||
func TestValidateAttach(t *testing.T) {
|
func TestValidateAttach(t *testing.T) {
|
||||||
valid := []string{
|
valid := []string{
|
||||||
"stdin",
|
"stdin",
|
||||||
@@ -64,12 +71,12 @@ func parseRun(args []string) (*container.Config, *container.HostConfig, *network
|
|||||||
if err := flags.Parse(args); err != nil {
|
if err := flags.Parse(args); err != nil {
|
||||||
return nil, nil, nil, err
|
return nil, nil, nil, err
|
||||||
}
|
}
|
||||||
// TODO: fix tests to accept ContainerConfig
|
// TODO(dnephin): fix tests to accept ContainerConfig; see https://github.com/moby/moby/pull/31621
|
||||||
containerConfig, err := parse(flags, copts, runtime.GOOS)
|
containerCfg, err := parse(flags, copts, runtime.GOOS)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, nil, err
|
return nil, nil, nil, err
|
||||||
}
|
}
|
||||||
return containerConfig.Config, containerConfig.HostConfig, containerConfig.NetworkingConfig, err
|
return containerCfg.Config, containerCfg.HostConfig, containerCfg.NetworkingConfig, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupRunFlags() (*pflag.FlagSet, *containerOptions) {
|
func setupRunFlags() (*pflag.FlagSet, *containerOptions) {
|
||||||
@@ -82,20 +89,81 @@ func setupRunFlags() (*pflag.FlagSet, *containerOptions) {
|
|||||||
|
|
||||||
func mustParse(t *testing.T, args string) (*container.Config, *container.HostConfig, *networktypes.NetworkingConfig) {
|
func mustParse(t *testing.T, args string) (*container.Config, *container.HostConfig, *networktypes.NetworkingConfig) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
config, hostConfig, networkingConfig, err := parseRun(append(strings.Split(args, " "), "ubuntu", "bash"))
|
config, hostConfig, nwConfig, err := parseRun(append(strings.Split(args, " "), "ubuntu", "bash"))
|
||||||
assert.NilError(t, err)
|
assert.NilError(t, err)
|
||||||
return config, hostConfig, networkingConfig
|
return config, hostConfig, nwConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseRunLinks(t *testing.T) {
|
func TestParseRunLinks(t *testing.T) {
|
||||||
if _, hostConfig, _ := mustParse(t, "--link a:b"); len(hostConfig.Links) == 0 || hostConfig.Links[0] != "a:b" {
|
tests := []struct {
|
||||||
t.Fatalf("Error parsing links. Expected []string{\"a:b\"}, received: %v", hostConfig.Links)
|
name string
|
||||||
|
input string
|
||||||
|
expHostConfigLinks []string
|
||||||
|
expNetConfigLinks map[string][]string
|
||||||
|
}{
|
||||||
|
// Default bridge - legacy links ...
|
||||||
|
{
|
||||||
|
name: "default/onelink",
|
||||||
|
input: "--link a:b",
|
||||||
|
expHostConfigLinks: []string{"a:b"},
|
||||||
|
expNetConfigLinks: map[string][]string{"default": nil},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "default/twolinks",
|
||||||
|
input: "--link a:b --link c:d",
|
||||||
|
expHostConfigLinks: []string{"a:b", "c:d"},
|
||||||
|
expNetConfigLinks: map[string][]string{"default": nil},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bridge/onelink",
|
||||||
|
input: "--network bridge --link a:b",
|
||||||
|
expHostConfigLinks: []string{"a:b"},
|
||||||
|
// expNetConfigLinks - no EndpointsConfig is created for a single named network with no options set.
|
||||||
|
// See the "For backward compatibility" comment in parseNetworkOpts().
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "default/nolinks",
|
||||||
|
expNetConfigLinks: map[string][]string{"default": nil},
|
||||||
|
},
|
||||||
|
|
||||||
|
// User-defined bridge - links become DNS aliases ...
|
||||||
|
{
|
||||||
|
name: "userdefnet/onelink",
|
||||||
|
input: "--network userdefnet --link a:b",
|
||||||
|
expHostConfigLinks: []string{"a:b"},
|
||||||
|
expNetConfigLinks: map[string][]string{"userdefnet": {"a:b"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "userdefnet/twolinks",
|
||||||
|
input: "--network userdefnet --link a:b --link c:d",
|
||||||
|
expHostConfigLinks: []string{"a:b", "c:d"},
|
||||||
|
expNetConfigLinks: map[string][]string{"userdefnet": {"a:b", "c:d"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "userdefnet/nolinks",
|
||||||
|
input: "--network userdefnet",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Link options are applied to the first network (and there's no "advanced syntax"
|
||||||
|
// link key, like "--network name=userdefnet,link=a:b").
|
||||||
|
name: "links apply to the first network",
|
||||||
|
input: "--network userdefnet --link a:b --network bar --link c:d",
|
||||||
|
expHostConfigLinks: []string{"a:b", "c:d"},
|
||||||
|
expNetConfigLinks: map[string][]string{"userdefnet": {"a:b", "c:d"}, "bar": nil},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
if _, hostConfig, _ := mustParse(t, "--link a:b --link c:d"); len(hostConfig.Links) < 2 || hostConfig.Links[0] != "a:b" || hostConfig.Links[1] != "c:d" {
|
|
||||||
t.Fatalf("Error parsing links. Expected []string{\"a:b\", \"c:d\"}, received: %v", hostConfig.Links)
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
_, hostConfig, netConfig := mustParse(t, tc.input)
|
||||||
|
assert.Check(t, is.DeepEqual(hostConfig.Links, tc.expHostConfigLinks))
|
||||||
|
assert.Check(t, is.Len(netConfig.EndpointsConfig, len(tc.expNetConfigLinks)))
|
||||||
|
for netName, expLinks := range tc.expNetConfigLinks {
|
||||||
|
nc, ok := netConfig.EndpointsConfig[netName]
|
||||||
|
assert.Assert(t, ok)
|
||||||
|
assert.Check(t, is.DeepEqual(nc.Links, expLinks))
|
||||||
}
|
}
|
||||||
if _, hostConfig, _ := mustParse(t, ""); len(hostConfig.Links) != 0 {
|
})
|
||||||
t.Fatalf("Error parsing links. No link expected, received: %v", hostConfig.Links)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,37 +362,7 @@ func compareRandomizedStrings(a, b, c, d string) error {
|
|||||||
if a == d && b == c {
|
if a == d && b == c {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return errors.Errorf("strings don't match")
|
return errors.New("strings don't match")
|
||||||
}
|
|
||||||
|
|
||||||
func mustNetworkPort(t *testing.T, value string) networktypes.Port {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
port, err := networktypes.ParsePort(value)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to parse network port %q: %v", value, err)
|
|
||||||
}
|
|
||||||
return port
|
|
||||||
}
|
|
||||||
|
|
||||||
func mustAddr(t *testing.T, value string) netip.Addr {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
addr, err := netip.ParseAddr(value)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to parse address %q: %v", value, err)
|
|
||||||
}
|
|
||||||
return addr
|
|
||||||
}
|
|
||||||
|
|
||||||
func mustAddrs(t *testing.T, values ...string) []netip.Addr {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
addrs := make([]netip.Addr, 0, len(values))
|
|
||||||
for _, value := range values {
|
|
||||||
addrs = append(addrs, mustAddr(t, value))
|
|
||||||
}
|
|
||||||
return addrs
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simple parse with MacAddress validation
|
// Simple parse with MacAddress validation
|
||||||
@@ -334,10 +372,11 @@ func TestParseWithMacAddress(t *testing.T) {
|
|||||||
if _, _, _, err := parseRun([]string{invalidMacAddress, "img", "cmd"}); err != nil && err.Error() != "invalidMacAddress is not a valid mac address" {
|
if _, _, _, err := parseRun([]string{invalidMacAddress, "img", "cmd"}); err != nil && err.Error() != "invalidMacAddress is not a valid mac address" {
|
||||||
t.Fatalf("Expected an error with %v mac-address, got %v", invalidMacAddress, err)
|
t.Fatalf("Expected an error with %v mac-address, got %v", invalidMacAddress, err)
|
||||||
}
|
}
|
||||||
_, hostConfig, networkingConfig := mustParse(t, validMacAddress)
|
_, hostConfig, nwConfig := mustParse(t, validMacAddress)
|
||||||
endpoint := networkingConfig.EndpointsConfig[string(hostConfig.NetworkMode)]
|
defaultNw := hostConfig.NetworkMode.NetworkName()
|
||||||
assert.Check(t, endpoint != nil)
|
if nwConfig.EndpointsConfig[defaultNw].MacAddress.String() != "92:d0:c6:0a:29:33" {
|
||||||
assert.Equal(t, "92:d0:c6:0a:29:33", endpoint.MacAddress.String())
|
t.Fatalf("Expected the default endpoint to have the MacAddress '92:d0:c6:0a:29:33' set, got '%v'", nwConfig.EndpointsConfig[defaultNw].MacAddress)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunFlagsParseWithMemory(t *testing.T) {
|
func TestRunFlagsParseWithMemory(t *testing.T) {
|
||||||
@@ -408,93 +447,144 @@ func TestParseHostnameDomainname(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestParseWithExpose(t *testing.T) {
|
func TestParseWithExpose(t *testing.T) {
|
||||||
invalids := []string{
|
t.Run("invalid", func(t *testing.T) {
|
||||||
":",
|
tests := map[string]string{
|
||||||
"8080:9090",
|
":": `invalid range format for --expose: invalid start port ':': invalid syntax`,
|
||||||
"/tcp",
|
"8080:9090": `invalid range format for --expose: invalid start port '8080:9090': invalid syntax`,
|
||||||
"/udp",
|
"/tcp": `invalid range format for --expose: invalid start port '': value is empty`,
|
||||||
"NaN/tcp",
|
"/udp": `invalid range format for --expose: invalid start port '': value is empty`,
|
||||||
"NaN-NaN/tcp",
|
"NaN/tcp": `invalid range format for --expose: invalid start port 'NaN': invalid syntax`,
|
||||||
"8080-NaN/tcp",
|
"NaN-NaN/tcp": `invalid range format for --expose: invalid start port 'NaN': invalid syntax`,
|
||||||
"1234567890-8080/tcp",
|
"8080-NaN/tcp": `invalid range format for --expose: invalid end port 'NaN': invalid syntax`,
|
||||||
|
"1234567890-8080/tcp": `invalid range format for --expose: invalid start port '1234567890': value out of range`,
|
||||||
}
|
}
|
||||||
valids := map[string][]nat.Port{
|
for expose, expectedError := range tests {
|
||||||
"8080/tcp": {"8080/tcp"},
|
t.Run(expose, func(t *testing.T) {
|
||||||
"8080/udp": {"8080/udp"},
|
_, _, _, err := parseRun([]string{fmt.Sprintf("--expose=%v", expose), "img", "cmd"})
|
||||||
"8080/ncp": {"8080/ncp"},
|
assert.Error(t, err, expectedError)
|
||||||
"8080-8080/udp": {"8080/udp"},
|
})
|
||||||
"8080-8082/tcp": {"8080/tcp", "8081/tcp", "8082/tcp"},
|
|
||||||
}
|
}
|
||||||
for _, expose := range invalids {
|
})
|
||||||
if _, _, _, err := parseRun([]string{fmt.Sprintf("--expose=%v", expose), "img", "cmd"}); err == nil {
|
t.Run("valid", func(t *testing.T) {
|
||||||
t.Fatalf("Expected error with '--expose=%v', got none", expose)
|
tests := map[string][]networktypes.Port{
|
||||||
|
"8080/tcp": {networktypes.MustParsePort("8080/tcp")},
|
||||||
|
"8080/udp": {networktypes.MustParsePort("8080/udp")},
|
||||||
|
"8080/ncp": {networktypes.MustParsePort("8080/ncp")},
|
||||||
|
"8080-8080/udp": {networktypes.MustParsePort("8080/udp")},
|
||||||
|
"8080-8082/tcp": {networktypes.MustParsePort("8080/tcp"), networktypes.MustParsePort("8081/tcp"), networktypes.MustParsePort("8082/tcp")},
|
||||||
}
|
}
|
||||||
}
|
for expose, exposedPorts := range tests {
|
||||||
for expose, exposedPorts := range valids {
|
t.Run(expose, func(t *testing.T) {
|
||||||
config, _, _, err := parseRun([]string{fmt.Sprintf("--expose=%v", expose), "img", "cmd"})
|
config, _, _, err := parseRun([]string{fmt.Sprintf("--expose=%v", expose), "img", "cmd"})
|
||||||
if err != nil {
|
assert.NilError(t, err)
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(config.ExposedPorts) != len(exposedPorts) {
|
|
||||||
t.Fatalf("Expected %v exposed port, got %v", len(exposedPorts), len(config.ExposedPorts))
|
|
||||||
}
|
|
||||||
for _, port := range exposedPorts {
|
for _, port := range exposedPorts {
|
||||||
if _, ok := config.ExposedPorts[mustNetworkPort(t, string(port))]; !ok {
|
_, ok := config.ExposedPorts[port]
|
||||||
t.Fatalf("Expected %v, got %v", exposedPorts, config.ExposedPorts)
|
assert.Check(t, ok, "missing port %q in exposed ports: %#+v", port, config.ExposedPorts[port])
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("merge with published", func(t *testing.T) {
|
||||||
// Merge with actual published port
|
// Merge with actual published port
|
||||||
config, _, _, err := parseRun([]string{"--publish=80", "--expose=80-81/tcp", "img", "cmd"})
|
config, _, _, err := parseRun([]string{"--publish=80", "--expose=80-81/tcp", "img", "cmd"})
|
||||||
if err != nil {
|
assert.NilError(t, err)
|
||||||
t.Fatal(err)
|
assert.Check(t, is.Len(config.ExposedPorts, 2))
|
||||||
}
|
ports := []networktypes.Port{networktypes.MustParsePort("80/tcp"), networktypes.MustParsePort("81/tcp")}
|
||||||
if len(config.ExposedPorts) != 2 {
|
|
||||||
t.Fatalf("Expected 2 exposed ports, got %v", config.ExposedPorts)
|
|
||||||
}
|
|
||||||
ports := []nat.Port{"80/tcp", "81/tcp"}
|
|
||||||
for _, port := range ports {
|
for _, port := range ports {
|
||||||
if _, ok := config.ExposedPorts[mustNetworkPort(t, string(port))]; !ok {
|
_, ok := config.ExposedPorts[port]
|
||||||
t.Fatalf("Expected %v, got %v", ports, config.ExposedPorts)
|
assert.Check(t, ok, "missing port %q in exposed ports: %#+v", port, config.ExposedPorts[port])
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseDevice(t *testing.T) {
|
func TestParseDevice(t *testing.T) {
|
||||||
skip.If(t, runtime.GOOS != "linux") // Windows and macOS validate server-side
|
skip.If(t, runtime.GOOS != "linux") // Windows and macOS validate server-side
|
||||||
valids := map[string]container.DeviceMapping{
|
testCases := []struct {
|
||||||
"/dev/snd": {
|
devices []string
|
||||||
|
deviceMapping *container.DeviceMapping
|
||||||
|
deviceRequests []container.DeviceRequest
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
devices: []string{"/dev/snd"},
|
||||||
|
deviceMapping: &container.DeviceMapping{
|
||||||
PathOnHost: "/dev/snd",
|
PathOnHost: "/dev/snd",
|
||||||
PathInContainer: "/dev/snd",
|
PathInContainer: "/dev/snd",
|
||||||
CgroupPermissions: "rwm",
|
CgroupPermissions: "rwm",
|
||||||
},
|
},
|
||||||
"/dev/snd:rw": {
|
},
|
||||||
|
{
|
||||||
|
devices: []string{"/dev/snd:rw"},
|
||||||
|
deviceMapping: &container.DeviceMapping{
|
||||||
PathOnHost: "/dev/snd",
|
PathOnHost: "/dev/snd",
|
||||||
PathInContainer: "/dev/snd",
|
PathInContainer: "/dev/snd",
|
||||||
CgroupPermissions: "rw",
|
CgroupPermissions: "rw",
|
||||||
},
|
},
|
||||||
"/dev/snd:/something": {
|
},
|
||||||
|
{
|
||||||
|
devices: []string{"/dev/snd:/something"},
|
||||||
|
deviceMapping: &container.DeviceMapping{
|
||||||
PathOnHost: "/dev/snd",
|
PathOnHost: "/dev/snd",
|
||||||
PathInContainer: "/something",
|
PathInContainer: "/something",
|
||||||
CgroupPermissions: "rwm",
|
CgroupPermissions: "rwm",
|
||||||
},
|
},
|
||||||
"/dev/snd:/something:rw": {
|
},
|
||||||
|
{
|
||||||
|
devices: []string{"/dev/snd:/something:rw"},
|
||||||
|
deviceMapping: &container.DeviceMapping{
|
||||||
PathOnHost: "/dev/snd",
|
PathOnHost: "/dev/snd",
|
||||||
PathInContainer: "/something",
|
PathInContainer: "/something",
|
||||||
CgroupPermissions: "rw",
|
CgroupPermissions: "rw",
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
devices: []string{"vendor.com/class=name"},
|
||||||
|
deviceMapping: nil,
|
||||||
|
deviceRequests: []container.DeviceRequest{
|
||||||
|
{
|
||||||
|
Driver: "cdi",
|
||||||
|
DeviceIDs: []string{"vendor.com/class=name"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
devices: []string{"vendor.com/class=name", "/dev/snd:/something:rw"},
|
||||||
|
deviceMapping: &container.DeviceMapping{
|
||||||
|
PathOnHost: "/dev/snd",
|
||||||
|
PathInContainer: "/something",
|
||||||
|
CgroupPermissions: "rw",
|
||||||
|
},
|
||||||
|
deviceRequests: []container.DeviceRequest{
|
||||||
|
{
|
||||||
|
Driver: "cdi",
|
||||||
|
DeviceIDs: []string{"vendor.com/class=name"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for device, deviceMapping := range valids {
|
|
||||||
_, hostconfig, _, err := parseRun([]string{fmt.Sprintf("--device=%v", device), "img", "cmd"})
|
for _, tc := range testCases {
|
||||||
if err != nil {
|
t.Run(fmt.Sprintf("%s", tc.devices), func(t *testing.T) {
|
||||||
t.Fatal(err)
|
var args []string
|
||||||
|
for _, d := range tc.devices {
|
||||||
|
args = append(args, fmt.Sprintf("--device=%v", d))
|
||||||
}
|
}
|
||||||
if len(hostconfig.Devices) != 1 {
|
args = append(args, "img", "cmd")
|
||||||
t.Fatalf("Expected 1 devices, got %v", hostconfig.Devices)
|
|
||||||
|
_, hostconfig, _, err := parseRun(args)
|
||||||
|
|
||||||
|
assert.NilError(t, err)
|
||||||
|
|
||||||
|
if tc.deviceMapping != nil {
|
||||||
|
if assert.Check(t, is.Len(hostconfig.Devices, 1)) {
|
||||||
|
assert.Check(t, is.DeepEqual(*tc.deviceMapping, hostconfig.Devices[0]))
|
||||||
}
|
}
|
||||||
if hostconfig.Devices[0] != deviceMapping {
|
} else {
|
||||||
t.Fatalf("Expected %v, got %v", deviceMapping, hostconfig.Devices)
|
assert.Check(t, is.Len(hostconfig.Devices, 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assert.Check(t, is.DeepEqual(tc.deviceRequests, hostconfig.DeviceRequests))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -576,20 +666,20 @@ func TestParseNetworkConfig(t *testing.T) {
|
|||||||
name string
|
name string
|
||||||
flags []string
|
flags []string
|
||||||
expected map[string]*networktypes.EndpointSettings
|
expected map[string]*networktypes.EndpointSettings
|
||||||
expectedCfg container.HostConfig
|
expectedHostCfg container.HostConfig
|
||||||
expectedErr string
|
expectedErr string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "single-network-legacy",
|
name: "single-network-legacy",
|
||||||
flags: []string{"--network", "net1"},
|
flags: []string{"--network", "net1"},
|
||||||
expected: map[string]*networktypes.EndpointSettings{},
|
expected: map[string]*networktypes.EndpointSettings{},
|
||||||
expectedCfg: container.HostConfig{NetworkMode: "net1"},
|
expectedHostCfg: container.HostConfig{NetworkMode: "net1"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "single-network-advanced",
|
name: "single-network-advanced",
|
||||||
flags: []string{"--network", "name=net1"},
|
flags: []string{"--network", "name=net1"},
|
||||||
expected: map[string]*networktypes.EndpointSettings{},
|
expected: map[string]*networktypes.EndpointSettings{},
|
||||||
expectedCfg: container.HostConfig{NetworkMode: "net1"},
|
expectedHostCfg: container.HostConfig{NetworkMode: "net1"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "single-network-legacy-with-options",
|
name: "single-network-legacy-with-options",
|
||||||
@@ -607,15 +697,15 @@ func TestParseNetworkConfig(t *testing.T) {
|
|||||||
expected: map[string]*networktypes.EndpointSettings{
|
expected: map[string]*networktypes.EndpointSettings{
|
||||||
"net1": {
|
"net1": {
|
||||||
IPAMConfig: &networktypes.EndpointIPAMConfig{
|
IPAMConfig: &networktypes.EndpointIPAMConfig{
|
||||||
IPv4Address: mustAddr(t, "172.20.88.22"),
|
IPv4Address: netip.MustParseAddr("172.20.88.22"),
|
||||||
IPv6Address: mustAddr(t, "2001:db8::8822"),
|
IPv6Address: netip.MustParseAddr("2001:db8::8822"),
|
||||||
LinkLocalIPs: mustAddrs(t, "169.254.2.2", "fe80::169:254:2:2"),
|
LinkLocalIPs: []netip.Addr{netip.MustParseAddr("169.254.2.2"), netip.MustParseAddr("fe80::169:254:2:2")},
|
||||||
},
|
},
|
||||||
Links: []string{"foo:bar", "bar:baz"},
|
Links: []string{"foo:bar", "bar:baz"},
|
||||||
Aliases: []string{"web1", "web2"},
|
Aliases: []string{"web1", "web2"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
expectedCfg: container.HostConfig{NetworkMode: "net1"},
|
expectedHostCfg: container.HostConfig{NetworkMode: "net1"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "multiple-network-advanced-mixed",
|
name: "multiple-network-advanced-mixed",
|
||||||
@@ -631,14 +721,15 @@ func TestParseNetworkConfig(t *testing.T) {
|
|||||||
"--network-alias", "web2",
|
"--network-alias", "web2",
|
||||||
"--network", "net2",
|
"--network", "net2",
|
||||||
"--network", "name=net3,alias=web3,driver-opt=field3=value3,ip=172.20.88.22,ip6=2001:db8::8822",
|
"--network", "name=net3,alias=web3,driver-opt=field3=value3,ip=172.20.88.22,ip6=2001:db8::8822",
|
||||||
|
"--network", "name=net4,mac-address=02:32:1c:23:00:04,link-local-ip=169.254.169.254",
|
||||||
},
|
},
|
||||||
expected: map[string]*networktypes.EndpointSettings{
|
expected: map[string]*networktypes.EndpointSettings{
|
||||||
"net1": {
|
"net1": {
|
||||||
DriverOpts: map[string]string{"field1": "value1"},
|
DriverOpts: map[string]string{"field1": "value1"},
|
||||||
IPAMConfig: &networktypes.EndpointIPAMConfig{
|
IPAMConfig: &networktypes.EndpointIPAMConfig{
|
||||||
IPv4Address: mustAddr(t, "172.20.88.22"),
|
IPv4Address: netip.MustParseAddr("172.20.88.22"),
|
||||||
IPv6Address: mustAddr(t, "2001:db8::8822"),
|
IPv6Address: netip.MustParseAddr("2001:db8::8822"),
|
||||||
LinkLocalIPs: mustAddrs(t, "169.254.2.2", "fe80::169:254:2:2"),
|
LinkLocalIPs: []netip.Addr{netip.MustParseAddr("169.254.2.2"), netip.MustParseAddr("fe80::169:254:2:2")},
|
||||||
},
|
},
|
||||||
Links: []string{"foo:bar", "bar:baz"},
|
Links: []string{"foo:bar", "bar:baz"},
|
||||||
Aliases: []string{"web1", "web2"},
|
Aliases: []string{"web1", "web2"},
|
||||||
@@ -647,17 +738,23 @@ func TestParseNetworkConfig(t *testing.T) {
|
|||||||
"net3": {
|
"net3": {
|
||||||
DriverOpts: map[string]string{"field3": "value3"},
|
DriverOpts: map[string]string{"field3": "value3"},
|
||||||
IPAMConfig: &networktypes.EndpointIPAMConfig{
|
IPAMConfig: &networktypes.EndpointIPAMConfig{
|
||||||
IPv4Address: mustAddr(t, "172.20.88.22"),
|
IPv4Address: netip.MustParseAddr("172.20.88.22"),
|
||||||
IPv6Address: mustAddr(t, "2001:db8::8822"),
|
IPv6Address: netip.MustParseAddr("2001:db8::8822"),
|
||||||
},
|
},
|
||||||
Aliases: []string{"web3"},
|
Aliases: []string{"web3"},
|
||||||
},
|
},
|
||||||
|
"net4": {
|
||||||
|
MacAddress: mustParseMAC("02:32:1c:23:00:04"),
|
||||||
|
IPAMConfig: &networktypes.EndpointIPAMConfig{
|
||||||
|
LinkLocalIPs: []netip.Addr{netip.MustParseAddr("169.254.169.254")},
|
||||||
},
|
},
|
||||||
expectedCfg: container.HostConfig{NetworkMode: "net1"},
|
},
|
||||||
|
},
|
||||||
|
expectedHostCfg: container.HostConfig{NetworkMode: "net1"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "single-network-advanced-with-options",
|
name: "single-network-advanced-with-options",
|
||||||
flags: []string{"--network", "name=net1,alias=web1,alias=web2,driver-opt=field1=value1,driver-opt=field2=value2,ip=172.20.88.22,ip6=2001:db8::8822"},
|
flags: []string{"--network", "name=net1,alias=web1,alias=web2,driver-opt=field1=value1,driver-opt=field2=value2,ip=172.20.88.22,ip6=2001:db8::8822,mac-address=02:32:1c:23:00:04"},
|
||||||
expected: map[string]*networktypes.EndpointSettings{
|
expected: map[string]*networktypes.EndpointSettings{
|
||||||
"net1": {
|
"net1": {
|
||||||
DriverOpts: map[string]string{
|
DriverOpts: map[string]string{
|
||||||
@@ -665,19 +762,31 @@ func TestParseNetworkConfig(t *testing.T) {
|
|||||||
"field2": "value2",
|
"field2": "value2",
|
||||||
},
|
},
|
||||||
IPAMConfig: &networktypes.EndpointIPAMConfig{
|
IPAMConfig: &networktypes.EndpointIPAMConfig{
|
||||||
IPv4Address: mustAddr(t, "172.20.88.22"),
|
IPv4Address: netip.MustParseAddr("172.20.88.22"),
|
||||||
IPv6Address: mustAddr(t, "2001:db8::8822"),
|
IPv6Address: netip.MustParseAddr("2001:db8::8822"),
|
||||||
},
|
},
|
||||||
Aliases: []string{"web1", "web2"},
|
Aliases: []string{"web1", "web2"},
|
||||||
|
MacAddress: mustParseMAC("02:32:1c:23:00:04"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
expectedCfg: container.HostConfig{NetworkMode: "net1"},
|
expectedHostCfg: container.HostConfig{NetworkMode: "net1"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "multiple-networks",
|
name: "multiple-networks",
|
||||||
flags: []string{"--network", "net1", "--network", "name=net2"},
|
flags: []string{"--network", "net1", "--network", "name=net2"},
|
||||||
expected: map[string]*networktypes.EndpointSettings{"net1": {}, "net2": {}},
|
expected: map[string]*networktypes.EndpointSettings{"net1": {}, "net2": {}},
|
||||||
expectedCfg: container.HostConfig{NetworkMode: "net1"},
|
expectedHostCfg: container.HostConfig{NetworkMode: "net1"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "advanced-options-with-standalone-mac-address-flag",
|
||||||
|
flags: []string{"--network=name=net1,alias=foobar", "--mac-address", "52:0f:f3:dc:50:10"},
|
||||||
|
expected: map[string]*networktypes.EndpointSettings{
|
||||||
|
"net1": {
|
||||||
|
Aliases: []string{"foobar"},
|
||||||
|
MacAddress: mustParseMAC("52:0f:f3:dc:50:10"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedHostCfg: container.HostConfig{NetworkMode: "net1"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "conflict-network",
|
name: "conflict-network",
|
||||||
@@ -699,13 +808,26 @@ func TestParseNetworkConfig(t *testing.T) {
|
|||||||
flags: []string{"--network", "name=net1,ip=172.20.88.22,ip6=2001:db8::8822", "--ip6", "2001:db8::8822"},
|
flags: []string{"--network", "name=net1,ip=172.20.88.22,ip6=2001:db8::8822", "--ip6", "2001:db8::8822"},
|
||||||
expectedErr: `conflicting options: cannot specify both --ip6 and per-network IPv6 address`,
|
expectedErr: `conflicting options: cannot specify both --ip6 and per-network IPv6 address`,
|
||||||
},
|
},
|
||||||
// case is skipped as it fails w/o any change
|
{
|
||||||
//
|
name: "invalid-mixed-network-types",
|
||||||
//{
|
flags: []string{"--network", "name=host", "--network", "net1"},
|
||||||
// name: "invalid-mixed-network-types",
|
expectedErr: `conflicting options: cannot attach both user-defined and non-user-defined network-modes`,
|
||||||
// flags: []string{"--network", "name=host", "--network", "net1"},
|
},
|
||||||
// expectedErr: `conflicting options: cannot attach both user-defined and non-user-defined network-modes`,
|
{
|
||||||
//},
|
name: "conflict-options-link-local-ip",
|
||||||
|
flags: []string{"--network", "name=net1,link-local-ip=169.254.169.254", "--link-local-ip", "169.254.10.8"},
|
||||||
|
expectedErr: `conflicting options: cannot specify both --link-local-ip and per-network link-local IP addresses`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "conflict-options-mac-address",
|
||||||
|
flags: []string{"--network", "name=net1,mac-address=02:32:1c:23:00:04", "--mac-address", "02:32:1c:23:00:04"},
|
||||||
|
expectedErr: `conflicting options: cannot specify both --mac-address and per-network MAC address`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid-mac-address",
|
||||||
|
flags: []string{"--network", "name=net1,mac-address=foobar"},
|
||||||
|
expectedErr: "foobar is not a valid mac address",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
@@ -718,10 +840,8 @@ func TestParseNetworkConfig(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
assert.NilError(t, err)
|
assert.NilError(t, err)
|
||||||
assert.DeepEqual(t, hConfig.NetworkMode, tc.expectedCfg.NetworkMode)
|
assert.DeepEqual(t, hConfig.NetworkMode, tc.expectedHostCfg.NetworkMode)
|
||||||
if diff := cmp.Diff(tc.expected, nwConfig.EndpointsConfig, cmpopts.EquateComparable(netip.Addr{})); diff != "" {
|
assert.DeepEqual(t, nwConfig.EndpointsConfig, tc.expected, cmpopts.EquateComparable(netip.Addr{}))
|
||||||
t.Fatalf("unexpected endpoints (-want +got):\n%s", diff)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -770,42 +890,84 @@ func TestRunFlagsParseShmSize(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestParseRestartPolicy(t *testing.T) {
|
func TestParseRestartPolicy(t *testing.T) {
|
||||||
invalids := map[string]string{
|
tests := []struct {
|
||||||
"always:2:3": "invalid restart policy format: maximum retry count must be an integer",
|
input string
|
||||||
"on-failure:invalid": "invalid restart policy format: maximum retry count must be an integer",
|
expected container.RestartPolicy
|
||||||
}
|
expectedErr string
|
||||||
valids := map[string]container.RestartPolicy{
|
}{
|
||||||
"": {},
|
{
|
||||||
"always": {
|
input: "",
|
||||||
Name: "always",
|
|
||||||
MaximumRetryCount: 0,
|
|
||||||
},
|
},
|
||||||
"on-failure:1": {
|
{
|
||||||
Name: "on-failure",
|
input: "no",
|
||||||
|
expected: container.RestartPolicy{
|
||||||
|
Name: container.RestartPolicyDisabled,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: ":1",
|
||||||
|
expectedErr: "invalid restart policy format: no policy provided before colon",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "always",
|
||||||
|
expected: container.RestartPolicy{
|
||||||
|
Name: container.RestartPolicyAlways,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "always:2:3",
|
||||||
|
expectedErr: "invalid restart policy format: maximum retry count must be an integer",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "on-failure:1",
|
||||||
|
expected: container.RestartPolicy{
|
||||||
|
Name: container.RestartPolicyOnFailure,
|
||||||
MaximumRetryCount: 1,
|
MaximumRetryCount: 1,
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "on-failure:invalid",
|
||||||
|
expectedErr: "invalid restart policy format: maximum retry count must be an integer",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "unless-stopped",
|
||||||
|
expected: container.RestartPolicy{
|
||||||
|
Name: container.RestartPolicyUnlessStopped,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "unless-stopped:invalid",
|
||||||
|
expectedErr: "invalid restart policy format: maximum retry count must be an integer",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Unknown / invalid combinations: validation is handled by the daemon>
|
||||||
|
{
|
||||||
|
input: "anything:123",
|
||||||
|
expected: container.RestartPolicy{Name: "anything", MaximumRetryCount: 123},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "negative:-123",
|
||||||
|
expected: container.RestartPolicy{Name: "negative", MaximumRetryCount: -123},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for restart, expectedError := range invalids {
|
for _, tc := range tests {
|
||||||
if _, _, _, err := parseRun([]string{"--restart=" + restart, "img", "cmd"}); err == nil || err.Error() != expectedError {
|
t.Run(tc.input, func(t *testing.T) {
|
||||||
t.Fatalf("Expected an error with message '%v' for %v, got %v", expectedError, restart, err)
|
_, hostConfig, _, err := parseRun([]string{"--restart=" + tc.input, "img", "cmd"})
|
||||||
}
|
if tc.expectedErr != "" {
|
||||||
}
|
assert.Check(t, is.Error(err, tc.expectedErr))
|
||||||
for restart, expected := range valids {
|
assert.Check(t, is.Nil(hostConfig))
|
||||||
_, hostconfig, _, err := parseRun([]string{fmt.Sprintf("--restart=%v", restart), "img", "cmd"})
|
} else {
|
||||||
if err != nil {
|
assert.NilError(t, err)
|
||||||
t.Fatal(err)
|
assert.Check(t, is.DeepEqual(hostConfig.RestartPolicy, tc.expected))
|
||||||
}
|
|
||||||
if hostconfig.RestartPolicy != expected {
|
|
||||||
t.Fatalf("Expected %v, got %v", expected, hostconfig.RestartPolicy)
|
|
||||||
}
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseRestartPolicyAutoRemove(t *testing.T) {
|
func TestParseRestartPolicyAutoRemove(t *testing.T) {
|
||||||
_, _, _, err := parseRun([]string{"--rm", "--restart=always", "img", "cmd"}) //nolint:dogsled // verbatim copy from docker/cli tests
|
_, _, _, err := parseRun([]string{"--rm", "--restart=always", "img", "cmd"}) //nolint:dogsled // verbatim copy from docker/cli tests
|
||||||
if err == nil {
|
const expected = "conflicting options: cannot specify both --restart and --rm"
|
||||||
t.Fatal("Expected error for conflicting --restart and --rm, but got none")
|
assert.Check(t, is.Error(err, expected))
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseHealth(t *testing.T) {
|
func TestParseHealth(t *testing.T) {
|
||||||
@@ -841,8 +1003,8 @@ func TestParseHealth(t *testing.T) {
|
|||||||
checkError("--no-healthcheck conflicts with --health-* options",
|
checkError("--no-healthcheck conflicts with --health-* options",
|
||||||
"--no-healthcheck", "--health-cmd=/check.sh -q", "img", "cmd")
|
"--no-healthcheck", "--health-cmd=/check.sh -q", "img", "cmd")
|
||||||
|
|
||||||
health = checkOk("--health-timeout=2s", "--health-retries=3", "--health-interval=4.5s", "--health-start-period=5s", "img", "cmd")
|
health = checkOk("--health-timeout=2s", "--health-retries=3", "--health-interval=4.5s", "--health-start-period=5s", "--health-start-interval=1s", "img", "cmd")
|
||||||
if health.Timeout != 2*time.Second || health.Retries != 3 || health.Interval != 4500*time.Millisecond || health.StartPeriod != 5*time.Second {
|
if health.Timeout != 2*time.Second || health.Retries != 3 || health.Interval != 4500*time.Millisecond || health.StartPeriod != 5*time.Second || health.StartInterval != 1*time.Second {
|
||||||
t.Fatalf("--health-*: got %#v", health)
|
t.Fatalf("--health-*: got %#v", health)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -863,13 +1025,13 @@ func TestParseLoggingOpts(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestParseEnvfileVariables(t *testing.T) { //nolint:dupl // verbatim copy from docker/cli tests
|
func TestParseEnvfileVariables(t *testing.T) { //nolint:dupl // verbatim copy from docker/cli tests
|
||||||
e := "open nonexistent: no such file or directory"
|
expErr := "--env-file: open nonexistent: no such file or directory"
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
e = "open nonexistent: The system cannot find the file specified."
|
expErr = "--env-file: open nonexistent: The system cannot find the file specified."
|
||||||
}
|
}
|
||||||
// env ko
|
// env ko
|
||||||
if _, _, _, err := parseRun([]string{"--env-file=nonexistent", "img", "cmd"}); err == nil || err.Error() != e {
|
if _, _, _, err := parseRun([]string{"--env-file=nonexistent", "img", "cmd"}); err == nil || err.Error() != expErr {
|
||||||
t.Fatalf("Expected an error with message '%s', got %v", e, err)
|
t.Fatalf("Expected an error with message '%s', got %v", expErr, err)
|
||||||
}
|
}
|
||||||
// env ok
|
// env ok
|
||||||
config, _, _, err := parseRun([]string{"--env-file=testdata/valid.env", "img", "cmd"})
|
config, _, _, err := parseRun([]string{"--env-file=testdata/valid.env", "img", "cmd"})
|
||||||
@@ -905,7 +1067,7 @@ func TestParseEnvfileVariablesWithBOMUnicode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// UTF16 with BOM
|
// UTF16 with BOM
|
||||||
e := "invalid env file"
|
e := "invalid utf8 bytes at line"
|
||||||
if _, _, _, err := parseRun([]string{"--env-file=testdata/utf16.env", "img", "cmd"}); err == nil || !strings.Contains(err.Error(), e) {
|
if _, _, _, err := parseRun([]string{"--env-file=testdata/utf16.env", "img", "cmd"}); err == nil || !strings.Contains(err.Error(), e) {
|
||||||
t.Fatalf("Expected an error with message '%s', got %v", e, err)
|
t.Fatalf("Expected an error with message '%s', got %v", e, err)
|
||||||
}
|
}
|
||||||
@@ -916,13 +1078,13 @@ func TestParseEnvfileVariablesWithBOMUnicode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestParseLabelfileVariables(t *testing.T) { //nolint:dupl // verbatim copy from docker/cli tests
|
func TestParseLabelfileVariables(t *testing.T) { //nolint:dupl // verbatim copy from docker/cli tests
|
||||||
e := "open nonexistent: no such file or directory"
|
expErr := "--label-file: open nonexistent: no such file or directory"
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
e = "open nonexistent: The system cannot find the file specified."
|
expErr = "--label-file: open nonexistent: The system cannot find the file specified."
|
||||||
}
|
}
|
||||||
// label ko
|
// label ko
|
||||||
if _, _, _, err := parseRun([]string{"--label-file=nonexistent", "img", "cmd"}); err == nil || err.Error() != e {
|
if _, _, _, err := parseRun([]string{"--label-file=nonexistent", "img", "cmd"}); err == nil || err.Error() != expErr {
|
||||||
t.Fatalf("Expected an error with message '%s', got %v", e, err)
|
t.Fatalf("Expected an error with message '%s', got %v", expErr, err)
|
||||||
}
|
}
|
||||||
// label ok
|
// label ok
|
||||||
config, _, _, err := parseRun([]string{"--label-file=testdata/valid.label", "img", "cmd"})
|
config, _, _, err := parseRun([]string{"--label-file=testdata/valid.label", "img", "cmd"})
|
||||||
@@ -943,12 +1105,8 @@ func TestParseLabelfileVariables(t *testing.T) { //nolint:dupl // verbatim copy
|
|||||||
|
|
||||||
func TestParseEntryPoint(t *testing.T) {
|
func TestParseEntryPoint(t *testing.T) {
|
||||||
config, _, _, err := parseRun([]string{"--entrypoint=anything", "cmd", "img"})
|
config, _, _, err := parseRun([]string{"--entrypoint=anything", "cmd", "img"})
|
||||||
if err != nil {
|
assert.NilError(t, err)
|
||||||
t.Fatal(err)
|
assert.Check(t, is.DeepEqual(config.Entrypoint, []string{"anything"}))
|
||||||
}
|
|
||||||
if len(config.Entrypoint) != 1 && config.Entrypoint[0] != "anything" {
|
|
||||||
t.Fatalf("Expected entrypoint 'anything', got %v", config.Entrypoint)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidateDevice(t *testing.T) {
|
func TestValidateDevice(t *testing.T) {
|
||||||
@@ -995,12 +1153,10 @@ func TestValidateDevice(t *testing.T) {
|
|||||||
for path, expectedError := range invalid {
|
for path, expectedError := range invalid {
|
||||||
if _, err := validateDevice(path, runtime.GOOS); err == nil {
|
if _, err := validateDevice(path, runtime.GOOS); err == nil {
|
||||||
t.Fatalf("ValidateDevice(`%q`) should have failed validation", path)
|
t.Fatalf("ValidateDevice(`%q`) should have failed validation", path)
|
||||||
} else {
|
} else if err.Error() != expectedError {
|
||||||
if err.Error() != expectedError {
|
|
||||||
t.Fatalf("ValidateDevice(`%q`) error should contain %q, got %q", path, expectedError, err.Error())
|
t.Fatalf("ValidateDevice(`%q`) error should contain %q, got %q", path, expectedError, err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidateDeviceByServerOS(t *testing.T) {
|
func TestValidateDeviceByServerOS(t *testing.T) {
|
||||||
@@ -1073,10 +1229,12 @@ func TestDeviceCgroupRulesAndInvalidParameter(t *testing.T) {
|
|||||||
if invalidParameter(nil) != nil {
|
if invalidParameter(nil) != nil {
|
||||||
t.Fatal("invalidParameter(nil) should be nil")
|
t.Fatal("invalidParameter(nil) should be nil")
|
||||||
}
|
}
|
||||||
err = invalidParameter(errors.New("bad input"))
|
cause := errors.New("bad input")
|
||||||
assert.Assert(t, err != nil)
|
err = invalidParameter(cause)
|
||||||
var invalid interface{ InvalidParameter() }
|
var invalid interface{ InvalidParameter() }
|
||||||
assert.Assert(t, errors.As(err, &invalid))
|
assert.Assert(t, errors.As(err, &invalid))
|
||||||
|
assert.Assert(t, errors.Is(err, cause))
|
||||||
|
assert.Equal(t, invalidParameter(err), err) // already invalid, so not wrapped twice
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseSystemPaths(t *testing.T) {
|
func TestParseSystemPaths(t *testing.T) {
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ import (
|
|||||||
"gitea.com/gitea/runner/act/filecollector"
|
"gitea.com/gitea/runner/act/filecollector"
|
||||||
|
|
||||||
"dario.cat/mergo"
|
"dario.cat/mergo"
|
||||||
"github.com/Masterminds/semver"
|
|
||||||
cerrdefs "github.com/containerd/errdefs"
|
cerrdefs "github.com/containerd/errdefs"
|
||||||
"github.com/docker/cli/cli/compose/loader"
|
"github.com/docker/cli/cli/compose/loader"
|
||||||
"github.com/docker/cli/cli/connhelper"
|
"github.com/docker/cli/cli/connhelper"
|
||||||
@@ -42,6 +41,7 @@ import (
|
|||||||
"github.com/moby/moby/api/types/network"
|
"github.com/moby/moby/api/types/network"
|
||||||
"github.com/moby/moby/api/types/system"
|
"github.com/moby/moby/api/types/system"
|
||||||
"github.com/moby/moby/client"
|
"github.com/moby/moby/client"
|
||||||
|
"github.com/moby/moby/client/pkg/versions"
|
||||||
specs "github.com/opencontainers/image-spec/specs-go/v1"
|
specs "github.com/opencontainers/image-spec/specs-go/v1"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
@@ -92,19 +92,11 @@ func (cr *containerReference) connectToNetwork(name string, aliases []string) co
|
|||||||
// supportsContainerImagePlatform returns true if the underlying Docker server
|
// supportsContainerImagePlatform returns true if the underlying Docker server
|
||||||
// API version is 1.41 and beyond
|
// API version is 1.41 and beyond
|
||||||
func supportsContainerImagePlatform(ctx context.Context, cli client.APIClient) bool {
|
func supportsContainerImagePlatform(ctx context.Context, cli client.APIClient) bool {
|
||||||
logger := common.Logger(ctx)
|
|
||||||
ver, err := cli.ServerVersion(ctx, client.ServerVersionOptions{})
|
ver, err := cli.ServerVersion(ctx, client.ServerVersionOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Panicf("Failed to get Docker API Version: %s", err)
|
common.Logger(ctx).Panicf("Failed to get Docker API Version: %s", err)
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
sv, err := semver.NewVersion(ver.APIVersion)
|
return versions.GreaterThanOrEqualTo(ver.APIVersion, "1.41")
|
||||||
if err != nil {
|
|
||||||
logger.Panicf("Failed to unmarshal Docker Version: %s", err)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
constraint, _ := semver.NewConstraint(">= 1.41")
|
|
||||||
return constraint.Check(sv)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cr *containerReference) Create(capAdd, capDrop []string) common.Executor {
|
func (cr *containerReference) Create(capAdd, capDrop []string) common.Executor {
|
||||||
@@ -588,7 +580,7 @@ func (cr *containerReference) create(capAdd, capDrop []string) common.Executor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var platSpecs *specs.Platform
|
var platSpecs *specs.Platform
|
||||||
if supportsContainerImagePlatform(ctx, cr.cli) && cr.input.Platform != "" {
|
if cr.input.Platform != "" && supportsContainerImagePlatform(ctx, cr.cli) {
|
||||||
platSpecs, err = parsePlatform(cr.input.Platform)
|
platSpecs, err = parsePlatform(cr.input.Platform)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ package container
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"runtime"
|
"runtime"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
"github.com/moby/moby/api/types/system"
|
"github.com/moby/moby/api/types/system"
|
||||||
"github.com/pkg/errors"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ImageExistsLocally returns a boolean indicating if an image with the
|
// ImageExistsLocally returns a boolean indicating if an image with the
|
||||||
|
|||||||
309
act/exprparser/functions.go
Normal file
309
act/exprparser/functions.go
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package exprparser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
|
"github.com/go-git/go-git/v5/plumbing/format/gitignore"
|
||||||
|
"github.com/rhysd/actionlint"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (impl *interperterImpl) contains(search, item reflect.Value) (bool, error) {
|
||||||
|
switch search.Kind() {
|
||||||
|
case reflect.String, reflect.Int, reflect.Float64, reflect.Bool, reflect.Invalid:
|
||||||
|
return strings.Contains(
|
||||||
|
strings.ToLower(CoerceToString(search)),
|
||||||
|
strings.ToLower(CoerceToString(item)),
|
||||||
|
), nil
|
||||||
|
|
||||||
|
case reflect.Slice:
|
||||||
|
for i := 0; i < search.Len(); i++ {
|
||||||
|
arrayItem := search.Index(i).Elem()
|
||||||
|
result, err := impl.compareValues(arrayItem, item, actionlint.CompareOpNodeKindEq)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if isEqual, ok := result.(bool); ok && isEqual {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *interperterImpl) startsWith(searchString, searchValue reflect.Value) (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
|
||||||
|
return strings.HasPrefix(
|
||||||
|
strings.ToLower(CoerceToString(searchString)),
|
||||||
|
strings.ToLower(CoerceToString(searchValue)),
|
||||||
|
), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *interperterImpl) endsWith(searchString, searchValue reflect.Value) (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
|
||||||
|
return strings.HasSuffix(
|
||||||
|
strings.ToLower(CoerceToString(searchString)),
|
||||||
|
strings.ToLower(CoerceToString(searchValue)),
|
||||||
|
), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
passThrough = iota
|
||||||
|
bracketOpen
|
||||||
|
bracketClose
|
||||||
|
)
|
||||||
|
|
||||||
|
func (impl *interperterImpl) format(str reflect.Value, replaceValue ...reflect.Value) (string, error) {
|
||||||
|
input := CoerceToString(str)
|
||||||
|
var output strings.Builder
|
||||||
|
replacementIndex := ""
|
||||||
|
|
||||||
|
state := passThrough
|
||||||
|
for _, character := range input {
|
||||||
|
switch state {
|
||||||
|
case passThrough: // normal buffer output
|
||||||
|
switch character {
|
||||||
|
case '{':
|
||||||
|
state = bracketOpen
|
||||||
|
|
||||||
|
case '}':
|
||||||
|
state = bracketClose
|
||||||
|
|
||||||
|
default:
|
||||||
|
output.WriteRune(character)
|
||||||
|
}
|
||||||
|
|
||||||
|
case bracketOpen: // found {
|
||||||
|
switch character {
|
||||||
|
case '{':
|
||||||
|
output.WriteString("{")
|
||||||
|
replacementIndex = ""
|
||||||
|
state = passThrough
|
||||||
|
|
||||||
|
case '}':
|
||||||
|
index, err := strconv.ParseInt(replacementIndex, 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("The following format string is invalid: '%s'", input)
|
||||||
|
}
|
||||||
|
|
||||||
|
replacementIndex = ""
|
||||||
|
|
||||||
|
if len(replaceValue) <= int(index) {
|
||||||
|
return "", fmt.Errorf("The following format string references more arguments than were supplied: '%s'", input)
|
||||||
|
}
|
||||||
|
|
||||||
|
output.WriteString(CoerceToString(replaceValue[index]))
|
||||||
|
|
||||||
|
state = passThrough
|
||||||
|
|
||||||
|
default:
|
||||||
|
replacementIndex += string(character)
|
||||||
|
}
|
||||||
|
|
||||||
|
case bracketClose: // found }
|
||||||
|
switch character {
|
||||||
|
case '}':
|
||||||
|
output.WriteString("}")
|
||||||
|
replacementIndex = ""
|
||||||
|
state = passThrough
|
||||||
|
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("Closing bracket without opening one. The following format string is invalid: '%s'", input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if state != passThrough {
|
||||||
|
switch state {
|
||||||
|
case bracketOpen:
|
||||||
|
return "", fmt.Errorf("Unclosed brackets. The following format string is invalid: '%s'", input)
|
||||||
|
|
||||||
|
case bracketClose:
|
||||||
|
return "", fmt.Errorf("Closing bracket without opening one. The following format string is invalid: '%s'", input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return output.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *interperterImpl) join(array, sep reflect.Value) (string, error) { //nolint:unparam // pre-existing issue from nektos/act
|
||||||
|
separator := CoerceToString(sep)
|
||||||
|
switch array.Kind() {
|
||||||
|
case reflect.Slice:
|
||||||
|
var items []string
|
||||||
|
for i := 0; i < array.Len(); i++ {
|
||||||
|
items = append(items, CoerceToString(array.Index(i)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(items, separator), nil
|
||||||
|
default:
|
||||||
|
return strings.Join([]string{CoerceToString(array)}, separator), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *interperterImpl) toJSON(value reflect.Value) (string, error) {
|
||||||
|
if value.Kind() == reflect.Invalid {
|
||||||
|
return "null", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
json, err := json.MarshalIndent(value.Interface(), "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("Cannot convert value to JSON. Cause: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(json), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *interperterImpl) fromJSON(value reflect.Value) (any, error) {
|
||||||
|
if value.Kind() != reflect.String {
|
||||||
|
return nil, fmt.Errorf("Cannot parse non-string type %v as JSON", value.Kind())
|
||||||
|
}
|
||||||
|
|
||||||
|
var data any
|
||||||
|
|
||||||
|
err := json.Unmarshal([]byte(value.String()), &data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Invalid JSON: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *interperterImpl) hashFiles(paths ...reflect.Value) (string, error) {
|
||||||
|
var ps []gitignore.Pattern
|
||||||
|
|
||||||
|
const cwdPrefix = "." + string(filepath.Separator)
|
||||||
|
const excludeCwdPrefix = "!" + cwdPrefix
|
||||||
|
for _, path := range paths {
|
||||||
|
if path.Kind() == reflect.String {
|
||||||
|
cleanPath := path.String()
|
||||||
|
if strings.HasPrefix(cleanPath, cwdPrefix) {
|
||||||
|
cleanPath = cleanPath[len(cwdPrefix):]
|
||||||
|
} else if strings.HasPrefix(cleanPath, excludeCwdPrefix) {
|
||||||
|
cleanPath = "!" + cleanPath[len(excludeCwdPrefix):]
|
||||||
|
}
|
||||||
|
ps = append(ps, gitignore.ParsePattern(cleanPath, nil))
|
||||||
|
} else {
|
||||||
|
return "", errors.New("Non-string path passed to hashFiles")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
matcher := gitignore.NewMatcher(ps)
|
||||||
|
|
||||||
|
var files []string
|
||||||
|
if err := filepath.Walk(impl.config.WorkingDir, func(path string, fi fs.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sansPrefix := strings.TrimPrefix(path, impl.config.WorkingDir+string(filepath.Separator))
|
||||||
|
parts := strings.Split(sansPrefix, string(filepath.Separator))
|
||||||
|
if fi.IsDir() || !matcher.Match(parts, fi.IsDir()) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
files = append(files, path)
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
return "", fmt.Errorf("Unable to filepath.Walk: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(files) == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
hasher := sha256.New()
|
||||||
|
|
||||||
|
for _, file := range files {
|
||||||
|
f, err := os.Open(file)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("Unable to os.Open: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := io.Copy(hasher, f); err != nil {
|
||||||
|
return "", fmt.Errorf("Unable to io.Copy: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := f.Close(); err != nil {
|
||||||
|
return "", fmt.Errorf("Unable to Close file: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hex.EncodeToString(hasher.Sum(nil)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *interperterImpl) getNeedsTransitive(job *model.Job) []string {
|
||||||
|
needs := job.Needs()
|
||||||
|
|
||||||
|
for _, need := range needs {
|
||||||
|
parentNeeds := impl.getNeedsTransitive(impl.config.Run.Workflow.GetJob(need))
|
||||||
|
needs = append(needs, parentNeeds...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return needs
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *interperterImpl) always() (bool, error) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *interperterImpl) jobSuccess() (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
|
||||||
|
jobs := impl.config.Run.Workflow.Jobs
|
||||||
|
jobNeeds := impl.getNeedsTransitive(impl.config.Run.Job())
|
||||||
|
|
||||||
|
for _, needs := range jobNeeds {
|
||||||
|
if jobs[needs].NeedsResult() != "success" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// jobStatus returns the current job status, treating a nil Job context as an
|
||||||
|
// empty status so status-check functions never panic on a nil dereference.
|
||||||
|
func (impl *interperterImpl) jobStatus() string {
|
||||||
|
if impl.env.Job == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return impl.env.Job.Status
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *interperterImpl) stepSuccess() (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
|
||||||
|
return impl.jobStatus() == "success", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *interperterImpl) jobFailure() (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
|
||||||
|
jobs := impl.config.Run.Workflow.Jobs
|
||||||
|
jobNeeds := impl.getNeedsTransitive(impl.config.Run.Job())
|
||||||
|
|
||||||
|
for _, needs := range jobNeeds {
|
||||||
|
if jobs[needs].NeedsResult() == "failure" {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *interperterImpl) stepFailure() (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
|
||||||
|
return impl.jobStatus() == "failure", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *interperterImpl) cancelled() (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
|
||||||
|
return impl.jobStatus() == "cancelled", nil
|
||||||
|
}
|
||||||
283
act/exprparser/functions_test.go
Normal file
283
act/exprparser/functions_test.go
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package exprparser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFunctionContains(t *testing.T) {
|
||||||
|
table := []struct {
|
||||||
|
input string
|
||||||
|
expected any
|
||||||
|
name string
|
||||||
|
}{
|
||||||
|
{"contains('search', 'item') }}", false, "contains-str-str"},
|
||||||
|
{`cOnTaInS('Hello', 'll') }}`, true, "contains-str-casing"},
|
||||||
|
{`contains('HELLO', 'll') }}`, true, "contains-str-casing"},
|
||||||
|
{`contains('3.141592', 3.14) }}`, true, "contains-str-number"},
|
||||||
|
{`contains(3.141592, '3.14') }}`, true, "contains-number-str"},
|
||||||
|
{`contains(3.141592, 3.14) }}`, true, "contains-number-number"},
|
||||||
|
{`contains(true, 'u') }}`, true, "contains-bool-str"},
|
||||||
|
{`contains(null, '') }}`, true, "contains-null-str"},
|
||||||
|
{`contains(fromJSON('["first","second"]'), 'first') }}`, true, "contains-item"},
|
||||||
|
{`contains(fromJSON('[null,"second"]'), '') }}`, true, "contains-item-null-empty-str"},
|
||||||
|
{`contains(fromJSON('["","second"]'), null) }}`, true, "contains-item-empty-str-null"},
|
||||||
|
{`contains(fromJSON('[true,"second"]'), 'true') }}`, false, "contains-item-bool-arr"},
|
||||||
|
{`contains(fromJSON('["true","second"]'), true) }}`, false, "contains-item-str-bool"},
|
||||||
|
{`contains(fromJSON('[3.14,"second"]'), '3.14') }}`, true, "contains-item-number-str"},
|
||||||
|
{`contains(fromJSON('[3.14,"second"]'), 3.14) }}`, true, "contains-item-number-number"},
|
||||||
|
{`contains(fromJSON('["","second"]'), fromJSON('[]')) }}`, false, "contains-item-str-arr"},
|
||||||
|
{`contains(fromJSON('["","second"]'), fromJSON('{}')) }}`, false, "contains-item-str-obj"},
|
||||||
|
}
|
||||||
|
|
||||||
|
env := &EvaluationEnvironment{}
|
||||||
|
|
||||||
|
for _, tt := range table {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
assert.Equal(t, tt.expected, output)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFunctionStartsWith(t *testing.T) {
|
||||||
|
table := []struct {
|
||||||
|
input string
|
||||||
|
expected any
|
||||||
|
name string
|
||||||
|
}{
|
||||||
|
{"startsWith('search', 'se') }}", true, "startswith-string"},
|
||||||
|
{"startsWith('search', 'sa') }}", false, "startswith-string"},
|
||||||
|
{"startsWith('123search', '123s') }}", true, "startswith-string"},
|
||||||
|
{"startsWith(123, 's') }}", false, "startswith-string"},
|
||||||
|
{"startsWith(123, '12') }}", true, "startswith-string"},
|
||||||
|
{"startsWith('123', 12) }}", true, "startswith-string"},
|
||||||
|
{"startsWith(null, '42') }}", false, "startswith-string"},
|
||||||
|
{"startsWith('null', null) }}", true, "startswith-string"},
|
||||||
|
{"startsWith('null', '') }}", true, "startswith-string"},
|
||||||
|
}
|
||||||
|
|
||||||
|
env := &EvaluationEnvironment{}
|
||||||
|
|
||||||
|
for _, tt := range table {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
assert.Equal(t, tt.expected, output)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFunctionEndsWith(t *testing.T) {
|
||||||
|
table := []struct {
|
||||||
|
input string
|
||||||
|
expected any
|
||||||
|
name string
|
||||||
|
}{
|
||||||
|
{"endsWith('search', 'ch') }}", true, "endsWith-string"},
|
||||||
|
{"endsWith('search', 'sa') }}", false, "endsWith-string"},
|
||||||
|
{"endsWith('search123s', '123s') }}", true, "endsWith-string"},
|
||||||
|
{"endsWith(123, 's') }}", false, "endsWith-string"},
|
||||||
|
{"endsWith(123, '23') }}", true, "endsWith-string"},
|
||||||
|
{"endsWith('123', 23) }}", true, "endsWith-string"},
|
||||||
|
{"endsWith(null, '42') }}", false, "endsWith-string"},
|
||||||
|
{"endsWith('null', null) }}", true, "endsWith-string"},
|
||||||
|
{"endsWith('null', '') }}", true, "endsWith-string"},
|
||||||
|
}
|
||||||
|
|
||||||
|
env := &EvaluationEnvironment{}
|
||||||
|
|
||||||
|
for _, tt := range table {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
assert.Equal(t, tt.expected, output)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFunctionJoin(t *testing.T) {
|
||||||
|
table := []struct {
|
||||||
|
input string
|
||||||
|
expected any
|
||||||
|
name string
|
||||||
|
}{
|
||||||
|
{"join(fromJSON('[\"a\", \"b\"]'), ',')", "a,b", "join-arr"},
|
||||||
|
{"join('string', ',')", "string", "join-str"},
|
||||||
|
{"join(1, ',')", "1", "join-number"},
|
||||||
|
{"join(null, ',')", "", "join-number"},
|
||||||
|
{"join(fromJSON('[\"a\", \"b\", null]'), null)", "ab", "join-number"},
|
||||||
|
{"join(fromJSON('[\"a\", \"b\"]'))", "a,b", "join-number"},
|
||||||
|
{"join(fromJSON('[\"a\", \"b\", null]'), 1)", "a1b1", "join-number"},
|
||||||
|
{"join(fromJSON('[1, true, null]'), '-')", "1-true-", "join-mixed-types"},
|
||||||
|
}
|
||||||
|
|
||||||
|
env := &EvaluationEnvironment{}
|
||||||
|
|
||||||
|
for _, tt := range table {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
assert.Equal(t, tt.expected, output)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFunctionToJSON(t *testing.T) {
|
||||||
|
table := []struct {
|
||||||
|
input string
|
||||||
|
expected any
|
||||||
|
name string
|
||||||
|
}{
|
||||||
|
{"toJSON(env) }}", "{\n \"key\": \"value\"\n}", "toJSON"},
|
||||||
|
{"toJSON(null)", "null", "toJSON-null"},
|
||||||
|
}
|
||||||
|
|
||||||
|
env := &EvaluationEnvironment{
|
||||||
|
Env: map[string]string{
|
||||||
|
"key": "value",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range table {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
assert.Equal(t, tt.expected, output)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFunctionFromJSON(t *testing.T) {
|
||||||
|
table := []struct {
|
||||||
|
input string
|
||||||
|
expected any
|
||||||
|
name string
|
||||||
|
}{
|
||||||
|
{"fromJSON('{\"foo\":\"bar\"}') }}", map[string]any{
|
||||||
|
"foo": "bar",
|
||||||
|
}, "fromJSON"},
|
||||||
|
}
|
||||||
|
|
||||||
|
env := &EvaluationEnvironment{}
|
||||||
|
|
||||||
|
for _, tt := range table {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
assert.Equal(t, tt.expected, output)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFunctionHashFiles(t *testing.T) {
|
||||||
|
table := []struct {
|
||||||
|
input string
|
||||||
|
expected any
|
||||||
|
name string
|
||||||
|
}{
|
||||||
|
{"hashFiles('**/non-extant-files') }}", "", "hash-non-existing-file"},
|
||||||
|
{"hashFiles('**/non-extant-files', '**/more-non-extant-files') }}", "", "hash-multiple-non-existing-files"},
|
||||||
|
{"hashFiles('./for-hashing-1.txt') }}", "66a045b452102c59d840ec097d59d9467e13a3f34f6494e539ffd32c1bb35f18", "hash-single-file"},
|
||||||
|
{"hashFiles('./for-hashing-*.txt') }}", "8e5935e7e13368cd9688fe8f48a0955293676a021562582c7e848dafe13fb046", "hash-multiple-files"},
|
||||||
|
{"hashFiles('./for-hashing-*.txt', '!./for-hashing-2.txt') }}", "66a045b452102c59d840ec097d59d9467e13a3f34f6494e539ffd32c1bb35f18", "hash-negative-pattern"},
|
||||||
|
{"hashFiles('./for-hashing-**') }}", "c418ba693753c84115ced0da77f876cddc662b9054f4b129b90f822597ee2f94", "hash-multiple-files-and-directories"},
|
||||||
|
{"hashFiles('./for-hashing-3/**') }}", "6f5696b546a7a9d6d42a449dc9a56bef244aaa826601ef27466168846139d2c2", "hash-nested-directories"},
|
||||||
|
{"hashFiles('./for-hashing-3/**/nested-data.txt') }}", "8ecadfb49f7f978d0a9f3a957e9c8da6cc9ab871f5203b5d9f9d1dc87d8af18c", "hash-nested-directories-2"},
|
||||||
|
}
|
||||||
|
|
||||||
|
env := &EvaluationEnvironment{}
|
||||||
|
|
||||||
|
for _, tt := range table {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
workdir, err := filepath.Abs("testdata")
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
output, err := NewInterpeter(env, Config{WorkingDir: workdir}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
assert.Equal(t, tt.expected, output)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFunctionFormat(t *testing.T) {
|
||||||
|
table := []struct {
|
||||||
|
input string
|
||||||
|
expected any
|
||||||
|
error any
|
||||||
|
name string
|
||||||
|
}{
|
||||||
|
{"format('text')", "text", nil, "format-plain-string"},
|
||||||
|
{"format('Hello {0} {1} {2}!', 'Mona', 'the', 'Octocat')", "Hello Mona the Octocat!", nil, "format-with-placeholders"},
|
||||||
|
{"format('{{Hello {0} {1} {2}!}}', 'Mona', 'the', 'Octocat')", "{Hello Mona the Octocat!}", nil, "format-with-escaped-braces"},
|
||||||
|
{"format('{{0}}', 'test')", "{0}", nil, "format-with-escaped-braces"},
|
||||||
|
{"format('{{{0}}}', 'test')", "{test}", nil, "format-with-escaped-braces-and-value"},
|
||||||
|
{"format('}}')", "}", nil, "format-output-closing-brace"},
|
||||||
|
{`format('Hello "{0}" {1} {2} {3} {4}', null, true, -3.14, NaN, Infinity)`, `Hello "" true -3.14 NaN Infinity`, nil, "format-with-primitives"},
|
||||||
|
{`format('Hello "{0}" {1} {2}', fromJSON('[0, true, "abc"]'), fromJSON('[{"a":1}]'), fromJSON('{"a":{"b":1}}'))`, `Hello "Array" Array Object`, nil, "format-with-complex-types"},
|
||||||
|
{"format(true)", "true", nil, "format-with-primitive-args"},
|
||||||
|
{"format('{0}', github)", "Object", nil, "format-with-context"},
|
||||||
|
{"format('echo Hello {0} ${{Test}}', github.undefined_property)", "echo Hello ${Test}", nil, "format-with-undefined-value"},
|
||||||
|
{"format('{0}}', '{1}', 'World')", nil, "Closing bracket without opening one. The following format string is invalid: '{0}}'", "format-invalid-format-string"},
|
||||||
|
{"format('a}b')", nil, "Closing bracket without opening one. The following format string is invalid: 'a}b'", "format-unmatched-closing-brace"},
|
||||||
|
{"format('{0', '{1}', 'World')", nil, "Unclosed brackets. The following format string is invalid: '{0'", "format-invalid-format-string"},
|
||||||
|
{"format('{2}', '{1}', 'World')", "", "The following format string references more arguments than were supplied: '{2}'", "format-invalid-replacement-reference"},
|
||||||
|
{"format('{2147483648}')", "", "The following format string is invalid: '{2147483648}'", "format-invalid-replacement-reference"},
|
||||||
|
{"format('{0} {1} {2} {3}', 1.0, 1.1, 1234567890.0, 12345678901234567890.0)", "1 1.1 1234567890 1.23456789012346E+19", nil, "format-floats"},
|
||||||
|
}
|
||||||
|
|
||||||
|
env := &EvaluationEnvironment{
|
||||||
|
Github: &model.GithubContext{},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range table {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||||
|
if tt.error != nil {
|
||||||
|
assert.Equal(t, tt.error, err.Error())
|
||||||
|
} else {
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Equal(t, tt.expected, output)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusFunctionsNilJob(t *testing.T) {
|
||||||
|
// A nil Job context must not panic: the status-check functions should treat
|
||||||
|
// it as an empty status and return false rather than dereferencing nil.
|
||||||
|
env := &EvaluationEnvironment{}
|
||||||
|
|
||||||
|
table := []struct {
|
||||||
|
input string
|
||||||
|
context string
|
||||||
|
name string
|
||||||
|
}{
|
||||||
|
{"cancelled()", "job", "cancelled-nil-job"},
|
||||||
|
{"success()", "step", "step-success-nil-job"},
|
||||||
|
{"failure()", "step", "step-failure-nil-job"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range table {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
output, err := NewInterpeter(env, Config{Context: tt.context}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Equal(t, false, output)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
658
act/exprparser/interpreter.go
Normal file
658
act/exprparser/interpreter.go
Normal file
@@ -0,0 +1,658 @@
|
|||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package exprparser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"reflect"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
|
"github.com/rhysd/actionlint"
|
||||||
|
)
|
||||||
|
|
||||||
|
type EvaluationEnvironment struct {
|
||||||
|
Github *model.GithubContext
|
||||||
|
Env map[string]string
|
||||||
|
Job *model.JobContext
|
||||||
|
Jobs *map[string]*model.WorkflowCallResult
|
||||||
|
Steps map[string]*model.StepResult
|
||||||
|
Runner map[string]any
|
||||||
|
Secrets map[string]string
|
||||||
|
Vars map[string]string
|
||||||
|
Strategy map[string]any
|
||||||
|
Matrix map[string]any
|
||||||
|
Needs map[string]Needs
|
||||||
|
Inputs map[string]any
|
||||||
|
HashFiles func([]reflect.Value) (any, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Needs struct {
|
||||||
|
Outputs map[string]string `json:"outputs"`
|
||||||
|
Result string `json:"result"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Run *model.Run
|
||||||
|
WorkingDir string
|
||||||
|
Context string
|
||||||
|
}
|
||||||
|
|
||||||
|
type DefaultStatusCheck int
|
||||||
|
|
||||||
|
const (
|
||||||
|
DefaultStatusCheckNone DefaultStatusCheck = iota
|
||||||
|
DefaultStatusCheckSuccess
|
||||||
|
DefaultStatusCheckAlways
|
||||||
|
DefaultStatusCheckCanceled
|
||||||
|
DefaultStatusCheckFailure
|
||||||
|
)
|
||||||
|
|
||||||
|
func (dsc DefaultStatusCheck) String() string {
|
||||||
|
switch dsc {
|
||||||
|
case DefaultStatusCheckSuccess:
|
||||||
|
return "success"
|
||||||
|
case DefaultStatusCheckAlways:
|
||||||
|
return "always"
|
||||||
|
case DefaultStatusCheckCanceled:
|
||||||
|
return "cancelled"
|
||||||
|
case DefaultStatusCheckFailure:
|
||||||
|
return "failure"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
type Interpreter interface {
|
||||||
|
Evaluate(input string, defaultStatusCheck DefaultStatusCheck) (any, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type interperterImpl struct {
|
||||||
|
env *EvaluationEnvironment
|
||||||
|
config Config
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewInterpeter(env *EvaluationEnvironment, config Config) Interpreter {
|
||||||
|
return &interperterImpl{
|
||||||
|
env: env,
|
||||||
|
config: config,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *interperterImpl) Evaluate(input string, defaultStatusCheck DefaultStatusCheck) (any, error) {
|
||||||
|
input = strings.TrimPrefix(input, "${{")
|
||||||
|
if defaultStatusCheck != DefaultStatusCheckNone && input == "" {
|
||||||
|
input = "success()"
|
||||||
|
}
|
||||||
|
parser := actionlint.NewExprParser()
|
||||||
|
exprNode, err := parser.Parse(actionlint.NewExprLexer(input + "}}"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Failed to parse: %s", err.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
if defaultStatusCheck != DefaultStatusCheckNone {
|
||||||
|
hasStatusCheckFunction := false
|
||||||
|
actionlint.VisitExprNode(exprNode, func(node, _ actionlint.ExprNode, entering bool) {
|
||||||
|
if funcCallNode, ok := node.(*actionlint.FuncCallNode); entering && ok {
|
||||||
|
switch strings.ToLower(funcCallNode.Callee) {
|
||||||
|
case "success", "always", "cancelled", "failure":
|
||||||
|
hasStatusCheckFunction = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if !hasStatusCheckFunction {
|
||||||
|
exprNode = &actionlint.LogicalOpNode{
|
||||||
|
Kind: actionlint.LogicalOpNodeKindAnd,
|
||||||
|
Left: &actionlint.FuncCallNode{
|
||||||
|
Callee: defaultStatusCheck.String(),
|
||||||
|
Args: []actionlint.ExprNode{},
|
||||||
|
},
|
||||||
|
Right: exprNode,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err2 := impl.evaluateNode(exprNode)
|
||||||
|
|
||||||
|
return result, err2
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *interperterImpl) evaluateNode(exprNode actionlint.ExprNode) (any, error) {
|
||||||
|
switch node := exprNode.(type) {
|
||||||
|
case *actionlint.VariableNode:
|
||||||
|
return impl.evaluateVariable(node)
|
||||||
|
case *actionlint.BoolNode:
|
||||||
|
return node.Value, nil
|
||||||
|
case *actionlint.NullNode:
|
||||||
|
return nil, nil //nolint:nilnil // pre-existing issue from nektos/act
|
||||||
|
case *actionlint.IntNode:
|
||||||
|
return node.Value, nil
|
||||||
|
case *actionlint.FloatNode:
|
||||||
|
return node.Value, nil
|
||||||
|
case *actionlint.StringNode:
|
||||||
|
return node.Value, nil
|
||||||
|
case *actionlint.IndexAccessNode:
|
||||||
|
return impl.evaluateIndexAccess(node)
|
||||||
|
case *actionlint.ObjectDerefNode:
|
||||||
|
return impl.evaluateObjectDeref(node)
|
||||||
|
case *actionlint.ArrayDerefNode:
|
||||||
|
return impl.evaluateArrayDeref(node)
|
||||||
|
case *actionlint.NotOpNode:
|
||||||
|
return impl.evaluateNot(node)
|
||||||
|
case *actionlint.CompareOpNode:
|
||||||
|
return impl.evaluateCompare(node)
|
||||||
|
case *actionlint.LogicalOpNode:
|
||||||
|
return impl.evaluateLogicalCompare(node)
|
||||||
|
case *actionlint.FuncCallNode:
|
||||||
|
return impl.evaluateFuncCall(node)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("Fatal error! Unknown node type: %s node: %+v", reflect.TypeOf(exprNode), exprNode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *interperterImpl) evaluateVariable(variableNode *actionlint.VariableNode) (any, error) {
|
||||||
|
switch strings.ToLower(variableNode.Name) {
|
||||||
|
case "github":
|
||||||
|
return impl.env.Github, nil
|
||||||
|
case "gitea": // compatible with Gitea
|
||||||
|
return impl.env.Github, nil
|
||||||
|
case "env":
|
||||||
|
return impl.env.Env, nil
|
||||||
|
case "job":
|
||||||
|
return impl.env.Job, nil
|
||||||
|
case "jobs":
|
||||||
|
if impl.env.Jobs == nil {
|
||||||
|
return nil, errors.New("Unavailable context: jobs")
|
||||||
|
}
|
||||||
|
return impl.env.Jobs, nil
|
||||||
|
case "steps":
|
||||||
|
return impl.env.Steps, nil
|
||||||
|
case "runner":
|
||||||
|
return impl.env.Runner, nil
|
||||||
|
case "secrets":
|
||||||
|
return impl.env.Secrets, nil
|
||||||
|
case "vars":
|
||||||
|
return impl.env.Vars, nil
|
||||||
|
case "strategy":
|
||||||
|
return impl.env.Strategy, nil
|
||||||
|
case "matrix":
|
||||||
|
return impl.env.Matrix, nil
|
||||||
|
case "needs":
|
||||||
|
return impl.env.Needs, nil
|
||||||
|
case "inputs":
|
||||||
|
return impl.env.Inputs, nil
|
||||||
|
case "infinity":
|
||||||
|
return math.Inf(1), nil
|
||||||
|
case "nan":
|
||||||
|
return math.NaN(), nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("Unavailable context: %s", variableNode.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *interperterImpl) evaluateIndexAccess(indexAccessNode *actionlint.IndexAccessNode) (any, error) {
|
||||||
|
left, err := impl.evaluateNode(indexAccessNode.Operand)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
leftValue := reflect.ValueOf(left)
|
||||||
|
|
||||||
|
right, err := impl.evaluateNode(indexAccessNode.Index)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rightValue := reflect.ValueOf(right)
|
||||||
|
|
||||||
|
switch rightValue.Kind() {
|
||||||
|
case reflect.String:
|
||||||
|
return impl.getPropertyValue(leftValue, rightValue.String())
|
||||||
|
|
||||||
|
case reflect.Int:
|
||||||
|
switch leftValue.Kind() {
|
||||||
|
case reflect.Slice:
|
||||||
|
if rightValue.Int() < 0 || rightValue.Int() >= int64(leftValue.Len()) {
|
||||||
|
return nil, nil //nolint:nilnil // pre-existing issue from nektos/act
|
||||||
|
}
|
||||||
|
return leftValue.Index(int(rightValue.Int())).Interface(), nil
|
||||||
|
default:
|
||||||
|
return nil, nil //nolint:nilnil // pre-existing issue from nektos/act
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return nil, nil //nolint:nilnil // pre-existing issue from nektos/act
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *interperterImpl) evaluateObjectDeref(objectDerefNode *actionlint.ObjectDerefNode) (any, error) {
|
||||||
|
left, err := impl.evaluateNode(objectDerefNode.Receiver)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return impl.getPropertyValue(reflect.ValueOf(left), objectDerefNode.Property)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *interperterImpl) evaluateArrayDeref(arrayDerefNode *actionlint.ArrayDerefNode) (any, error) {
|
||||||
|
left, err := impl.evaluateNode(arrayDerefNode.Receiver)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return impl.getSafeValue(reflect.ValueOf(left)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *interperterImpl) getPropertyValue(left reflect.Value, property string) (value any, err error) {
|
||||||
|
switch left.Kind() {
|
||||||
|
case reflect.Pointer:
|
||||||
|
return impl.getPropertyValue(left.Elem(), property)
|
||||||
|
|
||||||
|
case reflect.Struct:
|
||||||
|
leftType := left.Type()
|
||||||
|
for field := range leftType.Fields() {
|
||||||
|
jsonName := field.Tag.Get("json")
|
||||||
|
if jsonName == property {
|
||||||
|
property = field.Name
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldValue := left.FieldByNameFunc(func(name string) bool {
|
||||||
|
return strings.EqualFold(name, property)
|
||||||
|
})
|
||||||
|
|
||||||
|
if fieldValue.Kind() == reflect.Invalid {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
i := fieldValue.Interface()
|
||||||
|
// The type stepStatus int is an integer, but should be treated as string
|
||||||
|
if m, ok := i.(encoding.TextMarshaler); ok {
|
||||||
|
text, err := m.MarshalText()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return string(text), nil
|
||||||
|
}
|
||||||
|
return i, nil
|
||||||
|
|
||||||
|
case reflect.Map:
|
||||||
|
iter := left.MapRange()
|
||||||
|
|
||||||
|
for iter.Next() {
|
||||||
|
key := iter.Key()
|
||||||
|
|
||||||
|
switch key.Kind() {
|
||||||
|
case reflect.String:
|
||||||
|
if strings.EqualFold(key.String(), property) {
|
||||||
|
return impl.getMapValue(iter.Value())
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("'%s' in map key not implemented", key.Kind())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, nil //nolint:nilnil // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
case reflect.Slice:
|
||||||
|
var values []any
|
||||||
|
|
||||||
|
for i := 0; i < left.Len(); i++ {
|
||||||
|
value, err := impl.getPropertyValue(left.Index(i).Elem(), property)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
values = append(values, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return values, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, nil //nolint:nilnil // pre-existing issue from nektos/act
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *interperterImpl) getMapValue(value reflect.Value) (any, error) {
|
||||||
|
if value.Kind() == reflect.Pointer {
|
||||||
|
return impl.getMapValue(value.Elem())
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.Interface(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *interperterImpl) evaluateNot(notNode *actionlint.NotOpNode) (any, error) {
|
||||||
|
operand, err := impl.evaluateNode(notNode.Operand)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return !IsTruthy(operand), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *interperterImpl) evaluateCompare(compareNode *actionlint.CompareOpNode) (any, error) {
|
||||||
|
left, err := impl.evaluateNode(compareNode.Left)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
right, err := impl.evaluateNode(compareNode.Right)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
leftValue := reflect.ValueOf(left)
|
||||||
|
rightValue := reflect.ValueOf(right)
|
||||||
|
|
||||||
|
return impl.compareValues(leftValue, rightValue, compareNode.Kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *interperterImpl) compareValues(leftValue, rightValue reflect.Value, kind actionlint.CompareOpNodeKind) (any, error) {
|
||||||
|
if leftValue.Kind() != rightValue.Kind() {
|
||||||
|
if !impl.isNumber(leftValue) {
|
||||||
|
leftValue = impl.coerceToNumber(leftValue)
|
||||||
|
}
|
||||||
|
if !impl.isNumber(rightValue) {
|
||||||
|
rightValue = impl.coerceToNumber(rightValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch leftValue.Kind() {
|
||||||
|
case reflect.Bool:
|
||||||
|
return impl.compareNumber(float64(impl.coerceToNumber(leftValue).Int()), float64(impl.coerceToNumber(rightValue).Int()), kind)
|
||||||
|
case reflect.String:
|
||||||
|
return impl.compareString(strings.ToLower(leftValue.String()), strings.ToLower(rightValue.String()), kind)
|
||||||
|
|
||||||
|
case reflect.Int:
|
||||||
|
if rightValue.Kind() == reflect.Float64 {
|
||||||
|
return impl.compareNumber(float64(leftValue.Int()), rightValue.Float(), kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
return impl.compareNumber(float64(leftValue.Int()), float64(rightValue.Int()), kind)
|
||||||
|
|
||||||
|
case reflect.Float64:
|
||||||
|
if rightValue.Kind() == reflect.Int {
|
||||||
|
return impl.compareNumber(leftValue.Float(), float64(rightValue.Int()), kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
return impl.compareNumber(leftValue.Float(), rightValue.Float(), kind)
|
||||||
|
|
||||||
|
case reflect.Invalid:
|
||||||
|
if rightValue.Kind() == reflect.Invalid {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// not possible situation - params are converted to the same type in code above
|
||||||
|
return nil, fmt.Errorf("Compare params of Invalid type: left: %+v, right: %+v", leftValue.Kind(), rightValue.Kind())
|
||||||
|
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("Compare not implemented for types: left: %+v, right: %+v", leftValue.Kind(), rightValue.Kind())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *interperterImpl) coerceToNumber(value reflect.Value) reflect.Value {
|
||||||
|
switch value.Kind() {
|
||||||
|
case reflect.Invalid:
|
||||||
|
return reflect.ValueOf(0)
|
||||||
|
|
||||||
|
case reflect.Bool:
|
||||||
|
switch value.Bool() {
|
||||||
|
case true:
|
||||||
|
return reflect.ValueOf(1)
|
||||||
|
case false:
|
||||||
|
return reflect.ValueOf(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
case reflect.String:
|
||||||
|
if value.String() == "" {
|
||||||
|
return reflect.ValueOf(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// try to parse the string as a number
|
||||||
|
evaluated, err := impl.Evaluate(value.String(), DefaultStatusCheckNone)
|
||||||
|
if err != nil {
|
||||||
|
return reflect.ValueOf(math.NaN())
|
||||||
|
}
|
||||||
|
|
||||||
|
if value := reflect.ValueOf(evaluated); impl.isNumber(value) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return reflect.ValueOf(math.NaN())
|
||||||
|
}
|
||||||
|
|
||||||
|
// CoerceToString converts an evaluated expression value to a string the way GitHub does,
|
||||||
|
// see https://docs.github.com/en/actions/reference/workflows-and-actions/expressions#operators
|
||||||
|
// An already reflected value is accepted as-is, since Interface() would panic on an invalid one.
|
||||||
|
func CoerceToString(v any) string {
|
||||||
|
value, ok := v.(reflect.Value)
|
||||||
|
if !ok {
|
||||||
|
value = reflect.ValueOf(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch value.Kind() {
|
||||||
|
case reflect.Invalid:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
case reflect.Bool:
|
||||||
|
return strconv.FormatBool(value.Bool())
|
||||||
|
|
||||||
|
case reflect.String:
|
||||||
|
return value.String()
|
||||||
|
|
||||||
|
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||||
|
return strconv.FormatInt(value.Int(), 10)
|
||||||
|
|
||||||
|
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||||
|
return strconv.FormatUint(value.Uint(), 10)
|
||||||
|
|
||||||
|
case reflect.Float32, reflect.Float64:
|
||||||
|
if math.IsInf(value.Float(), 1) {
|
||||||
|
return "Infinity"
|
||||||
|
} else if math.IsInf(value.Float(), -1) {
|
||||||
|
return "-Infinity"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.15G", value.Float())
|
||||||
|
|
||||||
|
case reflect.Slice, reflect.Array:
|
||||||
|
return "Array"
|
||||||
|
|
||||||
|
// contexts such as `github` are pointers to structs, so they stringify as objects too
|
||||||
|
case reflect.Map, reflect.Struct:
|
||||||
|
return "Object"
|
||||||
|
|
||||||
|
case reflect.Interface, reflect.Pointer:
|
||||||
|
if value.IsNil() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return CoerceToString(value.Elem())
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%v", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *interperterImpl) compareString(left, right string, kind actionlint.CompareOpNodeKind) (bool, error) {
|
||||||
|
switch kind {
|
||||||
|
case actionlint.CompareOpNodeKindLess:
|
||||||
|
return left < right, nil
|
||||||
|
case actionlint.CompareOpNodeKindLessEq:
|
||||||
|
return left <= right, nil
|
||||||
|
case actionlint.CompareOpNodeKindGreater:
|
||||||
|
return left > right, nil
|
||||||
|
case actionlint.CompareOpNodeKindGreaterEq:
|
||||||
|
return left >= right, nil
|
||||||
|
case actionlint.CompareOpNodeKindEq:
|
||||||
|
return left == right, nil
|
||||||
|
case actionlint.CompareOpNodeKindNotEq:
|
||||||
|
return left != right, nil
|
||||||
|
default:
|
||||||
|
return false, fmt.Errorf("TODO: not implemented to compare '%+v'", kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *interperterImpl) compareNumber(left, right float64, kind actionlint.CompareOpNodeKind) (bool, error) {
|
||||||
|
switch kind {
|
||||||
|
case actionlint.CompareOpNodeKindLess:
|
||||||
|
return left < right, nil
|
||||||
|
case actionlint.CompareOpNodeKindLessEq:
|
||||||
|
return left <= right, nil
|
||||||
|
case actionlint.CompareOpNodeKindGreater:
|
||||||
|
return left > right, nil
|
||||||
|
case actionlint.CompareOpNodeKindGreaterEq:
|
||||||
|
return left >= right, nil
|
||||||
|
case actionlint.CompareOpNodeKindEq:
|
||||||
|
return left == right, nil
|
||||||
|
case actionlint.CompareOpNodeKindNotEq:
|
||||||
|
return left != right, nil
|
||||||
|
default:
|
||||||
|
return false, fmt.Errorf("TODO: not implemented to compare '%+v'", kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsTruthy(input any) bool {
|
||||||
|
value := reflect.ValueOf(input)
|
||||||
|
switch value.Kind() {
|
||||||
|
case reflect.Bool:
|
||||||
|
return value.Bool()
|
||||||
|
|
||||||
|
case reflect.String:
|
||||||
|
return value.String() != ""
|
||||||
|
|
||||||
|
case reflect.Int:
|
||||||
|
return value.Int() != 0
|
||||||
|
|
||||||
|
case reflect.Float64:
|
||||||
|
if math.IsNaN(value.Float()) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.Float() != 0
|
||||||
|
|
||||||
|
case reflect.Map, reflect.Slice:
|
||||||
|
return true
|
||||||
|
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *interperterImpl) isNumber(value reflect.Value) bool {
|
||||||
|
switch value.Kind() {
|
||||||
|
case reflect.Int, reflect.Float64:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *interperterImpl) getSafeValue(value reflect.Value) any {
|
||||||
|
switch value.Kind() {
|
||||||
|
case reflect.Invalid:
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case reflect.Float64:
|
||||||
|
if value.Float() == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.Interface()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *interperterImpl) evaluateLogicalCompare(compareNode *actionlint.LogicalOpNode) (any, error) {
|
||||||
|
left, err := impl.evaluateNode(compareNode.Left)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
leftValue := reflect.ValueOf(left)
|
||||||
|
|
||||||
|
if IsTruthy(left) == (compareNode.Kind == actionlint.LogicalOpNodeKindOr) {
|
||||||
|
return impl.getSafeValue(leftValue), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
right, err := impl.evaluateNode(compareNode.Right)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rightValue := reflect.ValueOf(right)
|
||||||
|
|
||||||
|
switch compareNode.Kind {
|
||||||
|
case actionlint.LogicalOpNodeKindAnd:
|
||||||
|
return impl.getSafeValue(rightValue), nil
|
||||||
|
case actionlint.LogicalOpNodeKindOr:
|
||||||
|
return impl.getSafeValue(rightValue), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("Unable to compare incompatibles types '%s' and '%s'", leftValue.Kind(), rightValue.Kind())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *interperterImpl) evaluateFuncCall(funcCallNode *actionlint.FuncCallNode) (any, error) {
|
||||||
|
args := make([]reflect.Value, 0)
|
||||||
|
|
||||||
|
for _, arg := range funcCallNode.Args {
|
||||||
|
value, err := impl.evaluateNode(arg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
args = append(args, reflect.ValueOf(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
switch strings.ToLower(funcCallNode.Callee) {
|
||||||
|
case "contains":
|
||||||
|
return impl.contains(args[0], args[1])
|
||||||
|
case "startswith":
|
||||||
|
return impl.startsWith(args[0], args[1])
|
||||||
|
case "endswith":
|
||||||
|
return impl.endsWith(args[0], args[1])
|
||||||
|
case "format":
|
||||||
|
return impl.format(args[0], args[1:]...)
|
||||||
|
case "join":
|
||||||
|
if len(args) == 1 {
|
||||||
|
return impl.join(args[0], reflect.ValueOf(","))
|
||||||
|
}
|
||||||
|
return impl.join(args[0], args[1])
|
||||||
|
case "tojson":
|
||||||
|
return impl.toJSON(args[0])
|
||||||
|
case "fromjson":
|
||||||
|
return impl.fromJSON(args[0])
|
||||||
|
case "hashfiles":
|
||||||
|
if impl.env.HashFiles != nil {
|
||||||
|
return impl.env.HashFiles(args)
|
||||||
|
}
|
||||||
|
return impl.hashFiles(args...)
|
||||||
|
case "always":
|
||||||
|
return impl.always()
|
||||||
|
case "success":
|
||||||
|
if impl.config.Context == "job" {
|
||||||
|
return impl.jobSuccess()
|
||||||
|
}
|
||||||
|
if impl.config.Context == "step" {
|
||||||
|
return impl.stepSuccess()
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("Context '%s' must be one of 'job' or 'step'", impl.config.Context)
|
||||||
|
case "failure":
|
||||||
|
if impl.config.Context == "job" {
|
||||||
|
return impl.jobFailure()
|
||||||
|
}
|
||||||
|
if impl.config.Context == "step" {
|
||||||
|
return impl.stepFailure()
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("Context '%s' must be one of 'job' or 'step'", impl.config.Context)
|
||||||
|
case "cancelled":
|
||||||
|
return impl.cancelled()
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("TODO: '%s' not implemented", funcCallNode.Callee)
|
||||||
|
}
|
||||||
|
}
|
||||||
684
act/exprparser/interpreter_test.go
Normal file
684
act/exprparser/interpreter_test.go
Normal file
@@ -0,0 +1,684 @@
|
|||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package exprparser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLiterals(t *testing.T) {
|
||||||
|
table := []struct {
|
||||||
|
input string
|
||||||
|
expected any
|
||||||
|
name string
|
||||||
|
}{
|
||||||
|
{"true", true, "true"},
|
||||||
|
{"false", false, "false"},
|
||||||
|
{"null", nil, "null"},
|
||||||
|
{"123", 123, "integer"},
|
||||||
|
{"-9.7", -9.7, "float"},
|
||||||
|
{"0xff", 255, "hex"},
|
||||||
|
{"-2.99e-2", -2.99e-2, "exponential"},
|
||||||
|
{"'foo'", "foo", "string"},
|
||||||
|
{"'it''s foo'", "it's foo", "string"},
|
||||||
|
}
|
||||||
|
|
||||||
|
env := &EvaluationEnvironment{}
|
||||||
|
|
||||||
|
for _, tt := range table {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
assert.Equal(t, tt.expected, output)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOperators(t *testing.T) {
|
||||||
|
table := []struct {
|
||||||
|
input string
|
||||||
|
expected any
|
||||||
|
name string
|
||||||
|
error string
|
||||||
|
}{
|
||||||
|
{"(false || (false || true))", true, "logical-grouping", ""},
|
||||||
|
{"github.action", "push", "property-dereference", ""},
|
||||||
|
{"github['action']", "push", "property-index", ""},
|
||||||
|
{"github.action[0]", nil, "string-index", ""},
|
||||||
|
{"github.action['0']", nil, "string-index", ""},
|
||||||
|
{"fromJSON('[0,1]')[1]", 1.0, "array-index", ""},
|
||||||
|
{"fromJSON('[0,1]')[1.1]", nil, "array-index", ""},
|
||||||
|
// Disabled weird things are happening
|
||||||
|
// {"fromJSON('[0,1]')['1.1']", nil, "array-index", ""},
|
||||||
|
{"(github.event.commits.*.author.username)[0]", "someone", "array-index-0", ""},
|
||||||
|
{"fromJSON('[0,1]')[2]", nil, "array-index-out-of-bounds-0", ""},
|
||||||
|
{"fromJSON('[0,1]')[34553]", nil, "array-index-out-of-bounds-1", ""},
|
||||||
|
{"fromJSON('[0,1]')[-1]", nil, "array-index-out-of-bounds-2", ""},
|
||||||
|
{"fromJSON('[0,1]')[-34553]", nil, "array-index-out-of-bounds-3", ""},
|
||||||
|
{"!true", false, "not", ""},
|
||||||
|
{"1 < 2", true, "less-than", ""},
|
||||||
|
{`'b' <= 'a'`, false, "less-than-or-equal", ""},
|
||||||
|
{"1 > 2", false, "greater-than", ""},
|
||||||
|
{`'b' >= 'a'`, true, "greater-than-or-equal", ""},
|
||||||
|
{`'a' == 'a'`, true, "equal", ""},
|
||||||
|
{`'a' != 'a'`, false, "not-equal", ""},
|
||||||
|
{`true && false`, false, "and", ""},
|
||||||
|
{`true || false`, true, "or", ""},
|
||||||
|
{`fromJSON('{}') && true`, true, "and-boolean-object", ""},
|
||||||
|
{`fromJSON('{}') || false`, make(map[string]any), "or-boolean-object", ""},
|
||||||
|
{"github.event.commits[0].author.username != github.event.commits[1].author.username", true, "property-comparison1", ""},
|
||||||
|
{"github.event.commits[0].author.username1 != github.event.commits[1].author.username", true, "property-comparison2", ""},
|
||||||
|
{"github.event.commits[0].author.username != github.event.commits[1].author.username1", true, "property-comparison3", ""},
|
||||||
|
{"github.event.commits[0].author.username1 != github.event.commits[1].author.username2", true, "property-comparison4", ""},
|
||||||
|
{"secrets != env", nil, "property-comparison5", "Compare not implemented for types: left: map, right: map"},
|
||||||
|
}
|
||||||
|
|
||||||
|
env := &EvaluationEnvironment{
|
||||||
|
Github: &model.GithubContext{
|
||||||
|
Action: "push",
|
||||||
|
Event: map[string]any{
|
||||||
|
"commits": []any{
|
||||||
|
map[string]any{
|
||||||
|
"author": map[string]any{
|
||||||
|
"username": "someone",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
map[string]any{
|
||||||
|
"author": map[string]any{
|
||||||
|
"username": "someone-else",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range table {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||||
|
if tt.error != "" {
|
||||||
|
assert.Error(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Equal(t, tt.error, err.Error())
|
||||||
|
} else {
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, tt.expected, output)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOperatorsCompare(t *testing.T) {
|
||||||
|
table := []struct {
|
||||||
|
input string
|
||||||
|
expected any
|
||||||
|
name string
|
||||||
|
}{
|
||||||
|
{"!null", true, "not-null"},
|
||||||
|
{"!-10", false, "not-neg-num"},
|
||||||
|
{"!0", true, "not-zero"},
|
||||||
|
{"!3.14", false, "not-pos-float"},
|
||||||
|
{"!''", true, "not-empty-str"},
|
||||||
|
{"!'abc'", false, "not-str"},
|
||||||
|
{"!fromJSON('{}')", false, "not-obj"},
|
||||||
|
{"!fromJSON('[]')", false, "not-arr"},
|
||||||
|
{`null == 0 }}`, true, "null-coercion"},
|
||||||
|
{`true == 1 }}`, true, "boolean-coercion"},
|
||||||
|
{`'' == 0 }}`, true, "string-0-coercion"},
|
||||||
|
{`'3' == 3 }}`, true, "string-3-coercion"},
|
||||||
|
{`0 == null }}`, true, "null-coercion-alt"},
|
||||||
|
{`1 == true }}`, true, "boolean-coercion-alt"},
|
||||||
|
{`0 == '' }}`, true, "string-0-coercion-alt"},
|
||||||
|
{`3 == '3' }}`, true, "string-3-coercion-alt"},
|
||||||
|
{`'TEST' == 'test' }}`, true, "string-casing"},
|
||||||
|
{"true > false }}", true, "bool-greater-than"},
|
||||||
|
{"true >= false }}", true, "bool-greater-than-eq"},
|
||||||
|
{"true >= true }}", true, "bool-greater-than-1"},
|
||||||
|
{"true != false }}", true, "bool-not-equal"},
|
||||||
|
{`fromJSON('{}') < 2 }}`, false, "object-with-less"},
|
||||||
|
{`fromJSON('{}') < fromJSON('[]') }}`, false, "object/arr-with-lt"},
|
||||||
|
{`fromJSON('{}') > fromJSON('[]') }}`, false, "object/arr-with-gt"},
|
||||||
|
}
|
||||||
|
|
||||||
|
env := &EvaluationEnvironment{
|
||||||
|
Github: &model.GithubContext{
|
||||||
|
Action: "push",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range table {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
assert.Equal(t, tt.expected, output)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOperatorsBooleanEvaluation(t *testing.T) {
|
||||||
|
table := []struct {
|
||||||
|
input string
|
||||||
|
expected any
|
||||||
|
name string
|
||||||
|
}{
|
||||||
|
// true &&
|
||||||
|
{"true && true", true, "true-and"},
|
||||||
|
{"true && false", false, "true-and"},
|
||||||
|
{"true && null", nil, "true-and"},
|
||||||
|
{"true && -10", -10, "true-and"},
|
||||||
|
{"true && 0", 0, "true-and"},
|
||||||
|
{"true && 10", 10, "true-and"},
|
||||||
|
{"true && 3.14", 3.14, "true-and"},
|
||||||
|
{"true && 0.0", 0, "true-and"},
|
||||||
|
{"true && Infinity", math.Inf(1), "true-and"},
|
||||||
|
// {"true && -Infinity", math.Inf(-1), "true-and"},
|
||||||
|
{"true && NaN", math.NaN(), "true-and"},
|
||||||
|
{"true && ''", "", "true-and"},
|
||||||
|
{"true && 'abc'", "abc", "true-and"},
|
||||||
|
// false &&
|
||||||
|
{"false && true", false, "false-and"},
|
||||||
|
{"false && false", false, "false-and"},
|
||||||
|
{"false && null", false, "false-and"},
|
||||||
|
{"false && -10", false, "false-and"},
|
||||||
|
{"false && 0", false, "false-and"},
|
||||||
|
{"false && 10", false, "false-and"},
|
||||||
|
{"false && 3.14", false, "false-and"},
|
||||||
|
{"false && 0.0", false, "false-and"},
|
||||||
|
{"false && Infinity", false, "false-and"},
|
||||||
|
// {"false && -Infinity", false, "false-and"},
|
||||||
|
{"false && NaN", false, "false-and"},
|
||||||
|
{"false && ''", false, "false-and"},
|
||||||
|
{"false && 'abc'", false, "false-and"},
|
||||||
|
// true ||
|
||||||
|
{"true || true", true, "true-or"},
|
||||||
|
{"true || false", true, "true-or"},
|
||||||
|
{"true || null", true, "true-or"},
|
||||||
|
{"true || -10", true, "true-or"},
|
||||||
|
{"true || 0", true, "true-or"},
|
||||||
|
{"true || 10", true, "true-or"},
|
||||||
|
{"true || 3.14", true, "true-or"},
|
||||||
|
{"true || 0.0", true, "true-or"},
|
||||||
|
{"true || Infinity", true, "true-or"},
|
||||||
|
// {"true || -Infinity", true, "true-or"},
|
||||||
|
{"true || NaN", true, "true-or"},
|
||||||
|
{"true || ''", true, "true-or"},
|
||||||
|
{"true || 'abc'", true, "true-or"},
|
||||||
|
// false ||
|
||||||
|
{"false || true", true, "false-or"},
|
||||||
|
{"false || false", false, "false-or"},
|
||||||
|
{"false || null", nil, "false-or"},
|
||||||
|
{"false || -10", -10, "false-or"},
|
||||||
|
{"false || 0", 0, "false-or"},
|
||||||
|
{"false || 10", 10, "false-or"},
|
||||||
|
{"false || 3.14", 3.14, "false-or"},
|
||||||
|
{"false || 0.0", 0, "false-or"},
|
||||||
|
{"false || Infinity", math.Inf(1), "false-or"},
|
||||||
|
// {"false || -Infinity", math.Inf(-1), "false-or"},
|
||||||
|
{"false || NaN", math.NaN(), "false-or"},
|
||||||
|
{"false || ''", "", "false-or"},
|
||||||
|
{"false || 'abc'", "abc", "false-or"},
|
||||||
|
// null &&
|
||||||
|
{"null && true", nil, "null-and"},
|
||||||
|
{"null && false", nil, "null-and"},
|
||||||
|
{"null && null", nil, "null-and"},
|
||||||
|
{"null && -10", nil, "null-and"},
|
||||||
|
{"null && 0", nil, "null-and"},
|
||||||
|
{"null && 10", nil, "null-and"},
|
||||||
|
{"null && 3.14", nil, "null-and"},
|
||||||
|
{"null && 0.0", nil, "null-and"},
|
||||||
|
{"null && Infinity", nil, "null-and"},
|
||||||
|
// {"null && -Infinity", nil, "null-and"},
|
||||||
|
{"null && NaN", nil, "null-and"},
|
||||||
|
{"null && ''", nil, "null-and"},
|
||||||
|
{"null && 'abc'", nil, "null-and"},
|
||||||
|
// null ||
|
||||||
|
{"null || true", true, "null-or"},
|
||||||
|
{"null || false", false, "null-or"},
|
||||||
|
{"null || null", nil, "null-or"},
|
||||||
|
{"null || -10", -10, "null-or"},
|
||||||
|
{"null || 0", 0, "null-or"},
|
||||||
|
{"null || 10", 10, "null-or"},
|
||||||
|
{"null || 3.14", 3.14, "null-or"},
|
||||||
|
{"null || 0.0", 0, "null-or"},
|
||||||
|
{"null || Infinity", math.Inf(1), "null-or"},
|
||||||
|
// {"null || -Infinity", math.Inf(-1), "null-or"},
|
||||||
|
{"null || NaN", math.NaN(), "null-or"},
|
||||||
|
{"null || ''", "", "null-or"},
|
||||||
|
{"null || 'abc'", "abc", "null-or"},
|
||||||
|
// -10 &&
|
||||||
|
{"-10 && true", true, "neg-num-and"},
|
||||||
|
{"-10 && false", false, "neg-num-and"},
|
||||||
|
{"-10 && null", nil, "neg-num-and"},
|
||||||
|
{"-10 && -10", -10, "neg-num-and"},
|
||||||
|
{"-10 && 0", 0, "neg-num-and"},
|
||||||
|
{"-10 && 10", 10, "neg-num-and"},
|
||||||
|
{"-10 && 3.14", 3.14, "neg-num-and"},
|
||||||
|
{"-10 && 0.0", 0, "neg-num-and"},
|
||||||
|
{"-10 && Infinity", math.Inf(1), "neg-num-and"},
|
||||||
|
// {"-10 && -Infinity", math.Inf(-1), "neg-num-and"},
|
||||||
|
{"-10 && NaN", math.NaN(), "neg-num-and"},
|
||||||
|
{"-10 && ''", "", "neg-num-and"},
|
||||||
|
{"-10 && 'abc'", "abc", "neg-num-and"},
|
||||||
|
// -10 ||
|
||||||
|
{"-10 || true", -10, "neg-num-or"},
|
||||||
|
{"-10 || false", -10, "neg-num-or"},
|
||||||
|
{"-10 || null", -10, "neg-num-or"},
|
||||||
|
{"-10 || -10", -10, "neg-num-or"},
|
||||||
|
{"-10 || 0", -10, "neg-num-or"},
|
||||||
|
{"-10 || 10", -10, "neg-num-or"},
|
||||||
|
{"-10 || 3.14", -10, "neg-num-or"},
|
||||||
|
{"-10 || 0.0", -10, "neg-num-or"},
|
||||||
|
{"-10 || Infinity", -10, "neg-num-or"},
|
||||||
|
// {"-10 || -Infinity", -10, "neg-num-or"},
|
||||||
|
{"-10 || NaN", -10, "neg-num-or"},
|
||||||
|
{"-10 || ''", -10, "neg-num-or"},
|
||||||
|
{"-10 || 'abc'", -10, "neg-num-or"},
|
||||||
|
// 0 &&
|
||||||
|
{"0 && true", 0, "zero-and"},
|
||||||
|
{"0 && false", 0, "zero-and"},
|
||||||
|
{"0 && null", 0, "zero-and"},
|
||||||
|
{"0 && -10", 0, "zero-and"},
|
||||||
|
{"0 && 0", 0, "zero-and"},
|
||||||
|
{"0 && 10", 0, "zero-and"},
|
||||||
|
{"0 && 3.14", 0, "zero-and"},
|
||||||
|
{"0 && 0.0", 0, "zero-and"},
|
||||||
|
{"0 && Infinity", 0, "zero-and"},
|
||||||
|
// {"0 && -Infinity", 0, "zero-and"},
|
||||||
|
{"0 && NaN", 0, "zero-and"},
|
||||||
|
{"0 && ''", 0, "zero-and"},
|
||||||
|
{"0 && 'abc'", 0, "zero-and"},
|
||||||
|
// 0 ||
|
||||||
|
{"0 || true", true, "zero-or"},
|
||||||
|
{"0 || false", false, "zero-or"},
|
||||||
|
{"0 || null", nil, "zero-or"},
|
||||||
|
{"0 || -10", -10, "zero-or"},
|
||||||
|
{"0 || 0", 0, "zero-or"},
|
||||||
|
{"0 || 10", 10, "zero-or"},
|
||||||
|
{"0 || 3.14", 3.14, "zero-or"},
|
||||||
|
{"0 || 0.0", 0, "zero-or"},
|
||||||
|
{"0 || Infinity", math.Inf(1), "zero-or"},
|
||||||
|
// {"0 || -Infinity", math.Inf(-1), "zero-or"},
|
||||||
|
{"0 || NaN", math.NaN(), "zero-or"},
|
||||||
|
{"0 || ''", "", "zero-or"},
|
||||||
|
{"0 || 'abc'", "abc", "zero-or"},
|
||||||
|
// 10 &&
|
||||||
|
{"10 && true", true, "pos-num-and"},
|
||||||
|
{"10 && false", false, "pos-num-and"},
|
||||||
|
{"10 && null", nil, "pos-num-and"},
|
||||||
|
{"10 && -10", -10, "pos-num-and"},
|
||||||
|
{"10 && 0", 0, "pos-num-and"},
|
||||||
|
{"10 && 10", 10, "pos-num-and"},
|
||||||
|
{"10 && 3.14", 3.14, "pos-num-and"},
|
||||||
|
{"10 && 0.0", 0, "pos-num-and"},
|
||||||
|
{"10 && Infinity", math.Inf(1), "pos-num-and"},
|
||||||
|
// {"10 && -Infinity", math.Inf(-1), "pos-num-and"},
|
||||||
|
{"10 && NaN", math.NaN(), "pos-num-and"},
|
||||||
|
{"10 && ''", "", "pos-num-and"},
|
||||||
|
{"10 && 'abc'", "abc", "pos-num-and"},
|
||||||
|
// 10 ||
|
||||||
|
{"10 || true", 10, "pos-num-or"},
|
||||||
|
{"10 || false", 10, "pos-num-or"},
|
||||||
|
{"10 || null", 10, "pos-num-or"},
|
||||||
|
{"10 || -10", 10, "pos-num-or"},
|
||||||
|
{"10 || 0", 10, "pos-num-or"},
|
||||||
|
{"10 || 10", 10, "pos-num-or"},
|
||||||
|
{"10 || 3.14", 10, "pos-num-or"},
|
||||||
|
{"10 || 0.0", 10, "pos-num-or"},
|
||||||
|
{"10 || Infinity", 10, "pos-num-or"},
|
||||||
|
// {"10 || -Infinity", 10, "pos-num-or"},
|
||||||
|
{"10 || NaN", 10, "pos-num-or"},
|
||||||
|
{"10 || ''", 10, "pos-num-or"},
|
||||||
|
{"10 || 'abc'", 10, "pos-num-or"},
|
||||||
|
// 3.14 &&
|
||||||
|
{"3.14 && true", true, "pos-float-and"},
|
||||||
|
{"3.14 && false", false, "pos-float-and"},
|
||||||
|
{"3.14 && null", nil, "pos-float-and"},
|
||||||
|
{"3.14 && -10", -10, "pos-float-and"},
|
||||||
|
{"3.14 && 0", 0, "pos-float-and"},
|
||||||
|
{"3.14 && 10", 10, "pos-float-and"},
|
||||||
|
{"3.14 && 3.14", 3.14, "pos-float-and"},
|
||||||
|
{"3.14 && 0.0", 0, "pos-float-and"},
|
||||||
|
{"3.14 && Infinity", math.Inf(1), "pos-float-and"},
|
||||||
|
// {"3.14 && -Infinity", math.Inf(-1), "pos-float-and"},
|
||||||
|
{"3.14 && NaN", math.NaN(), "pos-float-and"},
|
||||||
|
{"3.14 && ''", "", "pos-float-and"},
|
||||||
|
{"3.14 && 'abc'", "abc", "pos-float-and"},
|
||||||
|
// 3.14 ||
|
||||||
|
{"3.14 || true", 3.14, "pos-float-or"},
|
||||||
|
{"3.14 || false", 3.14, "pos-float-or"},
|
||||||
|
{"3.14 || null", 3.14, "pos-float-or"},
|
||||||
|
{"3.14 || -10", 3.14, "pos-float-or"},
|
||||||
|
{"3.14 || 0", 3.14, "pos-float-or"},
|
||||||
|
{"3.14 || 10", 3.14, "pos-float-or"},
|
||||||
|
{"3.14 || 3.14", 3.14, "pos-float-or"},
|
||||||
|
{"3.14 || 0.0", 3.14, "pos-float-or"},
|
||||||
|
{"3.14 || Infinity", 3.14, "pos-float-or"},
|
||||||
|
// {"3.14 || -Infinity", 3.14, "pos-float-or"},
|
||||||
|
{"3.14 || NaN", 3.14, "pos-float-or"},
|
||||||
|
{"3.14 || ''", 3.14, "pos-float-or"},
|
||||||
|
{"3.14 || 'abc'", 3.14, "pos-float-or"},
|
||||||
|
// Infinity &&
|
||||||
|
{"Infinity && true", true, "pos-inf-and"},
|
||||||
|
{"Infinity && false", false, "pos-inf-and"},
|
||||||
|
{"Infinity && null", nil, "pos-inf-and"},
|
||||||
|
{"Infinity && -10", -10, "pos-inf-and"},
|
||||||
|
{"Infinity && 0", 0, "pos-inf-and"},
|
||||||
|
{"Infinity && 10", 10, "pos-inf-and"},
|
||||||
|
{"Infinity && 3.14", 3.14, "pos-inf-and"},
|
||||||
|
{"Infinity && 0.0", 0, "pos-inf-and"},
|
||||||
|
{"Infinity && Infinity", math.Inf(1), "pos-inf-and"},
|
||||||
|
// {"Infinity && -Infinity", math.Inf(-1), "pos-inf-and"},
|
||||||
|
{"Infinity && NaN", math.NaN(), "pos-inf-and"},
|
||||||
|
{"Infinity && ''", "", "pos-inf-and"},
|
||||||
|
{"Infinity && 'abc'", "abc", "pos-inf-and"},
|
||||||
|
// Infinity ||
|
||||||
|
{"Infinity || true", math.Inf(1), "pos-inf-or"},
|
||||||
|
{"Infinity || false", math.Inf(1), "pos-inf-or"},
|
||||||
|
{"Infinity || null", math.Inf(1), "pos-inf-or"},
|
||||||
|
{"Infinity || -10", math.Inf(1), "pos-inf-or"},
|
||||||
|
{"Infinity || 0", math.Inf(1), "pos-inf-or"},
|
||||||
|
{"Infinity || 10", math.Inf(1), "pos-inf-or"},
|
||||||
|
{"Infinity || 3.14", math.Inf(1), "pos-inf-or"},
|
||||||
|
{"Infinity || 0.0", math.Inf(1), "pos-inf-or"},
|
||||||
|
{"Infinity || Infinity", math.Inf(1), "pos-inf-or"},
|
||||||
|
// {"Infinity || -Infinity", math.Inf(1), "pos-inf-or"},
|
||||||
|
{"Infinity || NaN", math.Inf(1), "pos-inf-or"},
|
||||||
|
{"Infinity || ''", math.Inf(1), "pos-inf-or"},
|
||||||
|
{"Infinity || 'abc'", math.Inf(1), "pos-inf-or"},
|
||||||
|
// -Infinity &&
|
||||||
|
// {"-Infinity && true", true, "neg-inf-and"},
|
||||||
|
// {"-Infinity && false", false, "neg-inf-and"},
|
||||||
|
// {"-Infinity && null", nil, "neg-inf-and"},
|
||||||
|
// {"-Infinity && -10", -10, "neg-inf-and"},
|
||||||
|
// {"-Infinity && 0", 0, "neg-inf-and"},
|
||||||
|
// {"-Infinity && 10", 10, "neg-inf-and"},
|
||||||
|
// {"-Infinity && 3.14", 3.14, "neg-inf-and"},
|
||||||
|
// {"-Infinity && 0.0", 0, "neg-inf-and"},
|
||||||
|
// {"-Infinity && Infinity", math.Inf(1), "neg-inf-and"},
|
||||||
|
// {"-Infinity && -Infinity", math.Inf(-1), "neg-inf-and"},
|
||||||
|
// {"-Infinity && NaN", math.NaN(), "neg-inf-and"},
|
||||||
|
// {"-Infinity && ''", "", "neg-inf-and"},
|
||||||
|
// {"-Infinity && 'abc'", "abc", "neg-inf-and"},
|
||||||
|
// -Infinity ||
|
||||||
|
// {"-Infinity || true", math.Inf(-1), "neg-inf-or"},
|
||||||
|
// {"-Infinity || false", math.Inf(-1), "neg-inf-or"},
|
||||||
|
// {"-Infinity || null", math.Inf(-1), "neg-inf-or"},
|
||||||
|
// {"-Infinity || -10", math.Inf(-1), "neg-inf-or"},
|
||||||
|
// {"-Infinity || 0", math.Inf(-1), "neg-inf-or"},
|
||||||
|
// {"-Infinity || 10", math.Inf(-1), "neg-inf-or"},
|
||||||
|
// {"-Infinity || 3.14", math.Inf(-1), "neg-inf-or"},
|
||||||
|
// {"-Infinity || 0.0", math.Inf(-1), "neg-inf-or"},
|
||||||
|
// {"-Infinity || Infinity", math.Inf(-1), "neg-inf-or"},
|
||||||
|
// {"-Infinity || -Infinity", math.Inf(-1), "neg-inf-or"},
|
||||||
|
// {"-Infinity || NaN", math.Inf(-1), "neg-inf-or"},
|
||||||
|
// {"-Infinity || ''", math.Inf(-1), "neg-inf-or"},
|
||||||
|
// {"-Infinity || 'abc'", math.Inf(-1), "neg-inf-or"},
|
||||||
|
// NaN &&
|
||||||
|
{"NaN && true", math.NaN(), "nan-and"},
|
||||||
|
{"NaN && false", math.NaN(), "nan-and"},
|
||||||
|
{"NaN && null", math.NaN(), "nan-and"},
|
||||||
|
{"NaN && -10", math.NaN(), "nan-and"},
|
||||||
|
{"NaN && 0", math.NaN(), "nan-and"},
|
||||||
|
{"NaN && 10", math.NaN(), "nan-and"},
|
||||||
|
{"NaN && 3.14", math.NaN(), "nan-and"},
|
||||||
|
{"NaN && 0.0", math.NaN(), "nan-and"},
|
||||||
|
{"NaN && Infinity", math.NaN(), "nan-and"},
|
||||||
|
// {"NaN && -Infinity", math.NaN(), "nan-and"},
|
||||||
|
{"NaN && NaN", math.NaN(), "nan-and"},
|
||||||
|
{"NaN && ''", math.NaN(), "nan-and"},
|
||||||
|
{"NaN && 'abc'", math.NaN(), "nan-and"},
|
||||||
|
// NaN ||
|
||||||
|
{"NaN || true", true, "nan-or"},
|
||||||
|
{"NaN || false", false, "nan-or"},
|
||||||
|
{"NaN || null", nil, "nan-or"},
|
||||||
|
{"NaN || -10", -10, "nan-or"},
|
||||||
|
{"NaN || 0", 0, "nan-or"},
|
||||||
|
{"NaN || 10", 10, "nan-or"},
|
||||||
|
{"NaN || 3.14", 3.14, "nan-or"},
|
||||||
|
{"NaN || 0.0", 0, "nan-or"},
|
||||||
|
{"NaN || Infinity", math.Inf(1), "nan-or"},
|
||||||
|
// {"NaN || -Infinity", math.Inf(-1), "nan-or"},
|
||||||
|
{"NaN || NaN", math.NaN(), "nan-or"},
|
||||||
|
{"NaN || ''", "", "nan-or"},
|
||||||
|
{"NaN || 'abc'", "abc", "nan-or"},
|
||||||
|
// "" &&
|
||||||
|
{"'' && true", "", "empty-str-and"},
|
||||||
|
{"'' && false", "", "empty-str-and"},
|
||||||
|
{"'' && null", "", "empty-str-and"},
|
||||||
|
{"'' && -10", "", "empty-str-and"},
|
||||||
|
{"'' && 0", "", "empty-str-and"},
|
||||||
|
{"'' && 10", "", "empty-str-and"},
|
||||||
|
{"'' && 3.14", "", "empty-str-and"},
|
||||||
|
{"'' && 0.0", "", "empty-str-and"},
|
||||||
|
{"'' && Infinity", "", "empty-str-and"},
|
||||||
|
// {"'' && -Infinity", "", "empty-str-and"},
|
||||||
|
{"'' && NaN", "", "empty-str-and"},
|
||||||
|
{"'' && ''", "", "empty-str-and"},
|
||||||
|
{"'' && 'abc'", "", "empty-str-and"},
|
||||||
|
// "" ||
|
||||||
|
{"'' || true", true, "empty-str-or"},
|
||||||
|
{"'' || false", false, "empty-str-or"},
|
||||||
|
{"'' || null", nil, "empty-str-or"},
|
||||||
|
{"'' || -10", -10, "empty-str-or"},
|
||||||
|
{"'' || 0", 0, "empty-str-or"},
|
||||||
|
{"'' || 10", 10, "empty-str-or"},
|
||||||
|
{"'' || 3.14", 3.14, "empty-str-or"},
|
||||||
|
{"'' || 0.0", 0, "empty-str-or"},
|
||||||
|
{"'' || Infinity", math.Inf(1), "empty-str-or"},
|
||||||
|
// {"'' || -Infinity", math.Inf(-1), "empty-str-or"},
|
||||||
|
{"'' || NaN", math.NaN(), "empty-str-or"},
|
||||||
|
{"'' || ''", "", "empty-str-or"},
|
||||||
|
{"'' || 'abc'", "abc", "empty-str-or"},
|
||||||
|
// "abc" &&
|
||||||
|
{"'abc' && true", true, "str-and"},
|
||||||
|
{"'abc' && false", false, "str-and"},
|
||||||
|
{"'abc' && null", nil, "str-and"},
|
||||||
|
{"'abc' && -10", -10, "str-and"},
|
||||||
|
{"'abc' && 0", 0, "str-and"},
|
||||||
|
{"'abc' && 10", 10, "str-and"},
|
||||||
|
{"'abc' && 3.14", 3.14, "str-and"},
|
||||||
|
{"'abc' && 0.0", 0, "str-and"},
|
||||||
|
{"'abc' && Infinity", math.Inf(1), "str-and"},
|
||||||
|
// {"'abc' && -Infinity", math.Inf(-1), "str-and"},
|
||||||
|
{"'abc' && NaN", math.NaN(), "str-and"},
|
||||||
|
{"'abc' && ''", "", "str-and"},
|
||||||
|
{"'abc' && 'abc'", "abc", "str-and"},
|
||||||
|
// "abc" ||
|
||||||
|
{"'abc' || true", "abc", "str-or"},
|
||||||
|
{"'abc' || false", "abc", "str-or"},
|
||||||
|
{"'abc' || null", "abc", "str-or"},
|
||||||
|
{"'abc' || -10", "abc", "str-or"},
|
||||||
|
{"'abc' || 0", "abc", "str-or"},
|
||||||
|
{"'abc' || 10", "abc", "str-or"},
|
||||||
|
{"'abc' || 3.14", "abc", "str-or"},
|
||||||
|
{"'abc' || 0.0", "abc", "str-or"},
|
||||||
|
{"'abc' || Infinity", "abc", "str-or"},
|
||||||
|
// {"'abc' || -Infinity", "abc", "str-or"},
|
||||||
|
{"'abc' || NaN", "abc", "str-or"},
|
||||||
|
{"'abc' || ''", "abc", "str-or"},
|
||||||
|
{"'abc' || 'abc'", "abc", "str-or"},
|
||||||
|
// extra tests
|
||||||
|
{"0.0 && true", 0, "float-evaluation-0-alt"},
|
||||||
|
{"-1.5 && true", true, "float-evaluation-neg-alt"},
|
||||||
|
}
|
||||||
|
|
||||||
|
env := &EvaluationEnvironment{
|
||||||
|
Github: &model.GithubContext{
|
||||||
|
Action: "push",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range table {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
if expected, ok := tt.expected.(float64); ok && math.IsNaN(expected) {
|
||||||
|
number, ok := output.(float64)
|
||||||
|
require.True(t, ok, "want a number, got %T", output)
|
||||||
|
assert.True(t, math.IsNaN(number))
|
||||||
|
} else {
|
||||||
|
assert.Equal(t, tt.expected, output)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContexts(t *testing.T) {
|
||||||
|
table := []struct {
|
||||||
|
input string
|
||||||
|
expected any
|
||||||
|
name string
|
||||||
|
}{
|
||||||
|
{"github.action", "push", "github-context"},
|
||||||
|
{"github.event.commits[0].message", nil, "github-context-noexist-prop"},
|
||||||
|
{"fromjson('{\"commits\":[]}').commits[0].message", nil, "github-context-noexist-prop"},
|
||||||
|
{"github.event.pull_request.labels.*.name", nil, "github-context-noexist-prop"},
|
||||||
|
{"env.TEST", "value", "env-context"},
|
||||||
|
{"job.status", "success", "job-context"},
|
||||||
|
{"steps.step-id.outputs.name", "value", "steps-context"},
|
||||||
|
{"steps.step-id.conclusion", "success", "steps-context-conclusion"},
|
||||||
|
{"steps.step-id.conclusion && true", true, "steps-context-conclusion"},
|
||||||
|
{"steps.step-id2.conclusion", "skipped", "steps-context-conclusion"},
|
||||||
|
{"steps.step-id2.conclusion && true", true, "steps-context-conclusion"},
|
||||||
|
{"steps.step-id.outcome", "success", "steps-context-outcome"},
|
||||||
|
{"steps.step-id['outcome']", "success", "steps-context-outcome"},
|
||||||
|
{"steps.step-id.outcome == 'success'", true, "steps-context-outcome"},
|
||||||
|
{"steps.step-id['outcome'] == 'success'", true, "steps-context-outcome"},
|
||||||
|
{"steps.step-id.outcome && true", true, "steps-context-outcome"},
|
||||||
|
{"steps['step-id']['outcome'] && true", true, "steps-context-outcome"},
|
||||||
|
{"steps.step-id2.outcome", "failure", "steps-context-outcome"},
|
||||||
|
{"steps.step-id2.outcome && true", true, "steps-context-outcome"},
|
||||||
|
// Disabled, since the interpreter is still too broken
|
||||||
|
// {"contains(steps.*.outcome, 'success')", true, "steps-context-array-outcome"},
|
||||||
|
// {"contains(steps.*.outcome, 'failure')", true, "steps-context-array-outcome"},
|
||||||
|
// {"contains(steps.*.outputs.name, 'value')", true, "steps-context-array-outputs"},
|
||||||
|
{"runner.os", "Linux", "runner-context"},
|
||||||
|
{"secrets.name", "value", "secrets-context"},
|
||||||
|
{"vars.name", "value", "vars-context"},
|
||||||
|
{"strategy.fail-fast", true, "strategy-context"},
|
||||||
|
{"matrix.os", "Linux", "matrix-context"},
|
||||||
|
{"needs.job-id.outputs.output-name", "value", "needs-context"},
|
||||||
|
{"needs.job-id.result", "success", "needs-context"},
|
||||||
|
{"inputs.name", "value", "inputs-context"},
|
||||||
|
}
|
||||||
|
|
||||||
|
env := &EvaluationEnvironment{
|
||||||
|
Github: &model.GithubContext{
|
||||||
|
Action: "push",
|
||||||
|
},
|
||||||
|
Env: map[string]string{
|
||||||
|
"TEST": "value",
|
||||||
|
},
|
||||||
|
Job: &model.JobContext{
|
||||||
|
Status: "success",
|
||||||
|
},
|
||||||
|
Steps: map[string]*model.StepResult{
|
||||||
|
"step-id": {
|
||||||
|
Outputs: map[string]string{
|
||||||
|
"name": "value",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"step-id2": {
|
||||||
|
Outcome: model.StepStatusFailure,
|
||||||
|
Conclusion: model.StepStatusSkipped,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Runner: map[string]any{
|
||||||
|
"os": "Linux",
|
||||||
|
"temp": "/tmp",
|
||||||
|
"tool_cache": "/opt/hostedtoolcache",
|
||||||
|
},
|
||||||
|
Secrets: map[string]string{
|
||||||
|
"name": "value",
|
||||||
|
},
|
||||||
|
Vars: map[string]string{
|
||||||
|
"name": "value",
|
||||||
|
},
|
||||||
|
Strategy: map[string]any{
|
||||||
|
"fail-fast": true,
|
||||||
|
},
|
||||||
|
Matrix: map[string]any{
|
||||||
|
"os": "Linux",
|
||||||
|
},
|
||||||
|
Needs: map[string]Needs{
|
||||||
|
"job-id": {
|
||||||
|
Outputs: map[string]string{
|
||||||
|
"output-name": "value",
|
||||||
|
},
|
||||||
|
Result: "success",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Inputs: map[string]any{
|
||||||
|
"name": "value",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range table {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
assert.Equal(t, tt.expected, output)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoerceToString(t *testing.T) {
|
||||||
|
type object struct{ Name string }
|
||||||
|
obj := object{Name: "x"}
|
||||||
|
var nilPointer *object
|
||||||
|
var nilMap map[string]any
|
||||||
|
var nilSlice []any
|
||||||
|
|
||||||
|
table := []struct {
|
||||||
|
input any
|
||||||
|
expected string
|
||||||
|
name string
|
||||||
|
}{
|
||||||
|
{nil, "", "null"},
|
||||||
|
{true, "true", "true"},
|
||||||
|
{false, "false", "false"},
|
||||||
|
{"foo", "foo", "string"},
|
||||||
|
{"", "", "empty-string"},
|
||||||
|
{123, "123", "int"},
|
||||||
|
{int64(-9), "-9", "int64"},
|
||||||
|
{uint8(7), "7", "uint8"},
|
||||||
|
{1.0, "1", "float-integral"},
|
||||||
|
{-9.7, "-9.7", "float"},
|
||||||
|
{2.99e-2, "0.0299", "float-exponential"},
|
||||||
|
{1e21, "1E+21", "float-large"},
|
||||||
|
{float32(1.5), "1.5", "float32"},
|
||||||
|
{math.NaN(), "NaN", "nan"},
|
||||||
|
{math.Inf(1), "Infinity", "positive-infinity"},
|
||||||
|
{math.Inf(-1), "-Infinity", "negative-infinity"},
|
||||||
|
{[]any{1, 2}, "Array", "slice"},
|
||||||
|
{nilSlice, "Array", "nil-slice"},
|
||||||
|
{[2]int{1, 2}, "Array", "fixed-size-array"},
|
||||||
|
{map[string]any{"a": 1}, "Object", "map"},
|
||||||
|
{nilMap, "Object", "nil-map"},
|
||||||
|
{obj, "Object", "struct"},
|
||||||
|
{&obj, "Object", "pointer-to-struct"},
|
||||||
|
{nilPointer, "", "nil-pointer"},
|
||||||
|
{&model.GithubContext{Action: "push"}, "Object", "github-context"},
|
||||||
|
{reflect.ValueOf(42), "42", "reflected-value"},
|
||||||
|
{reflect.Value{}, "", "invalid-reflected-value"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range table {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.expected, CoerceToString(tt.input))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
1
act/exprparser/testdata/for-hashing-1.txt
vendored
Normal file
1
act/exprparser/testdata/for-hashing-1.txt
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Hello
|
||||||
1
act/exprparser/testdata/for-hashing-2.txt
vendored
Normal file
1
act/exprparser/testdata/for-hashing-2.txt
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
World!
|
||||||
1
act/exprparser/testdata/for-hashing-3/data.txt
vendored
Normal file
1
act/exprparser/testdata/for-hashing-3/data.txt
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Knock knock!
|
||||||
1
act/exprparser/testdata/for-hashing-3/nested/nested-data.txt
vendored
Normal file
1
act/exprparser/testdata/for-hashing-3/nested/nested-data.txt
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Anybody home?
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
|
||||||
// Copyright 2021 The nektos/act Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
// Package ghcontext fills a model.GithubContext from the local git checkout.
|
|
||||||
// The pure data parts of the context live in the shared
|
|
||||||
// gitea.dev/actions-proto-go/pkg/model package, only the helpers that need a
|
|
||||||
// git repository on disk are kept here.
|
|
||||||
package ghcontext
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
|
||||||
"gitea.com/gitea/runner/act/common/git"
|
|
||||||
|
|
||||||
"gitea.dev/actions-proto-go/pkg/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
findGitRef = git.FindGitRef
|
|
||||||
findGitRevision = git.FindGitRevision
|
|
||||||
findGithubRepo = git.FindGithubRepo
|
|
||||||
)
|
|
||||||
|
|
||||||
func withDefaultBranch(ctx context.Context, b string, event map[string]any) map[string]any {
|
|
||||||
repoI, ok := event["repository"]
|
|
||||||
if !ok {
|
|
||||||
repoI = make(map[string]any)
|
|
||||||
}
|
|
||||||
|
|
||||||
repo, ok := repoI.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
common.Logger(ctx).Warnf("unable to set default branch to %v", b)
|
|
||||||
return event
|
|
||||||
}
|
|
||||||
|
|
||||||
// if the branch is already there return with no changes
|
|
||||||
if _, ok = repo["default_branch"]; ok {
|
|
||||||
return event
|
|
||||||
}
|
|
||||||
|
|
||||||
repo["default_branch"] = b
|
|
||||||
event["repository"] = repo
|
|
||||||
|
|
||||||
return event
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetRef resolves the ref of the context from its event payload, falling back
|
|
||||||
// to the ref checked out in repoPath.
|
|
||||||
func SetRef(ctx context.Context, ghc *model.GithubContext, defaultBranch, repoPath string) {
|
|
||||||
logger := common.Logger(ctx)
|
|
||||||
|
|
||||||
// https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows
|
|
||||||
// https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads
|
|
||||||
switch ghc.EventName {
|
|
||||||
case "pull_request_target":
|
|
||||||
ghc.Ref = "refs/heads/" + ghc.BaseRef
|
|
||||||
case "pull_request", "pull_request_review", "pull_request_review_comment":
|
|
||||||
ghc.Ref = fmt.Sprintf("refs/pull/%.0f/merge", ghc.Event["number"])
|
|
||||||
case "deployment", "deployment_status":
|
|
||||||
ghc.Ref = model.AsString(model.NestedMapLookup(ghc.Event, "deployment", "ref"))
|
|
||||||
case "release":
|
|
||||||
ghc.Ref = "refs/tags/" + model.AsString(model.NestedMapLookup(ghc.Event, "release", "tag_name"))
|
|
||||||
case "push", "create", "workflow_dispatch":
|
|
||||||
ghc.Ref = model.AsString(ghc.Event["ref"])
|
|
||||||
default:
|
|
||||||
defaultBranch := model.AsString(model.NestedMapLookup(ghc.Event, "repository", "default_branch"))
|
|
||||||
if defaultBranch != "" {
|
|
||||||
ghc.Ref = "refs/heads/" + defaultBranch
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ghc.Ref == "" {
|
|
||||||
ref, err := findGitRef(ctx, repoPath)
|
|
||||||
if err != nil {
|
|
||||||
logger.Warningf("unable to get git ref: %v", err)
|
|
||||||
} else {
|
|
||||||
logger.Debugf("using github ref: %s", ref)
|
|
||||||
ghc.Ref = ref
|
|
||||||
}
|
|
||||||
|
|
||||||
// set the branch in the event data
|
|
||||||
if defaultBranch != "" {
|
|
||||||
ghc.Event = withDefaultBranch(ctx, defaultBranch, ghc.Event)
|
|
||||||
} else {
|
|
||||||
ghc.Event = withDefaultBranch(ctx, "master", ghc.Event)
|
|
||||||
}
|
|
||||||
|
|
||||||
if ghc.Ref == "" {
|
|
||||||
ghc.Ref = "refs/heads/" + model.AsString(model.NestedMapLookup(ghc.Event, "repository", "default_branch"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetSha resolves the commit of the context from its event payload, falling
|
|
||||||
// back to the revision checked out in repoPath.
|
|
||||||
func SetSha(ctx context.Context, ghc *model.GithubContext, repoPath string) {
|
|
||||||
logger := common.Logger(ctx)
|
|
||||||
|
|
||||||
// https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows
|
|
||||||
// https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads
|
|
||||||
switch ghc.EventName {
|
|
||||||
case "pull_request_target":
|
|
||||||
ghc.Sha = model.AsString(model.NestedMapLookup(ghc.Event, "pull_request", "base", "sha"))
|
|
||||||
case "deployment", "deployment_status":
|
|
||||||
ghc.Sha = model.AsString(model.NestedMapLookup(ghc.Event, "deployment", "sha"))
|
|
||||||
case "push", "create", "workflow_dispatch":
|
|
||||||
if deleted, ok := ghc.Event["deleted"].(bool); ok && !deleted {
|
|
||||||
ghc.Sha = model.AsString(ghc.Event["after"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ghc.Sha == "" {
|
|
||||||
_, sha, err := findGitRevision(ctx, repoPath)
|
|
||||||
if err != nil {
|
|
||||||
logger.Warningf("unable to get git revision: %v", err)
|
|
||||||
} else {
|
|
||||||
ghc.Sha = sha
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetRepositoryAndOwner resolves the repository of the context from the git
|
|
||||||
// remote in repoPath when it is not set yet, and derives its owner.
|
|
||||||
func SetRepositoryAndOwner(ctx context.Context, ghc *model.GithubContext, githubInstance, remoteName, repoPath string) {
|
|
||||||
if ghc.Repository == "" {
|
|
||||||
repo, err := findGithubRepo(ctx, repoPath, githubInstance, remoteName)
|
|
||||||
if err != nil {
|
|
||||||
common.Logger(ctx).Warningf("unable to get git repo (githubInstance: %v; remoteName: %v, repoPath: %v): %v", githubInstance, remoteName, repoPath, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ghc.Repository = repo
|
|
||||||
}
|
|
||||||
ghc.RepositoryOwner = strings.Split(ghc.Repository, "/")[0]
|
|
||||||
}
|
|
||||||
138
act/model/action.go
Normal file
138
act/model/action.go
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"go.yaml.in/yaml/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ActionRunsUsing is the type of runner for the action
|
||||||
|
type ActionRunsUsing string
|
||||||
|
|
||||||
|
func (a *ActionRunsUsing) UnmarshalYAML(unmarshal func(any) error) error {
|
||||||
|
var using string
|
||||||
|
if err := unmarshal(&using); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force input to lowercase for case insensitive comparison
|
||||||
|
format := ActionRunsUsing(strings.ToLower(using))
|
||||||
|
switch format {
|
||||||
|
case ActionRunsUsingNode24, ActionRunsUsingNode20, ActionRunsUsingNode16, ActionRunsUsingNode12, ActionRunsUsingDocker, ActionRunsUsingComposite, ActionRunsUsingGo:
|
||||||
|
*a = format
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("The runs.using key in action.yml must be one of: %v, got %s", []string{
|
||||||
|
ActionRunsUsingComposite,
|
||||||
|
ActionRunsUsingDocker,
|
||||||
|
ActionRunsUsingNode12,
|
||||||
|
ActionRunsUsingNode16,
|
||||||
|
ActionRunsUsingNode20,
|
||||||
|
ActionRunsUsingNode24,
|
||||||
|
ActionRunsUsingGo,
|
||||||
|
}, format)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ActionRunsUsingNode12 for running with node12
|
||||||
|
ActionRunsUsingNode12 = "node12"
|
||||||
|
// ActionRunsUsingNode16 for running with node16
|
||||||
|
ActionRunsUsingNode16 = "node16"
|
||||||
|
// ActionRunsUsingNode20 for running with node20
|
||||||
|
ActionRunsUsingNode20 = "node20"
|
||||||
|
// ActionRunsUsingNode24 for running with node24
|
||||||
|
ActionRunsUsingNode24 = "node24"
|
||||||
|
// ActionRunsUsingDocker for running with docker
|
||||||
|
ActionRunsUsingDocker = "docker"
|
||||||
|
// ActionRunsUsingComposite for running composite
|
||||||
|
ActionRunsUsingComposite = "composite"
|
||||||
|
// ActionRunsUsingGo for running with go
|
||||||
|
ActionRunsUsingGo = "go"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a ActionRunsUsing) IsNode() bool {
|
||||||
|
switch a {
|
||||||
|
case ActionRunsUsingNode12, ActionRunsUsingNode16, ActionRunsUsingNode20, ActionRunsUsingNode24:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a ActionRunsUsing) IsDocker() bool {
|
||||||
|
return a == ActionRunsUsingDocker
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a ActionRunsUsing) IsComposite() bool {
|
||||||
|
return a == ActionRunsUsingComposite
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActionRuns are a field in Action
|
||||||
|
type ActionRuns struct {
|
||||||
|
Using ActionRunsUsing `yaml:"using"`
|
||||||
|
Env map[string]string `yaml:"env"`
|
||||||
|
Main string `yaml:"main"`
|
||||||
|
Pre string `yaml:"pre"`
|
||||||
|
PreIf string `yaml:"pre-if"`
|
||||||
|
Post string `yaml:"post"`
|
||||||
|
PostIf string `yaml:"post-if"`
|
||||||
|
Image string `yaml:"image"`
|
||||||
|
PreEntrypoint string `yaml:"pre-entrypoint"`
|
||||||
|
Entrypoint string `yaml:"entrypoint"`
|
||||||
|
PostEntrypoint string `yaml:"post-entrypoint"`
|
||||||
|
Args []string `yaml:"args"`
|
||||||
|
Steps []Step `yaml:"steps"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action describes a metadata file for GitHub actions. The metadata filename must be either action.yml or action.yaml. The data in the metadata file defines the inputs, outputs and main entrypoint for your action.
|
||||||
|
type Action struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Author string `yaml:"author"`
|
||||||
|
Description string `yaml:"description"`
|
||||||
|
Inputs map[string]Input `yaml:"inputs"`
|
||||||
|
Outputs map[string]Output `yaml:"outputs"`
|
||||||
|
Runs ActionRuns `yaml:"runs"`
|
||||||
|
Branding struct {
|
||||||
|
Color string `yaml:"color"`
|
||||||
|
Icon string `yaml:"icon"`
|
||||||
|
} `yaml:"branding"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input parameters allow you to specify data that the action expects to use during runtime. GitHub stores input parameters as environment variables. Input ids with uppercase letters are converted to lowercase during runtime. We recommended using lowercase input ids.
|
||||||
|
type Input struct {
|
||||||
|
Description string `yaml:"description"`
|
||||||
|
Required bool `yaml:"required"`
|
||||||
|
Default string `yaml:"default"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output parameters allow you to declare data that an action sets. Actions that run later in a workflow can use the output data set in previously run actions. For example, if you had an action that performed the addition of two inputs (x + y = z), the action could output the sum (z) for other actions to use as an input.
|
||||||
|
type Output struct {
|
||||||
|
Description string `yaml:"description"`
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadAction reads an action from a reader
|
||||||
|
func ReadAction(in io.Reader) (*Action, error) {
|
||||||
|
a := new(Action)
|
||||||
|
err := yaml.NewDecoder(in).Decode(a)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// set defaults
|
||||||
|
if a.Runs.PreIf == "" {
|
||||||
|
a.Runs.PreIf = "always()"
|
||||||
|
}
|
||||||
|
if a.Runs.PostIf == "" {
|
||||||
|
a.Runs.PostIf = "always()"
|
||||||
|
}
|
||||||
|
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
82
act/model/action_test.go
Normal file
82
act/model/action_test.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestReadActionDefaultsAndCaseInsensitiveUsing(t *testing.T) {
|
||||||
|
action, err := ReadAction(strings.NewReader(`
|
||||||
|
name: example
|
||||||
|
runs:
|
||||||
|
using: NoDe24
|
||||||
|
main: dist/index.js
|
||||||
|
`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if action.Runs.Using != ActionRunsUsingNode24 {
|
||||||
|
t.Fatalf("using = %q, want %q", action.Runs.Using, ActionRunsUsingNode24)
|
||||||
|
}
|
||||||
|
if action.Runs.PreIf != "always()" {
|
||||||
|
t.Fatalf("pre-if = %q, want always()", action.Runs.PreIf)
|
||||||
|
}
|
||||||
|
if action.Runs.PostIf != "always()" {
|
||||||
|
t.Fatalf("post-if = %q, want always()", action.Runs.PostIf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadActionPreservesExplicitConditions(t *testing.T) {
|
||||||
|
action, err := ReadAction(strings.NewReader(`
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
pre-if: success()
|
||||||
|
post-if: failure()
|
||||||
|
steps:
|
||||||
|
- run: echo hello
|
||||||
|
`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if action.Runs.PreIf != "success()" || action.Runs.PostIf != "failure()" {
|
||||||
|
t.Fatalf("conditions = %q/%q, want explicit values", action.Runs.PreIf, action.Runs.PostIf)
|
||||||
|
}
|
||||||
|
if !action.Runs.Using.IsComposite() || action.Runs.Using.IsDocker() || action.Runs.Using.IsNode() {
|
||||||
|
t.Fatalf("unexpected using predicates for %q", action.Runs.Using)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadActionRejectsUnknownUsing(t *testing.T) {
|
||||||
|
_, err := ReadAction(strings.NewReader(`
|
||||||
|
runs:
|
||||||
|
using: node99
|
||||||
|
`))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected unknown runs.using to fail")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "node99") {
|
||||||
|
t.Fatalf("error = %q, want invalid value", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadActionDockerEntrypoints(t *testing.T) {
|
||||||
|
action, err := ReadAction(strings.NewReader(`
|
||||||
|
runs:
|
||||||
|
using: docker
|
||||||
|
image: Dockerfile
|
||||||
|
pre-entrypoint: pre.sh
|
||||||
|
post-entrypoint: post.sh
|
||||||
|
`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if action.Runs.PreEntrypoint != "pre.sh" {
|
||||||
|
t.Fatalf("pre-entrypoint = %q, want pre.sh", action.Runs.PreEntrypoint)
|
||||||
|
}
|
||||||
|
if action.Runs.PostEntrypoint != "post.sh" {
|
||||||
|
t.Fatalf("post-entrypoint = %q, want post.sh", action.Runs.PostEntrypoint)
|
||||||
|
}
|
||||||
|
}
|
||||||
222
act/model/github_context.go
Normal file
222
act/model/github_context.go
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2021 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/act/common/git"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GithubContext struct {
|
||||||
|
Event map[string]any `json:"event"`
|
||||||
|
EventPath string `json:"event_path"`
|
||||||
|
Workflow string `json:"workflow"`
|
||||||
|
RunID string `json:"run_id"`
|
||||||
|
RunNumber string `json:"run_number"`
|
||||||
|
Actor string `json:"actor"`
|
||||||
|
Repository string `json:"repository"`
|
||||||
|
EventName string `json:"event_name"`
|
||||||
|
Sha string `json:"sha"`
|
||||||
|
Ref string `json:"ref"`
|
||||||
|
RefName string `json:"ref_name"`
|
||||||
|
RefType string `json:"ref_type"`
|
||||||
|
HeadRef string `json:"head_ref"`
|
||||||
|
BaseRef string `json:"base_ref"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
Workspace string `json:"workspace"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
ActionPath string `json:"action_path"`
|
||||||
|
ActionRef string `json:"action_ref"`
|
||||||
|
ActionRepository string `json:"action_repository"`
|
||||||
|
Job string `json:"job"`
|
||||||
|
JobName string `json:"job_name"`
|
||||||
|
RepositoryOwner string `json:"repository_owner"`
|
||||||
|
RetentionDays string `json:"retention_days"`
|
||||||
|
RunnerPerflog string `json:"runner_perflog"`
|
||||||
|
RunnerTrackingID string `json:"runner_tracking_id"`
|
||||||
|
ServerURL string `json:"server_url"`
|
||||||
|
APIURL string `json:"api_url"`
|
||||||
|
GraphQLURL string `json:"graphql_url"`
|
||||||
|
|
||||||
|
// For Gitea
|
||||||
|
RunAttempt string `json:"run_attempt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func asString(v any) string {
|
||||||
|
if v == nil {
|
||||||
|
return ""
|
||||||
|
} else if s, ok := v.(string); ok {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func nestedMapLookup(m map[string]any, ks ...string) (rval any) {
|
||||||
|
var ok bool
|
||||||
|
|
||||||
|
if len(ks) == 0 { // degenerate input
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if rval, ok = m[ks[0]]; !ok {
|
||||||
|
return nil
|
||||||
|
} else if len(ks) == 1 { // we've reached the final key
|
||||||
|
return rval
|
||||||
|
} else if m, ok = rval.(map[string]any); !ok {
|
||||||
|
return nil
|
||||||
|
} else { // 1+ more keys
|
||||||
|
return nestedMapLookup(m, ks[1:]...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func withDefaultBranch(ctx context.Context, b string, event map[string]any) map[string]any {
|
||||||
|
repoI, ok := event["repository"]
|
||||||
|
if !ok {
|
||||||
|
repoI = make(map[string]any)
|
||||||
|
}
|
||||||
|
|
||||||
|
repo, ok := repoI.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
common.Logger(ctx).Warnf("unable to set default branch to %v", b)
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
|
||||||
|
// if the branch is already there return with no changes
|
||||||
|
if _, ok = repo["default_branch"]; ok {
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
|
||||||
|
repo["default_branch"] = b
|
||||||
|
event["repository"] = repo
|
||||||
|
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
findGitRef = git.FindGitRef
|
||||||
|
findGitRevision = git.FindGitRevision
|
||||||
|
)
|
||||||
|
|
||||||
|
func (ghc *GithubContext) SetRef(ctx context.Context, defaultBranch, repoPath string) {
|
||||||
|
logger := common.Logger(ctx)
|
||||||
|
|
||||||
|
// https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows
|
||||||
|
// https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads
|
||||||
|
switch ghc.EventName {
|
||||||
|
case "pull_request_target":
|
||||||
|
ghc.Ref = "refs/heads/" + ghc.BaseRef
|
||||||
|
case "pull_request", "pull_request_review", "pull_request_review_comment":
|
||||||
|
ghc.Ref = fmt.Sprintf("refs/pull/%.0f/merge", ghc.Event["number"])
|
||||||
|
case "deployment", "deployment_status":
|
||||||
|
ghc.Ref = asString(nestedMapLookup(ghc.Event, "deployment", "ref"))
|
||||||
|
case "release":
|
||||||
|
ghc.Ref = "refs/tags/" + asString(nestedMapLookup(ghc.Event, "release", "tag_name"))
|
||||||
|
case "push", "create", "workflow_dispatch":
|
||||||
|
ghc.Ref = asString(ghc.Event["ref"])
|
||||||
|
default:
|
||||||
|
defaultBranch := asString(nestedMapLookup(ghc.Event, "repository", "default_branch"))
|
||||||
|
if defaultBranch != "" {
|
||||||
|
ghc.Ref = "refs/heads/" + defaultBranch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ghc.Ref == "" {
|
||||||
|
ref, err := findGitRef(ctx, repoPath)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warningf("unable to get git ref: %v", err)
|
||||||
|
} else {
|
||||||
|
logger.Debugf("using github ref: %s", ref)
|
||||||
|
ghc.Ref = ref
|
||||||
|
}
|
||||||
|
|
||||||
|
// set the branch in the event data
|
||||||
|
if defaultBranch != "" {
|
||||||
|
ghc.Event = withDefaultBranch(ctx, defaultBranch, ghc.Event)
|
||||||
|
} else {
|
||||||
|
ghc.Event = withDefaultBranch(ctx, "master", ghc.Event)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ghc.Ref == "" {
|
||||||
|
ghc.Ref = "refs/heads/" + asString(nestedMapLookup(ghc.Event, "repository", "default_branch"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ghc *GithubContext) SetSha(ctx context.Context, repoPath string) {
|
||||||
|
logger := common.Logger(ctx)
|
||||||
|
|
||||||
|
// https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows
|
||||||
|
// https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads
|
||||||
|
switch ghc.EventName {
|
||||||
|
case "pull_request_target":
|
||||||
|
ghc.Sha = asString(nestedMapLookup(ghc.Event, "pull_request", "base", "sha"))
|
||||||
|
case "deployment", "deployment_status":
|
||||||
|
ghc.Sha = asString(nestedMapLookup(ghc.Event, "deployment", "sha"))
|
||||||
|
case "push", "create", "workflow_dispatch":
|
||||||
|
if deleted, ok := ghc.Event["deleted"].(bool); ok && !deleted {
|
||||||
|
ghc.Sha = asString(ghc.Event["after"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ghc.Sha == "" {
|
||||||
|
_, sha, err := findGitRevision(ctx, repoPath)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warningf("unable to get git revision: %v", err)
|
||||||
|
} else {
|
||||||
|
ghc.Sha = sha
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ghc *GithubContext) SetRepositoryAndOwner(ctx context.Context, githubInstance, remoteName, repoPath string) {
|
||||||
|
if ghc.Repository == "" {
|
||||||
|
repo, err := git.FindGithubRepo(ctx, repoPath, githubInstance, remoteName)
|
||||||
|
if err != nil {
|
||||||
|
common.Logger(ctx).Warningf("unable to get git repo (githubInstance: %v; remoteName: %v, repoPath: %v): %v", githubInstance, remoteName, repoPath, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ghc.Repository = repo
|
||||||
|
}
|
||||||
|
ghc.RepositoryOwner = strings.Split(ghc.Repository, "/")[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ghc *GithubContext) SetRefTypeAndName() {
|
||||||
|
var refType, refName string
|
||||||
|
|
||||||
|
// https://docs.github.com/en/actions/learn-github-actions/environment-variables
|
||||||
|
if strings.HasPrefix(ghc.Ref, "refs/tags/") {
|
||||||
|
refType = "tag"
|
||||||
|
refName = ghc.Ref[len("refs/tags/"):]
|
||||||
|
} else if strings.HasPrefix(ghc.Ref, "refs/heads/") {
|
||||||
|
refType = "branch"
|
||||||
|
refName = ghc.Ref[len("refs/heads/"):]
|
||||||
|
} else if strings.HasPrefix(ghc.Ref, "refs/pull/") {
|
||||||
|
refType = ""
|
||||||
|
refName = ghc.Ref[len("refs/pull/"):]
|
||||||
|
}
|
||||||
|
|
||||||
|
if ghc.RefType == "" {
|
||||||
|
ghc.RefType = refType
|
||||||
|
}
|
||||||
|
|
||||||
|
if ghc.RefName == "" {
|
||||||
|
ghc.RefName = refName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ghc *GithubContext) SetBaseAndHeadRef() {
|
||||||
|
if ghc.EventName == "pull_request" || ghc.EventName == "pull_request_target" {
|
||||||
|
if ghc.BaseRef == "" {
|
||||||
|
ghc.BaseRef = asString(nestedMapLookup(ghc.Event, "pull_request", "base", "ref"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if ghc.HeadRef == "" {
|
||||||
|
ghc.HeadRef = asString(nestedMapLookup(ghc.Event, "pull_request", "head", "ref"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,14 +2,13 @@
|
|||||||
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
package ghcontext
|
package model
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.dev/actions-proto-go/pkg/model"
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
@@ -98,13 +97,13 @@ func TestSetRef(t *testing.T) {
|
|||||||
|
|
||||||
for _, table := range tables {
|
for _, table := range tables {
|
||||||
t.Run(table.eventName, func(t *testing.T) {
|
t.Run(table.eventName, func(t *testing.T) {
|
||||||
ghc := &model.GithubContext{
|
ghc := &GithubContext{
|
||||||
EventName: table.eventName,
|
EventName: table.eventName,
|
||||||
BaseRef: "master",
|
BaseRef: "master",
|
||||||
Event: table.event,
|
Event: table.event,
|
||||||
}
|
}
|
||||||
|
|
||||||
SetRef(context.Background(), ghc, "main", "/some/dir")
|
ghc.SetRef(context.Background(), "main", "/some/dir")
|
||||||
ghc.SetRefTypeAndName()
|
ghc.SetRefTypeAndName()
|
||||||
|
|
||||||
assert.Equal(t, table.ref, ghc.Ref)
|
assert.Equal(t, table.ref, ghc.Ref)
|
||||||
@@ -117,12 +116,12 @@ func TestSetRef(t *testing.T) {
|
|||||||
return "", errors.New("no default branch")
|
return "", errors.New("no default branch")
|
||||||
}
|
}
|
||||||
|
|
||||||
ghc := &model.GithubContext{
|
ghc := &GithubContext{
|
||||||
EventName: "no-default-branch",
|
EventName: "no-default-branch",
|
||||||
Event: map[string]any{},
|
Event: map[string]any{},
|
||||||
}
|
}
|
||||||
|
|
||||||
SetRef(context.Background(), ghc, "", "/some/dir")
|
ghc.SetRef(context.Background(), "", "/some/dir")
|
||||||
|
|
||||||
assert.Equal(t, "refs/heads/master", ghc.Ref)
|
assert.Equal(t, "refs/heads/master", ghc.Ref)
|
||||||
})
|
})
|
||||||
@@ -203,13 +202,13 @@ func TestSetSha(t *testing.T) {
|
|||||||
|
|
||||||
for _, table := range tables {
|
for _, table := range tables {
|
||||||
t.Run(table.eventName, func(t *testing.T) {
|
t.Run(table.eventName, func(t *testing.T) {
|
||||||
ghc := &model.GithubContext{
|
ghc := &GithubContext{
|
||||||
EventName: table.eventName,
|
EventName: table.eventName,
|
||||||
BaseRef: "master",
|
BaseRef: "master",
|
||||||
Event: table.event,
|
Event: table.event,
|
||||||
}
|
}
|
||||||
|
|
||||||
SetSha(context.Background(), ghc, "/some/dir")
|
ghc.SetSha(context.Background(), "/some/dir")
|
||||||
|
|
||||||
assert.Equal(t, table.sha, ghc.Sha)
|
assert.Equal(t, table.sha, ghc.Sha)
|
||||||
})
|
})
|
||||||
16
act/model/job_context.go
Normal file
16
act/model/job_context.go
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2021 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package model
|
||||||
|
|
||||||
|
type JobContext struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Container struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Network string `json:"network"`
|
||||||
|
} `json:"container"`
|
||||||
|
Services map[string]struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"services"`
|
||||||
|
}
|
||||||
410
act/model/planner.go
Normal file
410
act/model/planner.go
Normal file
@@ -0,0 +1,410 @@
|
|||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"slices"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WorkflowPlanner contains methods for creating plans
|
||||||
|
type WorkflowPlanner interface {
|
||||||
|
PlanEvent(eventName string) (*Plan, error)
|
||||||
|
PlanJob(jobName string) (*Plan, error)
|
||||||
|
PlanAll() (*Plan, error)
|
||||||
|
GetEvents() []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plan contains a list of stages to run in series
|
||||||
|
type Plan struct {
|
||||||
|
Stages []*Stage
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stage contains a list of runs to execute in parallel
|
||||||
|
type Stage struct {
|
||||||
|
Runs []*Run
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run represents a job from a workflow that needs to be run
|
||||||
|
type Run struct {
|
||||||
|
Workflow *Workflow
|
||||||
|
JobID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Run) String() string {
|
||||||
|
jobName := r.Job().Name
|
||||||
|
if jobName == "" {
|
||||||
|
jobName = r.JobID
|
||||||
|
}
|
||||||
|
return jobName
|
||||||
|
}
|
||||||
|
|
||||||
|
// Job returns the job for this Run
|
||||||
|
func (r *Run) Job() *Job {
|
||||||
|
return r.Workflow.GetJob(r.JobID)
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkflowFiles struct {
|
||||||
|
workflowDirEntry os.DirEntry
|
||||||
|
dirPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWorkflowPlanner will load a specific workflow, all workflows from a directory or all workflows from a directory and its subdirectories
|
||||||
|
func NewWorkflowPlanner(path string, noWorkflowRecurse bool) (WorkflowPlanner, error) {
|
||||||
|
path, err := filepath.Abs(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
fi, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var workflows []WorkflowFiles
|
||||||
|
|
||||||
|
if fi.IsDir() {
|
||||||
|
log.Debugf("Loading workflows from '%s'", path)
|
||||||
|
if noWorkflowRecurse {
|
||||||
|
files, err := os.ReadDir(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, v := range files {
|
||||||
|
workflows = append(workflows, WorkflowFiles{
|
||||||
|
dirPath: path,
|
||||||
|
workflowDirEntry: v,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Debug("Loading workflows recursively")
|
||||||
|
if err := filepath.Walk(path,
|
||||||
|
func(p string, f os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !f.IsDir() {
|
||||||
|
log.Debugf("Found workflow '%s' in '%s'", f.Name(), p)
|
||||||
|
workflows = append(workflows, WorkflowFiles{
|
||||||
|
dirPath: filepath.Dir(p),
|
||||||
|
workflowDirEntry: fs.FileInfoToDirEntry(f),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Debugf("Loading workflow '%s'", path)
|
||||||
|
dirname := filepath.Dir(path)
|
||||||
|
|
||||||
|
workflows = append(workflows, WorkflowFiles{
|
||||||
|
dirPath: dirname,
|
||||||
|
workflowDirEntry: fs.FileInfoToDirEntry(fi),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
wp := new(workflowPlanner)
|
||||||
|
for _, wf := range workflows {
|
||||||
|
ext := filepath.Ext(wf.workflowDirEntry.Name())
|
||||||
|
if ext == ".yml" || ext == ".yaml" {
|
||||||
|
f, err := os.Open(filepath.Join(wf.dirPath, wf.workflowDirEntry.Name()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debugf("Reading workflow '%s'", f.Name())
|
||||||
|
workflow, err := ReadWorkflow(f)
|
||||||
|
if err != nil {
|
||||||
|
_ = f.Close()
|
||||||
|
if err == io.EOF {
|
||||||
|
return nil, fmt.Errorf("unable to read workflow '%s': file is empty: %w", wf.workflowDirEntry.Name(), err)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("workflow is not valid. '%s': %w", wf.workflowDirEntry.Name(), err)
|
||||||
|
}
|
||||||
|
_, err = f.Seek(0, 0)
|
||||||
|
if err != nil {
|
||||||
|
_ = f.Close()
|
||||||
|
return nil, fmt.Errorf("error occurring when resetting io pointer in '%s': %w", wf.workflowDirEntry.Name(), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
workflow.File = wf.workflowDirEntry.Name()
|
||||||
|
if workflow.Name == "" {
|
||||||
|
workflow.Name = wf.workflowDirEntry.Name()
|
||||||
|
}
|
||||||
|
|
||||||
|
err = validateJobName(workflow)
|
||||||
|
if err != nil {
|
||||||
|
_ = f.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
wp.workflows = append(wp.workflows, workflow)
|
||||||
|
_ = f.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return wp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CombineWorkflowPlanner combines workflows to a WorkflowPlanner
|
||||||
|
func CombineWorkflowPlanner(workflows ...*Workflow) WorkflowPlanner {
|
||||||
|
return &workflowPlanner{
|
||||||
|
workflows: workflows,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSingleWorkflowPlanner(name string, f io.Reader) (WorkflowPlanner, error) {
|
||||||
|
wp := new(workflowPlanner)
|
||||||
|
|
||||||
|
log.Debugf("Reading workflow %s", name)
|
||||||
|
workflow, err := ReadWorkflow(f)
|
||||||
|
if err != nil {
|
||||||
|
if err == io.EOF {
|
||||||
|
return nil, fmt.Errorf("unable to read workflow '%s': file is empty: %w", name, err)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("workflow is not valid. '%s': %w", name, err)
|
||||||
|
}
|
||||||
|
workflow.File = name
|
||||||
|
if workflow.Name == "" {
|
||||||
|
workflow.Name = name
|
||||||
|
}
|
||||||
|
|
||||||
|
err = validateJobName(workflow)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
wp.workflows = append(wp.workflows, workflow)
|
||||||
|
|
||||||
|
return wp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateJobName(workflow *Workflow) error {
|
||||||
|
jobNameRegex := regexp.MustCompile(`^([[:alpha:]_][[:alnum:]_\-]*)$`)
|
||||||
|
for k := range workflow.Jobs {
|
||||||
|
if ok := jobNameRegex.MatchString(k); !ok {
|
||||||
|
return fmt.Errorf("workflow is not valid. '%s': Job name '%s' is invalid. Names must start with a letter or '_' and contain only alphanumeric characters, '-', or '_'", workflow.Name, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type workflowPlanner struct {
|
||||||
|
workflows []*Workflow
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlanEvent builds a new list of runs to execute in parallel for an event name
|
||||||
|
func (wp *workflowPlanner) PlanEvent(eventName string) (*Plan, error) {
|
||||||
|
plan := new(Plan)
|
||||||
|
if len(wp.workflows) == 0 {
|
||||||
|
log.Debug("no workflows found by planner")
|
||||||
|
return plan, nil
|
||||||
|
}
|
||||||
|
var lastErr error
|
||||||
|
|
||||||
|
for _, w := range wp.workflows {
|
||||||
|
events := w.On()
|
||||||
|
if len(events) == 0 {
|
||||||
|
log.Debugf("no events found for workflow: %s", w.File)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, e := range events {
|
||||||
|
if e == eventName {
|
||||||
|
stages, err := createStages(w, w.GetJobIDs()...)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn(err)
|
||||||
|
lastErr = err
|
||||||
|
} else {
|
||||||
|
plan.mergeStages(stages)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return plan, lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlanJob builds a new run to execute in parallel for a job name
|
||||||
|
func (wp *workflowPlanner) PlanJob(jobName string) (*Plan, error) {
|
||||||
|
plan := new(Plan)
|
||||||
|
if len(wp.workflows) == 0 {
|
||||||
|
log.Debugf("no jobs found for workflow: %s", jobName)
|
||||||
|
}
|
||||||
|
var lastErr error
|
||||||
|
|
||||||
|
for _, w := range wp.workflows {
|
||||||
|
stages, err := createStages(w, jobName)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn(err)
|
||||||
|
lastErr = err
|
||||||
|
} else {
|
||||||
|
plan.mergeStages(stages)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return plan, lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlanAll builds a new run to execute in parallel all
|
||||||
|
func (wp *workflowPlanner) PlanAll() (*Plan, error) {
|
||||||
|
plan := new(Plan)
|
||||||
|
if len(wp.workflows) == 0 {
|
||||||
|
log.Debug("no workflows found by planner")
|
||||||
|
return plan, nil
|
||||||
|
}
|
||||||
|
var lastErr error
|
||||||
|
|
||||||
|
for _, w := range wp.workflows {
|
||||||
|
stages, err := createStages(w, w.GetJobIDs()...)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn(err)
|
||||||
|
lastErr = err
|
||||||
|
} else {
|
||||||
|
plan.mergeStages(stages)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return plan, lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetEvents gets all the events in the workflows file
|
||||||
|
func (wp *workflowPlanner) GetEvents() []string {
|
||||||
|
events := make([]string, 0)
|
||||||
|
for _, w := range wp.workflows {
|
||||||
|
found := false
|
||||||
|
for _, e := range events {
|
||||||
|
if slices.Contains(w.On(), e) {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
if found {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
events = append(events, w.On()...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sort the list based on depth of dependencies
|
||||||
|
slices.Sort(events)
|
||||||
|
|
||||||
|
return events
|
||||||
|
}
|
||||||
|
|
||||||
|
// MaxRunNameLen determines the max name length of all jobs
|
||||||
|
func (p *Plan) MaxRunNameLen() int {
|
||||||
|
maxRunNameLen := 0
|
||||||
|
for _, stage := range p.Stages {
|
||||||
|
for _, run := range stage.Runs {
|
||||||
|
runNameLen := len(run.String())
|
||||||
|
if runNameLen > maxRunNameLen {
|
||||||
|
maxRunNameLen = runNameLen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return maxRunNameLen
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetJobIDs will get all the job names in the stage
|
||||||
|
func (s *Stage) GetJobIDs() []string {
|
||||||
|
names := make([]string, 0)
|
||||||
|
for _, r := range s.Runs {
|
||||||
|
names = append(names, r.JobID)
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge stages with existing stages in plan
|
||||||
|
func (p *Plan) mergeStages(stages []*Stage) {
|
||||||
|
newStages := make([]*Stage, int(math.Max(float64(len(p.Stages)), float64(len(stages)))))
|
||||||
|
for i := range newStages {
|
||||||
|
newStages[i] = new(Stage)
|
||||||
|
if i >= len(p.Stages) {
|
||||||
|
newStages[i].Runs = append(newStages[i].Runs, stages[i].Runs...)
|
||||||
|
} else if i >= len(stages) {
|
||||||
|
newStages[i].Runs = append(newStages[i].Runs, p.Stages[i].Runs...)
|
||||||
|
} else {
|
||||||
|
newStages[i].Runs = append(newStages[i].Runs, p.Stages[i].Runs...)
|
||||||
|
newStages[i].Runs = append(newStages[i].Runs, stages[i].Runs...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.Stages = newStages
|
||||||
|
}
|
||||||
|
|
||||||
|
func createStages(w *Workflow, jobIDs ...string) ([]*Stage, error) {
|
||||||
|
// first, build a list of all the necessary jobs to run, and their dependencies
|
||||||
|
jobDependencies := make(map[string][]string)
|
||||||
|
for len(jobIDs) > 0 {
|
||||||
|
newJobIDs := make([]string, 0)
|
||||||
|
for _, jID := range jobIDs {
|
||||||
|
// make sure we haven't visited this job yet
|
||||||
|
if _, ok := jobDependencies[jID]; !ok {
|
||||||
|
if job := w.GetJob(jID); job != nil {
|
||||||
|
jobDependencies[jID] = job.Needs()
|
||||||
|
newJobIDs = append(newJobIDs, job.Needs()...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
jobIDs = newJobIDs
|
||||||
|
}
|
||||||
|
|
||||||
|
// next, build an execution graph
|
||||||
|
stages := make([]*Stage, 0)
|
||||||
|
for len(jobDependencies) > 0 {
|
||||||
|
stage := new(Stage)
|
||||||
|
for jID, jDeps := range jobDependencies {
|
||||||
|
// make sure all deps are in the graph already
|
||||||
|
if listInStages(jDeps, stages...) {
|
||||||
|
stage.Runs = append(stage.Runs, &Run{
|
||||||
|
Workflow: w,
|
||||||
|
JobID: jID,
|
||||||
|
})
|
||||||
|
delete(jobDependencies, jID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(stage.Runs) == 0 {
|
||||||
|
return nil, fmt.Errorf("unable to build dependency graph for %s (%s)", w.Name, w.File)
|
||||||
|
}
|
||||||
|
stages = append(stages, stage)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(stages) == 0 {
|
||||||
|
return nil, errors.New("Could not find any stages to run. View the valid jobs with `act --list`. Use `act --help` to find how to filter by Job ID/Workflow/Event Name")
|
||||||
|
}
|
||||||
|
|
||||||
|
return stages, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// return true iff all strings in srcList exist in at least one of the stages
|
||||||
|
func listInStages(srcList []string, stages ...*Stage) bool {
|
||||||
|
for _, src := range srcList {
|
||||||
|
found := false
|
||||||
|
for _, stage := range stages {
|
||||||
|
for _, search := range stage.GetJobIDs() {
|
||||||
|
if src == search {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
199
act/model/planner_test.go
Normal file
199
act/model/planner_test.go
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2021 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WorkflowPlanTest struct {
|
||||||
|
workflowPath string
|
||||||
|
errorMessage string
|
||||||
|
noWorkflowRecurse bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanner(t *testing.T) {
|
||||||
|
log.SetLevel(log.DebugLevel)
|
||||||
|
|
||||||
|
tables := []WorkflowPlanTest{
|
||||||
|
{"invalid-job-name/invalid-1.yml", "workflow is not valid. 'invalid-job-name-1': Job name 'invalid-JOB-Name-v1.2.3-docker_hub' is invalid. Names must start with a letter or '_' and contain only alphanumeric characters, '-', or '_'", false},
|
||||||
|
{"invalid-job-name/invalid-2.yml", "workflow is not valid. 'invalid-job-name-2': Job name '1234invalid-JOB-Name-v123-docker_hub' is invalid. Names must start with a letter or '_' and contain only alphanumeric characters, '-', or '_'", false},
|
||||||
|
{"invalid-job-name/valid-1.yml", "", false},
|
||||||
|
{"invalid-job-name/valid-2.yml", "", false},
|
||||||
|
{"empty-workflow", "unable to read workflow 'push.yml': file is empty: EOF", false},
|
||||||
|
{"nested", "unable to read workflow 'fail.yml': file is empty: EOF", false},
|
||||||
|
{"nested", "", true},
|
||||||
|
}
|
||||||
|
|
||||||
|
workdir, err := filepath.Abs("testdata")
|
||||||
|
assert.NoError(t, err, workdir) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
for _, table := range tables {
|
||||||
|
fullWorkflowPath := filepath.Join(workdir, table.workflowPath)
|
||||||
|
_, err = NewWorkflowPlanner(fullWorkflowPath, table.noWorkflowRecurse)
|
||||||
|
if table.errorMessage == "" {
|
||||||
|
assert.NoError(t, err, "WorkflowPlanner should exit without any error")
|
||||||
|
} else {
|
||||||
|
assert.EqualError(t, err, table.errorMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkflow(t *testing.T) {
|
||||||
|
log.SetLevel(log.DebugLevel)
|
||||||
|
|
||||||
|
workflow := Workflow{
|
||||||
|
Jobs: map[string]*Job{
|
||||||
|
"valid_job": {
|
||||||
|
Name: "valid_job",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that an invalid job id returns error
|
||||||
|
result, err := createStages(&workflow, "invalid_job_id")
|
||||||
|
assert.Error(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Nil(t, result)
|
||||||
|
|
||||||
|
// Check that an valid job id returns non-error
|
||||||
|
result, err = createStages(&workflow, "valid_job")
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.NotNil(t, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewSingleWorkflowPlannerAndPlanMethods(t *testing.T) {
|
||||||
|
planner, err := NewSingleWorkflowPlanner("ci.yml", strings.NewReader(`
|
||||||
|
name: CI
|
||||||
|
on: [push, pull_request]
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build project
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: make build
|
||||||
|
test:
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: make test
|
||||||
|
`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, []string{"pull_request", "push"}, planner.GetEvents())
|
||||||
|
|
||||||
|
eventPlan, err := planner.PlanEvent("push")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, eventPlan.Stages, 2)
|
||||||
|
assert.Equal(t, []string{"build"}, eventPlan.Stages[0].GetJobIDs())
|
||||||
|
assert.Equal(t, []string{"test"}, eventPlan.Stages[1].GetJobIDs())
|
||||||
|
assert.Equal(t, len("Build project"), eventPlan.MaxRunNameLen())
|
||||||
|
assert.Equal(t, "Build project", eventPlan.Stages[0].Runs[0].String())
|
||||||
|
assert.Equal(t, "build", eventPlan.Stages[0].Runs[0].JobID)
|
||||||
|
assert.NotNil(t, eventPlan.Stages[0].Runs[0].Job())
|
||||||
|
|
||||||
|
jobPlan, err := planner.PlanJob("test")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, jobPlan.Stages, 2)
|
||||||
|
assert.Equal(t, []string{"build"}, jobPlan.Stages[0].GetJobIDs())
|
||||||
|
assert.Equal(t, []string{"test"}, jobPlan.Stages[1].GetJobIDs())
|
||||||
|
|
||||||
|
allPlan, err := planner.PlanAll()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, allPlan.Stages, 2)
|
||||||
|
assert.Equal(t, []string{"build"}, allPlan.Stages[0].GetJobIDs())
|
||||||
|
assert.Equal(t, []string{"test"}, allPlan.Stages[1].GetJobIDs())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCombineWorkflowPlannerMergesWorkflowStages(t *testing.T) {
|
||||||
|
first := mustReadWorkflow(t, `
|
||||||
|
name: First
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: make build
|
||||||
|
`)
|
||||||
|
second := mustReadWorkflow(t, `
|
||||||
|
name: Second
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: make lint
|
||||||
|
test:
|
||||||
|
needs: lint
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: make test
|
||||||
|
`)
|
||||||
|
|
||||||
|
planner := CombineWorkflowPlanner(first, second)
|
||||||
|
plan, err := planner.PlanEvent("push")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, plan.Stages, 2)
|
||||||
|
assert.ElementsMatch(t, []string{"build", "lint"}, plan.Stages[0].GetJobIDs())
|
||||||
|
assert.Equal(t, []string{"test"}, plan.Stages[1].GetJobIDs())
|
||||||
|
|
||||||
|
empty, err := planner.PlanEvent("schedule")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, empty.Stages)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlannerErrorsForMissingAndCyclicJobs(t *testing.T) {
|
||||||
|
workflow := mustReadWorkflow(t, `
|
||||||
|
name: Cyclic
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
a:
|
||||||
|
needs: b
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo a
|
||||||
|
b:
|
||||||
|
needs: a
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo b
|
||||||
|
`)
|
||||||
|
planner := CombineWorkflowPlanner(workflow)
|
||||||
|
|
||||||
|
plan, err := planner.PlanJob("missing")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Empty(t, plan.Stages)
|
||||||
|
assert.Contains(t, err.Error(), "Could not find any stages")
|
||||||
|
|
||||||
|
plan, err = planner.PlanEvent("push")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Empty(t, plan.Stages)
|
||||||
|
assert.Contains(t, err.Error(), "unable to build dependency graph")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewSingleWorkflowPlannerErrors(t *testing.T) {
|
||||||
|
_, err := NewSingleWorkflowPlanner("empty.yml", strings.NewReader(""))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "file is empty")
|
||||||
|
|
||||||
|
_, err = NewSingleWorkflowPlanner("invalid.yml", strings.NewReader("jobs: ["))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "workflow is not valid")
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustReadWorkflow(t *testing.T, content string) *Workflow {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
workflow, err := ReadWorkflow(strings.NewReader(content))
|
||||||
|
require.NoError(t, err)
|
||||||
|
if workflow.Name == "" {
|
||||||
|
workflow.Name = "workflow"
|
||||||
|
}
|
||||||
|
return workflow
|
||||||
|
}
|
||||||
49
act/model/step_result.go
Normal file
49
act/model/step_result.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2021 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package model
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
type stepStatus int
|
||||||
|
|
||||||
|
const (
|
||||||
|
StepStatusSuccess stepStatus = iota
|
||||||
|
StepStatusFailure
|
||||||
|
StepStatusSkipped
|
||||||
|
)
|
||||||
|
|
||||||
|
var stepStatusStrings = [...]string{
|
||||||
|
"success",
|
||||||
|
"failure",
|
||||||
|
"skipped",
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s stepStatus) MarshalText() ([]byte, error) {
|
||||||
|
return []byte(s.String()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stepStatus) UnmarshalText(b []byte) error {
|
||||||
|
str := string(b)
|
||||||
|
for i, name := range stepStatusStrings {
|
||||||
|
if name == str {
|
||||||
|
*s = stepStatus(i)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("invalid step status %q", str)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s stepStatus) String() string {
|
||||||
|
if int(s) >= len(stepStatusStrings) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return stepStatusStrings[s]
|
||||||
|
}
|
||||||
|
|
||||||
|
type StepResult struct {
|
||||||
|
Outputs map[string]string `json:"outputs"`
|
||||||
|
Conclusion stepStatus `json:"conclusion"`
|
||||||
|
Outcome stepStatus `json:"outcome"`
|
||||||
|
}
|
||||||
0
act/model/testdata/empty-workflow/push.yml
vendored
Normal file
0
act/model/testdata/empty-workflow/push.yml
vendored
Normal file
12
act/model/testdata/invalid-job-name/invalid-1.yml
vendored
Normal file
12
act/model/testdata/invalid-job-name/invalid-1.yml
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
name: invalid-job-name-1
|
||||||
|
on: push
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
invalid-JOB-Name-v1.2.3-docker_hub:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo hi
|
||||||
|
valid-JOB-Name-v123-docker_hub:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo hi
|
||||||
8
act/model/testdata/invalid-job-name/invalid-2.yml
vendored
Normal file
8
act/model/testdata/invalid-job-name/invalid-2.yml
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
name: invalid-job-name-2
|
||||||
|
on: push
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
1234invalid-JOB-Name-v123-docker_hub:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo hi
|
||||||
8
act/model/testdata/invalid-job-name/valid-1.yml
vendored
Normal file
8
act/model/testdata/invalid-job-name/valid-1.yml
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
name: valid-job-name-1
|
||||||
|
on: push
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
valid-JOB-Name-v123-docker_hub:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo hi
|
||||||
8
act/model/testdata/invalid-job-name/valid-2.yml
vendored
Normal file
8
act/model/testdata/invalid-job-name/valid-2.yml
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
name: valid-job-name-2
|
||||||
|
on: push
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
___valid-JOB-Name-v123-docker_hub:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo hi
|
||||||
9
act/model/testdata/nested/success.yml
vendored
Normal file
9
act/model/testdata/nested/success.yml
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
name: Hello World Workflow
|
||||||
|
on: push
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
hello-world:
|
||||||
|
name: Hello World Job
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo "Hello World!"
|
||||||
0
act/model/testdata/nested/workflows/fail.yml
vendored
Normal file
0
act/model/testdata/nested/workflows/fail.yml
vendored
Normal file
50
act/model/testdata/strategy/push.yml
vendored
Normal file
50
act/model/testdata/strategy/push.yml
vendored
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
---
|
||||||
|
jobs:
|
||||||
|
strategy-all:
|
||||||
|
name: ${{ matrix.node-version }} | ${{ matrix.site }} | ${{ matrix.datacenter }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo 'Hello!'
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
datacenter:
|
||||||
|
- site-c
|
||||||
|
- site-d
|
||||||
|
exclude:
|
||||||
|
- datacenter: site-d
|
||||||
|
node-version: 14.x
|
||||||
|
site: staging
|
||||||
|
include:
|
||||||
|
- php-version: 5.4
|
||||||
|
- datacenter: site-a
|
||||||
|
node-version: 10.x
|
||||||
|
site: prod
|
||||||
|
- datacenter: site-b
|
||||||
|
node-version: 12.x
|
||||||
|
site: dev
|
||||||
|
node-version: [14.x, 16.x]
|
||||||
|
site:
|
||||||
|
- staging
|
||||||
|
max-parallel: 2
|
||||||
|
strategy-no-matrix:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo 'Hello!'
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
max-parallel: 2
|
||||||
|
strategy-only-fail-fast:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo 'Hello!'
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
strategy-only-max-parallel:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo 'Hello!'
|
||||||
|
strategy:
|
||||||
|
max-parallel: 2
|
||||||
|
'on':
|
||||||
|
push: null
|
||||||
910
act/model/workflow.go
Normal file
910
act/model/workflow.go
Normal file
@@ -0,0 +1,910 @@
|
|||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"maps"
|
||||||
|
"reflect"
|
||||||
|
"regexp"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"go.yaml.in/yaml/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Workflow is the structure of the files in .github/workflows
|
||||||
|
type Workflow struct {
|
||||||
|
File string
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
RawOn yaml.Node `yaml:"on"`
|
||||||
|
Env map[string]string `yaml:"env"`
|
||||||
|
Jobs map[string]*Job `yaml:"jobs"`
|
||||||
|
Defaults Defaults `yaml:"defaults"`
|
||||||
|
RawConcurrency *RawConcurrency `yaml:"concurrency"`
|
||||||
|
RawPermissions yaml.Node `yaml:"permissions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// On events for the workflow
|
||||||
|
func (w *Workflow) On() []string {
|
||||||
|
switch w.RawOn.Kind {
|
||||||
|
case yaml.ScalarNode:
|
||||||
|
var val string
|
||||||
|
err := w.RawOn.Decode(&val)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
return []string{val}
|
||||||
|
case yaml.SequenceNode:
|
||||||
|
var val []string
|
||||||
|
err := w.RawOn.Decode(&val)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
case yaml.MappingNode:
|
||||||
|
var val map[string]any
|
||||||
|
err := w.RawOn.Decode(&val)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
var keys []string
|
||||||
|
for k := range val {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Workflow) OnEvent(event string) any {
|
||||||
|
if w.RawOn.Kind == yaml.MappingNode {
|
||||||
|
var val map[string]any
|
||||||
|
if !decodeNode(w.RawOn, &val) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return val[event]
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Workflow) OnSchedule() []string {
|
||||||
|
schedules := w.OnEvent("schedule")
|
||||||
|
if schedules == nil {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch val := schedules.(type) {
|
||||||
|
case []any:
|
||||||
|
allSchedules := []string{}
|
||||||
|
for _, v := range val {
|
||||||
|
entry, ok := v.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if cron, ok := entry["cron"].(string); ok {
|
||||||
|
allSchedules = append(allSchedules, cron)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return allSchedules
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkflowDispatchInput struct {
|
||||||
|
Description string `yaml:"description"`
|
||||||
|
Required bool `yaml:"required"`
|
||||||
|
Default string `yaml:"default"`
|
||||||
|
Type string `yaml:"type"`
|
||||||
|
Options []string `yaml:"options"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkflowDispatch struct {
|
||||||
|
Inputs map[string]WorkflowDispatchInput `yaml:"inputs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Workflow) WorkflowDispatchConfig() *WorkflowDispatch {
|
||||||
|
switch w.RawOn.Kind {
|
||||||
|
case yaml.ScalarNode:
|
||||||
|
var val string
|
||||||
|
if !decodeNode(w.RawOn, &val) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if val == "workflow_dispatch" {
|
||||||
|
return &WorkflowDispatch{}
|
||||||
|
}
|
||||||
|
case yaml.SequenceNode:
|
||||||
|
var val []string
|
||||||
|
if !decodeNode(w.RawOn, &val) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if slices.Contains(val, "workflow_dispatch") {
|
||||||
|
return &WorkflowDispatch{}
|
||||||
|
}
|
||||||
|
case yaml.MappingNode:
|
||||||
|
var val map[string]yaml.Node
|
||||||
|
if !decodeNode(w.RawOn, &val) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
n, found := val["workflow_dispatch"]
|
||||||
|
var workflowDispatch WorkflowDispatch
|
||||||
|
if found && decodeNode(n, &workflowDispatch) {
|
||||||
|
return &workflowDispatch
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkflowCallInput struct {
|
||||||
|
Description string `yaml:"description"`
|
||||||
|
Required bool `yaml:"required"`
|
||||||
|
Default string `yaml:"default"`
|
||||||
|
Type string `yaml:"type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkflowCallOutput struct {
|
||||||
|
Description string `yaml:"description"`
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkflowCall struct {
|
||||||
|
Inputs map[string]WorkflowCallInput `yaml:"inputs"`
|
||||||
|
Outputs map[string]WorkflowCallOutput `yaml:"outputs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkflowCallResult struct {
|
||||||
|
Outputs map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Workflow) WorkflowCallConfig() *WorkflowCall {
|
||||||
|
if w.RawOn.Kind != yaml.MappingNode {
|
||||||
|
// The callers expect for "on: workflow_call" and "on: [ workflow_call ]" a non nil return value
|
||||||
|
return &WorkflowCall{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var val map[string]yaml.Node
|
||||||
|
if !decodeNode(w.RawOn, &val) {
|
||||||
|
return &WorkflowCall{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var config WorkflowCall
|
||||||
|
node := val["workflow_call"]
|
||||||
|
if !decodeNode(node, &config) {
|
||||||
|
return &WorkflowCall{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &config
|
||||||
|
}
|
||||||
|
|
||||||
|
// Job is the structure of one job in a workflow
|
||||||
|
type Job struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
RawNeeds yaml.Node `yaml:"needs"`
|
||||||
|
RawRunsOn yaml.Node `yaml:"runs-on"`
|
||||||
|
Env yaml.Node `yaml:"env"`
|
||||||
|
If yaml.Node `yaml:"if"`
|
||||||
|
Steps []*Step `yaml:"steps"`
|
||||||
|
TimeoutMinutes string `yaml:"timeout-minutes"`
|
||||||
|
RawContinueOnError string `yaml:"continue-on-error"`
|
||||||
|
Services map[string]*ContainerSpec `yaml:"services"`
|
||||||
|
Strategy *Strategy `yaml:"strategy"`
|
||||||
|
RawContainer yaml.Node `yaml:"container"`
|
||||||
|
Defaults Defaults `yaml:"defaults"`
|
||||||
|
Outputs map[string]string `yaml:"outputs"`
|
||||||
|
Uses string `yaml:"uses"`
|
||||||
|
With map[string]any `yaml:"with"`
|
||||||
|
RawSecrets yaml.Node `yaml:"secrets"`
|
||||||
|
RawPermissions yaml.Node `yaml:"permissions"`
|
||||||
|
Result string
|
||||||
|
// Runtime fields set during execution (not from YAML):
|
||||||
|
ContinueOnError bool // true when all failing matrix combinations had continue-on-error=true
|
||||||
|
hasFirmFailure bool // true once any combination failed without continue-on-error
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetContinueOnError records whether this combination's failure should not fail the workflow.
|
||||||
|
// Must be called under the job lock. Safe across parallel matrix combinations.
|
||||||
|
func (j *Job) SetContinueOnError(continueOnErr bool) {
|
||||||
|
if continueOnErr {
|
||||||
|
if !j.hasFirmFailure {
|
||||||
|
j.ContinueOnError = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
j.hasFirmFailure = true
|
||||||
|
j.ContinueOnError = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NeedsResult returns the job result as seen by dependent jobs through the
|
||||||
|
// `needs` context. A job that failed but was tolerated via continue-on-error
|
||||||
|
// reports "success" to its dependents, matching GitHub: such a failure must not
|
||||||
|
// block jobs gated on the default `if: success()`, even though the overall
|
||||||
|
// workflow run is still marked as failed.
|
||||||
|
func (j *Job) NeedsResult() string {
|
||||||
|
if j.Result == "failure" && j.ContinueOnError {
|
||||||
|
return "success"
|
||||||
|
}
|
||||||
|
return j.Result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy for the job
|
||||||
|
type Strategy struct {
|
||||||
|
FailFast bool
|
||||||
|
MaxParallel int
|
||||||
|
FailFastString string `yaml:"fail-fast"`
|
||||||
|
MaxParallelString string `yaml:"max-parallel"`
|
||||||
|
RawMatrix yaml.Node `yaml:"matrix"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default settings that will apply to all steps in the job or workflow
|
||||||
|
type Defaults struct {
|
||||||
|
Run RunDefaults `yaml:"run"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defaults for all run steps in the job or workflow
|
||||||
|
type RunDefaults struct {
|
||||||
|
Shell string `yaml:"shell"`
|
||||||
|
WorkingDirectory string `yaml:"working-directory"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMaxParallel sets default and returns value for `max-parallel`
|
||||||
|
func (s Strategy) GetMaxParallel() int {
|
||||||
|
// MaxParallel default value is `GitHub will maximize the number of jobs run in parallel depending on the available runners on GitHub-hosted virtual machines`
|
||||||
|
// So I take the liberty to hardcode default limit to 4 and this is because:
|
||||||
|
// 1: tl;dr: self-hosted does only 1 parallel job - https://github.com/actions/runner/issues/639#issuecomment-825212735
|
||||||
|
// 2: GH has 20 parallel job limit (for free tier) - https://github.com/github/docs/blob/3ae84420bd10997bb5f35f629ebb7160fe776eae/content/actions/reference/usage-limits-billing-and-administration.md?plain=1#L45
|
||||||
|
// 3: I want to add support for MaxParallel to act and 20! parallel jobs is a bit overkill IMHO
|
||||||
|
maxParallel := 4
|
||||||
|
if s.MaxParallelString != "" {
|
||||||
|
var err error
|
||||||
|
if maxParallel, err = strconv.Atoi(s.MaxParallelString); err != nil {
|
||||||
|
log.Errorf("Failed to parse 'max-parallel' option: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return maxParallel
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFailFast sets default and returns value for `fail-fast`
|
||||||
|
func (s Strategy) GetFailFast() bool {
|
||||||
|
// FailFast option is true by default: https://github.com/github/docs/blob/3ae84420bd10997bb5f35f629ebb7160fe776eae/content/actions/reference/workflow-syntax-for-github-actions.md?plain=1#L1107
|
||||||
|
failFast := true
|
||||||
|
log.Debug(s.FailFastString)
|
||||||
|
if s.FailFastString != "" {
|
||||||
|
var err error
|
||||||
|
if failFast, err = strconv.ParseBool(s.FailFastString); err != nil {
|
||||||
|
log.Errorf("Failed to parse 'fail-fast' option: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return failFast
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *Job) InheritSecrets() bool {
|
||||||
|
if j.RawSecrets.Kind != yaml.ScalarNode {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var val string
|
||||||
|
if !decodeNode(j.RawSecrets, &val) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return val == "inherit"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *Job) Secrets() map[string]string {
|
||||||
|
if j.RawSecrets.Kind != yaml.MappingNode {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var val map[string]string
|
||||||
|
if !decodeNode(j.RawSecrets, &val) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
|
// Container details for the job
|
||||||
|
func (j *Job) Container() *ContainerSpec {
|
||||||
|
var val *ContainerSpec
|
||||||
|
switch j.RawContainer.Kind {
|
||||||
|
case yaml.ScalarNode:
|
||||||
|
val = new(ContainerSpec)
|
||||||
|
if !decodeNode(j.RawContainer, &val.Image) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
case yaml.MappingNode:
|
||||||
|
val = new(ContainerSpec)
|
||||||
|
if !decodeNode(j.RawContainer, val) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
|
// Needs list for Job
|
||||||
|
func (j *Job) Needs() []string {
|
||||||
|
switch j.RawNeeds.Kind {
|
||||||
|
case yaml.ScalarNode:
|
||||||
|
var val string
|
||||||
|
if !decodeNode(j.RawNeeds, &val) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []string{val}
|
||||||
|
case yaml.SequenceNode:
|
||||||
|
var val []string
|
||||||
|
if !decodeNode(j.RawNeeds, &val) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunsOn list for Job
|
||||||
|
func (j *Job) RunsOn() []string {
|
||||||
|
return RunsOnFromNode(j.RawRunsOn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunsOnFromNode parses the runs-on labels from a raw runs-on node, so callers can evaluate a
|
||||||
|
// copy of the node (avoiding mutation of the shared Job) before reading the labels.
|
||||||
|
func RunsOnFromNode(rawRunsOn yaml.Node) []string {
|
||||||
|
switch rawRunsOn.Kind {
|
||||||
|
case yaml.MappingNode:
|
||||||
|
var val struct {
|
||||||
|
Group string
|
||||||
|
Labels yaml.Node
|
||||||
|
}
|
||||||
|
|
||||||
|
if !decodeNode(rawRunsOn, &val) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
labels := nodeAsStringSlice(val.Labels)
|
||||||
|
|
||||||
|
if val.Group != "" {
|
||||||
|
labels = append(labels, val.Group)
|
||||||
|
}
|
||||||
|
|
||||||
|
return labels
|
||||||
|
default:
|
||||||
|
return nodeAsStringSlice(rawRunsOn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func nodeAsStringSlice(node yaml.Node) []string {
|
||||||
|
switch node.Kind {
|
||||||
|
case yaml.ScalarNode:
|
||||||
|
var val string
|
||||||
|
if !decodeNode(node, &val) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []string{val}
|
||||||
|
case yaml.SequenceNode:
|
||||||
|
var val []string
|
||||||
|
if !decodeNode(node, &val) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func environment(yml yaml.Node) map[string]string {
|
||||||
|
env := make(map[string]string)
|
||||||
|
if yml.Kind == yaml.MappingNode {
|
||||||
|
if !decodeNode(yml, &env) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
// Environment returns string-based key=value map for a job
|
||||||
|
func (j *Job) Environment() map[string]string {
|
||||||
|
return environment(j.Env)
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeMatrixValue converts a matrix value to []interface{}.
|
||||||
|
// Arrays pass through unchanged; scalars are wrapped in a single-element array.
|
||||||
|
// Unevaluated template expressions are wrapped as a fallback — proper resolution
|
||||||
|
// happens via EvaluateYamlNode before Matrix() is called. Nested maps are rejected.
|
||||||
|
func normalizeMatrixValue(key string, val any) ([]any, error) {
|
||||||
|
switch t := val.(type) {
|
||||||
|
case []any:
|
||||||
|
// Already an array - use as-is
|
||||||
|
return t, nil
|
||||||
|
case string, int, float64, bool, nil:
|
||||||
|
// Valid scalar types that can appear in YAML
|
||||||
|
// These can be unevaluated template expressions (strings) or literal values
|
||||||
|
return []any{t}, nil
|
||||||
|
case map[string]any:
|
||||||
|
// Nested map indicates misconfiguration - likely user error
|
||||||
|
return nil, fmt.Errorf("matrix key %q has invalid nested object value - expected scalar or array, got map", key)
|
||||||
|
default:
|
||||||
|
// Unknown types might indicate parsing issues
|
||||||
|
log.Warnf("matrix key %q has unexpected type %T, wrapping as single value", key, t)
|
||||||
|
return []any{t}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Matrix decodes the RawMatrix YAML node into a map[string][]interface{}.
|
||||||
|
// Scalar values are wrapped into single-element arrays automatically.
|
||||||
|
// Template expressions are resolved by EvaluateYamlNode before this method is
|
||||||
|
// called; if unresolved, the literal string is wrapped as a one-element fallback.
|
||||||
|
func (j *Job) Matrix() (map[string][]any, error) {
|
||||||
|
if j.Strategy == nil || j.Strategy.RawMatrix.Kind != yaml.MappingNode {
|
||||||
|
return map[string][]any{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode to flexible map first so that scalar values don't cause a type error.
|
||||||
|
var flexVal map[string]any
|
||||||
|
err := j.Strategy.RawMatrix.Decode(&flexVal)
|
||||||
|
if err != nil {
|
||||||
|
// Fall back to the strict array-only format for backward compatibility.
|
||||||
|
var val map[string][]any
|
||||||
|
if !decodeNode(j.Strategy.RawMatrix, &val) {
|
||||||
|
return map[string][]any{}, nil
|
||||||
|
}
|
||||||
|
return val, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert flexible format to expected format with validation
|
||||||
|
val := make(map[string][]any)
|
||||||
|
for k, v := range flexVal {
|
||||||
|
normalized, err := normalizeMatrixValue(k, v)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
val[k] = normalized
|
||||||
|
}
|
||||||
|
return val, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMatrixes returns the matrix cross product
|
||||||
|
// It skips includes and hard fails excludes for non-existing keys
|
||||||
|
func (j *Job) GetMatrixes() ([]map[string]any, error) {
|
||||||
|
matrixes := make([]map[string]any, 0)
|
||||||
|
if j.Strategy != nil {
|
||||||
|
// Always set these values, even if there's an error later
|
||||||
|
j.Strategy.FailFast = j.Strategy.GetFailFast()
|
||||||
|
j.Strategy.MaxParallel = j.Strategy.GetMaxParallel()
|
||||||
|
|
||||||
|
m, err := j.Matrix()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(m) > 0 {
|
||||||
|
includes := make([]map[string]any, 0)
|
||||||
|
extraIncludes := make([]map[string]any, 0)
|
||||||
|
addInclude := func(raw any) error {
|
||||||
|
include, ok := raw.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("the workflow is not valid. Matrix include %v is not a map of matrix keys to values", raw)
|
||||||
|
}
|
||||||
|
for k := range include {
|
||||||
|
if _, ok := m[k]; ok {
|
||||||
|
includes = append(includes, include)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
extraIncludes = append(extraIncludes, include)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, v := range m["include"] {
|
||||||
|
switch t := v.(type) {
|
||||||
|
case []any:
|
||||||
|
for _, i := range t {
|
||||||
|
if err := addInclude(i); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case any:
|
||||||
|
if err := addInclude(t); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete(m, "include")
|
||||||
|
|
||||||
|
excludes := make([]map[string]any, 0)
|
||||||
|
for _, e := range m["exclude"] {
|
||||||
|
exclude, ok := e.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("the workflow is not valid. Matrix exclude %v is not a map of matrix keys to values", e)
|
||||||
|
}
|
||||||
|
for k := range exclude {
|
||||||
|
if _, ok := m[k]; ok {
|
||||||
|
excludes = append(excludes, exclude)
|
||||||
|
} else {
|
||||||
|
// We fail completely here because that's what GitHub does for non-existing matrix keys, fail on exclude, silent skip on include
|
||||||
|
return nil, fmt.Errorf("the workflow is not valid. Matrix exclude key %q does not match any key within the matrix", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete(m, "exclude")
|
||||||
|
|
||||||
|
matrixProduct := common.CartesianProduct(m)
|
||||||
|
MATRIX:
|
||||||
|
for _, matrix := range matrixProduct {
|
||||||
|
for _, exclude := range excludes {
|
||||||
|
if commonKeysMatch(matrix, exclude) {
|
||||||
|
log.Debugf("Skipping matrix '%v' due to exclude '%v'", matrix, exclude)
|
||||||
|
continue MATRIX
|
||||||
|
}
|
||||||
|
}
|
||||||
|
matrixes = append(matrixes, matrix)
|
||||||
|
}
|
||||||
|
for _, include := range includes {
|
||||||
|
matched := false
|
||||||
|
for _, matrix := range matrixes {
|
||||||
|
if commonKeysMatch2(matrix, include, m) {
|
||||||
|
matched = true
|
||||||
|
log.Debugf("Adding include values '%v' to existing entry", include)
|
||||||
|
maps.Copy(matrix, include)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !matched {
|
||||||
|
extraIncludes = append(extraIncludes, include)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, include := range extraIncludes {
|
||||||
|
log.Debugf("Adding include '%v'", include)
|
||||||
|
matrixes = append(matrixes, include)
|
||||||
|
}
|
||||||
|
if len(matrixes) == 0 {
|
||||||
|
matrixes = append(matrixes, make(map[string]any))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
matrixes = append(matrixes, make(map[string]any))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
matrixes = append(matrixes, make(map[string]any))
|
||||||
|
log.Debugf("Empty Strategy, matrixes=%v", matrixes)
|
||||||
|
}
|
||||||
|
return matrixes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func commonKeysMatch(a, b map[string]any) bool {
|
||||||
|
for aKey, aVal := range a {
|
||||||
|
if bVal, ok := b[aKey]; ok && !reflect.DeepEqual(aVal, bVal) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func commonKeysMatch2(a, b map[string]any, m map[string][]any) bool {
|
||||||
|
for aKey, aVal := range a {
|
||||||
|
_, useKey := m[aKey]
|
||||||
|
if bVal, ok := b[aKey]; useKey && ok && !reflect.DeepEqual(aVal, bVal) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// JobType describes what type of job we are about to run
|
||||||
|
type JobType int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// JobTypeDefault is all jobs that have a `run` attribute
|
||||||
|
JobTypeDefault JobType = iota
|
||||||
|
|
||||||
|
// JobTypeReusableWorkflowLocal is all jobs that have a `uses` that is a local workflow in the .github/workflows directory
|
||||||
|
JobTypeReusableWorkflowLocal
|
||||||
|
|
||||||
|
// JobTypeReusableWorkflowRemote is all jobs that have a `uses` that references a workflow file in a github repo
|
||||||
|
JobTypeReusableWorkflowRemote
|
||||||
|
|
||||||
|
// JobTypeInvalid represents a job which is not configured correctly
|
||||||
|
JobTypeInvalid
|
||||||
|
)
|
||||||
|
|
||||||
|
func (j JobType) String() string {
|
||||||
|
switch j {
|
||||||
|
case JobTypeDefault:
|
||||||
|
return "default"
|
||||||
|
case JobTypeReusableWorkflowLocal:
|
||||||
|
return "local-reusable-workflow"
|
||||||
|
case JobTypeReusableWorkflowRemote:
|
||||||
|
return "remote-reusable-workflow"
|
||||||
|
}
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type returns the type of the job
|
||||||
|
func (j *Job) Type() (JobType, error) {
|
||||||
|
isReusable := j.Uses != ""
|
||||||
|
|
||||||
|
if isReusable {
|
||||||
|
isYaml, _ := regexp.MatchString(`\.(ya?ml)(?:$|@)`, j.Uses)
|
||||||
|
|
||||||
|
if isYaml {
|
||||||
|
isLocalPath := strings.HasPrefix(j.Uses, "./")
|
||||||
|
isRemotePath, _ := regexp.MatchString(`^[^.](.+?/){2,}.+\.ya?ml@`, j.Uses)
|
||||||
|
hasVersion, _ := regexp.MatchString(`\.ya?ml@`, j.Uses)
|
||||||
|
|
||||||
|
if isLocalPath {
|
||||||
|
return JobTypeReusableWorkflowLocal, nil
|
||||||
|
} else if isRemotePath && hasVersion {
|
||||||
|
return JobTypeReusableWorkflowRemote, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return JobTypeInvalid, fmt.Errorf("`uses` key references invalid workflow path '%s'. Must start with './' if it's a local workflow, or must start with '<org>/<repo>/' and include an '@' if it's a remote workflow", j.Uses)
|
||||||
|
}
|
||||||
|
|
||||||
|
return JobTypeDefault, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContainerSpec is the specification of the container to use for the job
|
||||||
|
type ContainerSpec struct {
|
||||||
|
Image string `yaml:"image"`
|
||||||
|
Env map[string]string `yaml:"env"`
|
||||||
|
Ports []string `yaml:"ports"`
|
||||||
|
Volumes []string `yaml:"volumes"`
|
||||||
|
Options string `yaml:"options"`
|
||||||
|
Credentials map[string]string `yaml:"credentials"`
|
||||||
|
Entrypoint string
|
||||||
|
Args string
|
||||||
|
Name string
|
||||||
|
Reuse bool
|
||||||
|
|
||||||
|
// Gitea specific
|
||||||
|
Cmd []string `yaml:"cmd"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step is the structure of one step in a job
|
||||||
|
type Step struct {
|
||||||
|
Number int `yaml:"-"`
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
If yaml.Node `yaml:"if"`
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Uses string `yaml:"uses"`
|
||||||
|
Run string `yaml:"run"`
|
||||||
|
WorkingDirectory string `yaml:"working-directory"`
|
||||||
|
Shell string `yaml:"shell"`
|
||||||
|
Env yaml.Node `yaml:"env"`
|
||||||
|
With map[string]string `yaml:"with"`
|
||||||
|
RawContinueOnError string `yaml:"continue-on-error"`
|
||||||
|
TimeoutMinutes string `yaml:"timeout-minutes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone returns a deep copy safe to mutate independently of s. Job steps are shared across
|
||||||
|
// parallel matrix runs, which mutate per-job fields (ID, Number, Shell) and evaluate the If/Env
|
||||||
|
// yaml.Nodes in place, so each job must own its copy.
|
||||||
|
func (s *Step) Clone() *Step {
|
||||||
|
clone := *s
|
||||||
|
clone.If = CloneYamlNode(s.If)
|
||||||
|
clone.Env = CloneYamlNode(s.Env)
|
||||||
|
clone.With = maps.Clone(s.With)
|
||||||
|
return &clone
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloneYamlNode returns a deep copy of a yaml.Node so callers can evaluate it in place without
|
||||||
|
// mutating a node shared across parallel jobs.
|
||||||
|
func CloneYamlNode(n yaml.Node) yaml.Node {
|
||||||
|
clone := n
|
||||||
|
if n.Content != nil {
|
||||||
|
clone.Content = make([]*yaml.Node, len(n.Content))
|
||||||
|
for i, child := range n.Content {
|
||||||
|
if child != nil {
|
||||||
|
childClone := CloneYamlNode(*child)
|
||||||
|
clone.Content[i] = &childClone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return clone
|
||||||
|
}
|
||||||
|
|
||||||
|
// String gets the name of step
|
||||||
|
func (s *Step) String() string {
|
||||||
|
if s.Name != "" {
|
||||||
|
return s.Name
|
||||||
|
} else if s.Uses != "" {
|
||||||
|
return s.Uses
|
||||||
|
} else if s.Run != "" {
|
||||||
|
return s.Run
|
||||||
|
}
|
||||||
|
return s.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Environment returns string-based key=value map for a step
|
||||||
|
func (s *Step) Environment() map[string]string {
|
||||||
|
return environment(s.Env)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetEnv gets the env for a step
|
||||||
|
func (s *Step) GetEnv() map[string]string {
|
||||||
|
env := s.Environment()
|
||||||
|
|
||||||
|
for k, v := range s.With {
|
||||||
|
envKey := regexp.MustCompile("[^A-Z0-9-]").ReplaceAllString(strings.ToUpper(k), "_")
|
||||||
|
envKey = "INPUT_" + strings.ToUpper(envKey)
|
||||||
|
env[envKey] = v
|
||||||
|
}
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShellCommand returns the command for the shell
|
||||||
|
func (s *Step) ShellCommand() string {
|
||||||
|
var shellCommand string
|
||||||
|
|
||||||
|
// Reference: https://github.com/actions/runner/blob/8109c962f09d9acc473d92c595ff43afceddb347/src/Runner.Worker/Handlers/ScriptHandlerHelpers.cs#L9-L17
|
||||||
|
switch s.Shell {
|
||||||
|
case "", "bash":
|
||||||
|
shellCommand = "bash --noprofile --norc -e -o pipefail {0}"
|
||||||
|
case "pwsh":
|
||||||
|
shellCommand = "pwsh -command . '{0}'"
|
||||||
|
case "python":
|
||||||
|
shellCommand = "python {0}"
|
||||||
|
case "sh":
|
||||||
|
shellCommand = "sh -e {0}"
|
||||||
|
case "cmd":
|
||||||
|
shellCommand = "cmd /D /E:ON /V:OFF /S /C \"CALL \"{0}\"\""
|
||||||
|
case "powershell":
|
||||||
|
shellCommand = "powershell -command . '{0}'"
|
||||||
|
default:
|
||||||
|
shellCommand = s.Shell
|
||||||
|
}
|
||||||
|
return shellCommand
|
||||||
|
}
|
||||||
|
|
||||||
|
// StepType describes what type of step we are about to run
|
||||||
|
type StepType int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// StepTypeRun is all steps that have a `run` attribute
|
||||||
|
StepTypeRun StepType = iota
|
||||||
|
|
||||||
|
// StepTypeUsesDockerURL is all steps that have a `uses` that is of the form `docker://...`
|
||||||
|
StepTypeUsesDockerURL
|
||||||
|
|
||||||
|
// StepTypeUsesActionLocal is all steps that have a `uses` that is a local action in a subdirectory
|
||||||
|
StepTypeUsesActionLocal
|
||||||
|
|
||||||
|
// StepTypeUsesActionRemote is all steps that have a `uses` that is a reference to a github repo
|
||||||
|
StepTypeUsesActionRemote
|
||||||
|
|
||||||
|
// StepTypeReusableWorkflowLocal is all steps that have a `uses` that is a local workflow in the .github/workflows directory
|
||||||
|
StepTypeReusableWorkflowLocal
|
||||||
|
|
||||||
|
// StepTypeReusableWorkflowRemote is all steps that have a `uses` that references a workflow file in a github repo
|
||||||
|
StepTypeReusableWorkflowRemote
|
||||||
|
|
||||||
|
// StepTypeInvalid is for steps that have invalid step action
|
||||||
|
StepTypeInvalid
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s StepType) String() string {
|
||||||
|
switch s {
|
||||||
|
case StepTypeInvalid:
|
||||||
|
return "invalid"
|
||||||
|
case StepTypeRun:
|
||||||
|
return "run"
|
||||||
|
case StepTypeUsesActionLocal:
|
||||||
|
return "local-action"
|
||||||
|
case StepTypeUsesActionRemote:
|
||||||
|
return "remote-action"
|
||||||
|
case StepTypeUsesDockerURL:
|
||||||
|
return "docker"
|
||||||
|
case StepTypeReusableWorkflowLocal:
|
||||||
|
return "local-reusable-workflow"
|
||||||
|
case StepTypeReusableWorkflowRemote:
|
||||||
|
return "remote-reusable-workflow"
|
||||||
|
}
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type returns the type of the step
|
||||||
|
func (s *Step) Type() StepType {
|
||||||
|
if s.Run == "" && s.Uses == "" {
|
||||||
|
return StepTypeInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.Run != "" {
|
||||||
|
if s.Uses != "" {
|
||||||
|
return StepTypeInvalid
|
||||||
|
}
|
||||||
|
return StepTypeRun
|
||||||
|
} else if strings.HasPrefix(s.Uses, "docker://") {
|
||||||
|
return StepTypeUsesDockerURL
|
||||||
|
} else if strings.HasPrefix(s.Uses, "./.github/workflows") && (strings.HasSuffix(s.Uses, ".yml") || strings.HasSuffix(s.Uses, ".yaml")) {
|
||||||
|
return StepTypeReusableWorkflowLocal
|
||||||
|
} else if !strings.HasPrefix(s.Uses, "./") && strings.Contains(s.Uses, ".github/workflows") && (strings.Contains(s.Uses, ".yml@") || strings.Contains(s.Uses, ".yaml@")) {
|
||||||
|
return StepTypeReusableWorkflowRemote
|
||||||
|
} else if strings.HasPrefix(s.Uses, "./") {
|
||||||
|
return StepTypeUsesActionLocal
|
||||||
|
}
|
||||||
|
return StepTypeUsesActionRemote
|
||||||
|
}
|
||||||
|
|
||||||
|
// UsesHash returns a hash of the uses string.
|
||||||
|
// For Gitea.
|
||||||
|
func (s *Step) UsesHash() string {
|
||||||
|
return fmt.Sprintf("%x", sha256.Sum256([]byte(s.Uses)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadWorkflow returns a list of jobs for a given workflow file reader
|
||||||
|
func ReadWorkflow(in io.Reader) (*Workflow, error) {
|
||||||
|
w := new(Workflow)
|
||||||
|
err := yaml.NewDecoder(in).Decode(w)
|
||||||
|
return w, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetJob will get a job by name in the workflow
|
||||||
|
func (w *Workflow) GetJob(jobID string) *Job {
|
||||||
|
for id, j := range w.Jobs {
|
||||||
|
if jobID == id {
|
||||||
|
if j.Name == "" {
|
||||||
|
j.Name = id
|
||||||
|
}
|
||||||
|
if j.If.Value == "" {
|
||||||
|
j.If.Value = "success()"
|
||||||
|
}
|
||||||
|
return j
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetJobIDs will get all the job names in the workflow
|
||||||
|
func (w *Workflow) GetJobIDs() []string {
|
||||||
|
ids := make([]string, 0)
|
||||||
|
for id := range w.Jobs {
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
var OnDecodeNodeError = func(node yaml.Node, out any, err error) {
|
||||||
|
log.Fatalf("Failed to decode node %v into %T: %v", node, out, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeNode(node yaml.Node, out any) bool {
|
||||||
|
if err := node.Decode(out); err != nil {
|
||||||
|
if OnDecodeNodeError != nil {
|
||||||
|
OnDecodeNodeError(node, out, err)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// For Gitea
|
||||||
|
// RawConcurrency represents a workflow concurrency or a job concurrency with uninterpolated options
|
||||||
|
type RawConcurrency struct {
|
||||||
|
Group string `yaml:"group,omitempty"`
|
||||||
|
CancelInProgress string `yaml:"cancel-in-progress,omitempty"`
|
||||||
|
RawExpression string `yaml:"-,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type objectConcurrency RawConcurrency
|
||||||
|
|
||||||
|
func (r *RawConcurrency) UnmarshalYAML(n *yaml.Node) error {
|
||||||
|
if err := n.Decode(&r.RawExpression); err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return n.Decode((*objectConcurrency)(r))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RawConcurrency) MarshalYAML() (any, error) {
|
||||||
|
if r.RawExpression != "" {
|
||||||
|
return r.RawExpression, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return (*objectConcurrency)(r), nil
|
||||||
|
}
|
||||||
1163
act/model/workflow_test.go
Normal file
1163
act/model/workflow_test.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -23,8 +23,8 @@ import (
|
|||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/common/git"
|
"gitea.com/gitea/runner/act/common/git"
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
"gitea.dev/actions-proto-go/pkg/model"
|
|
||||||
"github.com/kballard/go-shellquote"
|
"github.com/kballard/go-shellquote"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
"gitea.dev/actions-proto-go/pkg/model"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,8 +13,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/act/model"
|
||||||
"gitea.dev/actions-proto-go/pkg/model"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func evaluateCompositeInputAndEnv(ctx context.Context, parent *RunContext, step actionStep) map[string]string {
|
func evaluateCompositeInputAndEnv(ctx context.Context, parent *RunContext, step actionStep) map[string]string {
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ import (
|
|||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/common/git"
|
"gitea.com/gitea/runner/act/common/git"
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
"gitea.dev/actions-proto-go/pkg/model"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/act/exprparser"
|
||||||
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
"gitea.dev/actions-proto-go/pkg/exprparser"
|
|
||||||
"gitea.dev/actions-proto-go/pkg/model"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"go.yaml.in/yaml/v4"
|
"go.yaml.in/yaml/v4"
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
"gitea.dev/actions-proto-go/pkg/model"
|
|
||||||
"github.com/sirupsen/logrus/hooks/test"
|
"github.com/sirupsen/logrus/hooks/test"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,11 +17,11 @@ import (
|
|||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
|
"gitea.com/gitea/runner/act/exprparser"
|
||||||
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
_ "embed"
|
_ "embed"
|
||||||
|
|
||||||
"gitea.dev/actions-proto-go/pkg/exprparser"
|
|
||||||
"gitea.dev/actions-proto-go/pkg/model"
|
|
||||||
"go.yaml.in/yaml/v4"
|
"go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.dev/actions-proto-go/pkg/exprparser"
|
"gitea.com/gitea/runner/act/exprparser"
|
||||||
"gitea.dev/actions-proto-go/pkg/model"
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
assert "github.com/stretchr/testify/assert"
|
assert "github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
yaml "go.yaml.in/yaml/v4"
|
yaml "go.yaml.in/yaml/v4"
|
||||||
|
|||||||
@@ -23,9 +23,8 @@ import (
|
|||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
|
"gitea.com/gitea/runner/act/exprparser"
|
||||||
"gitea.dev/actions-proto-go/pkg/exprparser"
|
"gitea.com/gitea/runner/act/model"
|
||||||
"gitea.dev/actions-proto-go/pkg/model"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const maxJobSummaryBytes = 1024 * 1024
|
const maxJobSummaryBytes = 1024 * 1024
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ import (
|
|||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
"gitea.dev/actions-proto-go/pkg/model"
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
logrustest "github.com/sirupsen/logrus/hooks/test"
|
logrustest "github.com/sirupsen/logrus/hooks/test"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
"gitea.dev/actions-proto-go/pkg/model"
|
|
||||||
"github.com/sirupsen/logrus/hooks/test"
|
"github.com/sirupsen/logrus/hooks/test"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ package runner
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.dev/actions-proto-go/pkg/model"
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"go.yaml.in/yaml/v4"
|
"go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,8 +16,7 @@ import (
|
|||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/common/git"
|
"gitea.com/gitea/runner/act/common/git"
|
||||||
|
"gitea.com/gitea/runner/act/model"
|
||||||
"gitea.dev/actions-proto-go/pkg/model"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func newLocalReusableWorkflowExecutor(rc *RunContext) common.Executor {
|
func newLocalReusableWorkflowExecutor(rc *RunContext) common.Executor {
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common/git"
|
"gitea.com/gitea/runner/act/common/git"
|
||||||
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
"gitea.dev/actions-proto-go/pkg/model"
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -26,11 +26,10 @@ import (
|
|||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
"gitea.com/gitea/runner/act/ghcontext"
|
"gitea.com/gitea/runner/act/exprparser"
|
||||||
|
"gitea.com/gitea/runner/act/model"
|
||||||
"gitea.com/gitea/runner/internal/pkg/lock"
|
"gitea.com/gitea/runner/internal/pkg/lock"
|
||||||
|
|
||||||
"gitea.dev/actions-proto-go/pkg/exprparser"
|
|
||||||
"gitea.dev/actions-proto-go/pkg/model"
|
|
||||||
"github.com/docker/cli/cli/compose/loader"
|
"github.com/docker/cli/cli/compose/loader"
|
||||||
"github.com/docker/go-connections/nat"
|
"github.com/docker/go-connections/nat"
|
||||||
"github.com/moby/moby/api/types/mount"
|
"github.com/moby/moby/api/types/mount"
|
||||||
@@ -1155,12 +1154,12 @@ func (rc *RunContext) getGithubContext(ctx context.Context) *model.GithubContext
|
|||||||
|
|
||||||
ghc.SetBaseAndHeadRef()
|
ghc.SetBaseAndHeadRef()
|
||||||
repoPath := rc.Config.Workdir
|
repoPath := rc.Config.Workdir
|
||||||
ghcontext.SetRepositoryAndOwner(ctx, ghc, rc.Config.GitHubInstance, rc.Config.RemoteName, repoPath)
|
ghc.SetRepositoryAndOwner(ctx, rc.Config.GitHubInstance, rc.Config.RemoteName, repoPath)
|
||||||
if ghc.Ref == "" {
|
if ghc.Ref == "" {
|
||||||
ghcontext.SetRef(ctx, ghc, rc.Config.DefaultBranch, repoPath)
|
ghc.SetRef(ctx, rc.Config.DefaultBranch, repoPath)
|
||||||
}
|
}
|
||||||
if ghc.Sha == "" {
|
if ghc.Sha == "" {
|
||||||
ghcontext.SetSha(ctx, ghc, repoPath)
|
ghc.SetSha(ctx, repoPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
ghc.SetRefTypeAndName()
|
ghc.SetRefTypeAndName()
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ import (
|
|||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
|
"gitea.com/gitea/runner/act/exprparser"
|
||||||
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
"gitea.dev/actions-proto-go/pkg/exprparser"
|
|
||||||
"gitea.dev/actions-proto-go/pkg/model"
|
|
||||||
"github.com/docker/cli/cli/compose/loader"
|
"github.com/docker/cli/cli/compose/loader"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
assert "github.com/stretchr/testify/assert"
|
assert "github.com/stretchr/testify/assert"
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ import (
|
|||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
"gitea.dev/actions-proto-go/pkg/model"
|
|
||||||
docker_container "github.com/moby/moby/api/types/container"
|
docker_container "github.com/moby/moby/api/types/container"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
@@ -74,6 +74,7 @@ type Config struct {
|
|||||||
ContainerNetworkCreateOptions container.NewDockerNetworkCreateExecutorInput // the default network create options
|
ContainerNetworkCreateOptions container.NewDockerNetworkCreateExecutorInput // the default network create options
|
||||||
ActionCache ActionCache // Use a custom ActionCache Implementation
|
ActionCache ActionCache // Use a custom ActionCache Implementation
|
||||||
ProxyEnv map[string]string // the proxy variables the job runs with, also given to service containers and image builds
|
ProxyEnv map[string]string // the proxy variables the job runs with, also given to service containers and image builds
|
||||||
|
PatchToolkit bool // edit the @actions toolkit bundled into an action so it works against Gitea, see toolkit_patch.go
|
||||||
|
|
||||||
PresetGitHubContext *model.GithubContext // the preset github context, overrides some fields like DefaultBranch, Env, Secrets etc.
|
PresetGitHubContext *model.GithubContext // the preset github context, overrides some fields like DefaultBranch, Env, Secrets etc.
|
||||||
EventJSON string // the content of JSON file to use for event.json in containers, overrides EventPath
|
EventJSON string // the content of JSON file to use for event.json in containers, overrides EventPath
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
"gitea.dev/actions-proto-go/pkg/model"
|
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
assert "github.com/stretchr/testify/assert"
|
assert "github.com/stretchr/testify/assert"
|
||||||
@@ -312,7 +312,7 @@ func TestRunEvent(t *testing.T) {
|
|||||||
{workdir, "workflow_dispatch-scalar", "workflow_dispatch", "", platforms, secrets},
|
{workdir, "workflow_dispatch-scalar", "workflow_dispatch", "", platforms, secrets},
|
||||||
{workdir, "workflow_dispatch-scalar-composite-action", "workflow_dispatch", "", platforms, secrets},
|
{workdir, "workflow_dispatch-scalar-composite-action", "workflow_dispatch", "", platforms, secrets},
|
||||||
{workdir, "job-needs-context-contains-result", "push", "", platforms, secrets},
|
{workdir, "job-needs-context-contains-result", "push", "", platforms, secrets},
|
||||||
{workdir, "container-volumes", "push", "", platforms, secrets},
|
{"../model/testdata", "container-volumes", "push", "", platforms, secrets},
|
||||||
{workdir, "path-handling", "push", "", platforms, secrets},
|
{workdir, "path-handling", "push", "", platforms, secrets},
|
||||||
{workdir, "do-not-leak-step-env-in-composite", "push", "", platforms, secrets},
|
{workdir, "do-not-leak-step-env-in-composite", "push", "", platforms, secrets},
|
||||||
{workdir, "set-env-step-env-override", "push", "", platforms, secrets},
|
{workdir, "set-env-step-env-override", "push", "", platforms, secrets},
|
||||||
|
|||||||
@@ -15,9 +15,8 @@ import (
|
|||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
|
"gitea.com/gitea/runner/act/exprparser"
|
||||||
"gitea.dev/actions-proto-go/pkg/exprparser"
|
"gitea.com/gitea/runner/act/model"
|
||||||
"gitea.dev/actions-proto-go/pkg/model"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type step interface {
|
type step interface {
|
||||||
|
|||||||
@@ -16,8 +16,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/act/model"
|
||||||
"gitea.dev/actions-proto-go/pkg/model"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type stepActionLocal struct {
|
type stepActionLocal struct {
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
"gitea.dev/actions-proto-go/pkg/model"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
"go.yaml.in/yaml/v4"
|
"go.yaml.in/yaml/v4"
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ import (
|
|||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/common/git"
|
"gitea.com/gitea/runner/act/common/git"
|
||||||
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
"gitea.dev/actions-proto-go/pkg/model"
|
|
||||||
gogit "github.com/go-git/go-git/v5"
|
gogit "github.com/go-git/go-git/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -236,10 +236,10 @@ func (sar *stepActionRemote) toolkitBundles() (string, []string) {
|
|||||||
return dir, actionScriptPaths(filepath.Join(dir, sar.remoteAction.Path), sar.action)
|
return dir, actionScriptPaths(filepath.Join(dir, sar.remoteAction.Path), sar.action)
|
||||||
}
|
}
|
||||||
|
|
||||||
// patchActionToolkit edits the bundled toolkit so it works against Gitea, which lets the cache
|
// patchActionToolkit edits the bundled toolkit so it works against Gitea: the artifact actions
|
||||||
// client use the v2 API this runner serves. A no-op unless the runner serves it.
|
// stop refusing, and the cache client keeps to the cache server whichever API version it picks.
|
||||||
func (sar *stepActionRemote) patchActionToolkit(ctx context.Context) error {
|
func (sar *stepActionRemote) patchActionToolkit(ctx context.Context) error {
|
||||||
if sar.RunContext.GetEnv()[CacheServiceV2Env] != "" {
|
if sar.RunContext.Config.PatchToolkit {
|
||||||
dir, scripts := sar.toolkitBundles()
|
dir, scripts := sar.toolkitBundles()
|
||||||
patchToolkit(ctx, dir, scripts)
|
patchToolkit(ctx, dir, scripts)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ import (
|
|||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/common/git"
|
"gitea.com/gitea/runner/act/common/git"
|
||||||
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
"gitea.dev/actions-proto-go/pkg/model"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import (
|
|||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
"gitea.dev/actions-proto-go/pkg/model"
|
|
||||||
"github.com/kballard/go-shellquote"
|
"github.com/kballard/go-shellquote"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
"gitea.dev/actions-proto-go/pkg/model"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ package runner
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"gitea.dev/actions-proto-go/pkg/model"
|
"gitea.com/gitea/runner/act/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
type stepFactory interface {
|
type stepFactory interface {
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ package runner
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.dev/actions-proto-go/pkg/model"
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ import (
|
|||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
"gitea.com/gitea/runner/act/lookpath"
|
"gitea.com/gitea/runner/act/lookpath"
|
||||||
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
"gitea.dev/actions-proto-go/pkg/model"
|
|
||||||
"github.com/kballard/go-shellquote"
|
"github.com/kballard/go-shellquote"
|
||||||
yaml "go.yaml.in/yaml/v4"
|
yaml "go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
"gitea.dev/actions-proto-go/pkg/model"
|
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
"gitea.dev/actions-proto-go/pkg/model"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
"gitea.dev/actions-proto-go/pkg/model"
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
|
|||||||
@@ -13,8 +13,7 @@ import (
|
|||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/common/git"
|
"gitea.com/gitea/runner/act/common/git"
|
||||||
|
"gitea.com/gitea/runner/act/model"
|
||||||
"gitea.dev/actions-proto-go/pkg/model"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Actions bundle the @actions toolkit into their own JavaScript, and two of its lines keep it
|
// Actions bundle the @actions toolkit into their own JavaScript, and two of its lines keep it
|
||||||
|
|||||||
@@ -5,11 +5,15 @@ package runner
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -59,41 +63,65 @@ func bundleFromGitHub(t *testing.T, repo, ref, path string) string {
|
|||||||
return bundle
|
return bundle
|
||||||
}
|
}
|
||||||
|
|
||||||
// runCacheAction runs one entrypoint the way a job would: a real Gitea server URL, and a results
|
// jobEnv is the environment a job gets from this runner, which is what decides where an action's
|
||||||
// URL that points at Gitea rather than at the runner. Nothing about the environment is rewritten,
|
// toolkit looks for the cache and artifact services.
|
||||||
// so only the patch can make the client choose v2 and find the cache server.
|
type jobEnv struct {
|
||||||
func runCacheAction(t *testing.T, script, workspace, runnerTemp, cacheURL, token, key string) string {
|
workspace, runnerTemp string
|
||||||
|
cacheURL, resultsURL string
|
||||||
|
token string
|
||||||
|
}
|
||||||
|
|
||||||
|
// runActionEntrypoint runs one action entrypoint the way a job would. Adding another action to these tests
|
||||||
|
// means downloading its entrypoint with bundleFromGitHub and calling this with its inputs, whose
|
||||||
|
// names are the ones the action's own action.yml uses.
|
||||||
|
func runActionEntrypoint(t *testing.T, script string, env jobEnv, inputs map[string]string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
state := filepath.Join(runnerTemp, "state")
|
state := filepath.Join(env.runnerTemp, "state")
|
||||||
output := filepath.Join(runnerTemp, "output")
|
output := filepath.Join(env.runnerTemp, "output")
|
||||||
for _, name := range []string{state, output} {
|
for _, name := range []string{state, output} {
|
||||||
require.NoError(t, os.WriteFile(name, nil, 0o600))
|
require.NoError(t, os.WriteFile(name, nil, 0o600))
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.CommandContext(t.Context(), "node", script)
|
cmd := exec.CommandContext(t.Context(), "node", script)
|
||||||
cmd.Dir = workspace
|
cmd.Dir = env.workspace
|
||||||
cmd.Env = append(os.Environ(),
|
cmd.Env = append(os.Environ(),
|
||||||
"INPUT_PATH=to-cache",
|
"ACTIONS_RUNTIME_TOKEN="+env.token,
|
||||||
"INPUT_KEY="+key,
|
"ACTIONS_CACHE_URL="+env.cacheURL+"/",
|
||||||
"ACTIONS_RUNTIME_TOKEN="+token,
|
"ACTIONS_RESULTS_URL="+env.resultsURL,
|
||||||
"ACTIONS_CACHE_URL="+cacheURL+"/",
|
|
||||||
// Unreachable on purpose: the artifact service lives here, the cache service must not.
|
|
||||||
"ACTIONS_RESULTS_URL=https://gitea.example",
|
|
||||||
"ACTIONS_CACHE_SERVICE_V2=true",
|
"ACTIONS_CACHE_SERVICE_V2=true",
|
||||||
"GITHUB_SERVER_URL=https://gitea.example.com",
|
"GITHUB_SERVER_URL=https://gitea.example.com",
|
||||||
|
"GITHUB_REPOSITORY=testuser/testrepo",
|
||||||
|
"GITHUB_RUN_ID=1",
|
||||||
"GITHUB_REF=refs/heads/main",
|
"GITHUB_REF=refs/heads/main",
|
||||||
"GITHUB_EVENT_NAME=push",
|
"GITHUB_EVENT_NAME=push",
|
||||||
"GITHUB_WORKSPACE="+workspace,
|
"GITHUB_WORKSPACE="+env.workspace,
|
||||||
"RUNNER_TEMP="+runnerTemp,
|
"RUNNER_TEMP="+env.runnerTemp,
|
||||||
"GITHUB_STATE="+state,
|
"GITHUB_STATE="+state,
|
||||||
"GITHUB_OUTPUT="+output,
|
"GITHUB_OUTPUT="+output,
|
||||||
)
|
)
|
||||||
|
for name, value := range inputs {
|
||||||
|
cmd.Env = append(cmd.Env, "INPUT_"+strings.ToUpper(name)+"="+value)
|
||||||
|
}
|
||||||
out, err := cmd.CombinedOutput()
|
out, err := cmd.CombinedOutput()
|
||||||
t.Logf("%s:\n%s", filepath.Base(filepath.Dir(script)), out)
|
t.Logf("%s:\n%s", filepath.Base(script), out)
|
||||||
require.NoError(t, err, "%s failed", script)
|
require.NoError(t, err, "%s failed", script)
|
||||||
return string(out)
|
return string(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// patchedAction downloads one entrypoint and patches it exactly as a downloaded action would be,
|
||||||
|
// keeping the untouched original in the sidecar beside it.
|
||||||
|
func patchedAction(t *testing.T, repo, ref, entrypoint string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
body, err := os.ReadFile(bundleFromGitHub(t, repo, ref, entrypoint))
|
||||||
|
require.NoError(t, err)
|
||||||
|
dir := tempDirPath(t)
|
||||||
|
script := filepath.Join(dir, filepath.Base(entrypoint))
|
||||||
|
require.NoError(t, os.WriteFile(script, body, 0o600))
|
||||||
|
patchToolkit(t.Context(), dir, []string{script})
|
||||||
|
return script
|
||||||
|
}
|
||||||
|
|
||||||
// tempDirPath is TempDir with symlinks resolved, because macOS hands out /var paths that resolve
|
// tempDirPath is TempDir with symlinks resolved, because macOS hands out /var paths that resolve
|
||||||
// to /private/var and the client derives archive paths relative to the workspace.
|
// to /private/var and the client derives archive paths relative to the workspace.
|
||||||
func tempDirPath(t *testing.T) string {
|
func tempDirPath(t *testing.T) string {
|
||||||
@@ -113,43 +141,52 @@ func tempDirPath(t *testing.T) string {
|
|||||||
func TestCacheServiceV2EndToEnd(t *testing.T) {
|
func TestCacheServiceV2EndToEnd(t *testing.T) {
|
||||||
requireHostTools(t, "node")
|
requireHostTools(t, "node")
|
||||||
|
|
||||||
// A stand-in action directory, patched exactly as a downloaded one would be.
|
restore := patchedAction(t, "actions/cache", actionsCacheRef, "dist/restore/index.js")
|
||||||
actionDir := tempDirPath(t)
|
save := patchedAction(t, "actions/cache", actionsCacheRef, "dist/save/index.js")
|
||||||
scripts := map[string]string{}
|
|
||||||
for _, stage := range []string{"restore", "save"} {
|
|
||||||
body, err := os.ReadFile(bundleFromGitHub(t, "actions/cache", actionsCacheRef, "dist/"+stage+"/index.js"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
scripts[stage] = filepath.Join(actionDir, stage+".js")
|
|
||||||
require.NoError(t, os.WriteFile(scripts[stage], body, 0o600))
|
|
||||||
}
|
|
||||||
patchToolkit(t.Context(), actionDir, []string{scripts["restore"], scripts["save"]})
|
|
||||||
|
|
||||||
handler, err := artifactcache.StartHandler(filepath.Join(t.TempDir(), "cache"), "127.0.0.1", 0, "", nil)
|
handler, err := artifactcache.StartHandler(filepath.Join(t.TempDir(), "cache"), "127.0.0.1", 0, "", nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
t.Cleanup(func() { _ = handler.Close() })
|
t.Cleanup(func() { _ = handler.Close() })
|
||||||
const token, repo = "e2e-runtime-token", "testuser/testrepo"
|
const token, repo = "e2e-runtime-token", "testuser/testrepo"
|
||||||
handler.RegisterJob(token, repo)
|
handler.RegisterJob(token, artifactcache.JobCredential{Repo: repo})
|
||||||
|
|
||||||
workspace, runnerTemp := tempDirPath(t), tempDirPath(t)
|
env := jobEnv{
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(workspace, "to-cache"), 0o755))
|
workspace: tempDirPath(t),
|
||||||
|
runnerTemp: tempDirPath(t),
|
||||||
|
cacheURL: handler.ExternalURL(),
|
||||||
|
// The results service is the cache server's too, which is what the runner advertises.
|
||||||
|
resultsURL: handler.ExternalURL(),
|
||||||
|
token: token,
|
||||||
|
}
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(env.workspace, "to-cache"), 0o755))
|
||||||
content := []byte("cached through the patched gate")
|
content := []byte("cached through the patched gate")
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(workspace, "to-cache", "data.txt"), content, 0o600))
|
require.NoError(t, os.WriteFile(filepath.Join(env.workspace, "to-cache", "data.txt"), content, 0o600))
|
||||||
|
|
||||||
const key = "patched-gate-key"
|
const key = "patched-gate-key"
|
||||||
missed := runCacheAction(t, scripts["restore"], workspace, runnerTemp, handler.ExternalURL(), token, key)
|
inputs := map[string]string{"path": "to-cache", "key": key}
|
||||||
|
|
||||||
|
missed := runActionEntrypoint(t, restore, env, inputs)
|
||||||
require.Contains(t, missed, "Cache service version: v2", "the patch did not take, the client stayed on v1")
|
require.Contains(t, missed, "Cache service version: v2", "the patch did not take, the client stayed on v1")
|
||||||
require.Contains(t, missed, "Cache not found for input keys: "+key)
|
require.Contains(t, missed, "Cache not found for input keys: "+key)
|
||||||
|
|
||||||
saved := runCacheAction(t, scripts["save"], workspace, runnerTemp, handler.ExternalURL(), token, key)
|
saved := runActionEntrypoint(t, save, env, inputs)
|
||||||
require.Contains(t, saved, "Cache saved with key: "+key)
|
require.Contains(t, saved, "Cache saved with key: "+key)
|
||||||
|
|
||||||
restored := tempDirPath(t)
|
env.workspace = tempDirPath(t)
|
||||||
hit := runCacheAction(t, scripts["restore"], restored, runnerTemp, handler.ExternalURL(), token, key)
|
hit := runActionEntrypoint(t, restore, env, inputs)
|
||||||
require.Contains(t, hit, "Cache restored from key: "+key)
|
require.Contains(t, hit, "Cache restored from key: "+key)
|
||||||
|
|
||||||
got, err := os.ReadFile(filepath.Join(restored, "to-cache", "data.txt"))
|
got, err := os.ReadFile(filepath.Join(env.workspace, "to-cache", "data.txt"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, content, got)
|
assert.Equal(t, content, got)
|
||||||
|
|
||||||
|
// Untouched, the same client takes a Gitea host for GHES and stays on v1, which reaches the
|
||||||
|
// cache server on its own address. That is what a runner without a results service of its own
|
||||||
|
// leaves its jobs with, so it has to round trip too.
|
||||||
|
env.workspace = tempDirPath(t)
|
||||||
|
v1 := runActionEntrypoint(t, filepath.Join(sidecarDir(filepath.Dir(restore)), "index.js"), env, inputs)
|
||||||
|
require.Contains(t, v1, "Cache service version: v1")
|
||||||
|
require.Contains(t, v1, "Cache restored from key: "+key)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The gate and the URL getter are separate functions, and a bundler may put either first: the gap
|
// The gate and the URL getter are separate functions, and a bundler may put either first: the gap
|
||||||
@@ -158,19 +195,32 @@ func TestCacheServiceV2EndToEnd(t *testing.T) {
|
|||||||
// toolkit, patched but not run, is what keeps a future release from quietly matching only one of
|
// toolkit, patched but not run, is what keeps a future release from quietly matching only one of
|
||||||
// the two shapes and leaving every cache on v1.
|
// the two shapes and leaving every cache on v1.
|
||||||
func TestToolkitPatchAcrossActions(t *testing.T) {
|
func TestToolkitPatchAcrossActions(t *testing.T) {
|
||||||
for _, tc := range []struct{ repo, ref, path string }{
|
for _, tc := range []struct {
|
||||||
{"actions/setup-go", "v7.0.0", "dist/setup/index.js"},
|
repo, ref, path string
|
||||||
{"actions/setup-node", "v6.0.0", "dist/cache-save/index.js"},
|
wantPatched bool
|
||||||
{"actions/setup-python", "v6.0.0", "dist/setup/index.js"},
|
}{
|
||||||
{"ruby/setup-ruby", "v1.271.0", "dist/index.js"},
|
// The cache toolkit, in each bundler shape and from a spread of ecosystems, including the
|
||||||
{"pnpm/action-setup", "v6.0.9", "dist/index.js"},
|
// actions that drive a Go or a Rust cache client of their own.
|
||||||
|
{"actions/cache", actionsCacheRef, "dist/restore/index.js", true},
|
||||||
|
{"actions/setup-node", "v7.0.0", "dist/cache-save/index.js", true},
|
||||||
|
{"actions/setup-python", "v7.0.0", "dist/setup/index.js", true},
|
||||||
|
{"actions/setup-go", "v7.0.0", "dist/setup/index.js", true},
|
||||||
|
{"actions/setup-java", "v5.7.0", "dist/setup/index.js", true},
|
||||||
|
{"ruby/setup-ruby", "v1.321.0", "dist/index.js", true},
|
||||||
|
{"pnpm/action-setup", "v6.0.9", "dist/index.js", true},
|
||||||
|
{"oven-sh/setup-bun", "v2.2.0", "dist/setup/index.js", true},
|
||||||
|
{"Swatinem/rust-cache", "v2.9.1", "dist/restore/index.js", true},
|
||||||
|
{"docker/build-push-action", "v7.3.0", "dist/index.cjs", true},
|
||||||
// The artifact toolkit, where the gate is a refusal and there is nothing to redirect.
|
// The artifact toolkit, where the gate is a refusal and there is nothing to redirect.
|
||||||
// v4.4.0 is the first release whose gate carries the localhost test this matches; the
|
// v4.4.0 is the first release whose gate carries the localhost test this matches; the
|
||||||
// releases before it refuse in a shape the runner leaves alone.
|
// releases before it refuse in a shape the runner leaves alone.
|
||||||
{"actions/upload-artifact", "v4.4.0", "dist/upload/index.js"},
|
{"actions/upload-artifact", "v4.4.0", "dist/upload/index.js", true},
|
||||||
{"actions/upload-artifact", "v7.0.1", "dist/upload/index.js"},
|
{"actions/upload-artifact", "v7.0.1", "dist/upload/index.js", true},
|
||||||
{"actions/download-artifact", "v6.0.0", "dist/index.js"},
|
{"actions/download-artifact", "v8.0.1", "dist/index.js", true},
|
||||||
{"oven-sh/setup-bun", "v2.2.0", "dist/setup/index.js"},
|
// Neither toolkit's gate, so these have to come back byte for byte. sccache-action is the
|
||||||
|
// one that exports ACTIONS_CACHE_SERVICE_V2 itself, for the Rust client it installs.
|
||||||
|
{"actions/checkout", "v7.0.1", "dist/index.js", false},
|
||||||
|
{"mozilla-actions/sccache-action", "v0.0.11", "dist/setup/index.js", false},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.repo+"@"+tc.ref, func(t *testing.T) {
|
t.Run(tc.repo+"@"+tc.ref, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -179,7 +229,11 @@ func TestToolkitPatchAcrossActions(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
out, patched := patchedBundle(data)
|
out, patched := patchedBundle(data)
|
||||||
assert.True(t, patched, "the version gate was not patched")
|
require.Equal(t, tc.wantPatched, patched)
|
||||||
|
if !tc.wantPatched {
|
||||||
|
assert.Equal(t, data, out, "an untouched bundle must come back byte for byte")
|
||||||
|
return
|
||||||
|
}
|
||||||
assert.NotContains(t, string(out), ".LOCALHOST", "a copy of the gate was missed")
|
assert.NotContains(t, string(out), ".LOCALHOST", "a copy of the gate was missed")
|
||||||
|
|
||||||
if !strings.Contains(string(data), CacheServiceV2Env) {
|
if !strings.Contains(string(data), CacheServiceV2Env) {
|
||||||
@@ -194,3 +248,121 @@ func TestToolkitPatchAcrossActions(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The stock artifact actions refuse on a Gitea host until the gate is opened, and then they talk
|
||||||
|
// to the results service, which is this runner's cache server forwarding the artifact half on to
|
||||||
|
// Gitea. Running the real upload-artifact against a stand-in Gitea covers both halves at once:
|
||||||
|
// the patch, and the forwarding the job's registration set up.
|
||||||
|
func TestUploadArtifactThroughTheResultsService(t *testing.T) {
|
||||||
|
requireHostTools(t, "node")
|
||||||
|
|
||||||
|
var called []string
|
||||||
|
var zipped []byte
|
||||||
|
gitea := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
method := path.Base(r.URL.Path)
|
||||||
|
called = append(called, method)
|
||||||
|
w.Header().Set("x-ms-request-id", "stub")
|
||||||
|
switch method {
|
||||||
|
case "CreateArtifact":
|
||||||
|
_, _ = io.WriteString(w, `{"ok":true,"signed_upload_url":"http://`+r.Host+
|
||||||
|
`/twirp/github.actions.results.api.v1.ArtifactService/UploadArtifact?sig=x"}`)
|
||||||
|
case "FinalizeArtifact":
|
||||||
|
_, _ = io.WriteString(w, `{"ok":true,"artifact_id":"1"}`)
|
||||||
|
case "ListArtifacts":
|
||||||
|
_, _ = io.WriteString(w, `{"artifacts":[{"workflow_run_backend_id":"11",`+
|
||||||
|
`"workflow_job_run_backend_id":"22","database_id":"1","name":"an-artifact","size":"`+
|
||||||
|
strconv.Itoa(len(zipped))+`"}]}`)
|
||||||
|
case "GetSignedArtifactURL":
|
||||||
|
_, _ = io.WriteString(w, `{"signed_url":"http://`+r.Host+`/download"}`)
|
||||||
|
case "download":
|
||||||
|
w.Header().Set("Content-Type", "application/zip")
|
||||||
|
_, _ = w.Write(zipped)
|
||||||
|
default: // the zip on its way up, in the blocks the Azure protocol puts it in
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
switch r.URL.Query().Get("comp") {
|
||||||
|
case "block":
|
||||||
|
zipped = append(zipped, body...)
|
||||||
|
case "blocklist": // the ordering document, not content
|
||||||
|
default:
|
||||||
|
zipped = body
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer gitea.Close()
|
||||||
|
|
||||||
|
handler, err := artifactcache.StartHandler(filepath.Join(t.TempDir(), "cache"), "127.0.0.1", 0, "", nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = handler.Close() })
|
||||||
|
// The artifact client decodes the runtime token for the run ids it puts in its requests, where
|
||||||
|
// the cache client only presents it, so this one has to be shaped like Gitea's.
|
||||||
|
token := "e30." + base64.RawURLEncoding.EncodeToString([]byte(`{"scp":"Actions.Results:11:22"}`)) + ".sig"
|
||||||
|
defer handler.RegisterJob(token, artifactcache.JobCredential{Repo: "testuser/testrepo", Results: gitea.URL})()
|
||||||
|
|
||||||
|
upload := patchedAction(t, "actions/upload-artifact", "v7.0.1", "dist/upload/index.js")
|
||||||
|
|
||||||
|
env := jobEnv{
|
||||||
|
workspace: tempDirPath(t),
|
||||||
|
runnerTemp: tempDirPath(t),
|
||||||
|
cacheURL: handler.ExternalURL(),
|
||||||
|
resultsURL: handler.ExternalURL(),
|
||||||
|
token: token,
|
||||||
|
}
|
||||||
|
uploaded := []byte("through the results service")
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(env.workspace, "artifact.txt"), uploaded, 0o600))
|
||||||
|
|
||||||
|
out := runActionEntrypoint(t, upload, env, map[string]string{
|
||||||
|
"name": "an-artifact", "path": "artifact.txt", "if-no-files-found": "error",
|
||||||
|
"retention-days": "0", "compression-level": "6", "overwrite": "false",
|
||||||
|
"include-hidden-files": "false", "archive": "true",
|
||||||
|
})
|
||||||
|
|
||||||
|
require.Contains(t, out, "has been successfully uploaded")
|
||||||
|
|
||||||
|
// And back down again: listing and downloading go the same way, and the signed URL the
|
||||||
|
// artifact service hands out is fetched straight from it.
|
||||||
|
download := patchedAction(t, "actions/download-artifact", "v8.0.1", "dist/index.js")
|
||||||
|
env.workspace = tempDirPath(t)
|
||||||
|
out = runActionEntrypoint(t, download, env, map[string]string{
|
||||||
|
"name": "an-artifact", "path": "downloaded", "merge-multiple": "false",
|
||||||
|
"skip-decompress": "false", "include-hidden-files": "false", "github-token": "",
|
||||||
|
})
|
||||||
|
|
||||||
|
require.Contains(t, out, "Artifact download completed")
|
||||||
|
assert.Subset(t, called,
|
||||||
|
[]string{"CreateArtifact", "UploadArtifact", "FinalizeArtifact", "ListArtifacts", "GetSignedArtifactURL"},
|
||||||
|
"the artifact service was not reached through the cache server")
|
||||||
|
got, err := os.ReadFile(filepath.Join(env.workspace, "downloaded", "artifact.txt"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, uploaded, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The setup actions carry the same toolkit and reach the same service, from a key of their own
|
||||||
|
// making. setup-node is the cheapest of them to run: given a lockfile and no version to install,
|
||||||
|
// it does the cache lookup and nothing else.
|
||||||
|
func TestSetupActionFindsTheCacheService(t *testing.T) {
|
||||||
|
requireHostTools(t, "node", "npm")
|
||||||
|
|
||||||
|
setup := patchedAction(t, "actions/setup-node", "v7.0.0", "dist/setup/index.js")
|
||||||
|
|
||||||
|
handler, err := artifactcache.StartHandler(filepath.Join(t.TempDir(), "cache"), "127.0.0.1", 0, "", nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = handler.Close() })
|
||||||
|
const token = "setup-runtime-token"
|
||||||
|
defer handler.RegisterJob(token, artifactcache.JobCredential{Repo: "testuser/testrepo"})()
|
||||||
|
|
||||||
|
env := jobEnv{
|
||||||
|
workspace: tempDirPath(t),
|
||||||
|
runnerTemp: tempDirPath(t),
|
||||||
|
cacheURL: handler.ExternalURL(),
|
||||||
|
resultsURL: handler.ExternalURL(),
|
||||||
|
token: token,
|
||||||
|
}
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(env.workspace, "package-lock.json"),
|
||||||
|
[]byte(`{"lockfileVersion":3}`), 0o600))
|
||||||
|
|
||||||
|
out := runActionEntrypoint(t, setup, env, map[string]string{"cache": "npm"})
|
||||||
|
|
||||||
|
require.Contains(t, out, "Cache service version: v2")
|
||||||
|
require.Contains(t, out, "npm cache is not found", "the lookup did not reach the cache server")
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.dev/actions-proto-go/pkg/model"
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -282,7 +283,7 @@ func TestPatchBundleAfterTheActionMoved(t *testing.T) {
|
|||||||
// The wiring: a step patches its own bundles only when the runner serves the v2 API, and a step
|
// The wiring: a step patches its own bundles only when the runner serves the v2 API, and a step
|
||||||
// that fails gets them back. The action's path inside its repository is part of where they live.
|
// that fails gets them back. The action's path inside its repository is part of where they live.
|
||||||
func TestStepActionRemoteToolkitPatch(t *testing.T) {
|
func TestStepActionRemoteToolkitPatch(t *testing.T) {
|
||||||
newStep := func(t *testing.T, env map[string]string) (*stepActionRemote, string) {
|
newStep := func(t *testing.T, patch bool) (*stepActionRemote, string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
sar := &stepActionRemote{
|
sar := &stepActionRemote{
|
||||||
@@ -290,8 +291,7 @@ func TestStepActionRemoteToolkitPatch(t *testing.T) {
|
|||||||
remoteAction: &remoteAction{Org: "owner", Repo: "repo", Path: "sub", Ref: "v1"},
|
remoteAction: &remoteAction{Org: "owner", Repo: "repo", Path: "sub", Ref: "v1"},
|
||||||
action: &model.Action{Runs: model.ActionRuns{Using: "node20", Main: "index.js"}},
|
action: &model.Action{Runs: model.ActionRuns{Using: "node20", Main: "index.js"}},
|
||||||
RunContext: &RunContext{
|
RunContext: &RunContext{
|
||||||
Env: env,
|
Config: &Config{ActionCacheDir: t.TempDir(), PatchToolkit: patch},
|
||||||
Config: &Config{ActionCacheDir: t.TempDir()},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
script := filepath.Join(sar.actionDir(), "sub", "index.js")
|
script := filepath.Join(sar.actionDir(), "sub", "index.js")
|
||||||
@@ -300,8 +300,8 @@ func TestStepActionRemoteToolkitPatch(t *testing.T) {
|
|||||||
return sar, script
|
return sar, script
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Run("left alone when the runner does not serve the v2 API", func(t *testing.T) {
|
t.Run("left alone when the runner does not patch", func(t *testing.T) {
|
||||||
sar, script := newStep(t, map[string]string{})
|
sar, script := newStep(t, false)
|
||||||
require.NoError(t, sar.patchActionToolkit(t.Context()))
|
require.NoError(t, sar.patchActionToolkit(t.Context()))
|
||||||
|
|
||||||
body, err := os.ReadFile(script)
|
body, err := os.ReadFile(script)
|
||||||
@@ -310,7 +310,7 @@ func TestStepActionRemoteToolkitPatch(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("patched, and put back when the step fails", func(t *testing.T) {
|
t.Run("patched, and put back when the step fails", func(t *testing.T) {
|
||||||
sar, script := newStep(t, map[string]string{CacheServiceV2Env: "true"})
|
sar, script := newStep(t, true)
|
||||||
require.NoError(t, sar.patchActionToolkit(t.Context()))
|
require.NoError(t, sar.patchActionToolkit(t.Context()))
|
||||||
|
|
||||||
body, err := os.ReadFile(script)
|
body, err := os.ReadFile(script)
|
||||||
|
|||||||
30
go.mod
30
go.mod
@@ -5,31 +5,30 @@ go 1.26.0
|
|||||||
require (
|
require (
|
||||||
connectrpc.com/connect v1.20.0
|
connectrpc.com/connect v1.20.0
|
||||||
dario.cat/mergo v1.0.2
|
dario.cat/mergo v1.0.2
|
||||||
gitea.dev/actions-proto-go v0.6.1-0.20260804005058-906483894e64
|
gitea.dev/actions-proto-go v0.6.0
|
||||||
github.com/Masterminds/semver v1.5.0
|
|
||||||
github.com/avast/retry-go/v5 v5.0.0
|
github.com/avast/retry-go/v5 v5.0.0
|
||||||
github.com/containerd/errdefs v1.0.0
|
github.com/containerd/errdefs v1.0.0
|
||||||
github.com/creack/pty v1.1.24
|
github.com/creack/pty v1.1.24
|
||||||
github.com/distribution/reference v0.6.0
|
github.com/distribution/reference v0.6.0
|
||||||
github.com/docker/cli v29.6.2+incompatible
|
github.com/docker/cli v29.6.2+incompatible
|
||||||
github.com/docker/go-connections v0.7.0
|
github.com/docker/go-connections v0.8.1
|
||||||
github.com/go-git/go-billy/v5 v5.9.0
|
github.com/go-git/go-billy/v5 v5.9.1
|
||||||
github.com/go-git/go-git/v5 v5.19.1
|
github.com/go-git/go-git/v5 v5.19.1
|
||||||
github.com/gobwas/glob v0.2.3
|
github.com/gobwas/glob v0.2.3
|
||||||
github.com/google/go-cmp v0.7.0
|
github.com/google/go-cmp v0.7.0
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/julienschmidt/httprouter v1.3.0
|
github.com/julienschmidt/httprouter v1.3.0
|
||||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
|
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
|
||||||
github.com/mattn/go-isatty v0.0.23
|
github.com/mattn/go-isatty v0.0.24
|
||||||
github.com/moby/go-archive v0.2.0
|
github.com/moby/go-archive v0.2.1
|
||||||
github.com/moby/moby/api v1.55.0
|
github.com/moby/moby/api v1.55.0
|
||||||
github.com/moby/moby/client v0.5.0
|
github.com/moby/moby/client v0.5.1
|
||||||
github.com/moby/patternmatcher v0.6.1
|
github.com/moby/patternmatcher v0.6.1
|
||||||
github.com/opencontainers/image-spec v1.1.1
|
github.com/opencontainers/image-spec v1.1.1
|
||||||
github.com/opencontainers/selinux v1.15.1
|
github.com/opencontainers/selinux v1.15.1
|
||||||
github.com/pkg/errors v0.9.1
|
github.com/prometheus/client_golang v1.24.1
|
||||||
github.com/prometheus/client_golang v1.24.0
|
|
||||||
github.com/prometheus/client_model v0.6.2
|
github.com/prometheus/client_model v0.6.2
|
||||||
|
github.com/rhysd/actionlint v1.7.12
|
||||||
github.com/sirupsen/logrus v1.9.4
|
github.com/sirupsen/logrus v1.9.4
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
github.com/spf13/pflag v1.0.10
|
github.com/spf13/pflag v1.0.10
|
||||||
@@ -37,7 +36,7 @@ require (
|
|||||||
github.com/timshannon/bolthold v0.0.0-20240314194003-30aac6950928
|
github.com/timshannon/bolthold v0.0.0-20240314194003-30aac6950928
|
||||||
go.etcd.io/bbolt v1.5.0
|
go.etcd.io/bbolt v1.5.0
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.3
|
go.yaml.in/yaml/v4 v4.0.0-rc.3
|
||||||
golang.org/x/net v0.56.0
|
golang.org/x/net v0.57.0
|
||||||
golang.org/x/sys v0.47.0
|
golang.org/x/sys v0.47.0
|
||||||
golang.org/x/term v0.45.0
|
golang.org/x/term v0.45.0
|
||||||
golang.org/x/text v0.40.0
|
golang.org/x/text v0.40.0
|
||||||
@@ -73,22 +72,21 @@ require (
|
|||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||||
github.com/kevinburke/ssh_config v1.6.0 // indirect
|
github.com/kevinburke/ssh_config v1.6.0 // indirect
|
||||||
github.com/klauspost/compress v1.19.0 // indirect
|
github.com/klauspost/compress v1.19.1 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.21 // indirect
|
github.com/mattn/go-runewidth v0.0.21 // indirect
|
||||||
github.com/mattn/go-shellwords v1.0.12 // indirect
|
github.com/mattn/go-shellwords v1.0.12 // indirect
|
||||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||||
github.com/moby/sys/sequential v0.6.0 // indirect
|
github.com/moby/sys/sequential v0.7.0 // indirect
|
||||||
github.com/moby/sys/user v0.4.0 // indirect
|
github.com/moby/sys/user v0.4.1 // indirect
|
||||||
github.com/moby/sys/userns v0.1.0 // indirect
|
github.com/moby/sys/userns v0.1.0 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||||
github.com/pjbgf/sha1cd v0.6.0 // indirect
|
github.com/pjbgf/sha1cd v0.6.0 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/prometheus/common v0.70.0 // indirect
|
github.com/prometheus/common v0.70.1 // indirect
|
||||||
github.com/prometheus/procfs v0.21.1 // indirect
|
github.com/prometheus/procfs v0.21.1 // indirect
|
||||||
github.com/rhysd/actionlint v1.7.12 // indirect
|
|
||||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||||
github.com/sergi/go-diff v1.4.0 // indirect
|
github.com/sergi/go-diff v1.4.0 // indirect
|
||||||
github.com/skeema/knownhosts v1.3.2 // indirect
|
github.com/skeema/knownhosts v1.3.2 // indirect
|
||||||
@@ -105,7 +103,7 @@ require (
|
|||||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
|
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/crypto v0.53.0 // indirect
|
golang.org/x/crypto v0.54.0 // indirect
|
||||||
golang.org/x/sync v0.22.0 // indirect
|
golang.org/x/sync v0.22.0 // indirect
|
||||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
|||||||
54
go.sum
54
go.sum
@@ -4,12 +4,10 @@ cyphar.com/go-pathrs v0.2.3 h1:0pH8gep37wB0BgaXrEaN1OtZhUMeS7VvaejSr6i822o=
|
|||||||
cyphar.com/go-pathrs v0.2.3/go.mod h1:y8f1EMG7r+hCuFf/rXsKqMJrJAUoADZGNh5/vZPKcGc=
|
cyphar.com/go-pathrs v0.2.3/go.mod h1:y8f1EMG7r+hCuFf/rXsKqMJrJAUoADZGNh5/vZPKcGc=
|
||||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||||
gitea.dev/actions-proto-go v0.6.1-0.20260804005058-906483894e64 h1:ZD+XdGu5hbO8t3VIXMLHxADRUhLlZxSQoSxgKiktQE8=
|
gitea.dev/actions-proto-go v0.6.0 h1:gjllYQ5vmwlkqOeofTQu5qKTZpmf7kWsafoHvoPCSzY=
|
||||||
gitea.dev/actions-proto-go v0.6.1-0.20260804005058-906483894e64/go.mod h1:u7ERHK63QBsVOcJ4nD0EFZsEmhlovEo1muSOlKYqIRY=
|
gitea.dev/actions-proto-go v0.6.0/go.mod h1:p4RX+D9oqiEEzzkPMXscw2CmaGuYFPWFc6xIOmDNDqs=
|
||||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
|
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
|
||||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
|
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
|
||||||
github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww=
|
|
||||||
github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y=
|
|
||||||
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
||||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
@@ -51,8 +49,8 @@ github.com/docker/cli v29.6.2+incompatible h1:/bjePvcbbFTnRrMfWJBY7AjfICdsiLVgHn
|
|||||||
github.com/docker/cli v29.6.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
github.com/docker/cli v29.6.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||||
github.com/docker/docker-credential-helpers v0.9.6 h1:cT2PbRPSlnMmNTfT2TDMXRyQ1KMWHG7xoTLBcn1ZNv0=
|
github.com/docker/docker-credential-helpers v0.9.6 h1:cT2PbRPSlnMmNTfT2TDMXRyQ1KMWHG7xoTLBcn1ZNv0=
|
||||||
github.com/docker/docker-credential-helpers v0.9.6/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c=
|
github.com/docker/docker-credential-helpers v0.9.6/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c=
|
||||||
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
|
github.com/docker/go-connections v0.8.1 h1:JibmG5hULs5qXSr/cp/w3Pw5fZuStt4MOHMUExb29/M=
|
||||||
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
|
github.com/docker/go-connections v0.8.1/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
|
||||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||||
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
|
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
|
||||||
@@ -67,8 +65,8 @@ github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
|||||||
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
||||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
|
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
|
||||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
|
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
|
||||||
github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA=
|
github.com/go-git/go-billy/v5 v5.9.1 h1:8U73XiOTfINdItHVa6z4Gv7ToObcZ6grkqQbLryLCdA=
|
||||||
github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw=
|
github.com/go-git/go-billy/v5 v5.9.1/go.mod h1:ExsU+jcGwXTBOnyilvAnEM1wug1IxHr4yP2ZXsNRtV0=
|
||||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
|
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
|
||||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
|
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
|
||||||
github.com/go-git/go-git/v5 v5.19.1 h1:nX27AnaU43/K5bKktKwgBmR9lawoYVe1Ckg0rgzzN00=
|
github.com/go-git/go-git/v5 v5.19.1 h1:nX27AnaU43/K5bKktKwgBmR9lawoYVe1Ckg0rgzzN00=
|
||||||
@@ -102,8 +100,8 @@ github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNU
|
|||||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
||||||
github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY=
|
github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY=
|
||||||
github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
|
github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
|
||||||
github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ=
|
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
|
||||||
github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
@@ -117,26 +115,26 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
|
|||||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ=
|
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||||
github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||||
github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
|
github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
|
||||||
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||||
github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk=
|
github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk=
|
||||||
github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
|
github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
|
||||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||||
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
|
github.com/moby/go-archive v0.2.1 h1:fAa0wUS/ikZKyx7o/1fhUYmhZ7RgpthdeoDhJvunTLc=
|
||||||
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
|
github.com/moby/go-archive v0.2.1/go.mod h1:Npdv43fFqlhZW7Xo8fbm3ZMYFvAGNviUPqX21VERbcE=
|
||||||
github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc=
|
github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc=
|
||||||
github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
|
github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
|
||||||
github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc=
|
github.com/moby/moby/client v0.5.1 h1:tYNaJno4c0HXz12y5BiqEDy0rVTYkWzI26lGvnTMiJw=
|
||||||
github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s=
|
github.com/moby/moby/client v0.5.1/go.mod h1:odLstlZ6uSnfvAgVxMpvgmb8SUdd+siH2T0GBuxVAlM=
|
||||||
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
|
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
|
||||||
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
||||||
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
|
github.com/moby/sys/sequential v0.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8=
|
||||||
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
|
github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o=
|
||||||
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
|
github.com/moby/sys/user v0.4.1 h1:RgjRlaDKi/Xmyrz4t8lyzXT6v2ooFeO/7xtchmhVWE0=
|
||||||
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
|
github.com/moby/sys/user v0.4.1/go.mod h1:E9QsW5WRe1kUAf7kW8hXKwu1uhsZEAdPLYHYSDudF4Y=
|
||||||
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
|
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
|
||||||
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
@@ -155,12 +153,12 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
|||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/prometheus/client_golang v1.24.0 h1:5XStIklKuAtJSNpdD3s8XJj/Yv78IQmE1kbNk87JrAI=
|
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
|
||||||
github.com/prometheus/client_golang v1.24.0/go.mod h1:QcsNdotprC2nS4BTM2ucbcqxd2CeXTEa9jW7zHO9iDE=
|
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
|
||||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||||
github.com/prometheus/common v0.70.0 h1:bcpru3tWPVnxGnETLgOV5jbp/JRXgYEyv65CuBLAMMI=
|
github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY=
|
||||||
github.com/prometheus/common v0.70.0/go.mod h1:S/SFasQmgGiYH6C81LKCtYa8QACgthGg5zxL2udV7SY=
|
github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
|
||||||
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
|
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
|
||||||
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
|
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
|
||||||
github.com/rhysd/actionlint v1.7.12 h1:vQ4GeJN86C0QH+gTUQcs8McmK62OLT3kmakPMtEWYnY=
|
github.com/rhysd/actionlint v1.7.12 h1:vQ4GeJN86C0QH+gTUQcs8McmK62OLT3kmakPMtEWYnY=
|
||||||
@@ -233,13 +231,13 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
|||||||
go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go=
|
go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go=
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
||||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
||||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
|||||||
@@ -20,10 +20,10 @@ import (
|
|||||||
"gitea.com/gitea/runner/act/artifactcache"
|
"gitea.com/gitea/runner/act/artifactcache"
|
||||||
"gitea.com/gitea/runner/act/artifacts"
|
"gitea.com/gitea/runner/act/artifacts"
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/act/model"
|
||||||
"gitea.com/gitea/runner/act/runner"
|
"gitea.com/gitea/runner/act/runner"
|
||||||
"gitea.com/gitea/runner/internal/app/run"
|
"gitea.com/gitea/runner/internal/app/run"
|
||||||
|
|
||||||
"gitea.dev/actions-proto-go/pkg/model"
|
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
"github.com/moby/moby/api/types/container"
|
"github.com/moby/moby/api/types/container"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
@@ -416,7 +416,7 @@ func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command
|
|||||||
env[actionsRuntimeTokenEnvName] = actionsRuntimeToken
|
env[actionsRuntimeTokenEnvName] = actionsRuntimeToken
|
||||||
os.Setenv(actionsRuntimeTokenEnvName, actionsRuntimeToken)
|
os.Setenv(actionsRuntimeTokenEnvName, actionsRuntimeToken)
|
||||||
}
|
}
|
||||||
handler.RegisterJob(actionsRuntimeToken, "__local/__exec")
|
handler.RegisterJob(actionsRuntimeToken, artifactcache.JobCredential{Repo: "__local/__exec"})
|
||||||
|
|
||||||
// no service aliases: exec builds one config for the whole plan
|
// no service aliases: exec builds one config for the whole plan
|
||||||
run.BypassProxyForDockerHost(os.Getenv("DOCKER_HOST"))
|
run.BypassProxyForDockerHost(os.Getenv("DOCKER_HOST"))
|
||||||
@@ -427,6 +427,7 @@ func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command
|
|||||||
config := &runner.Config{
|
config := &runner.Config{
|
||||||
Workdir: execArgs.Workdir(),
|
Workdir: execArgs.Workdir(),
|
||||||
BindWorkdir: false,
|
BindWorkdir: false,
|
||||||
|
PatchToolkit: true, // the cache server started above is what the patch points at
|
||||||
ReuseContainers: false,
|
ReuseContainers: false,
|
||||||
ForcePull: execArgs.forcePull,
|
ForcePull: execArgs.forcePull,
|
||||||
ForceRebuild: execArgs.forceRebuild,
|
ForceRebuild: execArgs.forceRebuild,
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.dev/actions-proto-go/pkg/model"
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"go.yaml.in/yaml/v4"
|
"go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import (
|
|||||||
"gitea.com/gitea/runner/act/artifactcache"
|
"gitea.com/gitea/runner/act/artifactcache"
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
|
"gitea.com/gitea/runner/act/model"
|
||||||
"gitea.com/gitea/runner/act/runner"
|
"gitea.com/gitea/runner/act/runner"
|
||||||
"gitea.com/gitea/runner/internal/pkg/client"
|
"gitea.com/gitea/runner/internal/pkg/client"
|
||||||
"gitea.com/gitea/runner/internal/pkg/config"
|
"gitea.com/gitea/runner/internal/pkg/config"
|
||||||
@@ -33,7 +34,6 @@ import (
|
|||||||
"gitea.com/gitea/runner/internal/pkg/ver"
|
"gitea.com/gitea/runner/internal/pkg/ver"
|
||||||
|
|
||||||
"connectrpc.com/connect"
|
"connectrpc.com/connect"
|
||||||
"gitea.dev/actions-proto-go/pkg/model"
|
|
||||||
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
||||||
docker_container "github.com/moby/moby/api/types/container"
|
docker_container "github.com/moby/moby/api/types/container"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
@@ -89,7 +89,8 @@ func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client)
|
|||||||
var cacheHandler *artifactcache.Handler
|
var cacheHandler *artifactcache.Handler
|
||||||
if cfg.Cache.Enabled == nil || *cfg.Cache.Enabled {
|
if cfg.Cache.Enabled == nil || *cfg.Cache.Enabled {
|
||||||
if cfg.Cache.ExternalServer != "" {
|
if cfg.Cache.ExternalServer != "" {
|
||||||
envs["ACTIONS_CACHE_URL"] = cfg.Cache.ExternalServer
|
// The v1 client appends its path to this without a separator, so the slash is required.
|
||||||
|
envs["ACTIONS_CACHE_URL"] = strings.TrimRight(cfg.Cache.ExternalServer, "/") + "/"
|
||||||
} else {
|
} else {
|
||||||
warnIgnoredCacheSecret(cfg)
|
warnIgnoredCacheSecret(cfg)
|
||||||
handler, err := artifactcache.StartHandler(
|
handler, err := artifactcache.StartHandler(
|
||||||
@@ -109,12 +110,6 @@ func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if envs["ACTIONS_CACHE_URL"] != "" && (cfg.Cache.V2 == nil || *cfg.Cache.V2) {
|
|
||||||
// act patches the GHES check out of an action's bundle when it sees this, so the client
|
|
||||||
// uses the cache service v2 API this server also answers; see act/runner/toolkit_patch.go.
|
|
||||||
envs[runner.CacheServiceV2Env] = "true"
|
|
||||||
}
|
|
||||||
|
|
||||||
// set artifact gitea api
|
// set artifact gitea api
|
||||||
artifactGiteaAPI := strings.TrimSuffix(cli.Address(), "/") + "/api/actions_pipeline/"
|
artifactGiteaAPI := strings.TrimSuffix(cli.Address(), "/") + "/api/actions_pipeline/"
|
||||||
envs["ACTIONS_RUNTIME_URL"] = artifactGiteaAPI
|
envs["ACTIONS_RUNTIME_URL"] = artifactGiteaAPI
|
||||||
@@ -440,7 +435,13 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
|||||||
// lifetime. Only applies to the embedded cache server; when the operator
|
// lifetime. Only applies to the embedded cache server; when the operator
|
||||||
// points the runner at an external cache via cfg.Cache.ExternalServer, it
|
// points the runner at an external cache via cfg.Cache.ExternalServer, it
|
||||||
// is that server's responsibility to authenticate requests.
|
// is that server's responsibility to authenticate requests.
|
||||||
defer r.registerCacheForTask(giteaRuntimeToken, preset.Repository, reporter)()
|
revokeCache, resultsURL := r.registerCacheForTask(giteaRuntimeToken, preset.Repository, reporter)
|
||||||
|
defer revokeCache()
|
||||||
|
// A cache server that agreed to forward the artifact half is the whole results service, so
|
||||||
|
// the job is pointed at it and the v2 variable is finally true.
|
||||||
|
if resultsURL != "" {
|
||||||
|
envs["ACTIONS_RESULTS_URL"], envs[runner.CacheServiceV2Env] = resultsURL, "true"
|
||||||
|
}
|
||||||
|
|
||||||
eventJSON, err := json.Marshal(preset.Event)
|
eventJSON, err := json.Marshal(preset.Event)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -481,6 +482,7 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
|||||||
AllocatePTY: r.cfg.Runner.AllocatePTY,
|
AllocatePTY: r.cfg.Runner.AllocatePTY,
|
||||||
ActionOfflineMode: r.cfg.Cache.OfflineMode,
|
ActionOfflineMode: r.cfg.Cache.OfflineMode,
|
||||||
ActionCloneDepth: actionCloneDepth,
|
ActionCloneDepth: actionCloneDepth,
|
||||||
|
PatchToolkit: r.patchToolkit(),
|
||||||
|
|
||||||
ReuseContainers: false,
|
ReuseContainers: false,
|
||||||
ForcePull: r.cfg.Container.ForcePull,
|
ForcePull: r.cfg.Container.ForcePull,
|
||||||
@@ -553,6 +555,12 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
|||||||
return execErr
|
return execErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// patchToolkit reports whether act should edit the toolkit bundled into an action. It follows the
|
||||||
|
// cache URL, because that is what the edits point the client at; see act/runner/toolkit_patch.go.
|
||||||
|
func (r *Runner) patchToolkit() bool {
|
||||||
|
return r.envs["ACTIONS_CACHE_URL"] != "" && (r.cfg.Cache.V2 == nil || *r.cfg.Cache.V2)
|
||||||
|
}
|
||||||
|
|
||||||
// registerCacheForTask tells the cache server to accept requests authenticated
|
// registerCacheForTask tells the cache server to accept requests authenticated
|
||||||
// with the given runtime token for the duration of this task. Returns a
|
// with the given runtime token for the duration of this task. Returns a
|
||||||
// function the caller must invoke (typically via defer) to revoke the
|
// function the caller must invoke (typically via defer) to revoke the
|
||||||
@@ -565,18 +573,25 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
|||||||
// repo scoping over the network.
|
// repo scoping over the network.
|
||||||
//
|
//
|
||||||
// Safe with an empty token (older Gitea did not issue one).
|
// Safe with an empty token (older Gitea did not issue one).
|
||||||
func (r *Runner) registerCacheForTask(token, repo string, reporter *report.Reporter) func() {
|
// It also returns the URL to advertise as ACTIONS_RESULTS_URL, which is the cache server itself
|
||||||
|
// when it agreed to forward this instance's artifact service, and "" when it did not.
|
||||||
|
func (r *Runner) registerCacheForTask(token, repo string, reporter *report.Reporter) (func(), string) {
|
||||||
if token == "" {
|
if token == "" {
|
||||||
return func() {}
|
return func() {}, ""
|
||||||
|
}
|
||||||
|
cred := artifactcache.JobCredential{
|
||||||
|
Repo: repo,
|
||||||
|
Results: r.envs["ACTIONS_RESULTS_URL"], // the instance as the job would reach it
|
||||||
|
InsecureTLS: r.cfg.Runner.Insecure,
|
||||||
}
|
}
|
||||||
if r.cacheHandler != nil {
|
if r.cacheHandler != nil {
|
||||||
return r.cacheHandler.RegisterJob(token, repo)
|
return r.cacheHandler.RegisterJob(token, cred), r.cacheHandler.ResultsURL(cred)
|
||||||
}
|
}
|
||||||
if r.cfg.Cache.ExternalServer != "" && r.cfg.Cache.ExternalSecret != "" {
|
if r.cfg.Cache.ExternalServer != "" && r.cfg.Cache.ExternalSecret != "" {
|
||||||
return r.registerExternalCacheJob(token, repo, reporter)
|
return r.registerExternalCacheJob(token, cred, reporter)
|
||||||
}
|
}
|
||||||
// No cache server to register against: caching is disabled, or the built-in server failed to start.
|
// No cache server to register against: caching is disabled, or the built-in server failed to start.
|
||||||
return func() {}
|
return func() {}, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// registerExternalCacheJob POSTs to the remote cache-server's control-plane.
|
// registerExternalCacheJob POSTs to the remote cache-server's control-plane.
|
||||||
@@ -584,47 +599,54 @@ func (r *Runner) registerCacheForTask(token, repo string, reporter *report.Repor
|
|||||||
// 401 the job's requests — better than failing the whole task for a cache
|
// 401 the job's requests — better than failing the whole task for a cache
|
||||||
// outage. The warning is mirrored to the job log so users can see why their
|
// outage. The warning is mirrored to the job log so users can see why their
|
||||||
// cache calls 401, instead of having to read the runner daemon's stderr.
|
// cache calls 401, instead of having to read the runner daemon's stderr.
|
||||||
func (r *Runner) registerExternalCacheJob(token, repo string, reporter *report.Reporter) func() {
|
func (r *Runner) registerExternalCacheJob(token string, cred artifactcache.JobCredential, reporter *report.Reporter) (func(), string) {
|
||||||
base := strings.TrimRight(r.cfg.Cache.ExternalServer, "/")
|
base := strings.TrimRight(r.cfg.Cache.ExternalServer, "/")
|
||||||
if err := postInternalCache(base+"/_internal/register", r.cfg.Cache.ExternalSecret,
|
resultsURL := ""
|
||||||
map[string]string{"token": token, "repo": repo}); err != nil {
|
if body, err := postInternalCache(base+"/_internal/register", r.cfg.Cache.ExternalSecret, map[string]any{
|
||||||
|
"token": token, "repo": cred.Repo, "results": cred.Results, "insecure_tls": cred.InsecureTLS,
|
||||||
|
}); err != nil {
|
||||||
log.Warnf("cache external_server register failed (%s): %v", base, err)
|
log.Warnf("cache external_server register failed (%s): %v", base, err)
|
||||||
if reporter != nil {
|
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)
|
reporter.Logf("::warning::cache external_server register failed (%s): %v — cache requests from this job will be unauthenticated and likely return 401", base, err)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
resultsURL, _ = body["results_url"].(string) // absent from a server too old to forward
|
||||||
}
|
}
|
||||||
return func() {
|
return func() {
|
||||||
if err := postInternalCache(base+"/_internal/revoke", r.cfg.Cache.ExternalSecret,
|
if _, err := postInternalCache(base+"/_internal/revoke", r.cfg.Cache.ExternalSecret,
|
||||||
map[string]string{"token": token}); err != nil {
|
map[string]any{"token": token}); err != nil {
|
||||||
log.Warnf("cache external_server revoke failed (%s): %v", base, err)
|
log.Warnf("cache external_server revoke failed (%s): %v", base, err)
|
||||||
if reporter != nil {
|
if reporter != nil {
|
||||||
reporter.Logf("::warning::cache external_server revoke failed (%s): %v", base, err)
|
reporter.Logf("::warning::cache external_server revoke failed (%s): %v", base, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}, resultsURL
|
||||||
}
|
}
|
||||||
|
|
||||||
func postInternalCache(url, secret string, body map[string]string) error {
|
func postInternalCache(url, secret string, body map[string]any) (map[string]any, error) {
|
||||||
buf, err := json.Marshal(body)
|
buf, err := json.Marshal(body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(buf))
|
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(buf))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
req.Header.Set("Authorization", "Bearer "+secret)
|
req.Header.Set("Authorization", "Bearer "+secret)
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
client := &http.Client{Timeout: 5 * time.Second}
|
client := &http.Client{Timeout: 5 * time.Second}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode/100 != 2 {
|
if resp.StatusCode/100 != 2 {
|
||||||
return fmt.Errorf("status %d", resp.StatusCode)
|
return nil, fmt.Errorf("status %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
return nil
|
answer := map[string]any{}
|
||||||
|
// A server too old to answer with a body is not an error, it simply tells us nothing.
|
||||||
|
_ = json.NewDecoder(resp.Body).Decode(&answer)
|
||||||
|
return answer, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Runner) RunningCount() int64 {
|
func (r *Runner) RunningCount() int64 {
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -29,7 +31,7 @@ func TestRunner_registerCacheForTask(t *testing.T) {
|
|||||||
|
|
||||||
r := &Runner{cfg: emptyCfg(), cacheHandler: handler}
|
r := &Runner{cfg: emptyCfg(), cacheHandler: handler}
|
||||||
token := "run-token-123"
|
token := "run-token-123"
|
||||||
unregister := r.registerCacheForTask(token, "owner/repo", nil)
|
unregister, _ := r.registerCacheForTask(token, "owner/repo", nil)
|
||||||
|
|
||||||
base := handler.ExternalURL() + "/_apis/artifactcache"
|
base := handler.ExternalURL() + "/_apis/artifactcache"
|
||||||
probe := func() int {
|
probe := func() int {
|
||||||
@@ -53,7 +55,7 @@ func TestRunner_registerCacheForTask(t *testing.T) {
|
|||||||
func TestRunner_registerCacheForTask_NoOps(t *testing.T) {
|
func TestRunner_registerCacheForTask_NoOps(t *testing.T) {
|
||||||
t.Run("nil cacheHandler", func(t *testing.T) {
|
t.Run("nil cacheHandler", func(t *testing.T) {
|
||||||
r := &Runner{cfg: emptyCfg()}
|
r := &Runner{cfg: emptyCfg()}
|
||||||
unregister := r.registerCacheForTask("tok", "owner/repo", nil)
|
unregister, _ := r.registerCacheForTask("tok", "owner/repo", nil)
|
||||||
require.NotNil(t, unregister)
|
require.NotNil(t, unregister)
|
||||||
unregister()
|
unregister()
|
||||||
})
|
})
|
||||||
@@ -65,7 +67,7 @@ func TestRunner_registerCacheForTask_NoOps(t *testing.T) {
|
|||||||
defer handler.Close()
|
defer handler.Close()
|
||||||
|
|
||||||
r := &Runner{cfg: emptyCfg(), cacheHandler: handler}
|
r := &Runner{cfg: emptyCfg(), cacheHandler: handler}
|
||||||
unregister := r.registerCacheForTask("", "owner/repo", nil)
|
unregister, _ := r.registerCacheForTask("", "owner/repo", nil)
|
||||||
require.NotNil(t, unregister)
|
require.NotNil(t, unregister)
|
||||||
unregister()
|
unregister()
|
||||||
})
|
})
|
||||||
@@ -81,7 +83,7 @@ func TestRunner_CacheFullFlow_MatchesToolkit(t *testing.T) {
|
|||||||
|
|
||||||
r := &Runner{cfg: emptyCfg(), cacheHandler: handler}
|
r := &Runner{cfg: emptyCfg(), cacheHandler: handler}
|
||||||
token := "full-flow-token"
|
token := "full-flow-token"
|
||||||
unregister := r.registerCacheForTask(token, "owner/repo", nil)
|
unregister, _ := r.registerCacheForTask(token, "owner/repo", nil)
|
||||||
defer unregister()
|
defer unregister()
|
||||||
|
|
||||||
base := handler.ExternalURL() + "/_apis/artifactcache"
|
base := handler.ExternalURL() + "/_apis/artifactcache"
|
||||||
@@ -154,18 +156,27 @@ func decodeJSON(resp *http.Response, v any) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// End-to-end against a remote cache-server: token unknown → 401, register →
|
// End-to-end against a remote cache-server: token unknown → 401, register →
|
||||||
// reserve/upload/commit/find/download all OK, revoke → 401 again.
|
// reserve/upload/commit/find/download all OK, revoke → 401 again. Registering also names the
|
||||||
|
// instance, and the server answering with its own address is what makes a shared cache server the
|
||||||
|
// whole results service, as the built-in one is.
|
||||||
func TestRunner_ExternalCacheServer_RegisterRevoke(t *testing.T) {
|
func TestRunner_ExternalCacheServer_RegisterRevoke(t *testing.T) {
|
||||||
dir := filepath.Join(t.TempDir(), "remote-cache")
|
dir := filepath.Join(t.TempDir(), "remote-cache")
|
||||||
const secret = "shared-secret-for-tests"
|
const secret = "shared-secret-for-tests"
|
||||||
remote, err := artifactcache.StartHandler(dir, "127.0.0.1", 0, secret, nil)
|
remote, err := artifactcache.StartHandler(dir, "127.0.0.1", 0, secret, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer remote.Close()
|
defer remote.Close()
|
||||||
|
gitea := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, _ = io.WriteString(w, `{"ok":true}`)
|
||||||
|
}))
|
||||||
|
defer gitea.Close()
|
||||||
|
|
||||||
r := &Runner{cfg: &config.Config{Cache: config.Cache{
|
r := &Runner{
|
||||||
|
cfg: &config.Config{Cache: config.Cache{
|
||||||
ExternalServer: remote.ExternalURL(),
|
ExternalServer: remote.ExternalURL(),
|
||||||
ExternalSecret: secret,
|
ExternalSecret: secret,
|
||||||
}}}
|
}},
|
||||||
|
envs: map[string]string{"ACTIONS_RESULTS_URL": gitea.URL},
|
||||||
|
}
|
||||||
|
|
||||||
token := "external-task-token"
|
token := "external-task-token"
|
||||||
repo := "owner/repoX"
|
repo := "owner/repoX"
|
||||||
@@ -182,10 +193,21 @@ func TestRunner_ExternalCacheServer_RegisterRevoke(t *testing.T) {
|
|||||||
require.Equal(t, http.StatusUnauthorized, probe(),
|
require.Equal(t, http.StatusUnauthorized, probe(),
|
||||||
"token must be unknown to the remote server before registration")
|
"token must be unknown to the remote server before registration")
|
||||||
|
|
||||||
unregister := r.registerCacheForTask(token, repo, nil)
|
unregister, resultsURL := r.registerCacheForTask(token, repo, nil)
|
||||||
require.NotEqual(t, http.StatusUnauthorized, probe(),
|
require.NotEqual(t, http.StatusUnauthorized, probe(),
|
||||||
"token must be accepted after registerCacheForTask")
|
"token must be accepted after registerCacheForTask")
|
||||||
|
|
||||||
|
// The server took the results service over, so the artifact half reaches Gitea through it.
|
||||||
|
require.Equal(t, remote.ExternalURL(), resultsURL)
|
||||||
|
artifact, err := http.NewRequestWithContext(t.Context(), http.MethodPost,
|
||||||
|
resultsURL+"/twirp/github.actions.results.api.v1.ArtifactService/CreateArtifact", nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
artifact.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
forwarded, err := http.DefaultClient.Do(artifact)
|
||||||
|
require.NoError(t, err)
|
||||||
|
forwarded.Body.Close()
|
||||||
|
require.Equal(t, http.StatusOK, forwarded.StatusCode)
|
||||||
|
|
||||||
// Full reserve→upload→commit→find→download cycle, identical to what
|
// Full reserve→upload→commit→find→download cycle, identical to what
|
||||||
// @actions/cache does, against the remote (external) server.
|
// @actions/cache does, against the remote (external) server.
|
||||||
body := []byte("payload-from-task")
|
body := []byte("payload-from-task")
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ package run
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/runner"
|
"gitea.com/gitea/runner/act/runner"
|
||||||
@@ -124,21 +126,57 @@ func taskWithDefaultActionsURL(url string) *runnerv1.Task {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The cache service v2 API is announced to jobs unless it is turned off. Announcing it is what
|
// The results service is decided per task, because that is where the job's token and its
|
||||||
// makes act patch the GHES check out of an action's bundle, so the client can reach it.
|
// instance are both known. NewRunner only leaves the address Gitea serves.
|
||||||
func TestNewRunnerCacheServiceV2(t *testing.T) {
|
func TestNewRunnerCacheServiceV2(t *testing.T) {
|
||||||
announced := func(v2 *bool) string {
|
|
||||||
cfg := &config.Config{}
|
cfg := &config.Config{}
|
||||||
cfg.Cache.V2, cfg.Cache.Dir, cfg.Cache.Host = v2, t.TempDir(), "127.0.0.1"
|
cfg.Cache.Dir, cfg.Cache.Host = t.TempDir(), "127.0.0.1"
|
||||||
cli := clientmocks.NewClient(t)
|
cli := clientmocks.NewClient(t)
|
||||||
cli.On("Address").Return("https://gitea.example/").Maybe()
|
cli.On("Address").Return("https://gitea.example/").Maybe()
|
||||||
|
|
||||||
r := NewRunner(cfg, &config.Registration{Name: "runner"}, cli)
|
r := NewRunner(cfg, &config.Registration{Name: "runner"}, cli)
|
||||||
t.Cleanup(func() { _ = r.Close() })
|
t.Cleanup(func() { _ = r.Close() })
|
||||||
return r.envs[runner.CacheServiceV2Env]
|
const token = "task-token"
|
||||||
}
|
|
||||||
|
|
||||||
off := false
|
assert.Equal(t, "https://gitea.example", r.envs["ACTIONS_RESULTS_URL"])
|
||||||
assert.Equal(t, "true", announced(nil))
|
assert.Empty(t, r.envs[runner.CacheServiceV2Env], "a promise the runner has not made yet")
|
||||||
assert.Empty(t, announced(&off))
|
assert.True(t, r.patchToolkit())
|
||||||
|
|
||||||
|
// The registration is what makes it true: the cache server takes the results service over,
|
||||||
|
// having been told which instance to forward the artifact half to.
|
||||||
|
revoke, resultsURL := r.registerCacheForTask(token, "owner/repo", nil)
|
||||||
|
defer revoke()
|
||||||
|
require.Equal(t, r.cacheHandler.ExternalURL(), resultsURL)
|
||||||
|
|
||||||
|
// And what is advertised has to answer the cache service. A client without the GHES escape
|
||||||
|
// hatch, docker buildx among them, posts its cache calls at exactly this URL and nowhere else,
|
||||||
|
// so a 404 here is the failure this whole change is about.
|
||||||
|
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost,
|
||||||
|
resultsURL+"/twirp/github.actions.results.api.v1.CacheService/GetCacheEntryDownloadURL",
|
||||||
|
strings.NewReader(`{"key":"k","version":"v"}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode, "the advertised results service serves no cache service")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The v1 cache client appends its path to ACTIONS_CACHE_URL without a separator, so a configured
|
||||||
|
// server that is missing the slash would send it to a URL whose port swallows the path.
|
||||||
|
func TestNewRunnerNormalizesTheExternalCacheServer(t *testing.T) {
|
||||||
|
cfg := &config.Config{}
|
||||||
|
cfg.Cache.ExternalServer = "http://cache.local:8088//"
|
||||||
|
cli := clientmocks.NewClient(t)
|
||||||
|
cli.On("Address").Return("https://gitea.example/").Maybe()
|
||||||
|
|
||||||
|
r := NewRunner(cfg, &config.Registration{Name: "runner"}, cli)
|
||||||
|
|
||||||
|
assert.Equal(t, "http://cache.local:8088/", r.envs["ACTIONS_CACHE_URL"])
|
||||||
|
// Nothing to front the results service with, so the variable stays unset, but the bundles are
|
||||||
|
// still patched: artifacts v4 need that, and the patch keeps the cache client on the cache URL.
|
||||||
|
assert.Equal(t, "https://gitea.example", r.envs["ACTIONS_RESULTS_URL"])
|
||||||
|
assert.Empty(t, r.envs[runner.CacheServiceV2Env])
|
||||||
|
assert.True(t, r.patchToolkit())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.dev/actions-proto-go/pkg/model"
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
||||||
"go.yaml.in/yaml/v4"
|
"go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ package run
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.dev/actions-proto-go/pkg/model"
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"go.yaml.in/yaml/v4"
|
"go.yaml.in/yaml/v4"
|
||||||
|
|||||||
@@ -3,11 +3,7 @@
|
|||||||
|
|
||||||
package client
|
package client
|
||||||
|
|
||||||
import "gitea.dev/actions-proto-go/pkg/protocol"
|
|
||||||
|
|
||||||
// The headers are defined in the shared protocol package so that Gitea and the
|
|
||||||
// runner cannot drift apart.
|
|
||||||
const (
|
const (
|
||||||
UUIDHeader = protocol.UUIDHeader
|
UUIDHeader = "x-runner-uuid"
|
||||||
TokenHeader = protocol.TokenHeader
|
TokenHeader = "x-runner-token"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ cache:
|
|||||||
# Ignored when external_server is set.
|
# Ignored when external_server is set.
|
||||||
port: 0
|
port: 0
|
||||||
# URL of a shared `gitea-runner cache-server` to use instead of starting a local one.
|
# URL of a shared `gitea-runner cache-server` to use instead of starting a local one.
|
||||||
# Set on every runner that should share a cache pool. Must end with "/".
|
# Set on every runner that should share a cache pool. A trailing slash is optional.
|
||||||
# Example: "http://cache-host:8088/"
|
# Example: "http://cache-host:8088/"
|
||||||
# Requires external_secret (below) to match the value on the cache-server.
|
# Requires external_secret (below) to match the value on the cache-server.
|
||||||
external_server: ""
|
external_server: ""
|
||||||
|
|||||||
Reference in New Issue
Block a user