4158e05dae
After https://gitea.com/gitea/runner/pulls/1304, logrus stayed only to forward logs from docker/cli and moby/go-archive. The runner used a small part of those: volume parsing, the build context tar, the `ssh://` connection and registry credentials. Own code that matches them replaces them, so logrus leaves the module graph. docker/cli stays for its logrus-free `opts` and `connhelper/ssh` packages. Other dependencies that the standard library or existing ones cover go too: mergo, httprouter (replaced by `http.ServeMux`), retry-go, go-isatty, go-selinux, container-device-interface, gotest.tools and go-cmp (replaced by testify). The binary shrinks by 5%. Behaviour differences: 1. An unreadable file in a build context is skipped with a warning, where go-archive left a truncated tar. 2. Build contexts no longer carry `security.capability` xattrs. 3. The cache and artifact servers answer HEAD on GET routes, and no longer redirect trailing slashes or answer OPTIONS. *Written by Claude.* --------- Co-authored-by: bircni <bircni@icloud.com> Reviewed-on: https://gitea.com/gitea/runner/pulls/1319 Reviewed-by: bircni <bircni@icloud.com> Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com> Co-authored-by: silverwind <2021+silverwind@noreply.gitea.com>
243 lines
5.9 KiB
Go
243 lines
5.9 KiB
Go
// 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/v2"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.com/gitea/runner/act/common"
|
|
)
|
|
|
|
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"`
|
|
}
|
|
|
|
var gzipExtension = ".gz__"
|
|
|
|
func safeResolve(baseDir, relPath string) string {
|
|
return filepath.Join(baseDir, filepath.Clean(filepath.Join(string(os.PathSeparator), relPath)))
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, value any) {
|
|
data, err := json.Marshal(value)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
if _, err := w.Write(data); err != nil {
|
|
panic(err)
|
|
}
|
|
}
|
|
|
|
func uploads(router *http.ServeMux, baseDir string) {
|
|
router.HandleFunc("POST /_apis/pipelines/workflows/{runId}/artifacts", func(w http.ResponseWriter, req *http.Request) {
|
|
writeJSON(w, FileContainerResourceURL{
|
|
FileContainerResourceURL: fmt.Sprintf("http://%s/upload/%s", req.Host, req.PathValue("runId")),
|
|
})
|
|
})
|
|
|
|
router.HandleFunc("PUT /upload/{runId}", func(w http.ResponseWriter, req *http.Request) {
|
|
itemPath := req.URL.Query().Get("itemPath")
|
|
|
|
if req.Header.Get("Content-Encoding") == "gzip" {
|
|
itemPath += gzipExtension
|
|
}
|
|
|
|
safeRunPath := safeResolve(baseDir, req.PathValue("runId"))
|
|
safePath := safeResolve(safeRunPath, itemPath)
|
|
|
|
if err := os.MkdirAll(filepath.Dir(safePath), os.ModePerm); err != nil {
|
|
panic(err)
|
|
}
|
|
flags := os.O_CREATE | os.O_WRONLY | os.O_TRUNC
|
|
appendUpload := req.Header.Get("Content-Range")
|
|
if appendUpload != "" && !strings.HasPrefix(appendUpload, "bytes 0-") {
|
|
flags = os.O_CREATE | os.O_WRONLY | os.O_APPEND
|
|
}
|
|
file, err := os.OpenFile(safePath, flags, 0o644)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
defer file.Close()
|
|
if req.Body == nil {
|
|
panic(errors.New("no body given"))
|
|
}
|
|
|
|
_, err = io.Copy(file, req.Body)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
writeJSON(w, ResponseMessage{
|
|
Message: "success",
|
|
})
|
|
})
|
|
|
|
router.HandleFunc("PATCH /_apis/pipelines/workflows/{runId}/artifacts", func(w http.ResponseWriter, _req *http.Request) {
|
|
writeJSON(w, ResponseMessage{
|
|
Message: "success",
|
|
})
|
|
})
|
|
}
|
|
|
|
func downloads(router *http.ServeMux, baseDir string) {
|
|
router.HandleFunc("GET /_apis/pipelines/workflows/{runId}/artifacts", func(w http.ResponseWriter, req *http.Request) {
|
|
runID := req.PathValue("runId")
|
|
|
|
safePath := safeResolve(baseDir, runID)
|
|
|
|
entries, err := os.ReadDir(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),
|
|
})
|
|
}
|
|
|
|
writeJSON(w, NamedFileContainerResourceURLResponse{
|
|
Count: len(list),
|
|
Value: list,
|
|
})
|
|
})
|
|
|
|
router.HandleFunc("GET /download/{container}", func(w http.ResponseWriter, req *http.Request) {
|
|
container := req.PathValue("container")
|
|
itemPath := req.URL.Query().Get("itemPath")
|
|
safePath := safeResolve(baseDir, filepath.Join(container, itemPath))
|
|
|
|
var files []ContainerItem
|
|
err := filepath.WalkDir(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)
|
|
}
|
|
|
|
writeJSON(w, ContainerItemResponse{
|
|
Value: files,
|
|
})
|
|
})
|
|
|
|
router.HandleFunc("GET /artifact/{path...}", func(w http.ResponseWriter, req *http.Request) {
|
|
safePath := safeResolve(baseDir, req.PathValue("path"))
|
|
|
|
file, err := os.Open(safePath)
|
|
if err != nil {
|
|
// try gzip file
|
|
file, err = os.Open(safePath + gzipExtension)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
w.Header().Add("Content-Encoding", "gzip")
|
|
}
|
|
defer file.Close()
|
|
|
|
_, 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 := http.NewServeMux()
|
|
|
|
logger.Debugf("Artifacts base path '%s'", artifactPath)
|
|
uploads(router, artifactPath)
|
|
downloads(router, artifactPath)
|
|
|
|
server := &http.Server{
|
|
Addr: net.JoinHostPort(addr, port),
|
|
ReadHeaderTimeout: 2 * time.Second,
|
|
Handler: router,
|
|
}
|
|
|
|
// run server
|
|
go func() {
|
|
logger.Infof("Start server on http://%s", server.Addr)
|
|
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
logger.Error(err)
|
|
os.Exit(1)
|
|
}
|
|
}()
|
|
|
|
// 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
|
|
}
|