github.com/nektos/act@v0.2.63-0.20240520024548-8acde99bfa9c/pkg/artifacts/server.go (about)

     1  package artifacts
     2  
     3  import (
     4  	"context"
     5  	"encoding/json"
     6  	"errors"
     7  	"fmt"
     8  	"io"
     9  	"io/fs"
    10  	"net/http"
    11  	"os"
    12  	"path/filepath"
    13  	"strings"
    14  	"time"
    15  
    16  	"github.com/julienschmidt/httprouter"
    17  
    18  	"github.com/nektos/act/pkg/common"
    19  )
    20  
    21  type FileContainerResourceURL struct {
    22  	FileContainerResourceURL string `json:"fileContainerResourceUrl"`
    23  }
    24  
    25  type NamedFileContainerResourceURL struct {
    26  	Name                     string `json:"name"`
    27  	FileContainerResourceURL string `json:"fileContainerResourceUrl"`
    28  }
    29  
    30  type NamedFileContainerResourceURLResponse struct {
    31  	Count int                             `json:"count"`
    32  	Value []NamedFileContainerResourceURL `json:"value"`
    33  }
    34  
    35  type ContainerItem struct {
    36  	Path            string `json:"path"`
    37  	ItemType        string `json:"itemType"`
    38  	ContentLocation string `json:"contentLocation"`
    39  }
    40  
    41  type ContainerItemResponse struct {
    42  	Value []ContainerItem `json:"value"`
    43  }
    44  
    45  type ResponseMessage struct {
    46  	Message string `json:"message"`
    47  }
    48  
    49  type WritableFile interface {
    50  	io.WriteCloser
    51  }
    52  
    53  type WriteFS interface {
    54  	OpenWritable(name string) (WritableFile, error)
    55  	OpenAppendable(name string) (WritableFile, error)
    56  }
    57  
    58  type readWriteFSImpl struct {
    59  }
    60  
    61  func (fwfs readWriteFSImpl) Open(name string) (fs.File, error) {
    62  	return os.Open(name)
    63  }
    64  
    65  func (fwfs readWriteFSImpl) OpenWritable(name string) (WritableFile, error) {
    66  	if err := os.MkdirAll(filepath.Dir(name), os.ModePerm); err != nil {
    67  		return nil, err
    68  	}
    69  	return os.OpenFile(name, os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0o644)
    70  }
    71  
    72  func (fwfs readWriteFSImpl) OpenAppendable(name string) (WritableFile, error) {
    73  	if err := os.MkdirAll(filepath.Dir(name), os.ModePerm); err != nil {
    74  		return nil, err
    75  	}
    76  	file, err := os.OpenFile(name, os.O_CREATE|os.O_RDWR, 0o644)
    77  
    78  	if err != nil {
    79  		return nil, err
    80  	}
    81  
    82  	_, err = file.Seek(0, io.SeekEnd)
    83  	if err != nil {
    84  		return nil, err
    85  	}
    86  	return file, nil
    87  }
    88  
    89  var gzipExtension = ".gz__"
    90  
    91  func safeResolve(baseDir string, relPath string) string {
    92  	return filepath.Join(baseDir, filepath.Clean(filepath.Join(string(os.PathSeparator), relPath)))
    93  }
    94  
    95  func uploads(router *httprouter.Router, baseDir string, fsys WriteFS) {
    96  	router.POST("/_apis/pipelines/workflows/:runId/artifacts", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
    97  		runID := params.ByName("runId")
    98  
    99  		json, err := json.Marshal(FileContainerResourceURL{
   100  			FileContainerResourceURL: fmt.Sprintf("http://%s/upload/%s", req.Host, runID),
   101  		})
   102  		if err != nil {
   103  			panic(err)
   104  		}
   105  
   106  		_, err = w.Write(json)
   107  		if err != nil {
   108  			panic(err)
   109  		}
   110  	})
   111  
   112  	router.PUT("/upload/:runId", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
   113  		itemPath := req.URL.Query().Get("itemPath")
   114  		runID := params.ByName("runId")
   115  
   116  		if req.Header.Get("Content-Encoding") == "gzip" {
   117  			itemPath += gzipExtension
   118  		}
   119  
   120  		safeRunPath := safeResolve(baseDir, runID)
   121  		safePath := safeResolve(safeRunPath, itemPath)
   122  
   123  		file, err := func() (WritableFile, error) {
   124  			contentRange := req.Header.Get("Content-Range")
   125  			if contentRange != "" && !strings.HasPrefix(contentRange, "bytes 0-") {
   126  				return fsys.OpenAppendable(safePath)
   127  			}
   128  			return fsys.OpenWritable(safePath)
   129  		}()
   130  
   131  		if err != nil {
   132  			panic(err)
   133  		}
   134  		defer file.Close()
   135  
   136  		writer, ok := file.(io.Writer)
   137  		if !ok {
   138  			panic(errors.New("File is not writable"))
   139  		}
   140  
   141  		if req.Body == nil {
   142  			panic(errors.New("No body given"))
   143  		}
   144  
   145  		_, err = io.Copy(writer, req.Body)
   146  		if err != nil {
   147  			panic(err)
   148  		}
   149  
   150  		json, err := json.Marshal(ResponseMessage{
   151  			Message: "success",
   152  		})
   153  		if err != nil {
   154  			panic(err)
   155  		}
   156  
   157  		_, err = w.Write(json)
   158  		if err != nil {
   159  			panic(err)
   160  		}
   161  	})
   162  
   163  	router.PATCH("/_apis/pipelines/workflows/:runId/artifacts", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
   164  		json, err := json.Marshal(ResponseMessage{
   165  			Message: "success",
   166  		})
   167  		if err != nil {
   168  			panic(err)
   169  		}
   170  
   171  		_, err = w.Write(json)
   172  		if err != nil {
   173  			panic(err)
   174  		}
   175  	})
   176  }
   177  
   178  func downloads(router *httprouter.Router, baseDir string, fsys fs.FS) {
   179  	router.GET("/_apis/pipelines/workflows/:runId/artifacts", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
   180  		runID := params.ByName("runId")
   181  
   182  		safePath := safeResolve(baseDir, runID)
   183  
   184  		entries, err := fs.ReadDir(fsys, safePath)
   185  		if err != nil {
   186  			panic(err)
   187  		}
   188  
   189  		var list []NamedFileContainerResourceURL
   190  		for _, entry := range entries {
   191  			list = append(list, NamedFileContainerResourceURL{
   192  				Name:                     entry.Name(),
   193  				FileContainerResourceURL: fmt.Sprintf("http://%s/download/%s", req.Host, runID),
   194  			})
   195  		}
   196  
   197  		json, err := json.Marshal(NamedFileContainerResourceURLResponse{
   198  			Count: len(list),
   199  			Value: list,
   200  		})
   201  		if err != nil {
   202  			panic(err)
   203  		}
   204  
   205  		_, err = w.Write(json)
   206  		if err != nil {
   207  			panic(err)
   208  		}
   209  	})
   210  
   211  	router.GET("/download/:container", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
   212  		container := params.ByName("container")
   213  		itemPath := req.URL.Query().Get("itemPath")
   214  		safePath := safeResolve(baseDir, filepath.Join(container, itemPath))
   215  
   216  		var files []ContainerItem
   217  		err := fs.WalkDir(fsys, safePath, func(path string, entry fs.DirEntry, err error) error {
   218  			if !entry.IsDir() {
   219  				rel, err := filepath.Rel(safePath, path)
   220  				if err != nil {
   221  					panic(err)
   222  				}
   223  
   224  				// if it was upload as gzip
   225  				rel = strings.TrimSuffix(rel, gzipExtension)
   226  				path := filepath.Join(itemPath, rel)
   227  
   228  				rel = filepath.ToSlash(rel)
   229  				path = filepath.ToSlash(path)
   230  
   231  				files = append(files, ContainerItem{
   232  					Path:            path,
   233  					ItemType:        "file",
   234  					ContentLocation: fmt.Sprintf("http://%s/artifact/%s/%s/%s", req.Host, container, itemPath, rel),
   235  				})
   236  			}
   237  			return nil
   238  		})
   239  		if err != nil {
   240  			panic(err)
   241  		}
   242  
   243  		json, err := json.Marshal(ContainerItemResponse{
   244  			Value: files,
   245  		})
   246  		if err != nil {
   247  			panic(err)
   248  		}
   249  
   250  		_, err = w.Write(json)
   251  		if err != nil {
   252  			panic(err)
   253  		}
   254  	})
   255  
   256  	router.GET("/artifact/*path", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
   257  		path := params.ByName("path")[1:]
   258  
   259  		safePath := safeResolve(baseDir, path)
   260  
   261  		file, err := fsys.Open(safePath)
   262  		if err != nil {
   263  			// try gzip file
   264  			file, err = fsys.Open(safePath + gzipExtension)
   265  			if err != nil {
   266  				panic(err)
   267  			}
   268  			w.Header().Add("Content-Encoding", "gzip")
   269  		}
   270  
   271  		_, err = io.Copy(w, file)
   272  		if err != nil {
   273  			panic(err)
   274  		}
   275  	})
   276  }
   277  
   278  func Serve(ctx context.Context, artifactPath string, addr string, port string) context.CancelFunc {
   279  	serverContext, cancel := context.WithCancel(ctx)
   280  	logger := common.Logger(serverContext)
   281  
   282  	if artifactPath == "" {
   283  		return cancel
   284  	}
   285  
   286  	router := httprouter.New()
   287  
   288  	logger.Debugf("Artifacts base path '%s'", artifactPath)
   289  	fsys := readWriteFSImpl{}
   290  	uploads(router, artifactPath, fsys)
   291  	downloads(router, artifactPath, fsys)
   292  
   293  	server := &http.Server{
   294  		Addr:              fmt.Sprintf("%s:%s", addr, port),
   295  		ReadHeaderTimeout: 2 * time.Second,
   296  		Handler:           router,
   297  	}
   298  
   299  	// run server
   300  	go func() {
   301  		logger.Infof("Start server on http://%s:%s", addr, port)
   302  		if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
   303  			logger.Fatal(err)
   304  		}
   305  	}()
   306  
   307  	// wait for cancel to gracefully shutdown server
   308  	go func() {
   309  		<-serverContext.Done()
   310  
   311  		if err := server.Shutdown(ctx); err != nil {
   312  			logger.Errorf("Failed shutdown gracefully - force shutdown: %v", err)
   313  			server.Close()
   314  		}
   315  	}()
   316  
   317  	return cancel
   318  }