summaryrefslogtreecommitdiffstats
path: root/pkg/artifacts/server.go
diff options
context:
space:
mode:
Diffstat (limited to 'pkg/artifacts/server.go')
-rw-r--r--pkg/artifacts/server.go318
1 files changed, 318 insertions, 0 deletions
diff --git a/pkg/artifacts/server.go b/pkg/artifacts/server.go
new file mode 100644
index 0000000..4b88ea4
--- /dev/null
+++ b/pkg/artifacts/server.go
@@ -0,0 +1,318 @@
+package artifacts
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "io/fs"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/julienschmidt/httprouter"
+
+ "github.com/nektos/act/pkg/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"`
+}
+
+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 string, 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 string, addr string, 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
+}