mirror of
https://gitea.com/gitea/runner.git
synced 2026-11-13 11:23:42 +00:00
Merge gitea/act into act/
Merges the `gitea.com/gitea/act` fork into this repository as the `act/` directory and consumes it as a local package. The `replace github.com/nektos/act => gitea.com/gitea/act` directive is removed; act's dependencies are merged into the root `go.mod`. - Imports rewritten: `github.com/nektos/act/pkg/...` → `gitea.com/gitea/act_runner/act/...` (flattened — `pkg/` boundary dropped to match the layout forgejo-runner adopted). - Dropped act's CLI (`cmd/`, `main.go`) and all upstream project files; kept the library tree + `LICENSE`. - Added `// Copyright <year> The Gitea Authors ...` / `// Copyright <year> nektos` headers to 104 `.go` files. - Pre-existing act lint violations annotated inline with `//nolint:<linter> // pre-existing issue from nektos/act`. `.golangci.yml` is unchanged vs `main`. - Makefile test target: `-race -short` (matches forgejo-runner). - Pre-existing integration test failures fixed: race in parallel executor (atomic counters); TestSetupEnv / command_test / expression_test / run_context_test updated to match gitea fork runtime; TestJobExecutor and TestActionCache gated on `testing.Short()`. Full `gitea/act` commit history is reachable via the second parent. Co-Authored-By: Claude (Opus 4.7) <[email protected]>
This commit is contained in:
commit
fab2d6ae04
1324 files changed
+294024
-43
No files matched your search
@@ -127,7 +127,7 @@ tidy-check: tidy
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
test: fmt-check security-check
|
test: fmt-check security-check
|
||||||
@$(GO) test -race -v -cover -coverprofile coverage.txt ./... && echo "\n==>\033[32m Ok\033[m\n" || exit 1
|
@$(GO) test -race -short -v -cover -coverprofile coverage.txt ./... && echo "\n==>\033[32m Ok\033[m\n" || exit 1
|
||||||
|
|
||||||
install: $(GOFILES)
|
install: $(GOFILES)
|
||||||
$(GO) install -v -tags '$(TAGS)' -ldflags '$(EXTLDFLAGS)-s -w $(LDFLAGS)'
|
$(GO) install -v -tags '$(TAGS)' -ldflags '$(EXTLDFLAGS)-s -w $(LDFLAGS)'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# act runner
|
# act runner
|
||||||
|
|
||||||
Act runner is a runner for Gitea based on [Gitea fork](https://gitea.com/gitea/act) of [act](https://github.com/nektos/act).
|
Act runner is a runner for Gitea.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
|||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2022 The Gitea Authors
|
||||||
|
Copyright (c) 2019
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2023 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
// Package artifactcache provides a cache handler for the runner.
|
||||||
|
//
|
||||||
|
// Inspired by https://github.com/sp-ricard-valverde/github-act-cache-server
|
||||||
|
//
|
||||||
|
// TODO: Authorization
|
||||||
|
// TODO: Restrictions for accessing a cache, see https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#restrictions-for-accessing-a-cache
|
||||||
|
// TODO: Force deleting cache entries, see https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#force-deleting-cache-entries
|
||||||
|
package artifactcache
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,38 @@
|
|||||||
|
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2023 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package artifactcache
|
||||||
|
|
||||||
|
type Request struct {
|
||||||
|
Key string `json:"key" `
|
||||||
|
Version string `json:"version"`
|
||||||
|
Size int64 `json:"cacheSize"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Request) ToCache() *Cache {
|
||||||
|
if c == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ret := &Cache{
|
||||||
|
Key: c.Key,
|
||||||
|
Version: c.Version,
|
||||||
|
Size: c.Size,
|
||||||
|
}
|
||||||
|
if c.Size == 0 {
|
||||||
|
// So the request comes from old versions of actions, like `actions/cache@v2`.
|
||||||
|
// It doesn't send cache size. Set it to -1 to indicate that.
|
||||||
|
ret.Size = -1
|
||||||
|
}
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
type Cache struct {
|
||||||
|
ID uint64 `json:"id" boltholdKey:"ID"`
|
||||||
|
Key string `json:"key" boltholdIndex:"Key"`
|
||||||
|
Version string `json:"version" boltholdIndex:"Version"`
|
||||||
|
Size int64 `json:"cacheSize"`
|
||||||
|
Complete bool `json:"complete" boltholdIndex:"Complete"`
|
||||||
|
UsedAt int64 `json:"usedAt" boltholdIndex:"UsedAt"`
|
||||||
|
CreatedAt int64 `json:"createdAt" boltholdIndex:"CreatedAt"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2023 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package artifactcache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Storage struct {
|
||||||
|
rootDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStorage(rootDir string) (*Storage, error) {
|
||||||
|
if err := os.MkdirAll(rootDir, 0o755); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Storage{
|
||||||
|
rootDir: rootDir,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) Exist(id uint64) (bool, error) {
|
||||||
|
name := s.filename(id)
|
||||||
|
if _, err := os.Stat(name); os.IsNotExist(err) {
|
||||||
|
return false, nil
|
||||||
|
} else if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) Write(id uint64, offset int64, reader io.Reader) error {
|
||||||
|
name := s.tempName(id, offset)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(name), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
file, err := os.Create(name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
_, err = io.Copy(file, reader)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) Commit(id uint64, size int64) (int64, error) {
|
||||||
|
defer func() {
|
||||||
|
_ = os.RemoveAll(s.tempDir(id))
|
||||||
|
}()
|
||||||
|
|
||||||
|
name := s.filename(id)
|
||||||
|
tempNames, err := s.tempNames(id)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(name), 0o755); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
file, err := os.Create(name)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
var written int64
|
||||||
|
for _, v := range tempNames {
|
||||||
|
f, err := os.Open(v)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
n, err := io.Copy(file, f)
|
||||||
|
_ = f.Close()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
written += n
|
||||||
|
}
|
||||||
|
|
||||||
|
// If size is less than 0, it means the size is unknown.
|
||||||
|
// We can't check the size of the file, just skip the check.
|
||||||
|
// It happens when the request comes from old versions of actions, like `actions/cache@v2`.
|
||||||
|
if size >= 0 && written != size {
|
||||||
|
_ = file.Close()
|
||||||
|
_ = os.Remove(name)
|
||||||
|
return 0, fmt.Errorf("broken file: %v != %v", written, size)
|
||||||
|
}
|
||||||
|
|
||||||
|
return written, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) Serve(w http.ResponseWriter, r *http.Request, id uint64) {
|
||||||
|
name := s.filename(id)
|
||||||
|
http.ServeFile(w, r, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) Remove(id uint64) {
|
||||||
|
_ = os.Remove(s.filename(id))
|
||||||
|
_ = os.RemoveAll(s.tempDir(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) filename(id uint64) string {
|
||||||
|
return filepath.Join(s.rootDir, fmt.Sprintf("%02x", id%0xff), strconv.FormatUint(id, 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) tempDir(id uint64) string {
|
||||||
|
return filepath.Join(s.rootDir, "tmp", strconv.FormatUint(id, 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) tempName(id uint64, offset int64) string {
|
||||||
|
return filepath.Join(s.tempDir(id), fmt.Sprintf("%016x", offset))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) tempNames(id uint64) ([]string, error) {
|
||||||
|
dir := s.tempDir(id)
|
||||||
|
files, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var names []string
|
||||||
|
for _, v := range files {
|
||||||
|
if !v.IsDir() {
|
||||||
|
names = append(names, filepath.Join(dir, v.Name()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names, nil
|
||||||
|
}
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
# Copied from https://github.com/actions/cache#example-cache-workflow
|
||||||
|
name: Caching Primes
|
||||||
|
|
||||||
|
on: push
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- run: env
|
||||||
|
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Cache Primes
|
||||||
|
id: cache-primes
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: prime-numbers
|
||||||
|
key: ${{ runner.os }}-primes-${{ github.run_id }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-primes
|
||||||
|
${{ runner.os }}
|
||||||
|
|
||||||
|
- name: Generate Prime Numbers
|
||||||
|
if: steps.cache-primes.outputs.cache-hit != 'true'
|
||||||
|
run: cat /proc/sys/kernel/random/uuid > prime-numbers
|
||||||
|
|
||||||
|
- name: Use Prime Numbers
|
||||||
|
run: cat prime-numbers
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2021 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package artifacts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.com/gitea/act_runner/act/common"
|
||||||
|
|
||||||
|
"github.com/julienschmidt/httprouter"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FileContainerResourceURL struct {
|
||||||
|
FileContainerResourceURL string `json:"fileContainerResourceUrl"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NamedFileContainerResourceURL struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
FileContainerResourceURL string `json:"fileContainerResourceUrl"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NamedFileContainerResourceURLResponse struct {
|
||||||
|
Count int `json:"count"`
|
||||||
|
Value []NamedFileContainerResourceURL `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContainerItem struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
ItemType string `json:"itemType"`
|
||||||
|
ContentLocation string `json:"contentLocation"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContainerItemResponse struct {
|
||||||
|
Value []ContainerItem `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResponseMessage struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WritableFile interface {
|
||||||
|
io.WriteCloser
|
||||||
|
}
|
||||||
|
|
||||||
|
type WriteFS interface {
|
||||||
|
OpenWritable(name string) (WritableFile, error)
|
||||||
|
OpenAppendable(name string) (WritableFile, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type readWriteFSImpl struct{}
|
||||||
|
|
||||||
|
func (fwfs readWriteFSImpl) Open(name string) (fs.File, error) {
|
||||||
|
return os.Open(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fwfs readWriteFSImpl) OpenWritable(name string) (WritableFile, error) {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(name), os.ModePerm); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return os.OpenFile(name, os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fwfs readWriteFSImpl) OpenAppendable(name string) (WritableFile, error) {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(name), os.ModePerm); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
file, err := os.OpenFile(name, os.O_CREATE|os.O_RDWR, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = file.Seek(0, io.SeekEnd)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return file, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var gzipExtension = ".gz__"
|
||||||
|
|
||||||
|
func safeResolve(baseDir, relPath string) string {
|
||||||
|
return filepath.Join(baseDir, filepath.Clean(filepath.Join(string(os.PathSeparator), relPath)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func uploads(router *httprouter.Router, baseDir string, fsys WriteFS) {
|
||||||
|
router.POST("/_apis/pipelines/workflows/:runId/artifacts", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
||||||
|
runID := params.ByName("runId")
|
||||||
|
|
||||||
|
json, err := json.Marshal(FileContainerResourceURL{
|
||||||
|
FileContainerResourceURL: fmt.Sprintf("http://%s/upload/%s", req.Host, runID),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = w.Write(json)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.PUT("/upload/:runId", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
||||||
|
itemPath := req.URL.Query().Get("itemPath")
|
||||||
|
runID := params.ByName("runId")
|
||||||
|
|
||||||
|
if req.Header.Get("Content-Encoding") == "gzip" {
|
||||||
|
itemPath += gzipExtension
|
||||||
|
}
|
||||||
|
|
||||||
|
safeRunPath := safeResolve(baseDir, runID)
|
||||||
|
safePath := safeResolve(safeRunPath, itemPath)
|
||||||
|
|
||||||
|
file, err := func() (WritableFile, error) {
|
||||||
|
contentRange := req.Header.Get("Content-Range")
|
||||||
|
if contentRange != "" && !strings.HasPrefix(contentRange, "bytes 0-") {
|
||||||
|
return fsys.OpenAppendable(safePath)
|
||||||
|
}
|
||||||
|
return fsys.OpenWritable(safePath)
|
||||||
|
}()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
writer, ok := file.(io.Writer)
|
||||||
|
if !ok {
|
||||||
|
panic(errors.New("File is not writable"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Body == nil {
|
||||||
|
panic(errors.New("No body given"))
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = io.Copy(writer, req.Body)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
json, err := json.Marshal(ResponseMessage{
|
||||||
|
Message: "success",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = w.Write(json)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.PATCH("/_apis/pipelines/workflows/:runId/artifacts", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
||||||
|
json, err := json.Marshal(ResponseMessage{
|
||||||
|
Message: "success",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = w.Write(json)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func downloads(router *httprouter.Router, baseDir string, fsys fs.FS) {
|
||||||
|
router.GET("/_apis/pipelines/workflows/:runId/artifacts", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
||||||
|
runID := params.ByName("runId")
|
||||||
|
|
||||||
|
safePath := safeResolve(baseDir, runID)
|
||||||
|
|
||||||
|
entries, err := fs.ReadDir(fsys, safePath)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var list []NamedFileContainerResourceURL
|
||||||
|
for _, entry := range entries {
|
||||||
|
list = append(list, NamedFileContainerResourceURL{
|
||||||
|
Name: entry.Name(),
|
||||||
|
FileContainerResourceURL: fmt.Sprintf("http://%s/download/%s", req.Host, runID),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
json, err := json.Marshal(NamedFileContainerResourceURLResponse{
|
||||||
|
Count: len(list),
|
||||||
|
Value: list,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = w.Write(json)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.GET("/download/:container", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
||||||
|
container := params.ByName("container")
|
||||||
|
itemPath := req.URL.Query().Get("itemPath")
|
||||||
|
safePath := safeResolve(baseDir, filepath.Join(container, itemPath))
|
||||||
|
|
||||||
|
var files []ContainerItem
|
||||||
|
err := fs.WalkDir(fsys, safePath, func(path string, entry fs.DirEntry, err error) error {
|
||||||
|
if !entry.IsDir() {
|
||||||
|
rel, err := filepath.Rel(safePath, path)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// if it was upload as gzip
|
||||||
|
rel = strings.TrimSuffix(rel, gzipExtension)
|
||||||
|
path := filepath.Join(itemPath, rel)
|
||||||
|
|
||||||
|
rel = filepath.ToSlash(rel)
|
||||||
|
path = filepath.ToSlash(path)
|
||||||
|
|
||||||
|
files = append(files, ContainerItem{
|
||||||
|
Path: path,
|
||||||
|
ItemType: "file",
|
||||||
|
ContentLocation: fmt.Sprintf("http://%s/artifact/%s/%s/%s", req.Host, container, itemPath, rel),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
json, err := json.Marshal(ContainerItemResponse{
|
||||||
|
Value: files,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = w.Write(json)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.GET("/artifact/*path", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
||||||
|
path := params.ByName("path")[1:]
|
||||||
|
|
||||||
|
safePath := safeResolve(baseDir, path)
|
||||||
|
|
||||||
|
file, err := fsys.Open(safePath)
|
||||||
|
if err != nil {
|
||||||
|
// try gzip file
|
||||||
|
file, err = fsys.Open(safePath + gzipExtension)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
w.Header().Add("Content-Encoding", "gzip")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = io.Copy(w, file)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func Serve(ctx context.Context, artifactPath, addr, port string) context.CancelFunc {
|
||||||
|
serverContext, cancel := context.WithCancel(ctx)
|
||||||
|
logger := common.Logger(serverContext)
|
||||||
|
|
||||||
|
if artifactPath == "" {
|
||||||
|
return cancel
|
||||||
|
}
|
||||||
|
|
||||||
|
router := httprouter.New()
|
||||||
|
|
||||||
|
logger.Debugf("Artifacts base path '%s'", artifactPath)
|
||||||
|
fsys := readWriteFSImpl{}
|
||||||
|
uploads(router, artifactPath, fsys)
|
||||||
|
downloads(router, artifactPath, fsys)
|
||||||
|
|
||||||
|
server := &http.Server{
|
||||||
|
Addr: fmt.Sprintf("%s:%s", addr, port),
|
||||||
|
ReadHeaderTimeout: 2 * time.Second,
|
||||||
|
Handler: router,
|
||||||
|
}
|
||||||
|
|
||||||
|
// run server
|
||||||
|
go func() {
|
||||||
|
logger.Infof("Start server on http://%s:%s", addr, port)
|
||||||
|
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
logger.Fatal(err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// wait for cancel to gracefully shutdown server
|
||||||
|
go func() {
|
||||||
|
<-serverContext.Done()
|
||||||
|
|
||||||
|
if err := server.Shutdown(ctx); err != nil {
|
||||||
|
logger.Errorf("Failed shutdown gracefully - force shutdown: %v", err)
|
||||||
|
server.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return cancel
|
||||||
|
}
|
||||||
@@ -0,0 +1,399 @@
|
|||||||
|
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2021 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package artifacts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
|
||||||
|
"gitea.com/gitea/act_runner/act/model"
|
||||||
|
"gitea.com/gitea/act_runner/act/runner"
|
||||||
|
|
||||||
|
"github.com/julienschmidt/httprouter"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
type writableMapFile struct {
|
||||||
|
fstest.MapFile
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *writableMapFile) Write(data []byte) (int, error) {
|
||||||
|
f.Data = data
|
||||||
|
return len(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *writableMapFile) Close() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type writeMapFS struct {
|
||||||
|
fstest.MapFS
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fsys writeMapFS) OpenWritable(name string) (WritableFile, error) {
|
||||||
|
file := &writableMapFile{
|
||||||
|
MapFile: fstest.MapFile{
|
||||||
|
Data: []byte("content2"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
fsys.MapFS[name] = &file.MapFile
|
||||||
|
|
||||||
|
return file, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fsys writeMapFS) OpenAppendable(name string) (WritableFile, error) {
|
||||||
|
file := &writableMapFile{
|
||||||
|
MapFile: fstest.MapFile{
|
||||||
|
Data: []byte("content2"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
fsys.MapFS[name] = &file.MapFile
|
||||||
|
|
||||||
|
return file, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewArtifactUploadPrepare(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
memfs := fstest.MapFS(map[string]*fstest.MapFile{})
|
||||||
|
|
||||||
|
router := httprouter.New()
|
||||||
|
uploads(router, "artifact/server/path", writeMapFS{memfs})
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, "http://localhost/_apis/pipelines/workflows/1/artifacts", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if status := rr.Code; status != http.StatusOK {
|
||||||
|
assert.Fail("Wrong status")
|
||||||
|
}
|
||||||
|
|
||||||
|
response := FileContainerResourceURL{}
|
||||||
|
err := json.Unmarshal(rr.Body.Bytes(), &response)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal("http://localhost/upload/1", response.FileContainerResourceURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestArtifactUploadBlob(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
memfs := fstest.MapFS(map[string]*fstest.MapFile{})
|
||||||
|
|
||||||
|
router := httprouter.New()
|
||||||
|
uploads(router, "artifact/server/path", writeMapFS{memfs})
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodPut, "http://localhost/upload/1?itemPath=some/file", strings.NewReader("content"))
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if status := rr.Code; status != http.StatusOK {
|
||||||
|
assert.Fail("Wrong status")
|
||||||
|
}
|
||||||
|
|
||||||
|
response := ResponseMessage{}
|
||||||
|
err := json.Unmarshal(rr.Body.Bytes(), &response)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal("success", response.Message)
|
||||||
|
assert.Equal("content", string(memfs["artifact/server/path/1/some/file"].Data))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFinalizeArtifactUpload(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
memfs := fstest.MapFS(map[string]*fstest.MapFile{})
|
||||||
|
|
||||||
|
router := httprouter.New()
|
||||||
|
uploads(router, "artifact/server/path", writeMapFS{memfs})
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodPatch, "http://localhost/_apis/pipelines/workflows/1/artifacts", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if status := rr.Code; status != http.StatusOK {
|
||||||
|
assert.Fail("Wrong status")
|
||||||
|
}
|
||||||
|
|
||||||
|
response := ResponseMessage{}
|
||||||
|
err := json.Unmarshal(rr.Body.Bytes(), &response)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal("success", response.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListArtifacts(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
memfs := fstest.MapFS(map[string]*fstest.MapFile{
|
||||||
|
"artifact/server/path/1/file.txt": {
|
||||||
|
Data: []byte(""),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
router := httprouter.New()
|
||||||
|
downloads(router, "artifact/server/path", memfs)
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, "http://localhost/_apis/pipelines/workflows/1/artifacts", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if status := rr.Code; status != http.StatusOK {
|
||||||
|
assert.FailNow(fmt.Sprintf("Wrong status: %d", status))
|
||||||
|
}
|
||||||
|
|
||||||
|
response := NamedFileContainerResourceURLResponse{}
|
||||||
|
err := json.Unmarshal(rr.Body.Bytes(), &response)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(1, response.Count)
|
||||||
|
assert.Equal("file.txt", response.Value[0].Name)
|
||||||
|
assert.Equal("http://localhost/download/1", response.Value[0].FileContainerResourceURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListArtifactContainer(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
memfs := fstest.MapFS(map[string]*fstest.MapFile{
|
||||||
|
"artifact/server/path/1/some/file": {
|
||||||
|
Data: []byte(""),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
router := httprouter.New()
|
||||||
|
downloads(router, "artifact/server/path", memfs)
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, "http://localhost/download/1?itemPath=some/file", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if status := rr.Code; status != http.StatusOK {
|
||||||
|
assert.FailNow(fmt.Sprintf("Wrong status: %d", status))
|
||||||
|
}
|
||||||
|
|
||||||
|
response := ContainerItemResponse{}
|
||||||
|
err := json.Unmarshal(rr.Body.Bytes(), &response)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(1, len(response.Value)) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Equal("some/file", response.Value[0].Path)
|
||||||
|
assert.Equal("file", response.Value[0].ItemType)
|
||||||
|
assert.Equal("http://localhost/artifact/1/some/file/.", response.Value[0].ContentLocation)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDownloadArtifactFile(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
memfs := fstest.MapFS(map[string]*fstest.MapFile{
|
||||||
|
"artifact/server/path/1/some/file": {
|
||||||
|
Data: []byte("content"),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
router := httprouter.New()
|
||||||
|
downloads(router, "artifact/server/path", memfs)
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, "http://localhost/artifact/1/some/file", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if status := rr.Code; status != http.StatusOK {
|
||||||
|
assert.FailNow(fmt.Sprintf("Wrong status: %d", status))
|
||||||
|
}
|
||||||
|
|
||||||
|
data := rr.Body.Bytes()
|
||||||
|
|
||||||
|
assert.Equal("content", string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
type TestJobFileInfo struct {
|
||||||
|
workdir string
|
||||||
|
workflowPath string
|
||||||
|
eventName string
|
||||||
|
errorMessage string
|
||||||
|
platforms map[string]string
|
||||||
|
containerArchitecture string
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
artifactsPath = path.Join(os.TempDir(), "test-artifacts")
|
||||||
|
artifactsAddr = "127.0.0.1"
|
||||||
|
artifactsPort = "12345"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestArtifactFlow(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("skipping integration test")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
cancel := Serve(ctx, artifactsPath, artifactsAddr, artifactsPort)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
platforms := map[string]string{
|
||||||
|
"ubuntu-latest": "node:16-buster", // Don't use node:16-buster-slim because it doesn't have curl command, which is used in the tests
|
||||||
|
}
|
||||||
|
|
||||||
|
tables := []TestJobFileInfo{
|
||||||
|
{"testdata", "upload-and-download", "push", "", platforms, ""},
|
||||||
|
{"testdata", "GHSL-2023-004", "push", "", platforms, ""},
|
||||||
|
}
|
||||||
|
log.SetLevel(log.DebugLevel)
|
||||||
|
|
||||||
|
for _, table := range tables {
|
||||||
|
runTestJobFile(ctx, t, table)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runTestJobFile(ctx context.Context, t *testing.T, tjfi TestJobFileInfo) {
|
||||||
|
t.Run(tjfi.workflowPath, func(t *testing.T) {
|
||||||
|
fmt.Printf("::group::%s\n", tjfi.workflowPath) //nolint:forbidigo // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
if err := os.RemoveAll(artifactsPath); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
workdir, err := filepath.Abs(tjfi.workdir)
|
||||||
|
assert.Nil(t, err, workdir) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
fullWorkflowPath := filepath.Join(workdir, tjfi.workflowPath)
|
||||||
|
runnerConfig := &runner.Config{
|
||||||
|
Workdir: workdir,
|
||||||
|
BindWorkdir: false,
|
||||||
|
EventName: tjfi.eventName,
|
||||||
|
Platforms: tjfi.platforms,
|
||||||
|
ReuseContainers: false,
|
||||||
|
ContainerArchitecture: tjfi.containerArchitecture,
|
||||||
|
GitHubInstance: "github.com",
|
||||||
|
ArtifactServerPath: artifactsPath,
|
||||||
|
ArtifactServerAddr: artifactsAddr,
|
||||||
|
ArtifactServerPort: artifactsPort,
|
||||||
|
}
|
||||||
|
|
||||||
|
runner, err := runner.New(runnerConfig)
|
||||||
|
assert.Nil(t, err, tjfi.workflowPath) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
planner, err := model.NewWorkflowPlanner(fullWorkflowPath, true)
|
||||||
|
assert.Nil(t, err, fullWorkflowPath) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
plan, err := planner.PlanEvent(tjfi.eventName)
|
||||||
|
if err == nil {
|
||||||
|
err = runner.NewPlanExecutor(plan)(ctx)
|
||||||
|
if tjfi.errorMessage == "" {
|
||||||
|
assert.Nil(t, err, fullWorkflowPath) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
} else {
|
||||||
|
assert.Error(t, err, tjfi.errorMessage) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
assert.Nil(t, plan)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("::endgroup::") //nolint:forbidigo // pre-existing issue from nektos/act
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMkdirFsImplSafeResolve(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
baseDir := "/foo/bar"
|
||||||
|
|
||||||
|
tests := map[string]struct {
|
||||||
|
input string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
"simple": {input: "baz", want: "/foo/bar/baz"},
|
||||||
|
"nested": {input: "baz/blue", want: "/foo/bar/baz/blue"},
|
||||||
|
"dots in middle": {input: "baz/../../blue", want: "/foo/bar/blue"},
|
||||||
|
"leading dots": {input: "../../parent", want: "/foo/bar/parent"},
|
||||||
|
"root path": {input: "/root", want: "/foo/bar/root"},
|
||||||
|
"root": {input: "/", want: "/foo/bar"},
|
||||||
|
"empty": {input: "", want: "/foo/bar"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, tc := range tests {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
assert.Equal(tc.want, safeResolve(baseDir, tc.input))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDownloadArtifactFileUnsafePath(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
memfs := fstest.MapFS(map[string]*fstest.MapFile{
|
||||||
|
"artifact/server/path/some/file": {
|
||||||
|
Data: []byte("content"),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
router := httprouter.New()
|
||||||
|
downloads(router, "artifact/server/path", memfs)
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, "http://localhost/artifact/2/../../some/file", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if status := rr.Code; status != http.StatusOK {
|
||||||
|
assert.FailNow(fmt.Sprintf("Wrong status: %d", status))
|
||||||
|
}
|
||||||
|
|
||||||
|
data := rr.Body.Bytes()
|
||||||
|
|
||||||
|
assert.Equal("content", string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestArtifactUploadBlobUnsafePath(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
memfs := fstest.MapFS(map[string]*fstest.MapFile{})
|
||||||
|
|
||||||
|
router := httprouter.New()
|
||||||
|
uploads(router, "artifact/server/path", writeMapFS{memfs})
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodPut, "http://localhost/upload/1?itemPath=../../some/file", strings.NewReader("content"))
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if status := rr.Code; status != http.StatusOK {
|
||||||
|
assert.Fail("Wrong status")
|
||||||
|
}
|
||||||
|
|
||||||
|
response := ResponseMessage{}
|
||||||
|
err := json.Unmarshal(rr.Body.Bytes(), &response)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal("success", response.Message)
|
||||||
|
assert.Equal("content", string(memfs["artifact/server/path/1/some/file"].Data))
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
|
||||||
|
name: "GHSL-2023-0004"
|
||||||
|
on: push
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test-artifacts:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo "hello world" > test.txt
|
||||||
|
- name: curl upload
|
||||||
|
run: curl --silent --show-error --fail ${ACTIONS_RUNTIME_URL}upload/1?itemPath=../../my-artifact/secret.txt --upload-file test.txt
|
||||||
|
- uses: actions/download-artifact@v2
|
||||||
|
with:
|
||||||
|
name: my-artifact
|
||||||
|
path: test-artifacts
|
||||||
|
- name: 'Verify Artifact #1'
|
||||||
|
run: |
|
||||||
|
file="test-artifacts/secret.txt"
|
||||||
|
if [ ! -f $file ] ; then
|
||||||
|
echo "Expected file does not exist"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$(cat $file)" != "hello world" ] ; then
|
||||||
|
echo "File contents of downloaded artifact are incorrect"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
- name: Verify download should work by clean extra dots
|
||||||
|
run: curl --silent --show-error --fail --path-as-is -o out.txt ${ACTIONS_RUNTIME_URL}artifact/1/../../../1/my-artifact/secret.txt
|
||||||
|
- name: 'Verify download content'
|
||||||
|
run: |
|
||||||
|
file="out.txt"
|
||||||
|
if [ ! -f $file ] ; then
|
||||||
|
echo "Expected file does not exist"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$(cat $file)" != "hello world" ] ; then
|
||||||
|
echo "File contents of downloaded artifact are incorrect"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
|
||||||
|
name: "Test that artifact uploads and downloads succeed"
|
||||||
|
on: push
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test-artifacts:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: mkdir -p path/to/artifact
|
||||||
|
- run: echo hello > path/to/artifact/world.txt
|
||||||
|
- uses: actions/upload-artifact@v2
|
||||||
|
with:
|
||||||
|
name: my-artifact
|
||||||
|
path: path/to/artifact/world.txt
|
||||||
|
|
||||||
|
- run: rm -rf path
|
||||||
|
|
||||||
|
- uses: actions/download-artifact@v2
|
||||||
|
with:
|
||||||
|
name: my-artifact
|
||||||
|
- name: Display structure of downloaded files
|
||||||
|
run: ls -la
|
||||||
|
|
||||||
|
# Test end-to-end by uploading two artifacts and then downloading them
|
||||||
|
- name: Create artifact files
|
||||||
|
run: |
|
||||||
|
mkdir -p path/to/dir-1
|
||||||
|
mkdir -p path/to/dir-2
|
||||||
|
mkdir -p path/to/dir-3
|
||||||
|
mkdir -p path/to/dir-5
|
||||||
|
mkdir -p path/to/dir-6
|
||||||
|
mkdir -p path/to/dir-7
|
||||||
|
echo "Lorem ipsum dolor sit amet" > path/to/dir-1/file1.txt
|
||||||
|
echo "Hello world from file #2" > path/to/dir-2/file2.txt
|
||||||
|
echo "This is a going to be a test for a large enough file that should get compressed with GZip. The @actions/artifact package uses GZip to upload files. This text should have a compression ratio greater than 100% so it should get uploaded using GZip" > path/to/dir-3/gzip.txt
|
||||||
|
dd if=/dev/random of=path/to/dir-5/file5.rnd bs=1024 count=1024
|
||||||
|
dd if=/dev/random of=path/to/dir-6/file6.rnd bs=1024 count=$((10*1024))
|
||||||
|
dd if=/dev/random of=path/to/dir-7/file7.rnd bs=1024 count=$((10*1024))
|
||||||
|
|
||||||
|
# Upload a single file artifact
|
||||||
|
- name: 'Upload artifact #1'
|
||||||
|
uses: actions/upload-artifact@v2
|
||||||
|
with:
|
||||||
|
name: 'Artifact-A'
|
||||||
|
path: path/to/dir-1/file1.txt
|
||||||
|
|
||||||
|
# Upload using a wildcard pattern, name should default to 'artifact' if not provided
|
||||||
|
- name: 'Upload artifact #2'
|
||||||
|
uses: actions/upload-artifact@v2
|
||||||
|
with:
|
||||||
|
path: path/**/dir*/
|
||||||
|
|
||||||
|
# Upload a directory that contains a file that will be uploaded with GZip
|
||||||
|
- name: 'Upload artifact #3'
|
||||||
|
uses: actions/upload-artifact@v2
|
||||||
|
with:
|
||||||
|
name: 'GZip-Artifact'
|
||||||
|
path: path/to/dir-3/
|
||||||
|
|
||||||
|
# Upload a directory that contains a file that will be uploaded with GZip
|
||||||
|
- name: 'Upload artifact #4'
|
||||||
|
uses: actions/upload-artifact@v2
|
||||||
|
with:
|
||||||
|
name: 'Multi-Path-Artifact'
|
||||||
|
path: |
|
||||||
|
path/to/dir-1/*
|
||||||
|
path/to/dir-[23]/*
|
||||||
|
!path/to/dir-3/*.txt
|
||||||
|
|
||||||
|
# Upload a mid-size file artifact
|
||||||
|
- name: 'Upload artifact #5'
|
||||||
|
uses: actions/upload-artifact@v2
|
||||||
|
with:
|
||||||
|
name: 'Mid-Size-Artifact'
|
||||||
|
path: path/to/dir-5/file5.rnd
|
||||||
|
|
||||||
|
# Upload a big file artifact
|
||||||
|
- name: 'Upload artifact #6'
|
||||||
|
uses: actions/upload-artifact@v2
|
||||||
|
with:
|
||||||
|
name: 'Big-Artifact'
|
||||||
|
path: path/to/dir-6/file6.rnd
|
||||||
|
|
||||||
|
# Upload a big file artifact twice
|
||||||
|
- name: 'Upload artifact #7 (First)'
|
||||||
|
uses: actions/upload-artifact@v2
|
||||||
|
with:
|
||||||
|
name: 'Big-Uploaded-Twice'
|
||||||
|
path: path/to/dir-7/file7.rnd
|
||||||
|
|
||||||
|
# Upload a big file artifact twice
|
||||||
|
- name: 'Upload artifact #7 (Second)'
|
||||||
|
uses: actions/upload-artifact@v2
|
||||||
|
with:
|
||||||
|
name: 'Big-Uploaded-Twice'
|
||||||
|
path: path/to/dir-7/file7.rnd
|
||||||
|
|
||||||
|
# Verify artifacts. Switch to download-artifact@v2 once it's out of preview
|
||||||
|
|
||||||
|
# Download Artifact #1 and verify the correctness of the content
|
||||||
|
- name: 'Download artifact #1'
|
||||||
|
uses: actions/download-artifact@v2
|
||||||
|
with:
|
||||||
|
name: 'Artifact-A'
|
||||||
|
path: some/new/path
|
||||||
|
|
||||||
|
- name: 'Verify Artifact #1'
|
||||||
|
run: |
|
||||||
|
file="some/new/path/file1.txt"
|
||||||
|
if [ ! -f $file ] ; then
|
||||||
|
echo "Expected file does not exist"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$(cat $file)" != "Lorem ipsum dolor sit amet" ] ; then
|
||||||
|
echo "File contents of downloaded artifact are incorrect"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Download Artifact #2 and verify the correctness of the content
|
||||||
|
- name: 'Download artifact #2'
|
||||||
|
uses: actions/download-artifact@v2
|
||||||
|
with:
|
||||||
|
name: 'artifact'
|
||||||
|
path: some/other/path
|
||||||
|
|
||||||
|
- name: 'Verify Artifact #2'
|
||||||
|
run: |
|
||||||
|
file1="some/other/path/to/dir-1/file1.txt"
|
||||||
|
file2="some/other/path/to/dir-2/file2.txt"
|
||||||
|
if [ ! -f $file1 -o ! -f $file2 ] ; then
|
||||||
|
echo "Expected files do not exist"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$(cat $file1)" != "Lorem ipsum dolor sit amet" -o "$(cat $file2)" != "Hello world from file #2" ] ; then
|
||||||
|
echo "File contents of downloaded artifacts are incorrect"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Download Artifact #3 and verify the correctness of the content
|
||||||
|
- name: 'Download artifact #3'
|
||||||
|
uses: actions/download-artifact@v2
|
||||||
|
with:
|
||||||
|
name: 'GZip-Artifact'
|
||||||
|
path: gzip/artifact/path
|
||||||
|
|
||||||
|
# Because a directory was used as input during the upload the parent directories, path/to/dir-3/, should not be included in the uploaded artifact
|
||||||
|
- name: 'Verify Artifact #3'
|
||||||
|
run: |
|
||||||
|
gzipFile="gzip/artifact/path/gzip.txt"
|
||||||
|
if [ ! -f $gzipFile ] ; then
|
||||||
|
echo "Expected file do not exist"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$(cat $gzipFile)" != "This is a going to be a test for a large enough file that should get compressed with GZip. The @actions/artifact package uses GZip to upload files. This text should have a compression ratio greater than 100% so it should get uploaded using GZip" ] ; then
|
||||||
|
echo "File contents of downloaded artifact is incorrect"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: 'Download artifact #4'
|
||||||
|
uses: actions/download-artifact@v2
|
||||||
|
with:
|
||||||
|
name: 'Multi-Path-Artifact'
|
||||||
|
path: multi/artifact
|
||||||
|
|
||||||
|
- name: 'Verify Artifact #4'
|
||||||
|
run: |
|
||||||
|
file1="multi/artifact/dir-1/file1.txt"
|
||||||
|
file2="multi/artifact/dir-2/file2.txt"
|
||||||
|
if [ ! -f $file1 -o ! -f $file2 ] ; then
|
||||||
|
echo "Expected files do not exist"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$(cat $file1)" != "Lorem ipsum dolor sit amet" -o "$(cat $file2)" != "Hello world from file #2" ] ; then
|
||||||
|
echo "File contents of downloaded artifacts are incorrect"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: 'Download artifact #5'
|
||||||
|
uses: actions/download-artifact@v2
|
||||||
|
with:
|
||||||
|
name: 'Mid-Size-Artifact'
|
||||||
|
path: mid-size/artifact/path
|
||||||
|
|
||||||
|
- name: 'Verify Artifact #5'
|
||||||
|
run: |
|
||||||
|
file="mid-size/artifact/path/file5.rnd"
|
||||||
|
if [ ! -f $file ] ; then
|
||||||
|
echo "Expected file does not exist"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! diff $file path/to/dir-5/file5.rnd ; then
|
||||||
|
echo "File contents of downloaded artifact are incorrect"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: 'Download artifact #6'
|
||||||
|
uses: actions/download-artifact@v2
|
||||||
|
with:
|
||||||
|
name: 'Big-Artifact'
|
||||||
|
path: big/artifact/path
|
||||||
|
|
||||||
|
- name: 'Verify Artifact #6'
|
||||||
|
run: |
|
||||||
|
file="big/artifact/path/file6.rnd"
|
||||||
|
if [ ! -f $file ] ; then
|
||||||
|
echo "Expected file does not exist"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! diff $file path/to/dir-6/file6.rnd ; then
|
||||||
|
echo "File contents of downloaded artifact are incorrect"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: 'Download artifact #7'
|
||||||
|
uses: actions/download-artifact@v2
|
||||||
|
with:
|
||||||
|
name: 'Big-Uploaded-Twice'
|
||||||
|
path: big-uploaded-twice/artifact/path
|
||||||
|
|
||||||
|
- name: 'Verify Artifact #7'
|
||||||
|
run: |
|
||||||
|
file="big-uploaded-twice/artifact/path/file7.rnd"
|
||||||
|
if [ ! -f $file ] ; then
|
||||||
|
echo "Expected file does not exist"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! diff $file path/to/dir-7/file7.rnd ; then
|
||||||
|
echo "File contents of downloaded artifact are incorrect"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package common
|
||||||
|
|
||||||
|
// 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 := len(n) - 1; j >= 0; j-- {
|
||||||
|
n[j]++
|
||||||
|
if n[j] < len(a[j]) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
n[j] = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
@@ -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.Len(output, 0) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
input = map[string][]any{}
|
||||||
|
output = CartesianProduct(input)
|
||||||
|
assert.Len(output, 0) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Style is a specific style
|
||||||
|
type Style int
|
||||||
|
|
||||||
|
// Styles
|
||||||
|
const (
|
||||||
|
StyleDoubleLine = iota
|
||||||
|
StyleSingleLine
|
||||||
|
StyleDashedLine
|
||||||
|
StyleNoLine
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewPen creates a new pen
|
||||||
|
func NewPen(style Style, color int) *Pen {
|
||||||
|
bgcolor := 49
|
||||||
|
if os.Getenv("CLICOLOR") == "0" {
|
||||||
|
color = 0
|
||||||
|
bgcolor = 0
|
||||||
|
}
|
||||||
|
return &Pen{
|
||||||
|
style: style,
|
||||||
|
color: color,
|
||||||
|
bgcolor: bgcolor,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type styleDef struct {
|
||||||
|
cornerTL string
|
||||||
|
cornerTR string
|
||||||
|
cornerBL string
|
||||||
|
cornerBR string
|
||||||
|
lineH string
|
||||||
|
lineV string
|
||||||
|
}
|
||||||
|
|
||||||
|
var styleDefs = []styleDef{
|
||||||
|
{"\u2554", "\u2557", "\u255a", "\u255d", "\u2550", "\u2551"},
|
||||||
|
{"\u256d", "\u256e", "\u2570", "\u256f", "\u2500", "\u2502"},
|
||||||
|
{"\u250c", "\u2510", "\u2514", "\u2518", "\u254c", "\u254e"},
|
||||||
|
{" ", " ", " ", " ", " ", " "},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pen struct
|
||||||
|
type Pen struct {
|
||||||
|
style Style
|
||||||
|
color int
|
||||||
|
bgcolor int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drawing struct
|
||||||
|
type Drawing struct {
|
||||||
|
buf *strings.Builder
|
||||||
|
width int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Pen) drawTopBars(buf io.Writer, labels ...string) {
|
||||||
|
style := styleDefs[p.style]
|
||||||
|
for _, label := range labels {
|
||||||
|
bar := strings.Repeat(style.lineH, len(label)+2)
|
||||||
|
fmt.Fprintf(buf, " ")
|
||||||
|
fmt.Fprintf(buf, "\x1b[%d;%dm", p.color, p.bgcolor)
|
||||||
|
fmt.Fprintf(buf, "%s%s%s", style.cornerTL, bar, style.cornerTR)
|
||||||
|
fmt.Fprintf(buf, "\x1b[%dm", 0)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(buf, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Pen) drawBottomBars(buf io.Writer, labels ...string) {
|
||||||
|
style := styleDefs[p.style]
|
||||||
|
for _, label := range labels {
|
||||||
|
bar := strings.Repeat(style.lineH, len(label)+2)
|
||||||
|
fmt.Fprintf(buf, " ")
|
||||||
|
fmt.Fprintf(buf, "\x1b[%d;%dm", p.color, p.bgcolor)
|
||||||
|
fmt.Fprintf(buf, "%s%s%s", style.cornerBL, bar, style.cornerBR)
|
||||||
|
fmt.Fprintf(buf, "\x1b[%dm", 0)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(buf, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Pen) drawLabels(buf io.Writer, labels ...string) {
|
||||||
|
style := styleDefs[p.style]
|
||||||
|
for _, label := range labels {
|
||||||
|
fmt.Fprintf(buf, " ")
|
||||||
|
fmt.Fprintf(buf, "\x1b[%d;%dm", p.color, p.bgcolor)
|
||||||
|
fmt.Fprintf(buf, "%s %s %s", style.lineV, label, style.lineV)
|
||||||
|
fmt.Fprintf(buf, "\x1b[%dm", 0)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(buf, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// DrawArrow between boxes
|
||||||
|
func (p *Pen) DrawArrow() *Drawing {
|
||||||
|
drawing := &Drawing{
|
||||||
|
buf: new(strings.Builder),
|
||||||
|
width: 1,
|
||||||
|
}
|
||||||
|
fmt.Fprintf(drawing.buf, "\x1b[%dm", p.color)
|
||||||
|
fmt.Fprintf(drawing.buf, "\u2b07")
|
||||||
|
fmt.Fprintf(drawing.buf, "\x1b[%dm", 0)
|
||||||
|
return drawing
|
||||||
|
}
|
||||||
|
|
||||||
|
// DrawBoxes to draw boxes
|
||||||
|
func (p *Pen) DrawBoxes(labels ...string) *Drawing {
|
||||||
|
width := 0
|
||||||
|
for _, l := range labels {
|
||||||
|
width += len(l) + 2 + 2 + 1
|
||||||
|
}
|
||||||
|
drawing := &Drawing{
|
||||||
|
buf: new(strings.Builder),
|
||||||
|
width: width,
|
||||||
|
}
|
||||||
|
p.drawTopBars(drawing.buf, labels...)
|
||||||
|
p.drawLabels(drawing.buf, labels...)
|
||||||
|
p.drawBottomBars(drawing.buf, labels...)
|
||||||
|
|
||||||
|
return drawing
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw to writer
|
||||||
|
func (d *Drawing) Draw(writer io.Writer, centerOnWidth int) {
|
||||||
|
padSize := max((centerOnWidth-d.GetWidth())/2, 0)
|
||||||
|
for l := range strings.SplitSeq(d.buf.String(), "\n") {
|
||||||
|
if len(l) > 0 {
|
||||||
|
padding := strings.Repeat(" ", padSize)
|
||||||
|
fmt.Fprintf(writer, "%s%s\n", padding, l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWidth of drawing
|
||||||
|
func (d *Drawing) GetWidth() int {
|
||||||
|
return d.width
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
)
|
||||||
|
|
||||||
|
type dryrunContextKey string
|
||||||
|
|
||||||
|
const dryrunContextKeyVal = dryrunContextKey("dryrun")
|
||||||
|
|
||||||
|
// Dryrun returns true if the current context is dryrun
|
||||||
|
func Dryrun(ctx context.Context) bool {
|
||||||
|
val := ctx.Value(dryrunContextKeyVal)
|
||||||
|
if val != nil {
|
||||||
|
if dryrun, ok := val.(bool); ok {
|
||||||
|
return dryrun
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithDryrun adds a value to the context for dryrun
|
||||||
|
func WithDryrun(ctx context.Context, dryrun bool) context.Context {
|
||||||
|
return context.WithValue(ctx, dryrunContextKeyVal, dryrun)
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"runtime/debug"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Warning that implements `error` but safe to ignore
|
||||||
|
type Warning struct {
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error the contract for error
|
||||||
|
func (w Warning) Error() string {
|
||||||
|
return w.Message
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warningf create a warning
|
||||||
|
func Warningf(format string, args ...any) Warning {
|
||||||
|
w := Warning{
|
||||||
|
Message: fmt.Sprintf(format, args...),
|
||||||
|
}
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// Executor define contract for the steps of a workflow
|
||||||
|
type Executor func(ctx context.Context) error
|
||||||
|
|
||||||
|
// Conditional define contract for the conditional predicate
|
||||||
|
type Conditional func(ctx context.Context) bool
|
||||||
|
|
||||||
|
// NewInfoExecutor is an executor that logs messages
|
||||||
|
func NewInfoExecutor(format string, args ...any) Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
logger := Logger(ctx)
|
||||||
|
logger.Infof(format, args...)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDebugExecutor is an executor that logs messages
|
||||||
|
func NewDebugExecutor(format string, args ...any) Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
logger := Logger(ctx)
|
||||||
|
logger.Debugf(format, args...)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPipelineExecutor creates a new executor from a series of other executors
|
||||||
|
func NewPipelineExecutor(executors ...Executor) Executor {
|
||||||
|
if len(executors) == 0 {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var rtn Executor
|
||||||
|
for _, executor := range executors {
|
||||||
|
if rtn == nil {
|
||||||
|
rtn = executor
|
||||||
|
} else {
|
||||||
|
rtn = rtn.Then(executor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rtn
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewConditionalExecutor creates a new executor based on conditions
|
||||||
|
func NewConditionalExecutor(conditional Conditional, trueExecutor, falseExecutor Executor) Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
if conditional(ctx) {
|
||||||
|
if trueExecutor != nil {
|
||||||
|
return trueExecutor(ctx)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if falseExecutor != nil {
|
||||||
|
return falseExecutor(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewErrorExecutor creates a new executor that always errors out
|
||||||
|
func NewErrorExecutor(err error) Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewParallelExecutor creates a new executor from a parallel of other executors
|
||||||
|
func NewParallelExecutor(parallel int, executors ...Executor) Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
work := make(chan Executor, len(executors))
|
||||||
|
errs := make(chan error, len(executors))
|
||||||
|
|
||||||
|
if 1 > parallel {
|
||||||
|
log.Debugf("Parallel tasks (%d) below minimum, setting to 1", parallel)
|
||||||
|
parallel = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Infof("NewParallelExecutor: Creating %d workers for %d executors", parallel, len(executors))
|
||||||
|
|
||||||
|
for i := 0; i < parallel; i++ {
|
||||||
|
go func(workerID int, work <-chan Executor, errs chan<- error) {
|
||||||
|
log.Debugf("Worker %d started", workerID)
|
||||||
|
taskCount := 0
|
||||||
|
for executor := range work {
|
||||||
|
taskCount++
|
||||||
|
log.Debugf("Worker %d executing task %d", workerID, taskCount)
|
||||||
|
// Recover from panics in executors to avoid crashing the worker
|
||||||
|
// goroutine which would leave the runner process hung.
|
||||||
|
// https://gitea.com/gitea/act_runner/issues/371
|
||||||
|
errs <- func() (err error) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Errorf("panic in executor: %v\n%s", r, debug.Stack())
|
||||||
|
err = fmt.Errorf("panic: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return executor(ctx)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
log.Debugf("Worker %d finished (%d tasks executed)", workerID, taskCount)
|
||||||
|
}(i, work, errs)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range executors {
|
||||||
|
work <- executors[i]
|
||||||
|
}
|
||||||
|
close(work)
|
||||||
|
|
||||||
|
// Executor waits all executors to cleanup these resources.
|
||||||
|
var firstErr error
|
||||||
|
for range executors {
|
||||||
|
err := <-errs
|
||||||
|
if firstErr == nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return firstErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then runs another executor if this executor succeeds
|
||||||
|
func (e Executor) Then(then Executor) Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
err := e(ctx)
|
||||||
|
if err != nil {
|
||||||
|
switch err.(type) {
|
||||||
|
case Warning:
|
||||||
|
Logger(ctx).Warning(err.Error())
|
||||||
|
default:
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
return then(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If only runs this executor if conditional is true
|
||||||
|
func (e Executor) If(conditional Conditional) Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
if conditional(ctx) {
|
||||||
|
return e(ctx)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IfNot only runs this executor if conditional is true
|
||||||
|
func (e Executor) IfNot(conditional Conditional) Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
if !conditional(ctx) {
|
||||||
|
return e(ctx)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IfBool only runs this executor if conditional is true
|
||||||
|
func (e Executor) IfBool(conditional bool) Executor {
|
||||||
|
return e.If(func(ctx context.Context) bool {
|
||||||
|
return conditional
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finally adds an executor to run after other executor
|
||||||
|
func (e Executor) Finally(finally Executor) Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
err := e(ctx)
|
||||||
|
err2 := finally(ctx)
|
||||||
|
if err2 != nil {
|
||||||
|
return fmt.Errorf("Error occurred running finally: %v (original error: %v)", err2, err)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not return an inverted conditional
|
||||||
|
func (c Conditional) Not() Conditional {
|
||||||
|
return func(ctx context.Context) bool {
|
||||||
|
return !c(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Simple fast test that verifies max-parallel: 2 limits concurrency
|
||||||
|
func TestMaxParallel2Quick(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
var currentRunning atomic.Int32
|
||||||
|
var maxSimultaneous atomic.Int32
|
||||||
|
|
||||||
|
executors := make([]Executor, 4)
|
||||||
|
for i := range 4 {
|
||||||
|
executors[i] = func(ctx context.Context) error {
|
||||||
|
current := currentRunning.Add(1)
|
||||||
|
|
||||||
|
// Update max if needed
|
||||||
|
for {
|
||||||
|
maxValue := maxSimultaneous.Load()
|
||||||
|
if current <= maxValue || maxSimultaneous.CompareAndSwap(maxValue, current) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
currentRunning.Add(-1)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err := NewParallelExecutor(2, executors...)(ctx)
|
||||||
|
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.LessOrEqual(t, maxSimultaneous.Load(), int32(2),
|
||||||
|
"Should not exceed max-parallel: 2")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test that verifies max-parallel: 1 enforces sequential execution
|
||||||
|
func TestMaxParallel1Sequential(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
var currentRunning atomic.Int32
|
||||||
|
var maxSimultaneous atomic.Int32
|
||||||
|
var executionOrder []int
|
||||||
|
var orderMutex sync.Mutex
|
||||||
|
|
||||||
|
executors := make([]Executor, 5)
|
||||||
|
for i := range 5 {
|
||||||
|
taskID := i
|
||||||
|
executors[i] = func(ctx context.Context) error {
|
||||||
|
current := currentRunning.Add(1)
|
||||||
|
|
||||||
|
// Track execution order
|
||||||
|
orderMutex.Lock()
|
||||||
|
executionOrder = append(executionOrder, taskID)
|
||||||
|
orderMutex.Unlock()
|
||||||
|
|
||||||
|
// Update max if needed
|
||||||
|
for {
|
||||||
|
maxValue := maxSimultaneous.Load()
|
||||||
|
if current <= maxValue || maxSimultaneous.CompareAndSwap(maxValue, current) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
currentRunning.Add(-1)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err := NewParallelExecutor(1, executors...)(ctx)
|
||||||
|
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Equal(t, int32(1), maxSimultaneous.Load(),
|
||||||
|
"max-parallel: 1 should only run 1 task at a time")
|
||||||
|
assert.Len(t, executionOrder, 5, "All 5 tasks should have executed")
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestMaxParallelJobExecution tests actual job execution with max-parallel
|
||||||
|
func TestMaxParallelJobExecution(t *testing.T) {
|
||||||
|
t.Run("MaxParallel=1 Sequential", func(t *testing.T) {
|
||||||
|
var currentRunning atomic.Int32
|
||||||
|
var maxConcurrent int32
|
||||||
|
var executionOrder []int
|
||||||
|
var mu sync.Mutex
|
||||||
|
|
||||||
|
executors := make([]Executor, 5)
|
||||||
|
for i := range 5 {
|
||||||
|
taskID := i
|
||||||
|
executors[i] = func(ctx context.Context) error {
|
||||||
|
current := currentRunning.Add(1)
|
||||||
|
|
||||||
|
// Track max concurrent
|
||||||
|
for {
|
||||||
|
maxValue := atomic.LoadInt32(&maxConcurrent)
|
||||||
|
if current <= maxValue || atomic.CompareAndSwapInt32(&maxConcurrent, maxValue, current) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
executionOrder = append(executionOrder, taskID)
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
currentRunning.Add(-1)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
err := NewParallelExecutor(1, executors...)(ctx)
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
assert.Equal(t, int32(1), maxConcurrent, "Should never exceed 1 concurrent execution")
|
||||||
|
assert.Len(t, executionOrder, 5, "All tasks should execute")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("MaxParallel=3 Limited", func(t *testing.T) {
|
||||||
|
var currentRunning atomic.Int32
|
||||||
|
var maxConcurrent int32
|
||||||
|
|
||||||
|
executors := make([]Executor, 10)
|
||||||
|
for i := range 10 {
|
||||||
|
executors[i] = func(ctx context.Context) error {
|
||||||
|
current := currentRunning.Add(1)
|
||||||
|
|
||||||
|
for {
|
||||||
|
maxValue := atomic.LoadInt32(&maxConcurrent)
|
||||||
|
if current <= maxValue || atomic.CompareAndSwapInt32(&maxConcurrent, maxValue, current) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
currentRunning.Add(-1)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
err := NewParallelExecutor(3, executors...)(ctx)
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
assert.LessOrEqual(t, int(maxConcurrent), 3, "Should never exceed 3 concurrent executions")
|
||||||
|
assert.GreaterOrEqual(t, int(maxConcurrent), 1, "Should have at least 1 concurrent execution")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("MaxParallel=0 Uses1Worker", func(t *testing.T) {
|
||||||
|
var maxConcurrent int32
|
||||||
|
var currentRunning atomic.Int32
|
||||||
|
|
||||||
|
executors := make([]Executor, 5)
|
||||||
|
for i := range 5 {
|
||||||
|
executors[i] = func(ctx context.Context) error {
|
||||||
|
current := currentRunning.Add(1)
|
||||||
|
|
||||||
|
for {
|
||||||
|
maxValue := atomic.LoadInt32(&maxConcurrent)
|
||||||
|
if current <= maxValue || atomic.CompareAndSwapInt32(&maxConcurrent, maxValue, current) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
currentRunning.Add(-1)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
// When maxParallel is 0 or negative, it defaults to 1
|
||||||
|
err := NewParallelExecutor(0, executors...)(ctx)
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
assert.Equal(t, int32(1), maxConcurrent, "Should use 1 worker when max-parallel is 0")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMaxParallelWithErrors tests error handling with max-parallel
|
||||||
|
func TestMaxParallelWithErrors(t *testing.T) {
|
||||||
|
t.Run("OneTaskFailsOthersContinue", func(t *testing.T) {
|
||||||
|
var successCount int32
|
||||||
|
|
||||||
|
executors := make([]Executor, 5)
|
||||||
|
for i := range 5 {
|
||||||
|
taskID := i
|
||||||
|
executors[i] = func(ctx context.Context) error {
|
||||||
|
if taskID == 2 {
|
||||||
|
return assert.AnError
|
||||||
|
}
|
||||||
|
atomic.AddInt32(&successCount, 1)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
err := NewParallelExecutor(2, executors...)(ctx)
|
||||||
|
|
||||||
|
// Should return the error from task 2
|
||||||
|
assert.Error(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
// Other tasks should still execute
|
||||||
|
assert.Equal(t, int32(4), successCount, "4 tasks should succeed")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ContextCancellation", func(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
|
var startedCount int32
|
||||||
|
executors := make([]Executor, 10)
|
||||||
|
for i := range 10 {
|
||||||
|
executors[i] = func(ctx context.Context) error {
|
||||||
|
atomic.AddInt32(&startedCount, 1)
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel after a short delay
|
||||||
|
go func() {
|
||||||
|
time.Sleep(30 * time.Millisecond)
|
||||||
|
cancel()
|
||||||
|
}()
|
||||||
|
|
||||||
|
err := NewParallelExecutor(3, executors...)(ctx)
|
||||||
|
assert.Error(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.ErrorIs(t, err, context.Canceled) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
// Not all tasks should start due to cancellation (but timing may vary)
|
||||||
|
// Just verify cancellation occurred
|
||||||
|
t.Logf("Started %d tasks before cancellation", startedCount)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMaxParallelPerformance tests performance characteristics
|
||||||
|
func TestMaxParallelPerformance(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping performance test in short mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("ParallelFasterThanSequential", func(t *testing.T) {
|
||||||
|
executors := make([]Executor, 10)
|
||||||
|
for i := range 10 {
|
||||||
|
executors[i] = func(ctx context.Context) error {
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Sequential (max-parallel=1)
|
||||||
|
start := time.Now()
|
||||||
|
err := NewParallelExecutor(1, executors...)(ctx)
|
||||||
|
sequentialDuration := time.Since(start)
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
// Parallel (max-parallel=5)
|
||||||
|
start = time.Now()
|
||||||
|
err = NewParallelExecutor(5, executors...)(ctx)
|
||||||
|
parallelDuration := time.Since(start)
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
// Parallel should be significantly faster
|
||||||
|
assert.Less(t, parallelDuration, sequentialDuration/2,
|
||||||
|
"Parallel execution should be at least 2x faster")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("OptimalWorkerCount", func(t *testing.T) {
|
||||||
|
executors := make([]Executor, 20)
|
||||||
|
for i := range 20 {
|
||||||
|
executors[i] = func(ctx context.Context) error {
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Test with different worker counts
|
||||||
|
workerCounts := []int{1, 2, 5, 10, 20}
|
||||||
|
durations := make(map[int]time.Duration)
|
||||||
|
|
||||||
|
for _, count := range workerCounts {
|
||||||
|
start := time.Now()
|
||||||
|
err := NewParallelExecutor(count, executors...)(ctx)
|
||||||
|
durations[count] = time.Since(start)
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
}
|
||||||
|
|
||||||
|
// More workers should generally be faster (up to a point)
|
||||||
|
assert.Less(t, durations[5], durations[1], "5 workers should be faster than 1")
|
||||||
|
assert.Less(t, durations[10], durations[2], "10 workers should be faster than 2")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMaxParallelResourceSharing tests resource sharing scenarios
|
||||||
|
func TestMaxParallelResourceSharing(t *testing.T) {
|
||||||
|
t.Run("SharedResourceWithMutex", func(t *testing.T) {
|
||||||
|
var sharedCounter int
|
||||||
|
var mu sync.Mutex
|
||||||
|
|
||||||
|
executors := make([]Executor, 100)
|
||||||
|
for i := range 100 {
|
||||||
|
executors[i] = func(ctx context.Context) error {
|
||||||
|
mu.Lock()
|
||||||
|
sharedCounter++
|
||||||
|
mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
err := NewParallelExecutor(10, executors...)(ctx)
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
assert.Equal(t, 100, sharedCounter, "All tasks should increment counter")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ChannelCommunication", func(t *testing.T) {
|
||||||
|
resultChan := make(chan int, 50)
|
||||||
|
|
||||||
|
executors := make([]Executor, 50)
|
||||||
|
for i := range 50 {
|
||||||
|
taskID := i
|
||||||
|
executors[i] = func(ctx context.Context) error {
|
||||||
|
resultChan <- taskID
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
err := NewParallelExecutor(5, executors...)(ctx)
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
close(resultChan)
|
||||||
|
|
||||||
|
results := make(map[int]bool)
|
||||||
|
for result := range resultChan {
|
||||||
|
results[result] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Len(t, results, 50, "All task IDs should be received")
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewWorkflow(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// empty
|
||||||
|
emptyWorkflow := NewPipelineExecutor()
|
||||||
|
assert.Nil(emptyWorkflow(ctx)) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
// error case
|
||||||
|
errorWorkflow := NewErrorExecutor(errors.New("test error"))
|
||||||
|
assert.NotNil(errorWorkflow(ctx)) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
// multiple success case
|
||||||
|
runcount := 0
|
||||||
|
successWorkflow := NewPipelineExecutor(
|
||||||
|
func(ctx context.Context) error {
|
||||||
|
runcount++
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
func(ctx context.Context) error {
|
||||||
|
runcount++
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
assert.Nil(successWorkflow(ctx)) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Equal(2, runcount)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewConditionalExecutor(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
trueCount := 0
|
||||||
|
falseCount := 0
|
||||||
|
|
||||||
|
err := NewConditionalExecutor(func(ctx context.Context) bool {
|
||||||
|
return false
|
||||||
|
}, func(ctx context.Context) error {
|
||||||
|
trueCount++
|
||||||
|
return nil
|
||||||
|
}, func(ctx context.Context) error {
|
||||||
|
falseCount++
|
||||||
|
return nil
|
||||||
|
})(ctx)
|
||||||
|
|
||||||
|
assert.Nil(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Equal(0, trueCount)
|
||||||
|
assert.Equal(1, falseCount)
|
||||||
|
|
||||||
|
err = NewConditionalExecutor(func(ctx context.Context) bool {
|
||||||
|
return true
|
||||||
|
}, func(ctx context.Context) error {
|
||||||
|
trueCount++
|
||||||
|
return nil
|
||||||
|
}, func(ctx context.Context) error {
|
||||||
|
falseCount++
|
||||||
|
return nil
|
||||||
|
})(ctx)
|
||||||
|
|
||||||
|
assert.Nil(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Equal(1, trueCount)
|
||||||
|
assert.Equal(1, falseCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewParallelExecutor(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
var count, activeCount, maxCount atomic.Int32
|
||||||
|
emptyWorkflow := NewPipelineExecutor(func(ctx context.Context) error {
|
||||||
|
count.Add(1)
|
||||||
|
|
||||||
|
active := activeCount.Add(1)
|
||||||
|
for {
|
||||||
|
m := maxCount.Load()
|
||||||
|
if active <= m || maxCount.CompareAndSwap(m, active) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
activeCount.Add(-1)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
err := NewParallelExecutor(2, emptyWorkflow, emptyWorkflow, emptyWorkflow)(ctx)
|
||||||
|
|
||||||
|
assert.Equal(int32(3), count.Load(), "should run all 3 executors")
|
||||||
|
assert.Equal(int32(2), maxCount.Load(), "should run at most 2 executors in parallel")
|
||||||
|
assert.Nil(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
// Reset to test running the executor with 0 parallelism
|
||||||
|
count.Store(0)
|
||||||
|
activeCount.Store(0)
|
||||||
|
maxCount.Store(0)
|
||||||
|
|
||||||
|
errSingle := NewParallelExecutor(0, emptyWorkflow, emptyWorkflow, emptyWorkflow)(ctx)
|
||||||
|
|
||||||
|
assert.Equal(int32(3), count.Load(), "should run all 3 executors")
|
||||||
|
assert.Equal(int32(1), maxCount.Load(), "should run at most 1 executors in parallel")
|
||||||
|
assert.Nil(errSingle) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewParallelExecutorFailed(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
count := 0
|
||||||
|
errorWorkflow := NewPipelineExecutor(func(ctx context.Context) error {
|
||||||
|
count++
|
||||||
|
return errors.New("fake error")
|
||||||
|
})
|
||||||
|
err := NewParallelExecutor(1, errorWorkflow)(ctx)
|
||||||
|
assert.Equal(1, count)
|
||||||
|
assert.ErrorIs(context.Canceled, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewParallelExecutorCanceled(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
errExpected := errors.New("fake error")
|
||||||
|
|
||||||
|
var count atomic.Int32
|
||||||
|
successWorkflow := NewPipelineExecutor(func(ctx context.Context) error {
|
||||||
|
count.Add(1)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
errorWorkflow := NewPipelineExecutor(func(ctx context.Context) error {
|
||||||
|
count.Add(1)
|
||||||
|
return errExpected
|
||||||
|
})
|
||||||
|
err := NewParallelExecutor(3, errorWorkflow, successWorkflow, successWorkflow)(ctx)
|
||||||
|
assert.Equal(int32(3), count.Load())
|
||||||
|
assert.Error(errExpected, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CopyFile copy file
|
||||||
|
func CopyFile(source, dest string) (err error) {
|
||||||
|
sourcefile, err := os.Open(source)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer sourcefile.Close()
|
||||||
|
|
||||||
|
destfile, err := os.Create(dest)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer destfile.Close()
|
||||||
|
|
||||||
|
_, err = io.Copy(destfile, sourcefile)
|
||||||
|
if err == nil {
|
||||||
|
sourceinfo, err := os.Stat(source)
|
||||||
|
if err != nil {
|
||||||
|
_ = os.Chmod(dest, sourceinfo.Mode())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CopyDir recursive copy of directory
|
||||||
|
func CopyDir(source, dest string) (err error) {
|
||||||
|
// get properties of source dir
|
||||||
|
sourceinfo, err := os.Stat(source)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// create dest dir
|
||||||
|
|
||||||
|
err = os.MkdirAll(dest, sourceinfo.Mode())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
objects, err := os.ReadDir(source)
|
||||||
|
|
||||||
|
for _, obj := range objects {
|
||||||
|
sourcefilepointer := source + "/" + obj.Name()
|
||||||
|
|
||||||
|
destinationfilepointer := dest + "/" + obj.Name()
|
||||||
|
|
||||||
|
if obj.IsDir() {
|
||||||
|
// create sub-directories - recursively
|
||||||
|
err = CopyDir(sourcefilepointer, destinationfilepointer)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err) //nolint:forbidigo // pre-existing issue from nektos/act
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// perform copy
|
||||||
|
err = CopyFile(sourcefilepointer, destinationfilepointer)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err) //nolint:forbidigo // pre-existing issue from nektos/act
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,419 @@
|
|||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"gitea.com/gitea/act_runner/act/common"
|
||||||
|
|
||||||
|
"github.com/go-git/go-git/v5"
|
||||||
|
"github.com/go-git/go-git/v5/config"
|
||||||
|
"github.com/go-git/go-git/v5/plumbing"
|
||||||
|
"github.com/go-git/go-git/v5/plumbing/storer"
|
||||||
|
"github.com/go-git/go-git/v5/plumbing/transport/http"
|
||||||
|
"github.com/mattn/go-isatty"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
codeCommitHTTPRegex = regexp.MustCompile(`^https?://git-codecommit\.(.+)\.amazonaws.com/v1/repos/(.+)$`)
|
||||||
|
codeCommitSSHRegex = regexp.MustCompile(`ssh://git-codecommit\.(.+)\.amazonaws.com/v1/repos/(.+)$`)
|
||||||
|
githubHTTPRegex = regexp.MustCompile(`^https?://.*github.com.*/(.+)/(.+?)(?:.git)?$`)
|
||||||
|
githubSSHRegex = regexp.MustCompile(`github.com[:/](.+)/(.+?)(?:.git)?$`)
|
||||||
|
|
||||||
|
cloneLock sync.Mutex
|
||||||
|
|
||||||
|
ErrShortRef = errors.New("short SHA references are not supported")
|
||||||
|
ErrNoRepo = errors.New("unable to find git repo")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Error struct {
|
||||||
|
err error
|
||||||
|
commit string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Error) Error() string {
|
||||||
|
return e.err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Error) Unwrap() error {
|
||||||
|
return e.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Error) Commit() string {
|
||||||
|
return e.commit
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindGitRevision get the current git revision
|
||||||
|
func FindGitRevision(ctx context.Context, file string) (shortSha, sha string, err error) {
|
||||||
|
logger := common.Logger(ctx)
|
||||||
|
|
||||||
|
gitDir, err := git.PlainOpenWithOptions(
|
||||||
|
file,
|
||||||
|
&git.PlainOpenOptions{
|
||||||
|
DetectDotGit: true,
|
||||||
|
EnableDotGitCommonDir: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
logger.WithError(err).Error("path", file, "not located inside a git repository")
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
head, err := gitDir.Reference(plumbing.HEAD, true)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if head.Hash().IsZero() {
|
||||||
|
return "", "", errors.New("HEAD sha1 could not be resolved")
|
||||||
|
}
|
||||||
|
|
||||||
|
hash := head.Hash().String()
|
||||||
|
|
||||||
|
logger.Debugf("Found revision: %s", hash)
|
||||||
|
return hash[:7], strings.TrimSpace(hash), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindGitRef get the current git ref
|
||||||
|
func FindGitRef(ctx context.Context, file string) (string, error) {
|
||||||
|
logger := common.Logger(ctx)
|
||||||
|
|
||||||
|
logger.Debugf("Loading revision from git directory")
|
||||||
|
_, ref, err := FindGitRevision(ctx, file)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Debugf("HEAD points to '%s'", ref)
|
||||||
|
|
||||||
|
// Prefer the git library to iterate over the references and find a matching tag or branch.
|
||||||
|
refTag := ""
|
||||||
|
refBranch := ""
|
||||||
|
repo, err := git.PlainOpenWithOptions(
|
||||||
|
file,
|
||||||
|
&git.PlainOpenOptions{
|
||||||
|
DetectDotGit: true,
|
||||||
|
EnableDotGitCommonDir: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
iter, err := repo.References()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// find the reference that matches the revision's has
|
||||||
|
err = iter.ForEach(func(r *plumbing.Reference) error {
|
||||||
|
/* tags and branches will have the same hash
|
||||||
|
* when a user checks out a tag, it is not mentioned explicitly
|
||||||
|
* in the go-git package, we must identify the revision
|
||||||
|
* then check if any tag matches that revision,
|
||||||
|
* if so then we checked out a tag
|
||||||
|
* else we look for branches and if matches,
|
||||||
|
* it means we checked out a branch
|
||||||
|
*
|
||||||
|
* If a branches matches first we must continue and check all tags (all references)
|
||||||
|
* in case we match with a tag later in the interation
|
||||||
|
*/
|
||||||
|
if r.Hash().String() == ref {
|
||||||
|
if r.Name().IsTag() {
|
||||||
|
refTag = r.Name().String()
|
||||||
|
}
|
||||||
|
if r.Name().IsBranch() {
|
||||||
|
refBranch = r.Name().String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// we found what we where looking for
|
||||||
|
if refTag != "" && refBranch != "" {
|
||||||
|
return storer.ErrStop
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// order matters here see above comment.
|
||||||
|
if refTag != "" {
|
||||||
|
return refTag, nil
|
||||||
|
}
|
||||||
|
if refBranch != "" {
|
||||||
|
return refBranch, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("failed to identify reference (tag/branch) for the checked-out revision '%s'", ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindGithubRepo get the repo
|
||||||
|
func FindGithubRepo(ctx context.Context, file, githubInstance, remoteName string) (string, error) {
|
||||||
|
if remoteName == "" {
|
||||||
|
remoteName = "origin"
|
||||||
|
}
|
||||||
|
|
||||||
|
url, err := findGitRemoteURL(ctx, file, remoteName)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
_, slug, err := findGitSlug(url, githubInstance)
|
||||||
|
return slug, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func findGitRemoteURL(_ context.Context, file, remoteName string) (string, error) {
|
||||||
|
repo, err := git.PlainOpenWithOptions(
|
||||||
|
file,
|
||||||
|
&git.PlainOpenOptions{
|
||||||
|
DetectDotGit: true,
|
||||||
|
EnableDotGitCommonDir: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
remote, err := repo.Remote(remoteName)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(remote.Config().URLs) < 1 {
|
||||||
|
return "", fmt.Errorf("remote '%s' exists but has no URL", remoteName)
|
||||||
|
}
|
||||||
|
|
||||||
|
return remote.Config().URLs[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func findGitSlug(url, githubInstance string) (string, string, error) { //nolint:unparam // pre-existing issue from nektos/act
|
||||||
|
if matches := codeCommitHTTPRegex.FindStringSubmatch(url); matches != nil {
|
||||||
|
return "CodeCommit", matches[2], nil
|
||||||
|
} else if matches := codeCommitSSHRegex.FindStringSubmatch(url); matches != nil {
|
||||||
|
return "CodeCommit", matches[2], nil
|
||||||
|
} else if matches := githubHTTPRegex.FindStringSubmatch(url); matches != nil {
|
||||||
|
return "GitHub", fmt.Sprintf("%s/%s", matches[1], matches[2]), nil
|
||||||
|
} else if matches := githubSSHRegex.FindStringSubmatch(url); matches != nil {
|
||||||
|
return "GitHub", fmt.Sprintf("%s/%s", matches[1], matches[2]), nil
|
||||||
|
} else if githubInstance != "github.com" {
|
||||||
|
gheHTTPRegex := regexp.MustCompile(fmt.Sprintf(`^https?://%s/(.+)/(.+?)(?:.git)?$`, githubInstance))
|
||||||
|
gheSSHRegex := regexp.MustCompile(githubInstance + "[:/](.+)/(.+?)(?:.git)?$")
|
||||||
|
if matches := gheHTTPRegex.FindStringSubmatch(url); matches != nil {
|
||||||
|
return "GitHubEnterprise", fmt.Sprintf("%s/%s", matches[1], matches[2]), nil
|
||||||
|
} else if matches := gheSSHRegex.FindStringSubmatch(url); matches != nil {
|
||||||
|
return "GitHubEnterprise", fmt.Sprintf("%s/%s", matches[1], matches[2]), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", url, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGitCloneExecutorInput the input for the NewGitCloneExecutor
|
||||||
|
type NewGitCloneExecutorInput struct {
|
||||||
|
URL string
|
||||||
|
Ref string
|
||||||
|
Dir string
|
||||||
|
Token string
|
||||||
|
OfflineMode bool
|
||||||
|
|
||||||
|
// For Gitea
|
||||||
|
InsecureSkipTLS bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloneIfRequired ...
|
||||||
|
func CloneIfRequired(ctx context.Context, refName plumbing.ReferenceName, input NewGitCloneExecutorInput, logger log.FieldLogger) (*git.Repository, error) {
|
||||||
|
r, err := git.PlainOpen(input.Dir)
|
||||||
|
if err != nil {
|
||||||
|
var progressWriter io.Writer
|
||||||
|
if isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) {
|
||||||
|
if entry, ok := logger.(*log.Entry); ok {
|
||||||
|
progressWriter = entry.WriterLevel(log.DebugLevel)
|
||||||
|
} else if lgr, ok := logger.(*log.Logger); ok {
|
||||||
|
progressWriter = lgr.WriterLevel(log.DebugLevel)
|
||||||
|
} else {
|
||||||
|
log.Errorf("Unable to get writer from logger (type=%T)", logger)
|
||||||
|
progressWriter = os.Stdout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cloneOptions := git.CloneOptions{
|
||||||
|
URL: input.URL,
|
||||||
|
Progress: progressWriter,
|
||||||
|
|
||||||
|
InsecureSkipTLS: input.InsecureSkipTLS, // For Gitea
|
||||||
|
}
|
||||||
|
if input.Token != "" {
|
||||||
|
cloneOptions.Auth = &http.BasicAuth{
|
||||||
|
Username: "token",
|
||||||
|
Password: input.Token,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
r, err = git.PlainCloneContext(ctx, input.Dir, false, &cloneOptions)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Unable to clone %v %s: %v", input.URL, refName, err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = os.Chmod(input.Dir, 0o755); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func gitOptions(token string) (fetchOptions git.FetchOptions, pullOptions git.PullOptions) {
|
||||||
|
fetchOptions.RefSpecs = []config.RefSpec{"refs/*:refs/*", "HEAD:refs/heads/HEAD"}
|
||||||
|
pullOptions.Force = true
|
||||||
|
|
||||||
|
if token != "" {
|
||||||
|
auth := &http.BasicAuth{
|
||||||
|
Username: "token",
|
||||||
|
Password: token,
|
||||||
|
}
|
||||||
|
fetchOptions.Auth = auth
|
||||||
|
pullOptions.Auth = auth
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetchOptions, pullOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGitCloneExecutor creates an executor to clone git repos
|
||||||
|
//
|
||||||
|
//nolint:gocyclo // function handles many cases
|
||||||
|
func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
logger := common.Logger(ctx)
|
||||||
|
logger.Infof(" \u2601 git clone '%s' # ref=%s", input.URL, input.Ref)
|
||||||
|
logger.Debugf(" cloning %s to %s", input.URL, input.Dir)
|
||||||
|
|
||||||
|
cloneLock.Lock()
|
||||||
|
defer cloneLock.Unlock()
|
||||||
|
|
||||||
|
refName := plumbing.ReferenceName("refs/heads/" + input.Ref)
|
||||||
|
r, err := CloneIfRequired(ctx, refName, input, logger)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
isOfflineMode := input.OfflineMode
|
||||||
|
|
||||||
|
// fetch latest changes
|
||||||
|
fetchOptions, pullOptions := gitOptions(input.Token)
|
||||||
|
|
||||||
|
if input.InsecureSkipTLS { // For Gitea
|
||||||
|
fetchOptions.InsecureSkipTLS = true
|
||||||
|
pullOptions.InsecureSkipTLS = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isOfflineMode {
|
||||||
|
err = r.Fetch(&fetchOptions)
|
||||||
|
if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var hash *plumbing.Hash
|
||||||
|
rev := plumbing.Revision(input.Ref)
|
||||||
|
if hash, err = r.ResolveRevision(rev); err != nil {
|
||||||
|
logger.Errorf("Unable to resolve %s: %v", input.Ref, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if hash.String() != input.Ref && strings.HasPrefix(hash.String(), input.Ref) {
|
||||||
|
return &Error{
|
||||||
|
err: ErrShortRef,
|
||||||
|
commit: hash.String(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// At this point we need to know if it's a tag or a branch
|
||||||
|
// And the easiest way to do it is duck typing
|
||||||
|
//
|
||||||
|
// If err is nil, it's a tag so let's proceed with that hash like we would if
|
||||||
|
// it was a sha
|
||||||
|
refType := "tag"
|
||||||
|
rev = plumbing.Revision(path.Join("refs", "tags", input.Ref))
|
||||||
|
if _, err := r.Tag(input.Ref); errors.Is(err, git.ErrTagNotFound) {
|
||||||
|
rName := plumbing.ReferenceName(path.Join("refs", "remotes", "origin", input.Ref))
|
||||||
|
if _, err := r.Reference(rName, false); errors.Is(err, plumbing.ErrReferenceNotFound) {
|
||||||
|
refType = "sha"
|
||||||
|
rev = plumbing.Revision(input.Ref)
|
||||||
|
} else {
|
||||||
|
refType = "branch"
|
||||||
|
rev = plumbing.Revision(rName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if hash, err = r.ResolveRevision(rev); err != nil {
|
||||||
|
logger.Errorf("Unable to resolve %s: %v", input.Ref, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var w *git.Worktree
|
||||||
|
if w, err = r.Worktree(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the hash resolved doesn't match the ref provided in a workflow then we're
|
||||||
|
// using a branch or tag ref, not a sha
|
||||||
|
//
|
||||||
|
// Repos on disk point to commit hashes, and need to checkout input.Ref before
|
||||||
|
// we try and pull down any changes
|
||||||
|
if hash.String() != input.Ref && refType == "branch" {
|
||||||
|
logger.Debugf("Provided ref is not a sha. Checking out branch before pulling changes")
|
||||||
|
sourceRef := plumbing.ReferenceName(path.Join("refs", "remotes", "origin", input.Ref))
|
||||||
|
if err = w.Checkout(&git.CheckoutOptions{
|
||||||
|
Branch: sourceRef,
|
||||||
|
Force: true,
|
||||||
|
}); err != nil {
|
||||||
|
logger.Errorf("Unable to checkout %s: %v", sourceRef, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !isOfflineMode {
|
||||||
|
if err = w.Pull(&pullOptions); err != nil && err != git.NoErrAlreadyUpToDate {
|
||||||
|
logger.Debugf("Unable to pull %s: %v", refName, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.Debugf("Cloned %s to %s", input.URL, input.Dir)
|
||||||
|
|
||||||
|
if hash.String() != input.Ref && refType == "branch" {
|
||||||
|
logger.Debugf("Provided ref is not a sha. Updating branch ref after pull")
|
||||||
|
if hash, err = r.ResolveRevision(rev); err != nil {
|
||||||
|
logger.Errorf("Unable to resolve %s: %v", input.Ref, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err = w.Checkout(&git.CheckoutOptions{
|
||||||
|
Hash: *hash,
|
||||||
|
Force: true,
|
||||||
|
}); err != nil {
|
||||||
|
logger.Errorf("Unable to checkout %s: %v", *hash, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = w.Reset(&git.ResetOptions{
|
||||||
|
Mode: git.HardReset,
|
||||||
|
Commit: *hash,
|
||||||
|
}); err != nil {
|
||||||
|
logger.Errorf("Unable to reset to %s: %v", hash.String(), err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Debugf("Checked out %s", input.Ref)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFindGitSlug(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
slugTests := []struct {
|
||||||
|
url string // input
|
||||||
|
provider string // expected result
|
||||||
|
slug string // expected result
|
||||||
|
}{
|
||||||
|
{"https://git-codecommit.us-east-1.amazonaws.com/v1/repos/my-repo-name", "CodeCommit", "my-repo-name"},
|
||||||
|
{"ssh://git-codecommit.us-west-2.amazonaws.com/v1/repos/my-repo", "CodeCommit", "my-repo"},
|
||||||
|
{"[email protected]:nektos/act.git", "GitHub", "nektos/act"},
|
||||||
|
{"[email protected]:nektos/act", "GitHub", "nektos/act"},
|
||||||
|
{"https://github.com/nektos/act.git", "GitHub", "nektos/act"},
|
||||||
|
{"http://github.com/nektos/act.git", "GitHub", "nektos/act"},
|
||||||
|
{"https://github.com/nektos/act", "GitHub", "nektos/act"},
|
||||||
|
{"http://github.com/nektos/act", "GitHub", "nektos/act"},
|
||||||
|
{"git+ssh://[email protected]/owner/repo.git", "GitHub", "owner/repo"},
|
||||||
|
{"http://myotherrepo.com/act.git", "", "http://myotherrepo.com/act.git"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range slugTests {
|
||||||
|
provider, slug, err := findGitSlug(tt.url, "github.com")
|
||||||
|
|
||||||
|
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Equal(tt.provider, provider)
|
||||||
|
assert.Equal(tt.slug, slug)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDir(t *testing.T) string {
|
||||||
|
return t.TempDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanGitHooks(dir string) error {
|
||||||
|
hooksDir := filepath.Join(dir, ".git", "hooks")
|
||||||
|
files, err := os.ReadDir(hooksDir)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, f := range files {
|
||||||
|
if f.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
relName := filepath.Join(hooksDir, f.Name())
|
||||||
|
if err := os.Remove(relName); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindGitRemoteURL(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
basedir := testDir(t)
|
||||||
|
gitConfig()
|
||||||
|
err := gitCmd("init", basedir)
|
||||||
|
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
err = cleanGitHooks(basedir)
|
||||||
|
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
remoteURL := "https://git-codecommit.us-east-1.amazonaws.com/v1/repos/my-repo-name"
|
||||||
|
err = gitCmd("-C", basedir, "remote", "add", "origin", remoteURL)
|
||||||
|
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
u, err := findGitRemoteURL(context.Background(), basedir, "origin")
|
||||||
|
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Equal(remoteURL, u)
|
||||||
|
|
||||||
|
remoteURL = "[email protected]/AwesomeOwner/MyAwesomeRepo.git"
|
||||||
|
err = gitCmd("-C", basedir, "remote", "add", "upstream", remoteURL)
|
||||||
|
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
u, err = findGitRemoteURL(context.Background(), basedir, "upstream")
|
||||||
|
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Equal(remoteURL, u)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitFindRef(t *testing.T) {
|
||||||
|
basedir := testDir(t)
|
||||||
|
gitConfig()
|
||||||
|
|
||||||
|
for name, tt := range map[string]struct {
|
||||||
|
Prepare func(t *testing.T, dir string)
|
||||||
|
Assert func(t *testing.T, ref string, err error)
|
||||||
|
}{
|
||||||
|
"new_repo": {
|
||||||
|
Prepare: func(t *testing.T, dir string) {},
|
||||||
|
Assert: func(t *testing.T, ref string, err error) {
|
||||||
|
require.Error(t, err)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"new_repo_with_commit": {
|
||||||
|
Prepare: func(t *testing.T, dir string) {
|
||||||
|
require.NoError(t, gitCmd("-C", dir, "commit", "--allow-empty", "-m", "msg"))
|
||||||
|
},
|
||||||
|
Assert: func(t *testing.T, ref string, err error) {
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "refs/heads/master", ref)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"current_head_is_tag": {
|
||||||
|
Prepare: func(t *testing.T, dir string) {
|
||||||
|
require.NoError(t, gitCmd("-C", dir, "commit", "--allow-empty", "-m", "commit msg"))
|
||||||
|
require.NoError(t, gitCmd("-C", dir, "tag", "v1.2.3"))
|
||||||
|
require.NoError(t, gitCmd("-C", dir, "checkout", "v1.2.3"))
|
||||||
|
},
|
||||||
|
Assert: func(t *testing.T, ref string, err error) {
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "refs/tags/v1.2.3", ref)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"current_head_is_same_as_tag": {
|
||||||
|
Prepare: func(t *testing.T, dir string) {
|
||||||
|
require.NoError(t, gitCmd("-C", dir, "commit", "--allow-empty", "-m", "1.4.2 release"))
|
||||||
|
require.NoError(t, gitCmd("-C", dir, "tag", "v1.4.2"))
|
||||||
|
},
|
||||||
|
Assert: func(t *testing.T, ref string, err error) {
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "refs/tags/v1.4.2", ref)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"current_head_is_not_tag": {
|
||||||
|
Prepare: func(t *testing.T, dir string) {
|
||||||
|
require.NoError(t, gitCmd("-C", dir, "commit", "--allow-empty", "-m", "msg"))
|
||||||
|
require.NoError(t, gitCmd("-C", dir, "tag", "v1.4.2"))
|
||||||
|
require.NoError(t, gitCmd("-C", dir, "commit", "--allow-empty", "-m", "msg2"))
|
||||||
|
},
|
||||||
|
Assert: func(t *testing.T, ref string, err error) {
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "refs/heads/master", ref)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"current_head_is_another_branch": {
|
||||||
|
Prepare: func(t *testing.T, dir string) {
|
||||||
|
require.NoError(t, gitCmd("-C", dir, "checkout", "-b", "mybranch"))
|
||||||
|
require.NoError(t, gitCmd("-C", dir, "commit", "--allow-empty", "-m", "msg"))
|
||||||
|
},
|
||||||
|
Assert: func(t *testing.T, ref string, err error) {
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "refs/heads/mybranch", ref)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
dir := filepath.Join(basedir, name)
|
||||||
|
require.NoError(t, os.MkdirAll(dir, 0o755))
|
||||||
|
require.NoError(t, gitCmd("-C", dir, "init", "--initial-branch=master"))
|
||||||
|
require.NoError(t, cleanGitHooks(dir))
|
||||||
|
tt.Prepare(t, dir)
|
||||||
|
ref, err := FindGitRef(context.Background(), dir)
|
||||||
|
tt.Assert(t, ref, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitCloneExecutor(t *testing.T) {
|
||||||
|
for name, tt := range map[string]struct {
|
||||||
|
Err error
|
||||||
|
URL, Ref string
|
||||||
|
}{
|
||||||
|
"tag": {
|
||||||
|
Err: nil,
|
||||||
|
URL: "https://github.com/actions/checkout",
|
||||||
|
Ref: "v2",
|
||||||
|
},
|
||||||
|
"branch": {
|
||||||
|
Err: nil,
|
||||||
|
URL: "https://github.com/anchore/scan-action",
|
||||||
|
Ref: "act-fails",
|
||||||
|
},
|
||||||
|
"sha": {
|
||||||
|
Err: nil,
|
||||||
|
URL: "https://github.com/actions/checkout",
|
||||||
|
Ref: "5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f", // v2
|
||||||
|
},
|
||||||
|
"short-sha": {
|
||||||
|
Err: &Error{ErrShortRef, "5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f"},
|
||||||
|
URL: "https://github.com/actions/checkout",
|
||||||
|
Ref: "5a4ac90", // v2
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||||
|
URL: tt.URL,
|
||||||
|
Ref: tt.Ref,
|
||||||
|
Dir: testDir(t),
|
||||||
|
})
|
||||||
|
|
||||||
|
err := clone(context.Background())
|
||||||
|
if tt.Err != nil {
|
||||||
|
assert.Error(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Equal(t, tt.Err, err)
|
||||||
|
} else {
|
||||||
|
assert.Empty(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func gitConfig() {
|
||||||
|
if os.Getenv("GITHUB_ACTIONS") == "true" {
|
||||||
|
var err error
|
||||||
|
if err = gitCmd("config", "--global", "user.email", "[email protected]"); err != nil {
|
||||||
|
log.Error(err)
|
||||||
|
}
|
||||||
|
if err = gitCmd("config", "--global", "user.name", "Unit Test"); err != nil {
|
||||||
|
log.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func gitCmd(args ...string) error {
|
||||||
|
cmd := exec.Command("git", args...)
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
|
||||||
|
err := cmd.Run()
|
||||||
|
if exitError, ok := err.(*exec.ExitError); ok {
|
||||||
|
if waitStatus, ok := exitError.Sys().(syscall.WaitStatus); ok {
|
||||||
|
return fmt.Errorf("Exit error %d", waitStatus.ExitStatus())
|
||||||
|
}
|
||||||
|
return exitError
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2021 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
)
|
||||||
|
|
||||||
|
type jobErrorContextKey string
|
||||||
|
|
||||||
|
const jobErrorContextKeyVal = jobErrorContextKey("job.error")
|
||||||
|
|
||||||
|
// JobError returns the job error for current context if any
|
||||||
|
func JobError(ctx context.Context) error {
|
||||||
|
val := ctx.Value(jobErrorContextKeyVal)
|
||||||
|
if val != nil {
|
||||||
|
if container, ok := val.(map[string]error); ok {
|
||||||
|
return container["error"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetJobError(ctx context.Context, err error) {
|
||||||
|
ctx.Value(jobErrorContextKeyVal).(map[string]error)["error"] = err
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithJobErrorContainer adds a value to the context as a container for an error
|
||||||
|
func WithJobErrorContainer(ctx context.Context) context.Context {
|
||||||
|
container := map[string]error{}
|
||||||
|
return context.WithValue(ctx, jobErrorContextKeyVal, container)
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LineHandler is a callback function for handling a line
|
||||||
|
type LineHandler func(line string) bool
|
||||||
|
|
||||||
|
type lineWriter struct {
|
||||||
|
buffer bytes.Buffer
|
||||||
|
handlers []LineHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLineWriter creates a new instance of a line writer
|
||||||
|
func NewLineWriter(handlers ...LineHandler) io.Writer {
|
||||||
|
w := new(lineWriter)
|
||||||
|
w.handlers = handlers
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lw *lineWriter) Write(p []byte) (n int, err error) {
|
||||||
|
pBuf := bytes.NewBuffer(p)
|
||||||
|
written := 0
|
||||||
|
for {
|
||||||
|
line, err := pBuf.ReadString('\n')
|
||||||
|
w, _ := lw.buffer.WriteString(line)
|
||||||
|
written += w
|
||||||
|
if err == nil {
|
||||||
|
lw.handleLine(lw.buffer.String())
|
||||||
|
lw.buffer.Reset()
|
||||||
|
} else if err == io.EOF {
|
||||||
|
break
|
||||||
|
} else {
|
||||||
|
return written, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return written, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lw *lineWriter) handleLine(line string) {
|
||||||
|
for _, h := range lw.handlers {
|
||||||
|
ok := h(line)
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// Copyright 2020 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 TestLineWriter(t *testing.T) {
|
||||||
|
lines := make([]string, 0)
|
||||||
|
lineHandler := func(s string) bool {
|
||||||
|
lines = append(lines, s)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
lineWriter := NewLineWriter(lineHandler)
|
||||||
|
|
||||||
|
assert := assert.New(t)
|
||||||
|
write := func(s string) {
|
||||||
|
n, err := lineWriter.Write([]byte(s))
|
||||||
|
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Equal(len(s), n, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
write("hello")
|
||||||
|
write(" ")
|
||||||
|
write("world!!\nextra")
|
||||||
|
write(" line\n and another\nlast")
|
||||||
|
write(" line\n")
|
||||||
|
write("no newline here...")
|
||||||
|
|
||||||
|
assert.Len(lines, 4)
|
||||||
|
assert.Equal("hello world!!\n", lines[0])
|
||||||
|
assert.Equal("extra line\n", lines[1])
|
||||||
|
assert.Equal(" and another\n", lines[2])
|
||||||
|
assert.Equal("last line\n", lines[3])
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
type loggerContextKey string
|
||||||
|
|
||||||
|
const loggerContextKeyVal = loggerContextKey("logrus.FieldLogger")
|
||||||
|
|
||||||
|
// Logger returns the appropriate logger for current context
|
||||||
|
func Logger(ctx context.Context) logrus.FieldLogger {
|
||||||
|
val := ctx.Value(loggerContextKeyVal)
|
||||||
|
if val != nil {
|
||||||
|
if logger, ok := val.(logrus.FieldLogger); ok {
|
||||||
|
return logger
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return logrus.StandardLogger()
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithLogger adds a value to the context for the logger
|
||||||
|
func WithLogger(ctx context.Context, logger logrus.FieldLogger) context.Context {
|
||||||
|
return context.WithValue(ctx, loggerContextKeyVal, logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
type loggerHookKey string
|
||||||
|
|
||||||
|
const loggerHookKeyVal = loggerHookKey("logrus.Hook")
|
||||||
|
|
||||||
|
// LoggerHook returns the appropriate logger hook for current context
|
||||||
|
// the hook affects job logger, not global logger
|
||||||
|
func LoggerHook(ctx context.Context) logrus.Hook {
|
||||||
|
val := ctx.Value(loggerHookKeyVal)
|
||||||
|
if val != nil {
|
||||||
|
if hook, ok := val.(logrus.Hook); ok {
|
||||||
|
return hook
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithLoggerHook adds a value to the context for the logger hook
|
||||||
|
func WithLoggerHook(ctx context.Context, hook logrus.Hook) context.Context {
|
||||||
|
return context.WithValue(ctx, loggerHookKeyVal, hook)
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2021 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetOutboundIP returns an outbound IP address of this machine.
|
||||||
|
// It tries to access the internet and returns the local IP address of the connection.
|
||||||
|
// If the machine cannot access the internet, it returns a preferred IP address from network interfaces.
|
||||||
|
// It returns nil if no IP address is found.
|
||||||
|
func GetOutboundIP() net.IP {
|
||||||
|
// See https://stackoverflow.com/a/37382208
|
||||||
|
conn, err := net.Dial("udp", "8.8.8.8:80")
|
||||||
|
if err == nil {
|
||||||
|
defer conn.Close()
|
||||||
|
return conn.LocalAddr().(*net.UDPAddr).IP
|
||||||
|
}
|
||||||
|
|
||||||
|
// So the machine cannot access the internet. Pick an IP address from network interfaces.
|
||||||
|
if ifs, err := net.Interfaces(); err == nil {
|
||||||
|
type IP struct {
|
||||||
|
net.IP
|
||||||
|
net.Interface
|
||||||
|
}
|
||||||
|
var ips []IP
|
||||||
|
for _, i := range ifs {
|
||||||
|
if addrs, err := i.Addrs(); err == nil {
|
||||||
|
for _, addr := range addrs {
|
||||||
|
var ip net.IP
|
||||||
|
switch v := addr.(type) {
|
||||||
|
case *net.IPNet:
|
||||||
|
ip = v.IP
|
||||||
|
case *net.IPAddr:
|
||||||
|
ip = v.IP
|
||||||
|
}
|
||||||
|
if ip.IsGlobalUnicast() {
|
||||||
|
ips = append(ips, IP{ip, i})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(ips) > 1 {
|
||||||
|
sort.Slice(ips, func(i, j int) bool {
|
||||||
|
ifi := ips[i].Interface
|
||||||
|
ifj := ips[j].Interface
|
||||||
|
|
||||||
|
// ethernet is preferred
|
||||||
|
if vi, vj := strings.HasPrefix(ifi.Name, "e"), strings.HasPrefix(ifj.Name, "e"); vi != vj {
|
||||||
|
return vi
|
||||||
|
}
|
||||||
|
|
||||||
|
ipi := ips[i].IP
|
||||||
|
ipj := ips[j].IP
|
||||||
|
|
||||||
|
// IPv4 is preferred
|
||||||
|
if vi, vj := ipi.To4() != nil, ipj.To4() != nil; vi != vj {
|
||||||
|
return vi
|
||||||
|
}
|
||||||
|
|
||||||
|
// en0 is preferred to en1
|
||||||
|
if ifi.Name != ifj.Name {
|
||||||
|
return ifi.Name < ifj.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// fallback
|
||||||
|
return ipi.String() < ipj.String()
|
||||||
|
})
|
||||||
|
return ips[0].IP
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
https://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
Copyright 2013-2017 Docker, Inc.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2023 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"gitea.com/gitea/act_runner/act/common"
|
||||||
|
|
||||||
|
"github.com/docker/go-connections/nat"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewContainerInput the input for the New function
|
||||||
|
type NewContainerInput struct {
|
||||||
|
Image string
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
Entrypoint []string
|
||||||
|
Cmd []string
|
||||||
|
WorkingDir string
|
||||||
|
Env []string
|
||||||
|
Binds []string
|
||||||
|
Mounts map[string]string
|
||||||
|
Name string
|
||||||
|
Stdout io.Writer
|
||||||
|
Stderr io.Writer
|
||||||
|
NetworkMode string
|
||||||
|
Privileged bool
|
||||||
|
UsernsMode string
|
||||||
|
Platform string
|
||||||
|
Options string
|
||||||
|
NetworkAliases []string
|
||||||
|
ExposedPorts nat.PortSet
|
||||||
|
PortBindings nat.PortMap
|
||||||
|
|
||||||
|
// Gitea specific
|
||||||
|
AutoRemove bool
|
||||||
|
ValidVolumes []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileEntry is a file to copy to a container
|
||||||
|
type FileEntry struct {
|
||||||
|
Name string
|
||||||
|
Mode int64
|
||||||
|
Body string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Container for managing docker run containers
|
||||||
|
type Container interface {
|
||||||
|
Create(capAdd, capDrop []string) common.Executor
|
||||||
|
ConnectToNetwork(name string) common.Executor
|
||||||
|
Copy(destPath string, files ...*FileEntry) common.Executor
|
||||||
|
CopyTarStream(ctx context.Context, destPath string, tarStream io.Reader) error
|
||||||
|
CopyDir(destPath, srcPath string, useGitIgnore bool) common.Executor
|
||||||
|
GetContainerArchive(ctx context.Context, srcPath string) (io.ReadCloser, error)
|
||||||
|
Pull(forcePull bool) common.Executor
|
||||||
|
Start(attach bool) common.Executor
|
||||||
|
Exec(command []string, env map[string]string, user, workdir string) common.Executor
|
||||||
|
UpdateFromEnv(srcPath string, env *map[string]string) common.Executor
|
||||||
|
UpdateFromImageEnv(env *map[string]string) common.Executor
|
||||||
|
Remove() common.Executor
|
||||||
|
Close() common.Executor
|
||||||
|
ReplaceLogWriter(io.Writer, io.Writer) (io.Writer, io.Writer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDockerBuildExecutorInput the input for the NewDockerBuildExecutor function
|
||||||
|
type NewDockerBuildExecutorInput struct {
|
||||||
|
ContextDir string
|
||||||
|
Dockerfile string
|
||||||
|
BuildContext io.Reader
|
||||||
|
ImageTag string
|
||||||
|
Platform string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDockerPullExecutorInput the input for the NewDockerPullExecutor function
|
||||||
|
type NewDockerPullExecutorInput struct {
|
||||||
|
Image string
|
||||||
|
ForcePull bool
|
||||||
|
Platform string
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2021 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.com/gitea/act_runner/act/common"
|
||||||
|
|
||||||
|
"github.com/docker/cli/cli/config"
|
||||||
|
"github.com/docker/cli/cli/config/credentials"
|
||||||
|
"github.com/docker/docker/api/types/registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
func LoadDockerAuthConfig(ctx context.Context, image string) (registry.AuthConfig, error) {
|
||||||
|
logger := common.Logger(ctx)
|
||||||
|
config, err := config.Load(config.Dir())
|
||||||
|
if err != nil {
|
||||||
|
logger.Warnf("Could not load docker config: %v", err)
|
||||||
|
return registry.AuthConfig{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !config.ContainsAuth() {
|
||||||
|
config.CredentialsStore = credentials.DetectDefaultStore(config.CredentialsStore)
|
||||||
|
}
|
||||||
|
|
||||||
|
hostName := "index.docker.io"
|
||||||
|
index := strings.IndexRune(image, '/')
|
||||||
|
if index > -1 && (strings.ContainsAny(image[:index], ".:") || image[:index] == "localhost") {
|
||||||
|
hostName = image[:index]
|
||||||
|
}
|
||||||
|
|
||||||
|
authConfig, err := config.GetAuthConfig(hostName)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warnf("Could not get auth config from docker config: %v", err)
|
||||||
|
return registry.AuthConfig{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return registry.AuthConfig(authConfig), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadDockerAuthConfigs(ctx context.Context) map[string]registry.AuthConfig {
|
||||||
|
logger := common.Logger(ctx)
|
||||||
|
config, err := config.Load(config.Dir())
|
||||||
|
if err != nil {
|
||||||
|
logger.Warnf("Could not load docker config: %v", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !config.ContainsAuth() {
|
||||||
|
config.CredentialsStore = credentials.DetectDefaultStore(config.CredentialsStore)
|
||||||
|
}
|
||||||
|
|
||||||
|
creds, _ := config.GetAllCredentials()
|
||||||
|
authConfigs := make(map[string]registry.AuthConfig, len(creds))
|
||||||
|
for k, v := range creds {
|
||||||
|
authConfigs[k] = registry.AuthConfig(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
return authConfigs
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"gitea.com/gitea/act_runner/act/common"
|
||||||
|
|
||||||
|
"github.com/docker/docker/api/types"
|
||||||
|
"github.com/docker/docker/pkg/archive"
|
||||||
|
// github.com/docker/docker/builder/dockerignore is deprecated
|
||||||
|
"github.com/moby/buildkit/frontend/dockerfile/dockerignore"
|
||||||
|
"github.com/moby/patternmatcher"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewDockerBuildExecutor function to create a run executor for the container
|
||||||
|
func NewDockerBuildExecutor(input NewDockerBuildExecutorInput) common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
logger := common.Logger(ctx)
|
||||||
|
if input.Platform != "" {
|
||||||
|
logger.Infof("%sdocker build -t %s --platform %s %s", logPrefix, input.ImageTag, input.Platform, input.ContextDir)
|
||||||
|
} else {
|
||||||
|
logger.Infof("%sdocker build -t %s %s", logPrefix, input.ImageTag, input.ContextDir)
|
||||||
|
}
|
||||||
|
if common.Dryrun(ctx) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cli, err := GetDockerClient(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer cli.Close()
|
||||||
|
|
||||||
|
logger.Debugf("Building image from '%v'", input.ContextDir)
|
||||||
|
|
||||||
|
tags := []string{input.ImageTag}
|
||||||
|
options := types.ImageBuildOptions{
|
||||||
|
Tags: tags,
|
||||||
|
Remove: true,
|
||||||
|
Platform: input.Platform,
|
||||||
|
AuthConfigs: LoadDockerAuthConfigs(ctx),
|
||||||
|
Dockerfile: input.Dockerfile,
|
||||||
|
}
|
||||||
|
var buildContext io.ReadCloser
|
||||||
|
if input.BuildContext != nil {
|
||||||
|
buildContext = io.NopCloser(input.BuildContext)
|
||||||
|
} else {
|
||||||
|
buildContext, err = createBuildContext(ctx, input.ContextDir, input.Dockerfile)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer buildContext.Close()
|
||||||
|
|
||||||
|
logger.Debugf("Creating image from context dir '%s' with tag '%s' and platform '%s'", input.ContextDir, input.ImageTag, input.Platform)
|
||||||
|
resp, err := cli.ImageBuild(ctx, buildContext, options)
|
||||||
|
|
||||||
|
err = logDockerResponse(logger, resp.Body, err != nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createBuildContext(ctx context.Context, contextDir, relDockerfile string) (io.ReadCloser, error) {
|
||||||
|
common.Logger(ctx).Debugf("Creating archive for build context dir '%s' with relative dockerfile '%s'", contextDir, relDockerfile)
|
||||||
|
|
||||||
|
// And canonicalize dockerfile name to a platform-independent one
|
||||||
|
relDockerfile = archive.CanonicalTarNameForPath(relDockerfile)
|
||||||
|
|
||||||
|
f, err := os.Open(filepath.Join(contextDir, ".dockerignore"))
|
||||||
|
if err != nil && !os.IsNotExist(err) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
var excludes []string
|
||||||
|
if err == nil {
|
||||||
|
excludes, err = dockerignore.ReadAll(f) //nolint:staticcheck // pre-existing issue from nektos/act
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If .dockerignore mentions .dockerignore or the Dockerfile
|
||||||
|
// then make sure we send both files over to the daemon
|
||||||
|
// because Dockerfile is, obviously, needed no matter what, and
|
||||||
|
// .dockerignore is needed to know if either one needs to be
|
||||||
|
// removed. The daemon will remove them for us, if needed, after it
|
||||||
|
// parses the Dockerfile. Ignore errors here, as they will have been
|
||||||
|
// caught by validateContextDirectory above.
|
||||||
|
includes := []string{"."}
|
||||||
|
keepThem1, _ := patternmatcher.Matches(".dockerignore", excludes)
|
||||||
|
keepThem2, _ := patternmatcher.Matches(relDockerfile, excludes)
|
||||||
|
if keepThem1 || keepThem2 {
|
||||||
|
includes = append(includes, ".dockerignore", relDockerfile)
|
||||||
|
}
|
||||||
|
|
||||||
|
compression := archive.Uncompressed
|
||||||
|
buildCtx, err := archive.TarWithOptions(contextDir, &archive.TarOptions{
|
||||||
|
Compression: compression,
|
||||||
|
ExcludePatterns: excludes,
|
||||||
|
IncludeFiles: includes,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildCtx, nil
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,64 @@
|
|||||||
|
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/docker/docker/api/types"
|
||||||
|
"github.com/docker/docker/client"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ImageExistsLocally returns a boolean indicating if an image with the
|
||||||
|
// requested name, tag and architecture exists in the local docker image store
|
||||||
|
func ImageExistsLocally(ctx context.Context, imageName, platform string) (bool, error) {
|
||||||
|
cli, err := GetDockerClient(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer cli.Close()
|
||||||
|
|
||||||
|
inspectImage, _, err := cli.ImageInspectWithRaw(ctx, imageName)
|
||||||
|
if client.IsErrNotFound(err) {
|
||||||
|
return false, nil
|
||||||
|
} else if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if platform == "" || platform == "any" || fmt.Sprintf("%s/%s", inspectImage.Os, inspectImage.Architecture) == platform {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveImage removes image from local store, the function is used to run different
|
||||||
|
// container image architectures
|
||||||
|
func RemoveImage(ctx context.Context, imageName string, force, pruneChildren bool) (bool, error) {
|
||||||
|
cli, err := GetDockerClient(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer cli.Close()
|
||||||
|
|
||||||
|
inspectImage, _, err := cli.ImageInspectWithRaw(ctx, imageName)
|
||||||
|
if client.IsErrNotFound(err) {
|
||||||
|
return false, nil
|
||||||
|
} else if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err = cli.ImageRemove(ctx, inspectImage.ID, types.ImageRemoveOptions{
|
||||||
|
Force: force,
|
||||||
|
PruneChildren: pruneChildren,
|
||||||
|
}); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/docker/docker/api/types"
|
||||||
|
"github.com/docker/docker/client"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
log.SetLevel(log.DebugLevel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImageExistsLocally(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("skipping integration test")
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
// to help make this test reliable and not flaky, we need to have
|
||||||
|
// an image that will exist, and onew that won't exist
|
||||||
|
|
||||||
|
// Test if image exists with specific tag
|
||||||
|
invalidImageTag, err := ImageExistsLocally(ctx, "library/alpine:this-random-tag-will-never-exist", "linux/amd64")
|
||||||
|
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Equal(t, false, invalidImageTag) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
// Test if image exists with specific architecture (image platform)
|
||||||
|
invalidImagePlatform, err := ImageExistsLocally(ctx, "alpine:latest", "windows/amd64")
|
||||||
|
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Equal(t, false, invalidImagePlatform) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
// pull an image
|
||||||
|
cli, err := client.NewClientWithOpts(client.FromEnv)
|
||||||
|
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
cli.NegotiateAPIVersion(context.Background())
|
||||||
|
|
||||||
|
// Chose alpine latest because it's so small
|
||||||
|
// maybe we should build an image instead so that tests aren't reliable on dockerhub
|
||||||
|
readerDefault, err := cli.ImagePull(ctx, "node:16-buster-slim", types.ImagePullOptions{
|
||||||
|
Platform: "linux/amd64",
|
||||||
|
})
|
||||||
|
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
defer readerDefault.Close()
|
||||||
|
_, err = io.ReadAll(readerDefault)
|
||||||
|
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
imageDefaultArchExists, err := ImageExistsLocally(ctx, "node:16-buster-slim", "linux/amd64")
|
||||||
|
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Equal(t, true, imageDefaultArchExists) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
// Validate if another architecture platform can be pulled
|
||||||
|
readerArm64, err := cli.ImagePull(ctx, "node:16-buster-slim", types.ImagePullOptions{
|
||||||
|
Platform: "linux/arm64",
|
||||||
|
})
|
||||||
|
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
defer readerArm64.Close()
|
||||||
|
_, err = io.ReadAll(readerArm64)
|
||||||
|
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
imageArm64Exists, err := ImageExistsLocally(ctx, "node:16-buster-slim", "linux/arm64")
|
||||||
|
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Equal(t, true, imageArm64Exists) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
type dockerMessage struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Stream string `json:"stream"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
ErrorDetail struct {
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
Status string `json:"status"`
|
||||||
|
Progress string `json:"progress"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const logPrefix = " \U0001F433 "
|
||||||
|
|
||||||
|
func logDockerResponse(logger logrus.FieldLogger, dockerResponse io.ReadCloser, isError bool) error {
|
||||||
|
if dockerResponse == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer dockerResponse.Close()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(dockerResponse)
|
||||||
|
msg := dockerMessage{}
|
||||||
|
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Bytes()
|
||||||
|
|
||||||
|
msg.ID = ""
|
||||||
|
msg.Stream = ""
|
||||||
|
msg.Error = ""
|
||||||
|
msg.ErrorDetail.Message = ""
|
||||||
|
msg.Status = ""
|
||||||
|
msg.Progress = ""
|
||||||
|
|
||||||
|
if err := json.Unmarshal(line, &msg); err != nil {
|
||||||
|
writeLog(logger, false, "Unable to unmarshal line [%s] ==> %v", string(line), err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if msg.Error != "" {
|
||||||
|
writeLog(logger, isError, "%s", msg.Error)
|
||||||
|
return errors.New(msg.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
if msg.ErrorDetail.Message != "" {
|
||||||
|
writeLog(logger, isError, "%s", msg.ErrorDetail.Message)
|
||||||
|
return errors.New(msg.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
if msg.Status != "" {
|
||||||
|
if msg.Progress != "" {
|
||||||
|
writeLog(logger, isError, "%s :: %s :: %s\n", msg.Status, msg.ID, msg.Progress)
|
||||||
|
} else {
|
||||||
|
writeLog(logger, isError, "%s :: %s\n", msg.Status, msg.ID)
|
||||||
|
}
|
||||||
|
} else if msg.Stream != "" {
|
||||||
|
writeLog(logger, isError, "%s", msg.Stream)
|
||||||
|
} else {
|
||||||
|
writeLog(logger, false, "Unable to handle line: %s", string(line))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeLog(logger logrus.FieldLogger, isError bool, format string, args ...any) {
|
||||||
|
if isError {
|
||||||
|
logger.Errorf(format, args...)
|
||||||
|
} else {
|
||||||
|
logger.Debugf(format, args...)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2023 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"gitea.com/gitea/act_runner/act/common"
|
||||||
|
|
||||||
|
"github.com/docker/docker/api/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewDockerNetworkCreateExecutor(name string) common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
cli, err := GetDockerClient(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer cli.Close()
|
||||||
|
|
||||||
|
// Only create the network if it doesn't exist
|
||||||
|
networks, err := cli.NetworkList(ctx, types.NetworkListOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// For Gitea, reduce log noise
|
||||||
|
// common.Logger(ctx).Debugf("%v", networks)
|
||||||
|
for _, network := range networks {
|
||||||
|
if network.Name == name {
|
||||||
|
common.Logger(ctx).Debugf("Network %v exists", name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = cli.NetworkCreate(ctx, name, types.NetworkCreate{
|
||||||
|
Driver: "bridge",
|
||||||
|
Scope: "local",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDockerNetworkRemoveExecutor(name string) common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
cli, err := GetDockerClient(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer cli.Close()
|
||||||
|
|
||||||
|
// Make shure that all network of the specified name are removed
|
||||||
|
// cli.NetworkRemove refuses to remove a network if there are duplicates
|
||||||
|
networks, err := cli.NetworkList(ctx, types.NetworkListOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// For Gitea, reduce log noise
|
||||||
|
// common.Logger(ctx).Debugf("%v", networks)
|
||||||
|
for _, network := range networks {
|
||||||
|
if network.Name == name {
|
||||||
|
result, err := cli.NetworkInspect(ctx, network.ID, types.NetworkInspectOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Containers) == 0 {
|
||||||
|
if err = cli.NetworkRemove(ctx, network.ID); err != nil {
|
||||||
|
common.Logger(ctx).Debugf("%v", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
common.Logger(ctx).Debugf("Refusing to remove network %v because it still has active endpoints", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.com/gitea/act_runner/act/common"
|
||||||
|
|
||||||
|
"github.com/distribution/reference"
|
||||||
|
"github.com/docker/docker/api/types"
|
||||||
|
"github.com/docker/docker/api/types/registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewDockerPullExecutor function to create a run executor for the container
|
||||||
|
func NewDockerPullExecutor(input NewDockerPullExecutorInput) common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
logger := common.Logger(ctx)
|
||||||
|
logger.Debugf("%sdocker pull %v", logPrefix, input.Image)
|
||||||
|
|
||||||
|
if common.Dryrun(ctx) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
pull := input.ForcePull
|
||||||
|
if !pull {
|
||||||
|
imageExists, err := ImageExistsLocally(ctx, input.Image, input.Platform)
|
||||||
|
logger.Debugf("Image exists? %v", imageExists)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to determine if image already exists for image '%s' (%s): %w", input.Image, input.Platform, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !imageExists {
|
||||||
|
pull = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !pull {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
imageRef := cleanImage(ctx, input.Image)
|
||||||
|
logger.Debugf("pulling image '%v' (%s)", imageRef, input.Platform)
|
||||||
|
|
||||||
|
cli, err := GetDockerClient(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer cli.Close()
|
||||||
|
|
||||||
|
imagePullOptions, err := getImagePullOptions(ctx, input)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
reader, err := cli.ImagePull(ctx, imageRef, imagePullOptions)
|
||||||
|
|
||||||
|
_ = logDockerResponse(logger, reader, err != nil)
|
||||||
|
if err != nil {
|
||||||
|
if imagePullOptions.RegistryAuth != "" && strings.Contains(err.Error(), "unauthorized") {
|
||||||
|
logger.Errorf("pulling image '%v' (%s) failed with credentials %s retrying without them, please check for stale docker config files", imageRef, input.Platform, err.Error())
|
||||||
|
imagePullOptions.RegistryAuth = ""
|
||||||
|
reader, err = cli.ImagePull(ctx, imageRef, imagePullOptions)
|
||||||
|
|
||||||
|
_ = logDockerResponse(logger, reader, err != nil)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getImagePullOptions(ctx context.Context, input NewDockerPullExecutorInput) (types.ImagePullOptions, error) {
|
||||||
|
imagePullOptions := types.ImagePullOptions{
|
||||||
|
Platform: input.Platform,
|
||||||
|
}
|
||||||
|
logger := common.Logger(ctx)
|
||||||
|
|
||||||
|
if input.Username != "" && input.Password != "" {
|
||||||
|
logger.Debugf("using authentication for docker pull")
|
||||||
|
|
||||||
|
authConfig := registry.AuthConfig{
|
||||||
|
Username: input.Username,
|
||||||
|
Password: input.Password,
|
||||||
|
}
|
||||||
|
|
||||||
|
encodedJSON, err := json.Marshal(authConfig)
|
||||||
|
if err != nil {
|
||||||
|
return imagePullOptions, err
|
||||||
|
}
|
||||||
|
|
||||||
|
imagePullOptions.RegistryAuth = base64.URLEncoding.EncodeToString(encodedJSON)
|
||||||
|
} else {
|
||||||
|
authConfig, err := LoadDockerAuthConfig(ctx, input.Image)
|
||||||
|
if err != nil {
|
||||||
|
return imagePullOptions, err
|
||||||
|
}
|
||||||
|
if authConfig.Username == "" && authConfig.Password == "" {
|
||||||
|
return imagePullOptions, nil
|
||||||
|
}
|
||||||
|
logger.Info("using DockerAuthConfig authentication for docker pull")
|
||||||
|
|
||||||
|
encodedJSON, err := json.Marshal(authConfig)
|
||||||
|
if err != nil {
|
||||||
|
return imagePullOptions, err
|
||||||
|
}
|
||||||
|
|
||||||
|
imagePullOptions.RegistryAuth = base64.URLEncoding.EncodeToString(encodedJSON)
|
||||||
|
}
|
||||||
|
|
||||||
|
return imagePullOptions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanImage(ctx context.Context, image string) string {
|
||||||
|
ref, err := reference.ParseAnyReference(image)
|
||||||
|
if err != nil {
|
||||||
|
common.Logger(ctx).Error(err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return ref.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/docker/cli/cli/config"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
assert "github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
log.SetLevel(log.DebugLevel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanImage(t *testing.T) {
|
||||||
|
tables := []struct {
|
||||||
|
imageIn string
|
||||||
|
imageOut string
|
||||||
|
}{
|
||||||
|
{"myhost.com/foo/bar", "myhost.com/foo/bar"},
|
||||||
|
{"localhost:8000/canonical/ubuntu", "localhost:8000/canonical/ubuntu"},
|
||||||
|
{"localhost/canonical/ubuntu:latest", "localhost/canonical/ubuntu:latest"},
|
||||||
|
{"localhost:8000/canonical/ubuntu:latest", "localhost:8000/canonical/ubuntu:latest"},
|
||||||
|
{"ubuntu", "docker.io/library/ubuntu"},
|
||||||
|
{"ubuntu:18.04", "docker.io/library/ubuntu:18.04"},
|
||||||
|
{"cibuilds/hugo:0.53", "docker.io/cibuilds/hugo:0.53"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, table := range tables {
|
||||||
|
imageOut := cleanImage(context.Background(), table.imageIn)
|
||||||
|
assert.Equal(t, table.imageOut, imageOut)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetImagePullOptions(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
config.SetDir("/non-existent/docker")
|
||||||
|
|
||||||
|
options, err := getImagePullOptions(ctx, NewDockerPullExecutorInput{})
|
||||||
|
assert.Nil(t, err, "Failed to create ImagePullOptions") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Equal(t, "", options.RegistryAuth, "RegistryAuth should be empty if no username or password is set") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
options, err = getImagePullOptions(ctx, NewDockerPullExecutorInput{
|
||||||
|
Image: "",
|
||||||
|
Username: "username",
|
||||||
|
Password: "password",
|
||||||
|
})
|
||||||
|
assert.Nil(t, err, "Failed to create ImagePullOptions") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Equal(t, "eyJ1c2VybmFtZSI6InVzZXJuYW1lIiwicGFzc3dvcmQiOiJwYXNzd29yZCJ9", options.RegistryAuth, "Username and Password should be provided")
|
||||||
|
|
||||||
|
config.SetDir("testdata/docker-pull-options")
|
||||||
|
|
||||||
|
options, err = getImagePullOptions(ctx, NewDockerPullExecutorInput{
|
||||||
|
Image: "nektos/act",
|
||||||
|
})
|
||||||
|
assert.Nil(t, err, "Failed to create ImagePullOptions") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Equal(t, "eyJ1c2VybmFtZSI6InVzZXJuYW1lIiwicGFzc3dvcmQiOiJwYXNzd29yZFxuIiwic2VydmVyYWRkcmVzcyI6Imh0dHBzOi8vaW5kZXguZG9ja2VyLmlvL3YxLyJ9", options.RegistryAuth, "RegistryAuth should be taken from local docker config")
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,329 @@
|
|||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.com/gitea/act_runner/act/common"
|
||||||
|
|
||||||
|
"github.com/docker/docker/api/types"
|
||||||
|
"github.com/docker/docker/api/types/container"
|
||||||
|
"github.com/docker/docker/client"
|
||||||
|
"github.com/sirupsen/logrus/hooks/test"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/mock"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDocker(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
client, err := GetDockerClient(ctx)
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
dockerBuild := NewDockerBuildExecutor(NewDockerBuildExecutorInput{
|
||||||
|
ContextDir: "testdata",
|
||||||
|
ImageTag: "envmergetest",
|
||||||
|
})
|
||||||
|
|
||||||
|
err = dockerBuild(ctx)
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
cr := &containerReference{
|
||||||
|
cli: client,
|
||||||
|
input: &NewContainerInput{
|
||||||
|
Image: "envmergetest",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
env := map[string]string{
|
||||||
|
"PATH": "/usr/local/bin:/usr/bin:/usr/sbin:/bin:/sbin",
|
||||||
|
"RANDOM_VAR": "WITH_VALUE",
|
||||||
|
"ANOTHER_VAR": "",
|
||||||
|
"CONFLICT_VAR": "I_EXIST_IN_MULTIPLE_PLACES",
|
||||||
|
}
|
||||||
|
|
||||||
|
envExecutor := cr.extractFromImageEnv(&env)
|
||||||
|
err = envExecutor(ctx)
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Equal(t, map[string]string{
|
||||||
|
"PATH": "/usr/local/bin:/usr/bin:/usr/sbin:/bin:/sbin:/this/path/does/not/exists/anywhere:/this/either",
|
||||||
|
"RANDOM_VAR": "WITH_VALUE",
|
||||||
|
"ANOTHER_VAR": "",
|
||||||
|
"SOME_RANDOM_VAR": "",
|
||||||
|
"ANOTHER_ONE": "BUT_I_HAVE_VALUE",
|
||||||
|
"CONFLICT_VAR": "I_EXIST_IN_MULTIPLE_PLACES",
|
||||||
|
}, env)
|
||||||
|
}
|
||||||
|
|
||||||
|
type mockDockerClient struct {
|
||||||
|
client.APIClient
|
||||||
|
mock.Mock
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockDockerClient) ContainerExecCreate(ctx context.Context, id string, opts types.ExecConfig) (types.IDResponse, error) {
|
||||||
|
args := m.Called(ctx, id, opts)
|
||||||
|
return args.Get(0).(types.IDResponse), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockDockerClient) ContainerExecAttach(ctx context.Context, id string, opts types.ExecStartCheck) (types.HijackedResponse, error) {
|
||||||
|
args := m.Called(ctx, id, opts)
|
||||||
|
return args.Get(0).(types.HijackedResponse), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockDockerClient) ContainerExecInspect(ctx context.Context, execID string) (types.ContainerExecInspect, error) {
|
||||||
|
args := m.Called(ctx, execID)
|
||||||
|
return args.Get(0).(types.ContainerExecInspect), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockDockerClient) CopyToContainer(ctx context.Context, id, path string, content io.Reader, options types.CopyToContainerOptions) error {
|
||||||
|
args := m.Called(ctx, id, path, content, options)
|
||||||
|
return args.Error(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
type endlessReader struct {
|
||||||
|
io.Reader
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r endlessReader) Read(_ []byte) (n int, err error) {
|
||||||
|
return 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type mockConn struct {
|
||||||
|
net.Conn
|
||||||
|
mock.Mock
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockConn) Write(b []byte) (n int, err error) {
|
||||||
|
args := m.Called(b)
|
||||||
|
return args.Int(0), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockConn) Close() (err error) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDockerExecAbort(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
|
conn := &mockConn{}
|
||||||
|
conn.On("Write", mock.AnythingOfType("[]uint8")).Return(1, nil)
|
||||||
|
|
||||||
|
client := &mockDockerClient{}
|
||||||
|
client.On("ContainerExecCreate", ctx, "123", mock.AnythingOfType("types.ExecConfig")).Return(types.IDResponse{ID: "id"}, nil)
|
||||||
|
client.On("ContainerExecAttach", ctx, "id", mock.AnythingOfType("types.ExecStartCheck")).Return(types.HijackedResponse{
|
||||||
|
Conn: conn,
|
||||||
|
Reader: bufio.NewReader(endlessReader{}),
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
cr := &containerReference{
|
||||||
|
id: "123",
|
||||||
|
cli: client,
|
||||||
|
input: &NewContainerInput{
|
||||||
|
Image: "image",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
channel := make(chan error)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
channel <- cr.exec([]string{""}, map[string]string{}, "user", "workdir")(ctx)
|
||||||
|
}()
|
||||||
|
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
err := <-channel
|
||||||
|
assert.ErrorIs(t, err, context.Canceled) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
conn.AssertExpectations(t)
|
||||||
|
client.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDockerExecFailure(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
conn := &mockConn{}
|
||||||
|
|
||||||
|
client := &mockDockerClient{}
|
||||||
|
client.On("ContainerExecCreate", ctx, "123", mock.AnythingOfType("types.ExecConfig")).Return(types.IDResponse{ID: "id"}, nil)
|
||||||
|
client.On("ContainerExecAttach", ctx, "id", mock.AnythingOfType("types.ExecStartCheck")).Return(types.HijackedResponse{
|
||||||
|
Conn: conn,
|
||||||
|
Reader: bufio.NewReader(strings.NewReader("output")),
|
||||||
|
}, nil)
|
||||||
|
client.On("ContainerExecInspect", ctx, "id").Return(types.ContainerExecInspect{
|
||||||
|
ExitCode: 1,
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
cr := &containerReference{
|
||||||
|
id: "123",
|
||||||
|
cli: client,
|
||||||
|
input: &NewContainerInput{
|
||||||
|
Image: "image",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := cr.exec([]string{""}, map[string]string{}, "user", "workdir")(ctx)
|
||||||
|
assert.Error(t, err, "exit with `FAILURE`: 1") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
conn.AssertExpectations(t)
|
||||||
|
client.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDockerCopyTarStream(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
conn := &mockConn{}
|
||||||
|
|
||||||
|
client := &mockDockerClient{}
|
||||||
|
client.On("CopyToContainer", ctx, "123", "/", mock.Anything, mock.AnythingOfType("types.CopyToContainerOptions")).Return(nil)
|
||||||
|
client.On("CopyToContainer", ctx, "123", "/var/run/act", mock.Anything, mock.AnythingOfType("types.CopyToContainerOptions")).Return(nil)
|
||||||
|
cr := &containerReference{
|
||||||
|
id: "123",
|
||||||
|
cli: client,
|
||||||
|
input: &NewContainerInput{
|
||||||
|
Image: "image",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{})
|
||||||
|
|
||||||
|
conn.AssertExpectations(t)
|
||||||
|
client.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDockerCopyTarStreamErrorInCopyFiles(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
conn := &mockConn{}
|
||||||
|
|
||||||
|
merr := errors.New("Failure")
|
||||||
|
|
||||||
|
client := &mockDockerClient{}
|
||||||
|
client.On("CopyToContainer", ctx, "123", "/", mock.Anything, mock.AnythingOfType("types.CopyToContainerOptions")).Return(merr)
|
||||||
|
client.On("CopyToContainer", ctx, "123", "/", mock.Anything, mock.AnythingOfType("types.CopyToContainerOptions")).Return(merr)
|
||||||
|
cr := &containerReference{
|
||||||
|
id: "123",
|
||||||
|
cli: client,
|
||||||
|
input: &NewContainerInput{
|
||||||
|
Image: "image",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{})
|
||||||
|
assert.ErrorIs(t, err, merr) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
conn.AssertExpectations(t)
|
||||||
|
client.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDockerCopyTarStreamErrorInMkdir(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
conn := &mockConn{}
|
||||||
|
|
||||||
|
merr := errors.New("Failure")
|
||||||
|
|
||||||
|
client := &mockDockerClient{}
|
||||||
|
client.On("CopyToContainer", ctx, "123", "/", mock.Anything, mock.AnythingOfType("types.CopyToContainerOptions")).Return(nil)
|
||||||
|
client.On("CopyToContainer", ctx, "123", "/var/run/act", mock.Anything, mock.AnythingOfType("types.CopyToContainerOptions")).Return(merr)
|
||||||
|
cr := &containerReference{
|
||||||
|
id: "123",
|
||||||
|
cli: client,
|
||||||
|
input: &NewContainerInput{
|
||||||
|
Image: "image",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{})
|
||||||
|
assert.ErrorIs(t, err, merr) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
conn.AssertExpectations(t)
|
||||||
|
client.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type assert containerReference implements ExecutionsEnvironment
|
||||||
|
var _ ExecutionsEnvironment = &containerReference{}
|
||||||
|
|
||||||
|
func TestCheckVolumes(t *testing.T) {
|
||||||
|
testCases := []struct {
|
||||||
|
desc string
|
||||||
|
validVolumes []string
|
||||||
|
binds []string
|
||||||
|
expectedBinds []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
desc: "match all volumes",
|
||||||
|
validVolumes: []string{"**"},
|
||||||
|
binds: []string{
|
||||||
|
"shared_volume:/shared_volume",
|
||||||
|
"/home/test/data:/test_data",
|
||||||
|
"/etc/conf.d/base.json:/config/base.json",
|
||||||
|
"sql_data:/sql_data",
|
||||||
|
"/secrets/keys:/keys",
|
||||||
|
},
|
||||||
|
expectedBinds: []string{
|
||||||
|
"shared_volume:/shared_volume",
|
||||||
|
"/home/test/data:/test_data",
|
||||||
|
"/etc/conf.d/base.json:/config/base.json",
|
||||||
|
"sql_data:/sql_data",
|
||||||
|
"/secrets/keys:/keys",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "no volumes can be matched",
|
||||||
|
validVolumes: []string{},
|
||||||
|
binds: []string{
|
||||||
|
"shared_volume:/shared_volume",
|
||||||
|
"/home/test/data:/test_data",
|
||||||
|
"/etc/conf.d/base.json:/config/base.json",
|
||||||
|
"sql_data:/sql_data",
|
||||||
|
"/secrets/keys:/keys",
|
||||||
|
},
|
||||||
|
expectedBinds: []string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "only allowed volumes can be matched",
|
||||||
|
validVolumes: []string{
|
||||||
|
"shared_volume",
|
||||||
|
"/home/test/data",
|
||||||
|
"/etc/conf.d/*.json",
|
||||||
|
},
|
||||||
|
binds: []string{
|
||||||
|
"shared_volume:/shared_volume",
|
||||||
|
"/home/test/data:/test_data",
|
||||||
|
"/etc/conf.d/base.json:/config/base.json",
|
||||||
|
"sql_data:/sql_data",
|
||||||
|
"/secrets/keys:/keys",
|
||||||
|
},
|
||||||
|
expectedBinds: []string{
|
||||||
|
"shared_volume:/shared_volume",
|
||||||
|
"/home/test/data:/test_data",
|
||||||
|
"/etc/conf.d/base.json:/config/base.json",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.desc, func(t *testing.T) {
|
||||||
|
logger, _ := test.NewNullLogger()
|
||||||
|
ctx := common.WithLogger(context.Background(), logger)
|
||||||
|
cr := &containerReference{
|
||||||
|
input: &NewContainerInput{
|
||||||
|
ValidVolumes: tc.validVolumes,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, hostConf := cr.sanitizeConfig(ctx, &container.Config{}, &container.HostConfig{Binds: tc.binds})
|
||||||
|
assert.Equal(t, tc.expectedBinds, hostConf.Binds)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2024 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
var CommonSocketLocations = []string{
|
||||||
|
"/var/run/docker.sock",
|
||||||
|
"/run/podman/podman.sock",
|
||||||
|
"$HOME/.colima/docker.sock",
|
||||||
|
"$XDG_RUNTIME_DIR/docker.sock",
|
||||||
|
"$XDG_RUNTIME_DIR/podman/podman.sock",
|
||||||
|
`\\.\pipe\docker_engine`,
|
||||||
|
"$HOME/.docker/run/docker.sock",
|
||||||
|
}
|
||||||
|
|
||||||
|
// returns socket URI or false if not found any
|
||||||
|
func socketLocation() (string, bool) {
|
||||||
|
if dockerHost, exists := os.LookupEnv("DOCKER_HOST"); exists {
|
||||||
|
return dockerHost, true
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range CommonSocketLocations {
|
||||||
|
if _, err := os.Lstat(os.ExpandEnv(p)); err == nil {
|
||||||
|
if strings.HasPrefix(p, `\\.\`) {
|
||||||
|
return "npipe://" + filepath.ToSlash(os.ExpandEnv(p)), true
|
||||||
|
}
|
||||||
|
return "unix://" + filepath.ToSlash(os.ExpandEnv(p)), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// This function, `isDockerHostURI`, takes a string argument `daemonPath`. It checks if the
|
||||||
|
// `daemonPath` is a valid Docker host URI. It does this by checking if the scheme of the URI (the
|
||||||
|
// part before "://") contains only alphabetic characters. If it does, the function returns true,
|
||||||
|
// indicating that the `daemonPath` is a Docker host URI. If it doesn't, or if the "://" delimiter
|
||||||
|
// is not found in the `daemonPath`, the function returns false.
|
||||||
|
func isDockerHostURI(daemonPath string) bool {
|
||||||
|
if before, _, ok := strings.Cut(daemonPath, "://"); ok {
|
||||||
|
scheme := before
|
||||||
|
if strings.IndexFunc(scheme, func(r rune) bool {
|
||||||
|
return (r < 'a' || r > 'z') && (r < 'A' || r > 'Z')
|
||||||
|
}) == -1 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
type SocketAndHost struct {
|
||||||
|
Socket string
|
||||||
|
Host string
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetSocketAndHost(containerSocket string) (SocketAndHost, error) {
|
||||||
|
log.Debugf("Handling container host and socket")
|
||||||
|
|
||||||
|
// Prefer DOCKER_HOST, don't override it
|
||||||
|
dockerHost, hasDockerHost := socketLocation()
|
||||||
|
socketHost := SocketAndHost{Socket: containerSocket, Host: dockerHost}
|
||||||
|
|
||||||
|
// ** socketHost.Socket cases **
|
||||||
|
// Case 1: User does _not_ want to mount a daemon socket (passes a dash)
|
||||||
|
// Case 2: User passes a filepath to the socket; is that even valid?
|
||||||
|
// Case 3: User passes a valid socket; do nothing
|
||||||
|
// Case 4: User omitted the flag; set a sane default
|
||||||
|
|
||||||
|
// ** DOCKER_HOST cases **
|
||||||
|
// Case A: DOCKER_HOST is set; use it, i.e. do nothing
|
||||||
|
// Case B: DOCKER_HOST is empty; use sane defaults
|
||||||
|
|
||||||
|
// Set host for sanity's sake, when the socket isn't useful
|
||||||
|
if !hasDockerHost && (socketHost.Socket == "-" || !isDockerHostURI(socketHost.Socket) || socketHost.Socket == "") {
|
||||||
|
// Cases: 1B, 2B, 4B
|
||||||
|
socket, found := socketLocation()
|
||||||
|
socketHost.Host = socket
|
||||||
|
hasDockerHost = found
|
||||||
|
}
|
||||||
|
|
||||||
|
// A - (dash) in socketHost.Socket means don't mount, preserve this value
|
||||||
|
// otherwise if socketHost.Socket is a filepath don't use it as socket
|
||||||
|
// Exit early if we're in an invalid state (e.g. when no DOCKER_HOST and user supplied "-", a dash or omitted)
|
||||||
|
if !hasDockerHost && socketHost.Socket != "" && !isDockerHostURI(socketHost.Socket) {
|
||||||
|
// Cases: 1B, 2B
|
||||||
|
// Should we early-exit here, since there is no host nor socket to talk to?
|
||||||
|
return SocketAndHost{}, fmt.Errorf("DOCKER_HOST was not set, couldn't be found in the usual locations, and the container daemon socket ('%s') is invalid", socketHost.Socket)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to DOCKER_HOST if set
|
||||||
|
if socketHost.Socket == "" && hasDockerHost {
|
||||||
|
// Cases: 4A
|
||||||
|
log.Debugf("Defaulting container socket to DOCKER_HOST")
|
||||||
|
socketHost.Socket = socketHost.Host
|
||||||
|
}
|
||||||
|
// Set sane default socket location if user omitted it
|
||||||
|
if socketHost.Socket == "" {
|
||||||
|
// Cases: 4B
|
||||||
|
socket, _ := socketLocation()
|
||||||
|
// socket is empty if it isn't found, so assignment here is at worst a no-op
|
||||||
|
log.Debugf("Defaulting container socket to default '%s'", socket)
|
||||||
|
socketHost.Socket = socket
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exit if both the DOCKER_HOST and socket are fulfilled
|
||||||
|
if hasDockerHost {
|
||||||
|
// Cases: 1A, 2A, 3A, 4A
|
||||||
|
if !isDockerHostURI(socketHost.Socket) {
|
||||||
|
// Cases: 1A, 2A
|
||||||
|
log.Debugf("DOCKER_HOST is set, but socket is invalid '%s'", socketHost.Socket)
|
||||||
|
}
|
||||||
|
return socketHost, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set a sane DOCKER_HOST default if we can
|
||||||
|
if isDockerHostURI(socketHost.Socket) {
|
||||||
|
// Cases: 3B
|
||||||
|
log.Debugf("Setting DOCKER_HOST to container socket '%s'", socketHost.Socket)
|
||||||
|
socketHost.Host = socketHost.Socket
|
||||||
|
// Both DOCKER_HOST and container socket are valid; short-circuit exit
|
||||||
|
return socketHost, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Here there is no DOCKER_HOST _and_ the supplied container socket is not a valid URI (either invalid or a file path)
|
||||||
|
// Cases: 2B <- but is already handled at the top
|
||||||
|
// I.e. this path should never be taken
|
||||||
|
return SocketAndHost{}, fmt.Errorf("no DOCKER_HOST and an invalid container socket '%s'", socketHost.Socket)
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2024 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
assert "github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
log.SetLevel(log.DebugLevel)
|
||||||
|
}
|
||||||
|
|
||||||
|
var originalCommonSocketLocations = CommonSocketLocations
|
||||||
|
|
||||||
|
func TestGetSocketAndHostWithSocket(t *testing.T) {
|
||||||
|
// Arrange
|
||||||
|
CommonSocketLocations = originalCommonSocketLocations
|
||||||
|
dockerHost := "unix:///my/docker/host.sock"
|
||||||
|
socketURI := "/path/to/my.socket"
|
||||||
|
t.Setenv("DOCKER_HOST", dockerHost)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
ret, err := GetSocketAndHost(socketURI)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Equal(t, SocketAndHost{socketURI, dockerHost}, ret)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSocketAndHostNoSocket(t *testing.T) {
|
||||||
|
// Arrange
|
||||||
|
dockerHost := "unix:///my/docker/host.sock"
|
||||||
|
t.Setenv("DOCKER_HOST", dockerHost)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
ret, err := GetSocketAndHost("")
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Equal(t, SocketAndHost{dockerHost, dockerHost}, ret)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSocketAndHostOnlySocket(t *testing.T) {
|
||||||
|
// Arrange
|
||||||
|
socketURI := "/path/to/my.socket"
|
||||||
|
os.Unsetenv("DOCKER_HOST")
|
||||||
|
CommonSocketLocations = originalCommonSocketLocations
|
||||||
|
defaultSocket, defaultSocketFound := socketLocation()
|
||||||
|
|
||||||
|
// Act
|
||||||
|
ret, err := GetSocketAndHost(socketURI)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assert.NoError(t, err, "Expected no error from GetSocketAndHost") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Equal(t, true, defaultSocketFound, "Expected to find default socket") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Equal(t, socketURI, ret.Socket, "Expected socket to match common location")
|
||||||
|
assert.Equal(t, defaultSocket, ret.Host, "Expected ret.Host to match default socket location")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSocketAndHostDontMount(t *testing.T) {
|
||||||
|
// Arrange
|
||||||
|
CommonSocketLocations = originalCommonSocketLocations
|
||||||
|
dockerHost := "unix:///my/docker/host.sock"
|
||||||
|
t.Setenv("DOCKER_HOST", dockerHost)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
ret, err := GetSocketAndHost("-")
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Equal(t, SocketAndHost{"-", dockerHost}, ret)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSocketAndHostNoHostNoSocket(t *testing.T) {
|
||||||
|
// Arrange
|
||||||
|
CommonSocketLocations = originalCommonSocketLocations
|
||||||
|
os.Unsetenv("DOCKER_HOST")
|
||||||
|
defaultSocket, found := socketLocation()
|
||||||
|
|
||||||
|
// Act
|
||||||
|
ret, err := GetSocketAndHost("")
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assert.Equal(t, true, found, "Expected a default socket to be found") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Nil(t, err, "Expected no error from GetSocketAndHost") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Equal(t, SocketAndHost{defaultSocket, defaultSocket}, ret, "Expected to match default socket location")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Catch
|
||||||
|
// > Your code breaks setting DOCKER_HOST if shouldMount is false.
|
||||||
|
// > This happens if neither DOCKER_HOST nor --container-daemon-socket has a value, but socketLocation() returns a URI
|
||||||
|
func TestGetSocketAndHostNoHostNoSocketDefaultLocation(t *testing.T) {
|
||||||
|
// Arrange
|
||||||
|
mySocketFile, tmpErr := os.CreateTemp(t.TempDir(), "act-*.sock")
|
||||||
|
mySocket := mySocketFile.Name()
|
||||||
|
unixSocket := "unix://" + mySocket
|
||||||
|
defer os.RemoveAll(mySocket)
|
||||||
|
assert.NoError(t, tmpErr) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
os.Unsetenv("DOCKER_HOST")
|
||||||
|
|
||||||
|
CommonSocketLocations = []string{mySocket}
|
||||||
|
defaultSocket, found := socketLocation()
|
||||||
|
|
||||||
|
// Act
|
||||||
|
ret, err := GetSocketAndHost("")
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assert.Equal(t, unixSocket, defaultSocket, "Expected default socket to match common socket location")
|
||||||
|
assert.Equal(t, true, found, "Expected default socket to be found") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Nil(t, err, "Expected no error from GetSocketAndHost") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Equal(t, SocketAndHost{unixSocket, unixSocket}, ret, "Expected to match default socket location")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSocketAndHostNoHostInvalidSocket(t *testing.T) {
|
||||||
|
// Arrange
|
||||||
|
os.Unsetenv("DOCKER_HOST")
|
||||||
|
mySocket := "/my/socket/path.sock"
|
||||||
|
CommonSocketLocations = []string{"/unusual", "/socket", "/location"}
|
||||||
|
defaultSocket, found := socketLocation()
|
||||||
|
|
||||||
|
// Act
|
||||||
|
ret, err := GetSocketAndHost(mySocket)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assert.Equal(t, false, found, "Expected no default socket to be found") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Equal(t, "", defaultSocket, "Expected no default socket to be found") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Equal(t, SocketAndHost{}, ret, "Expected to match default socket location")
|
||||||
|
assert.Error(t, err, "Expected an error in invalid state")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSocketAndHostOnlySocketValidButUnusualLocation(t *testing.T) {
|
||||||
|
// Arrange
|
||||||
|
socketURI := "unix:///path/to/my.socket"
|
||||||
|
CommonSocketLocations = []string{"/unusual", "/location"}
|
||||||
|
os.Unsetenv("DOCKER_HOST")
|
||||||
|
defaultSocket, found := socketLocation()
|
||||||
|
|
||||||
|
// Act
|
||||||
|
ret, err := GetSocketAndHost(socketURI)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
// Default socket locations
|
||||||
|
assert.Equal(t, "", defaultSocket, "Expect default socket location to be empty") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Equal(t, false, found, "Expected no default socket to be found") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
// Sane default
|
||||||
|
assert.Nil(t, err, "Expect no error from GetSocketAndHost") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Equal(t, socketURI, ret.Host, "Expect host to default to unusual socket")
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2023 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build WITHOUT_DOCKER || !(linux || darwin || windows || netbsd)
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"runtime"
|
||||||
|
|
||||||
|
"gitea.com/gitea/act_runner/act/common"
|
||||||
|
|
||||||
|
"github.com/docker/docker/api/types"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ImageExistsLocally returns a boolean indicating if an image with the
|
||||||
|
// requested name, tag and architecture exists in the local docker image store
|
||||||
|
func ImageExistsLocally(ctx context.Context, imageName, platform string) (bool, error) {
|
||||||
|
return false, errors.New("Unsupported Operation")
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveImage removes image from local store, the function is used to run different
|
||||||
|
// container image architectures
|
||||||
|
func RemoveImage(ctx context.Context, imageName string, force, pruneChildren bool) (bool, error) {
|
||||||
|
return false, errors.New("Unsupported Operation")
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDockerBuildExecutor function to create a run executor for the container
|
||||||
|
func NewDockerBuildExecutor(input NewDockerBuildExecutorInput) common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
return errors.New("Unsupported Operation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDockerPullExecutor function to create a run executor for the container
|
||||||
|
func NewDockerPullExecutor(input NewDockerPullExecutorInput) common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
return errors.New("Unsupported Operation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewContainer creates a reference to a container
|
||||||
|
func NewContainer(input *NewContainerInput) ExecutionsEnvironment {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func RunnerArch(ctx context.Context) string {
|
||||||
|
return runtime.GOOS
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetHostInfo(ctx context.Context) (info types.Info, err error) {
|
||||||
|
return types.Info{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDockerVolumeRemoveExecutor(volume string, force bool) common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDockerNetworkCreateExecutor(name string) common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDockerNetworkRemoveExecutor(name string) common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"gitea.com/gitea/act_runner/act/common"
|
||||||
|
|
||||||
|
"github.com/docker/docker/api/types/filters"
|
||||||
|
"github.com/docker/docker/api/types/volume"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewDockerVolumeRemoveExecutor(volumeName string, force bool) common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
cli, err := GetDockerClient(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer cli.Close()
|
||||||
|
|
||||||
|
list, err := cli.VolumeList(ctx, volume.ListOptions{Filters: filters.NewArgs()})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, vol := range list.Volumes {
|
||||||
|
if vol.Name == volumeName {
|
||||||
|
return removeExecutor(volumeName, force)(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Volume not found - do nothing
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeExecutor(volume string, force bool) common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
logger := common.Logger(ctx)
|
||||||
|
logger.Debugf("%sdocker volume rm %s", logPrefix, volume)
|
||||||
|
|
||||||
|
if common.Dryrun(ctx) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cli, err := GetDockerClient(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer cli.Close()
|
||||||
|
|
||||||
|
return cli.VolumeRemove(ctx, volume, force)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
type ExecutionsEnvironment interface {
|
||||||
|
Container
|
||||||
|
ToContainerPath(string) string
|
||||||
|
GetActPath() string
|
||||||
|
GetPathVariableName() string
|
||||||
|
DefaultPathVariable() string
|
||||||
|
JoinPathVariable(...string) string
|
||||||
|
GetRunnerContext(ctx context.Context) map[string]any
|
||||||
|
// On windows PATH and Path are the same key
|
||||||
|
IsEnvironmentCaseInsensitive() bool
|
||||||
|
}
|
||||||
@@ -0,0 +1,474 @@
|
|||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.com/gitea/act_runner/act/common"
|
||||||
|
"gitea.com/gitea/act_runner/act/filecollector"
|
||||||
|
"gitea.com/gitea/act_runner/act/lookpath"
|
||||||
|
|
||||||
|
"github.com/go-git/go-billy/v5/helper/polyfill"
|
||||||
|
"github.com/go-git/go-billy/v5/osfs"
|
||||||
|
"github.com/go-git/go-git/v5/plumbing/format/gitignore"
|
||||||
|
"golang.org/x/term"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HostEnvironment struct {
|
||||||
|
Path string
|
||||||
|
TmpDir string
|
||||||
|
ToolCache string
|
||||||
|
Workdir string
|
||||||
|
ActPath string
|
||||||
|
CleanUp func()
|
||||||
|
StdOut io.Writer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *HostEnvironment) Create(_, _ []string) common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *HostEnvironment) ConnectToNetwork(name string) common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *HostEnvironment) Close() common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *HostEnvironment) Copy(destPath string, files ...*FileEntry) common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
for _, f := range files {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(filepath.Join(destPath, f.Name)), 0o777); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(destPath, f.Name), []byte(f.Body), fs.FileMode(f.Mode)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *HostEnvironment) CopyTarStream(ctx context.Context, destPath string, tarStream io.Reader) error {
|
||||||
|
if err := os.RemoveAll(destPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tr := tar.NewReader(tarStream)
|
||||||
|
cp := &filecollector.CopyCollector{
|
||||||
|
DstDir: destPath,
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
ti, err := tr.Next()
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
return nil
|
||||||
|
} else if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if ti.FileInfo().IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return errors.New("CopyTarStream has been cancelled")
|
||||||
|
}
|
||||||
|
if err := cp.WriteFile(ti.Name, ti.FileInfo(), ti.Linkname, tr); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *HostEnvironment) CopyDir(destPath, srcPath string, useGitIgnore bool) common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
logger := common.Logger(ctx)
|
||||||
|
srcPrefix := filepath.Dir(srcPath)
|
||||||
|
if !strings.HasSuffix(srcPrefix, string(filepath.Separator)) {
|
||||||
|
srcPrefix += string(filepath.Separator)
|
||||||
|
}
|
||||||
|
logger.Debugf("Stripping prefix:%s src:%s", srcPrefix, srcPath)
|
||||||
|
var ignorer gitignore.Matcher
|
||||||
|
if useGitIgnore {
|
||||||
|
ps, err := gitignore.ReadPatterns(polyfill.New(osfs.New(srcPath)), nil)
|
||||||
|
if err != nil {
|
||||||
|
logger.Debugf("Error loading .gitignore: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ignorer = gitignore.NewMatcher(ps)
|
||||||
|
}
|
||||||
|
fc := &filecollector.FileCollector{
|
||||||
|
Fs: &filecollector.DefaultFs{},
|
||||||
|
Ignorer: ignorer,
|
||||||
|
SrcPath: srcPath,
|
||||||
|
SrcPrefix: srcPrefix,
|
||||||
|
Handler: &filecollector.CopyCollector{
|
||||||
|
DstDir: destPath,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return filepath.Walk(srcPath, fc.CollectFiles(ctx, []string{}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *HostEnvironment) GetContainerArchive(ctx context.Context, srcPath string) (io.ReadCloser, error) {
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
tw := tar.NewWriter(buf)
|
||||||
|
defer tw.Close()
|
||||||
|
srcPath = filepath.Clean(srcPath)
|
||||||
|
fi, err := os.Lstat(srcPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tc := &filecollector.TarCollector{
|
||||||
|
TarWriter: tw,
|
||||||
|
}
|
||||||
|
if fi.IsDir() {
|
||||||
|
srcPrefix := srcPath
|
||||||
|
if !strings.HasSuffix(srcPrefix, string(filepath.Separator)) {
|
||||||
|
srcPrefix += string(filepath.Separator)
|
||||||
|
}
|
||||||
|
fc := &filecollector.FileCollector{
|
||||||
|
Fs: &filecollector.DefaultFs{},
|
||||||
|
SrcPath: srcPath,
|
||||||
|
SrcPrefix: srcPrefix,
|
||||||
|
Handler: tc,
|
||||||
|
}
|
||||||
|
err = filepath.Walk(srcPath, fc.CollectFiles(ctx, []string{}))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var f io.ReadCloser
|
||||||
|
var linkname string
|
||||||
|
if fi.Mode()&fs.ModeSymlink != 0 {
|
||||||
|
linkname, err = os.Readlink(srcPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
f, err = os.Open(srcPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
}
|
||||||
|
err := tc.WriteFile(fi.Name(), fi, linkname, f)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return io.NopCloser(buf), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *HostEnvironment) Pull(_ bool) common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *HostEnvironment) Start(_ bool) common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ptyWriter struct {
|
||||||
|
Out io.Writer
|
||||||
|
AutoStop bool
|
||||||
|
dirtyLine bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *ptyWriter) Write(buf []byte) (int, error) {
|
||||||
|
if w.AutoStop && len(buf) > 0 && buf[len(buf)-1] == 4 {
|
||||||
|
n, err := w.Out.Write(buf[:len(buf)-1])
|
||||||
|
if err != nil {
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
if w.dirtyLine || len(buf) > 1 && buf[len(buf)-2] != '\n' {
|
||||||
|
_, _ = w.Out.Write([]byte("\n"))
|
||||||
|
return n, io.EOF
|
||||||
|
}
|
||||||
|
return n, io.EOF
|
||||||
|
}
|
||||||
|
w.dirtyLine = strings.LastIndex(string(buf), "\n") < len(buf)-1
|
||||||
|
return w.Out.Write(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
type localEnv struct {
|
||||||
|
env map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *localEnv) Getenv(name string) string {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
for k, v := range l.env {
|
||||||
|
if strings.EqualFold(name, k) {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return l.env[name]
|
||||||
|
}
|
||||||
|
|
||||||
|
func lookupPathHost(cmd string, env map[string]string, writer io.Writer) (string, error) {
|
||||||
|
f, err := lookpath.LookPath2(cmd, &localEnv{env: env})
|
||||||
|
if err != nil {
|
||||||
|
err := "Cannot find: " + cmd + " in PATH"
|
||||||
|
if _, _err := writer.Write([]byte(err + "\n")); _err != nil {
|
||||||
|
return "", fmt.Errorf("%v: %w", err, _err)
|
||||||
|
}
|
||||||
|
return "", errors.New(err)
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupPty(cmd *exec.Cmd, cmdline string) (*os.File, *os.File, error) {
|
||||||
|
ppty, tty, err := openPty()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if term.IsTerminal(int(tty.Fd())) {
|
||||||
|
_, err := term.MakeRaw(int(tty.Fd()))
|
||||||
|
if err != nil {
|
||||||
|
ppty.Close()
|
||||||
|
tty.Close()
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cmd.Stdin = tty
|
||||||
|
cmd.Stdout = tty
|
||||||
|
cmd.Stderr = tty
|
||||||
|
cmd.SysProcAttr = getSysProcAttr(cmdline, true)
|
||||||
|
return ppty, tty, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeKeepAlive(ppty io.Writer) {
|
||||||
|
c := 1
|
||||||
|
var err error
|
||||||
|
for c == 1 && err == nil {
|
||||||
|
c, err = ppty.Write([]byte{4})
|
||||||
|
<-time.After(time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyPtyOutput(writer io.Writer, ppty io.Reader, finishLog context.CancelFunc) {
|
||||||
|
defer func() {
|
||||||
|
finishLog()
|
||||||
|
}()
|
||||||
|
if _, err := io.Copy(writer, ppty); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *HostEnvironment) UpdateFromImageEnv(_ *map[string]string) common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEnvListFromMap(env map[string]string) []string {
|
||||||
|
envList := make([]string, 0)
|
||||||
|
for k, v := range env {
|
||||||
|
envList = append(envList, fmt.Sprintf("%s=%s", k, v))
|
||||||
|
}
|
||||||
|
return envList
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline string, env map[string]string, _, workdir string) error {
|
||||||
|
envList := getEnvListFromMap(env)
|
||||||
|
var wd string
|
||||||
|
if workdir != "" {
|
||||||
|
if filepath.IsAbs(workdir) {
|
||||||
|
wd = workdir
|
||||||
|
} else {
|
||||||
|
wd = filepath.Join(e.Path, workdir)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
wd = e.Path
|
||||||
|
}
|
||||||
|
f, err := lookupPathHost(command[0], env, e.StdOut)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cmd := exec.CommandContext(ctx, f)
|
||||||
|
cmd.Path = f
|
||||||
|
cmd.Args = command
|
||||||
|
cmd.Stdin = nil
|
||||||
|
cmd.Stdout = e.StdOut
|
||||||
|
cmd.Env = envList
|
||||||
|
cmd.Stderr = e.StdOut
|
||||||
|
cmd.Dir = wd
|
||||||
|
cmd.SysProcAttr = getSysProcAttr(cmdline, false)
|
||||||
|
var ppty *os.File
|
||||||
|
var tty *os.File
|
||||||
|
defer func() {
|
||||||
|
if ppty != nil {
|
||||||
|
ppty.Close()
|
||||||
|
}
|
||||||
|
if tty != nil {
|
||||||
|
tty.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if true /* allocate Terminal */ {
|
||||||
|
var err error
|
||||||
|
ppty, tty, err = setupPty(cmd, cmdline)
|
||||||
|
if err != nil {
|
||||||
|
common.Logger(ctx).Debugf("Failed to setup Pty %v\n", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writer := &ptyWriter{Out: e.StdOut}
|
||||||
|
logctx, finishLog := context.WithCancel(context.Background())
|
||||||
|
if ppty != nil {
|
||||||
|
go copyPtyOutput(writer, ppty, finishLog)
|
||||||
|
} else {
|
||||||
|
finishLog()
|
||||||
|
}
|
||||||
|
if ppty != nil {
|
||||||
|
go writeKeepAlive(ppty)
|
||||||
|
}
|
||||||
|
err = cmd.Run()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tty != nil {
|
||||||
|
writer.AutoStop = true
|
||||||
|
if _, err := tty.WriteString("\x04"); err != nil {
|
||||||
|
common.Logger(ctx).Debug("Failed to write EOT")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
<-logctx.Done()
|
||||||
|
|
||||||
|
if ppty != nil {
|
||||||
|
ppty.Close()
|
||||||
|
ppty = nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *HostEnvironment) Exec(command []string /*cmdline string, */, env map[string]string, user, workdir string) common.Executor {
|
||||||
|
return e.ExecWithCmdLine(command, "", env, user, workdir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *HostEnvironment) ExecWithCmdLine(command []string, cmdline string, env map[string]string, user, workdir string) common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
if err := e.exec(ctx, command, cmdline, env, user, workdir); err != nil {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return fmt.Errorf("this step has been cancelled: %w", err)
|
||||||
|
default:
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *HostEnvironment) UpdateFromEnv(srcPath string, env *map[string]string) common.Executor {
|
||||||
|
return parseEnvFile(e, srcPath, env)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *HostEnvironment) Remove() common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
if e.CleanUp != nil {
|
||||||
|
e.CleanUp()
|
||||||
|
}
|
||||||
|
return os.RemoveAll(e.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *HostEnvironment) ToContainerPath(path string) string {
|
||||||
|
if bp, err := filepath.Rel(e.Workdir, path); err != nil {
|
||||||
|
return filepath.Join(e.Path, bp)
|
||||||
|
} else if filepath.Clean(e.Workdir) == filepath.Clean(path) {
|
||||||
|
return e.Path
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *HostEnvironment) GetActPath() string {
|
||||||
|
actPath := e.ActPath
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
actPath = strings.ReplaceAll(actPath, "\\", "/")
|
||||||
|
}
|
||||||
|
return actPath
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*HostEnvironment) GetPathVariableName() string {
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "plan9":
|
||||||
|
return "path"
|
||||||
|
case "windows":
|
||||||
|
return "Path" // Actually we need a case insensitive map
|
||||||
|
}
|
||||||
|
return "PATH"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *HostEnvironment) DefaultPathVariable() string {
|
||||||
|
v, _ := os.LookupEnv(e.GetPathVariableName())
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*HostEnvironment) JoinPathVariable(paths ...string) string {
|
||||||
|
return strings.Join(paths, string(filepath.ListSeparator))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reference for Arch values for runner.arch
|
||||||
|
// https://docs.github.com/en/actions/learn-github-actions/contexts#runner-context
|
||||||
|
func goArchToActionArch(arch string) string {
|
||||||
|
archMapper := map[string]string{
|
||||||
|
"x86_64": "X64",
|
||||||
|
"386": "X86",
|
||||||
|
"aarch64": "ARM64",
|
||||||
|
}
|
||||||
|
if arch, ok := archMapper[arch]; ok {
|
||||||
|
return arch
|
||||||
|
}
|
||||||
|
return arch
|
||||||
|
}
|
||||||
|
|
||||||
|
func goOsToActionOs(os string) string {
|
||||||
|
osMapper := map[string]string{
|
||||||
|
"darwin": "macOS",
|
||||||
|
}
|
||||||
|
if os, ok := osMapper[os]; ok {
|
||||||
|
return os
|
||||||
|
}
|
||||||
|
return os
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *HostEnvironment) GetRunnerContext(_ context.Context) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"os": goOsToActionOs(runtime.GOOS),
|
||||||
|
"arch": goArchToActionArch(runtime.GOARCH),
|
||||||
|
"temp": e.TmpDir,
|
||||||
|
"tool_cache": e.ToolCache,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *HostEnvironment) ReplaceLogWriter(stdout, _ io.Writer) (io.Writer, io.Writer) {
|
||||||
|
org := e.StdOut
|
||||||
|
e.StdOut = stdout
|
||||||
|
return org, org
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*HostEnvironment) IsEnvironmentCaseInsensitive() bool {
|
||||||
|
return runtime.GOOS == "windows"
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Type assert HostEnvironment implements ExecutionsEnvironment
|
||||||
|
var _ ExecutionsEnvironment = &HostEnvironment{}
|
||||||
|
|
||||||
|
func TestCopyDir(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
ctx := context.Background()
|
||||||
|
e := &HostEnvironment{
|
||||||
|
Path: filepath.Join(dir, "path"),
|
||||||
|
TmpDir: filepath.Join(dir, "tmp"),
|
||||||
|
ToolCache: filepath.Join(dir, "tool_cache"),
|
||||||
|
ActPath: filepath.Join(dir, "act_path"),
|
||||||
|
StdOut: os.Stdout,
|
||||||
|
Workdir: path.Join("testdata", "scratch"),
|
||||||
|
}
|
||||||
|
_ = os.MkdirAll(e.Path, 0o700)
|
||||||
|
_ = os.MkdirAll(e.TmpDir, 0o700)
|
||||||
|
_ = os.MkdirAll(e.ToolCache, 0o700)
|
||||||
|
_ = os.MkdirAll(e.ActPath, 0o700)
|
||||||
|
err := e.CopyDir(e.Workdir, e.Path, true)(ctx)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetContainerArchive(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
ctx := context.Background()
|
||||||
|
e := &HostEnvironment{
|
||||||
|
Path: filepath.Join(dir, "path"),
|
||||||
|
TmpDir: filepath.Join(dir, "tmp"),
|
||||||
|
ToolCache: filepath.Join(dir, "tool_cache"),
|
||||||
|
ActPath: filepath.Join(dir, "act_path"),
|
||||||
|
StdOut: os.Stdout,
|
||||||
|
Workdir: path.Join("testdata", "scratch"),
|
||||||
|
}
|
||||||
|
_ = os.MkdirAll(e.Path, 0o700)
|
||||||
|
_ = os.MkdirAll(e.TmpDir, 0o700)
|
||||||
|
_ = os.MkdirAll(e.ToolCache, 0o700)
|
||||||
|
_ = os.MkdirAll(e.ActPath, 0o700)
|
||||||
|
expectedContent := []byte("sdde/7sh")
|
||||||
|
err := os.WriteFile(filepath.Join(e.Path, "action.yml"), expectedContent, 0o600)
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
archive, err := e.GetContainerArchive(ctx, e.Path)
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
defer archive.Close()
|
||||||
|
reader := tar.NewReader(archive)
|
||||||
|
h, err := reader.Next()
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Equal(t, "action.yml", h.Name)
|
||||||
|
content, err := io.ReadAll(reader)
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Equal(t, expectedContent, content)
|
||||||
|
_, err = reader.Next()
|
||||||
|
assert.ErrorIs(t, err, io.EOF)
|
||||||
|
}
|
||||||
Loaded 50 of 1324 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user