diff options
Diffstat (limited to 'pkg/artifacts/server.go')
-rw-r--r-- | pkg/artifacts/server.go | 318 |
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 +} |