You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

artifacts.go 15KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498
  1. // Copyright 2023 The Gitea Authors. All rights reserved.
  2. // SPDX-License-Identifier: MIT
  3. package actions
  4. // GitHub Actions Artifacts API Simple Description
  5. //
  6. // 1. Upload artifact
  7. // 1.1. Post upload url
  8. // Post: /api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts?api-version=6.0-preview
  9. // Request:
  10. // {
  11. // "Type": "actions_storage",
  12. // "Name": "artifact"
  13. // }
  14. // Response:
  15. // {
  16. // "fileContainerResourceUrl":"/api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts/{artifact_id}/upload"
  17. // }
  18. // it acquires an upload url for artifact upload
  19. // 1.2. Upload artifact
  20. // PUT: /api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts/{artifact_id}/upload?itemPath=artifact%2Ffilename
  21. // it upload chunk with headers:
  22. // x-tfs-filelength: 1024 // total file length
  23. // content-length: 1024 // chunk length
  24. // x-actions-results-md5: md5sum // md5sum of chunk
  25. // content-range: bytes 0-1023/1024 // chunk range
  26. // we save all chunks to one storage directory after md5sum check
  27. // 1.3. Confirm upload
  28. // PATCH: /api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts/{artifact_id}/upload?itemPath=artifact%2Ffilename
  29. // it confirm upload and merge all chunks to one file, save this file to storage
  30. //
  31. // 2. Download artifact
  32. // 2.1 list artifacts
  33. // GET: /api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts?api-version=6.0-preview
  34. // Response:
  35. // {
  36. // "count": 1,
  37. // "value": [
  38. // {
  39. // "name": "artifact",
  40. // "fileContainerResourceUrl": "/api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts/{artifact_id}/path"
  41. // }
  42. // ]
  43. // }
  44. // 2.2 download artifact
  45. // GET: /api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts/{artifact_id}/path?api-version=6.0-preview
  46. // Response:
  47. // {
  48. // "value": [
  49. // {
  50. // "contentLocation": "/api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts/{artifact_id}/download",
  51. // "path": "artifact/filename",
  52. // "itemType": "file"
  53. // }
  54. // ]
  55. // }
  56. // 2.3 download artifact file
  57. // GET: /api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts/{artifact_id}/download?itemPath=artifact%2Ffilename
  58. // Response:
  59. // download file
  60. //
  61. import (
  62. "crypto/md5"
  63. "errors"
  64. "fmt"
  65. "net/http"
  66. "strconv"
  67. "strings"
  68. "code.gitea.io/gitea/models/actions"
  69. "code.gitea.io/gitea/models/db"
  70. "code.gitea.io/gitea/modules/json"
  71. "code.gitea.io/gitea/modules/log"
  72. "code.gitea.io/gitea/modules/setting"
  73. "code.gitea.io/gitea/modules/storage"
  74. "code.gitea.io/gitea/modules/util"
  75. "code.gitea.io/gitea/modules/web"
  76. web_types "code.gitea.io/gitea/modules/web/types"
  77. actions_service "code.gitea.io/gitea/services/actions"
  78. "code.gitea.io/gitea/services/context"
  79. )
  80. const artifactRouteBase = "/_apis/pipelines/workflows/{run_id}/artifacts"
  81. type artifactContextKeyType struct{}
  82. var artifactContextKey = artifactContextKeyType{}
  83. type ArtifactContext struct {
  84. *context.Base
  85. ActionTask *actions.ActionTask
  86. }
  87. func init() {
  88. web.RegisterResponseStatusProvider[*ArtifactContext](func(req *http.Request) web_types.ResponseStatusProvider {
  89. return req.Context().Value(artifactContextKey).(*ArtifactContext)
  90. })
  91. }
  92. func ArtifactsRoutes(prefix string) *web.Route {
  93. m := web.NewRoute()
  94. m.Use(ArtifactContexter())
  95. r := artifactRoutes{
  96. prefix: prefix,
  97. fs: storage.ActionsArtifacts,
  98. }
  99. m.Group(artifactRouteBase, func() {
  100. // retrieve, list and confirm artifacts
  101. m.Combo("").Get(r.listArtifacts).Post(r.getUploadArtifactURL).Patch(r.comfirmUploadArtifact)
  102. // handle container artifacts list and download
  103. m.Put("/{artifact_hash}/upload", r.uploadArtifact)
  104. // handle artifacts download
  105. m.Get("/{artifact_hash}/download_url", r.getDownloadArtifactURL)
  106. m.Get("/{artifact_id}/download", r.downloadArtifact)
  107. })
  108. return m
  109. }
  110. func ArtifactContexter() func(next http.Handler) http.Handler {
  111. return func(next http.Handler) http.Handler {
  112. return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
  113. base, baseCleanUp := context.NewBaseContext(resp, req)
  114. defer baseCleanUp()
  115. ctx := &ArtifactContext{Base: base}
  116. ctx.AppendContextValue(artifactContextKey, ctx)
  117. // action task call server api with Bearer ACTIONS_RUNTIME_TOKEN
  118. // we should verify the ACTIONS_RUNTIME_TOKEN
  119. authHeader := req.Header.Get("Authorization")
  120. if len(authHeader) == 0 || !strings.HasPrefix(authHeader, "Bearer ") {
  121. ctx.Error(http.StatusUnauthorized, "Bad authorization header")
  122. return
  123. }
  124. // New act_runner uses jwt to authenticate
  125. tID, err := actions_service.ParseAuthorizationToken(req)
  126. var task *actions.ActionTask
  127. if err == nil {
  128. task, err = actions.GetTaskByID(req.Context(), tID)
  129. if err != nil {
  130. log.Error("Error runner api getting task by ID: %v", err)
  131. ctx.Error(http.StatusInternalServerError, "Error runner api getting task by ID")
  132. return
  133. }
  134. if task.Status != actions.StatusRunning {
  135. log.Error("Error runner api getting task: task is not running")
  136. ctx.Error(http.StatusInternalServerError, "Error runner api getting task: task is not running")
  137. return
  138. }
  139. } else {
  140. // Old act_runner uses GITEA_TOKEN to authenticate
  141. authToken := strings.TrimPrefix(authHeader, "Bearer ")
  142. task, err = actions.GetRunningTaskByToken(req.Context(), authToken)
  143. if err != nil {
  144. log.Error("Error runner api getting task: %v", err)
  145. ctx.Error(http.StatusInternalServerError, "Error runner api getting task")
  146. return
  147. }
  148. }
  149. if err := task.LoadJob(req.Context()); err != nil {
  150. log.Error("Error runner api getting job: %v", err)
  151. ctx.Error(http.StatusInternalServerError, "Error runner api getting job")
  152. return
  153. }
  154. ctx.ActionTask = task
  155. next.ServeHTTP(ctx.Resp, ctx.Req)
  156. })
  157. }
  158. }
  159. type artifactRoutes struct {
  160. prefix string
  161. fs storage.ObjectStorage
  162. }
  163. func (ar artifactRoutes) buildArtifactURL(runID int64, artifactHash, suffix string) string {
  164. uploadURL := strings.TrimSuffix(setting.AppURL, "/") + strings.TrimSuffix(ar.prefix, "/") +
  165. strings.ReplaceAll(artifactRouteBase, "{run_id}", strconv.FormatInt(runID, 10)) +
  166. "/" + artifactHash + "/" + suffix
  167. return uploadURL
  168. }
  169. type getUploadArtifactRequest struct {
  170. Type string
  171. Name string
  172. RetentionDays int64
  173. }
  174. type getUploadArtifactResponse struct {
  175. FileContainerResourceURL string `json:"fileContainerResourceUrl"`
  176. }
  177. // getUploadArtifactURL generates a URL for uploading an artifact
  178. func (ar artifactRoutes) getUploadArtifactURL(ctx *ArtifactContext) {
  179. _, runID, ok := validateRunID(ctx)
  180. if !ok {
  181. return
  182. }
  183. var req getUploadArtifactRequest
  184. if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil {
  185. log.Error("Error decode request body: %v", err)
  186. ctx.Error(http.StatusInternalServerError, "Error decode request body")
  187. return
  188. }
  189. // set retention days
  190. retentionQuery := ""
  191. if req.RetentionDays > 0 {
  192. retentionQuery = fmt.Sprintf("?retentionDays=%d", req.RetentionDays)
  193. }
  194. // use md5(artifact_name) to create upload url
  195. artifactHash := fmt.Sprintf("%x", md5.Sum([]byte(req.Name)))
  196. resp := getUploadArtifactResponse{
  197. FileContainerResourceURL: ar.buildArtifactURL(runID, artifactHash, "upload"+retentionQuery),
  198. }
  199. log.Debug("[artifact] get upload url: %s", resp.FileContainerResourceURL)
  200. ctx.JSON(http.StatusOK, resp)
  201. }
  202. func (ar artifactRoutes) uploadArtifact(ctx *ArtifactContext) {
  203. task, runID, ok := validateRunID(ctx)
  204. if !ok {
  205. return
  206. }
  207. artifactName, artifactPath, ok := parseArtifactItemPath(ctx)
  208. if !ok {
  209. return
  210. }
  211. // get upload file size
  212. fileRealTotalSize, contentLength, err := getUploadFileSize(ctx)
  213. if err != nil {
  214. log.Error("Error get upload file size: %v", err)
  215. ctx.Error(http.StatusInternalServerError, "Error get upload file size")
  216. return
  217. }
  218. // get artifact retention days
  219. expiredDays := setting.Actions.ArtifactRetentionDays
  220. if queryRetentionDays := ctx.Req.URL.Query().Get("retentionDays"); queryRetentionDays != "" {
  221. expiredDays, err = strconv.ParseInt(queryRetentionDays, 10, 64)
  222. if err != nil {
  223. log.Error("Error parse retention days: %v", err)
  224. ctx.Error(http.StatusBadRequest, "Error parse retention days")
  225. return
  226. }
  227. }
  228. log.Debug("[artifact] upload chunk, name: %s, path: %s, size: %d, retention days: %d",
  229. artifactName, artifactPath, fileRealTotalSize, expiredDays)
  230. // create or get artifact with name and path
  231. artifact, err := actions.CreateArtifact(ctx, task, artifactName, artifactPath, expiredDays)
  232. if err != nil {
  233. log.Error("Error create or get artifact: %v", err)
  234. ctx.Error(http.StatusInternalServerError, "Error create or get artifact")
  235. return
  236. }
  237. // save chunk to storage, if success, return chunk stotal size
  238. // if artifact is not gzip when uploading, chunksTotalSize == fileRealTotalSize
  239. // if artifact is gzip when uploading, chunksTotalSize < fileRealTotalSize
  240. chunksTotalSize, err := saveUploadChunk(ar.fs, ctx, artifact, contentLength, runID)
  241. if err != nil {
  242. log.Error("Error save upload chunk: %v", err)
  243. ctx.Error(http.StatusInternalServerError, "Error save upload chunk")
  244. return
  245. }
  246. // update artifact size if zero or not match, over write artifact size
  247. if artifact.FileSize == 0 ||
  248. artifact.FileCompressedSize == 0 ||
  249. artifact.FileSize != fileRealTotalSize ||
  250. artifact.FileCompressedSize != chunksTotalSize {
  251. artifact.FileSize = fileRealTotalSize
  252. artifact.FileCompressedSize = chunksTotalSize
  253. artifact.ContentEncoding = ctx.Req.Header.Get("Content-Encoding")
  254. if err := actions.UpdateArtifactByID(ctx, artifact.ID, artifact); err != nil {
  255. log.Error("Error update artifact: %v", err)
  256. ctx.Error(http.StatusInternalServerError, "Error update artifact")
  257. return
  258. }
  259. log.Debug("[artifact] update artifact size, artifact_id: %d, size: %d, compressed size: %d",
  260. artifact.ID, artifact.FileSize, artifact.FileCompressedSize)
  261. }
  262. ctx.JSON(http.StatusOK, map[string]string{
  263. "message": "success",
  264. })
  265. }
  266. // comfirmUploadArtifact comfirm upload artifact.
  267. // if all chunks are uploaded, merge them to one file.
  268. func (ar artifactRoutes) comfirmUploadArtifact(ctx *ArtifactContext) {
  269. _, runID, ok := validateRunID(ctx)
  270. if !ok {
  271. return
  272. }
  273. artifactName := ctx.Req.URL.Query().Get("artifactName")
  274. if artifactName == "" {
  275. log.Error("Error artifact name is empty")
  276. ctx.Error(http.StatusBadRequest, "Error artifact name is empty")
  277. return
  278. }
  279. if err := mergeChunksForRun(ctx, ar.fs, runID, artifactName); err != nil {
  280. log.Error("Error merge chunks: %v", err)
  281. ctx.Error(http.StatusInternalServerError, "Error merge chunks")
  282. return
  283. }
  284. ctx.JSON(http.StatusOK, map[string]string{
  285. "message": "success",
  286. })
  287. }
  288. type (
  289. listArtifactsResponse struct {
  290. Count int64 `json:"count"`
  291. Value []listArtifactsResponseItem `json:"value"`
  292. }
  293. listArtifactsResponseItem struct {
  294. Name string `json:"name"`
  295. FileContainerResourceURL string `json:"fileContainerResourceUrl"`
  296. }
  297. )
  298. func (ar artifactRoutes) listArtifacts(ctx *ArtifactContext) {
  299. _, runID, ok := validateRunID(ctx)
  300. if !ok {
  301. return
  302. }
  303. artifacts, err := db.Find[actions.ActionArtifact](ctx, actions.FindArtifactsOptions{RunID: runID})
  304. if err != nil {
  305. log.Error("Error getting artifacts: %v", err)
  306. ctx.Error(http.StatusInternalServerError, err.Error())
  307. return
  308. }
  309. if len(artifacts) == 0 {
  310. log.Debug("[artifact] handleListArtifacts, no artifacts")
  311. ctx.Error(http.StatusNotFound)
  312. return
  313. }
  314. var (
  315. items []listArtifactsResponseItem
  316. values = make(map[string]bool)
  317. )
  318. for _, art := range artifacts {
  319. if values[art.ArtifactName] {
  320. continue
  321. }
  322. artifactHash := fmt.Sprintf("%x", md5.Sum([]byte(art.ArtifactName)))
  323. item := listArtifactsResponseItem{
  324. Name: art.ArtifactName,
  325. FileContainerResourceURL: ar.buildArtifactURL(runID, artifactHash, "download_url"),
  326. }
  327. items = append(items, item)
  328. values[art.ArtifactName] = true
  329. log.Debug("[artifact] handleListArtifacts, name: %s, url: %s", item.Name, item.FileContainerResourceURL)
  330. }
  331. respData := listArtifactsResponse{
  332. Count: int64(len(items)),
  333. Value: items,
  334. }
  335. ctx.JSON(http.StatusOK, respData)
  336. }
  337. type (
  338. downloadArtifactResponse struct {
  339. Value []downloadArtifactResponseItem `json:"value"`
  340. }
  341. downloadArtifactResponseItem struct {
  342. Path string `json:"path"`
  343. ItemType string `json:"itemType"`
  344. ContentLocation string `json:"contentLocation"`
  345. }
  346. )
  347. // getDownloadArtifactURL generates download url for each artifact
  348. func (ar artifactRoutes) getDownloadArtifactURL(ctx *ArtifactContext) {
  349. _, runID, ok := validateRunID(ctx)
  350. if !ok {
  351. return
  352. }
  353. itemPath := util.PathJoinRel(ctx.Req.URL.Query().Get("itemPath"))
  354. if !validateArtifactHash(ctx, itemPath) {
  355. return
  356. }
  357. artifacts, err := db.Find[actions.ActionArtifact](ctx, actions.FindArtifactsOptions{
  358. RunID: runID,
  359. ArtifactName: itemPath,
  360. })
  361. if err != nil {
  362. log.Error("Error getting artifacts: %v", err)
  363. ctx.Error(http.StatusInternalServerError, err.Error())
  364. return
  365. }
  366. if len(artifacts) == 0 {
  367. log.Debug("[artifact] getDownloadArtifactURL, no artifacts")
  368. ctx.Error(http.StatusNotFound)
  369. return
  370. }
  371. if itemPath != artifacts[0].ArtifactName {
  372. log.Error("Error dismatch artifact name, itemPath: %v, artifact: %v", itemPath, artifacts[0].ArtifactName)
  373. ctx.Error(http.StatusBadRequest, "Error dismatch artifact name")
  374. return
  375. }
  376. var items []downloadArtifactResponseItem
  377. for _, artifact := range artifacts {
  378. var downloadURL string
  379. if setting.Actions.ArtifactStorage.MinioConfig.ServeDirect {
  380. u, err := ar.fs.URL(artifact.StoragePath, artifact.ArtifactName)
  381. if err != nil && !errors.Is(err, storage.ErrURLNotSupported) {
  382. log.Error("Error getting serve direct url: %v", err)
  383. }
  384. if u != nil {
  385. downloadURL = u.String()
  386. }
  387. }
  388. if downloadURL == "" {
  389. downloadURL = ar.buildArtifactURL(runID, strconv.FormatInt(artifact.ID, 10), "download")
  390. }
  391. item := downloadArtifactResponseItem{
  392. Path: util.PathJoinRel(itemPath, artifact.ArtifactPath),
  393. ItemType: "file",
  394. ContentLocation: downloadURL,
  395. }
  396. log.Debug("[artifact] getDownloadArtifactURL, path: %s, url: %s", item.Path, item.ContentLocation)
  397. items = append(items, item)
  398. }
  399. respData := downloadArtifactResponse{
  400. Value: items,
  401. }
  402. ctx.JSON(http.StatusOK, respData)
  403. }
  404. // downloadArtifact downloads artifact content
  405. func (ar artifactRoutes) downloadArtifact(ctx *ArtifactContext) {
  406. _, runID, ok := validateRunID(ctx)
  407. if !ok {
  408. return
  409. }
  410. artifactID := ctx.ParamsInt64("artifact_id")
  411. artifact, exist, err := db.GetByID[actions.ActionArtifact](ctx, artifactID)
  412. if err != nil {
  413. log.Error("Error getting artifact: %v", err)
  414. ctx.Error(http.StatusInternalServerError, err.Error())
  415. return
  416. } else if !exist {
  417. log.Error("artifact with ID %d does not exist", artifactID)
  418. ctx.Error(http.StatusNotFound, fmt.Sprintf("artifact with ID %d does not exist", artifactID))
  419. return
  420. }
  421. if artifact.RunID != runID {
  422. log.Error("Error dismatch runID and artifactID, task: %v, artifact: %v", runID, artifactID)
  423. ctx.Error(http.StatusBadRequest, err.Error())
  424. return
  425. }
  426. fd, err := ar.fs.Open(artifact.StoragePath)
  427. if err != nil {
  428. log.Error("Error opening file: %v", err)
  429. ctx.Error(http.StatusInternalServerError, err.Error())
  430. return
  431. }
  432. defer fd.Close()
  433. // if artifact is compressed, set content-encoding header to gzip
  434. if artifact.ContentEncoding == "gzip" {
  435. ctx.Resp.Header().Set("Content-Encoding", "gzip")
  436. }
  437. log.Debug("[artifact] downloadArtifact, name: %s, path: %s, storage: %s, size: %d", artifact.ArtifactName, artifact.ArtifactPath, artifact.StoragePath, artifact.FileSize)
  438. ctx.ServeContent(fd, &context.ServeHeaderOptions{
  439. Filename: artifact.ArtifactName,
  440. LastModified: artifact.CreatedUnix.AsLocalTime(),
  441. })
  442. }