gitea源码

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504
  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/httplib"
  71. "code.gitea.io/gitea/modules/json"
  72. "code.gitea.io/gitea/modules/log"
  73. "code.gitea.io/gitea/modules/setting"
  74. "code.gitea.io/gitea/modules/storage"
  75. "code.gitea.io/gitea/modules/util"
  76. "code.gitea.io/gitea/modules/web"
  77. web_types "code.gitea.io/gitea/modules/web/types"
  78. actions_service "code.gitea.io/gitea/services/actions"
  79. "code.gitea.io/gitea/services/context"
  80. )
  81. const artifactRouteBase = "/_apis/pipelines/workflows/{run_id}/artifacts"
  82. type artifactContextKeyType struct{}
  83. var artifactContextKey = artifactContextKeyType{}
  84. type ArtifactContext struct {
  85. *context.Base
  86. ActionTask *actions.ActionTask
  87. }
  88. func init() {
  89. web.RegisterResponseStatusProvider[*ArtifactContext](func(req *http.Request) web_types.ResponseStatusProvider {
  90. return req.Context().Value(artifactContextKey).(*ArtifactContext)
  91. })
  92. }
  93. func ArtifactsRoutes(prefix string) *web.Router {
  94. m := web.NewRouter()
  95. m.Use(ArtifactContexter())
  96. r := artifactRoutes{
  97. prefix: prefix,
  98. fs: storage.ActionsArtifacts,
  99. }
  100. m.Group(artifactRouteBase, func() {
  101. // retrieve, list and confirm artifacts
  102. m.Combo("").Get(r.listArtifacts).Post(r.getUploadArtifactURL).Patch(r.comfirmUploadArtifact)
  103. // handle container artifacts list and download
  104. m.Put("/{artifact_hash}/upload", r.uploadArtifact)
  105. // handle artifacts download
  106. m.Get("/{artifact_hash}/download_url", r.getDownloadArtifactURL)
  107. m.Get("/{artifact_id}/download", r.downloadArtifact)
  108. })
  109. return m
  110. }
  111. func ArtifactContexter() func(next http.Handler) http.Handler {
  112. return func(next http.Handler) http.Handler {
  113. return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
  114. base := context.NewBaseContext(resp, req)
  115. ctx := &ArtifactContext{Base: base}
  116. ctx.SetContextValue(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.HTTPError(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.HTTPError(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.HTTPError(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.HTTPError(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.HTTPError(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(ctx *ArtifactContext, runID int64, artifactHash, suffix string) string {
  164. uploadURL := strings.TrimSuffix(httplib.GuessCurrentAppURL(ctx), "/") + 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.HTTPError(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(ctx, 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 := getUploadFileSize(ctx)
  213. // get artifact retention days
  214. expiredDays := setting.Actions.ArtifactRetentionDays
  215. if queryRetentionDays := ctx.Req.URL.Query().Get("retentionDays"); queryRetentionDays != "" {
  216. var err error
  217. expiredDays, err = strconv.ParseInt(queryRetentionDays, 10, 64)
  218. if err != nil {
  219. log.Error("Error parse retention days: %v", err)
  220. ctx.HTTPError(http.StatusBadRequest, "Error parse retention days")
  221. return
  222. }
  223. }
  224. log.Debug("[artifact] upload chunk, name: %s, path: %s, size: %d, retention days: %d",
  225. artifactName, artifactPath, fileRealTotalSize, expiredDays)
  226. // create or get artifact with name and path
  227. artifact, err := actions.CreateArtifact(ctx, task, artifactName, artifactPath, expiredDays)
  228. if err != nil {
  229. log.Error("Error create or get artifact: %v", err)
  230. ctx.HTTPError(http.StatusInternalServerError, "Error create or get artifact")
  231. return
  232. }
  233. // save chunk to storage, if success, return chunk stotal size
  234. // if artifact is not gzip when uploading, chunksTotalSize == fileRealTotalSize
  235. // if artifact is gzip when uploading, chunksTotalSize < fileRealTotalSize
  236. chunksTotalSize, err := saveUploadChunk(ar.fs, ctx, artifact, contentLength, runID)
  237. if err != nil {
  238. log.Error("Error save upload chunk: %v", err)
  239. ctx.HTTPError(http.StatusInternalServerError, "Error save upload chunk")
  240. return
  241. }
  242. // update artifact size if zero or not match, over write artifact size
  243. if artifact.FileSize == 0 ||
  244. artifact.FileCompressedSize == 0 ||
  245. artifact.FileSize != fileRealTotalSize ||
  246. artifact.FileCompressedSize != chunksTotalSize {
  247. artifact.FileSize = fileRealTotalSize
  248. artifact.FileCompressedSize = chunksTotalSize
  249. artifact.ContentEncoding = ctx.Req.Header.Get("Content-Encoding")
  250. if err := actions.UpdateArtifactByID(ctx, artifact.ID, artifact); err != nil {
  251. log.Error("Error update artifact: %v", err)
  252. ctx.HTTPError(http.StatusInternalServerError, "Error update artifact")
  253. return
  254. }
  255. log.Debug("[artifact] update artifact size, artifact_id: %d, size: %d, compressed size: %d",
  256. artifact.ID, artifact.FileSize, artifact.FileCompressedSize)
  257. }
  258. ctx.JSON(http.StatusOK, map[string]string{
  259. "message": "success",
  260. })
  261. }
  262. // comfirmUploadArtifact confirm upload artifact.
  263. // if all chunks are uploaded, merge them to one file.
  264. func (ar artifactRoutes) comfirmUploadArtifact(ctx *ArtifactContext) {
  265. _, runID, ok := validateRunID(ctx)
  266. if !ok {
  267. return
  268. }
  269. artifactName := ctx.Req.URL.Query().Get("artifactName")
  270. if artifactName == "" {
  271. log.Error("Error artifact name is empty")
  272. ctx.HTTPError(http.StatusBadRequest, "Error artifact name is empty")
  273. return
  274. }
  275. if err := mergeChunksForRun(ctx, ar.fs, runID, artifactName); err != nil {
  276. log.Error("Error merge chunks: %v", err)
  277. ctx.HTTPError(http.StatusInternalServerError, "Error merge chunks")
  278. return
  279. }
  280. ctx.JSON(http.StatusOK, map[string]string{
  281. "message": "success",
  282. })
  283. }
  284. type (
  285. listArtifactsResponse struct {
  286. Count int64 `json:"count"`
  287. Value []listArtifactsResponseItem `json:"value"`
  288. }
  289. listArtifactsResponseItem struct {
  290. Name string `json:"name"`
  291. FileContainerResourceURL string `json:"fileContainerResourceUrl"`
  292. }
  293. )
  294. func (ar artifactRoutes) listArtifacts(ctx *ArtifactContext) {
  295. _, runID, ok := validateRunID(ctx)
  296. if !ok {
  297. return
  298. }
  299. artifacts, err := db.Find[actions.ActionArtifact](ctx, actions.FindArtifactsOptions{
  300. RunID: runID,
  301. Status: int(actions.ArtifactStatusUploadConfirmed),
  302. })
  303. if err != nil {
  304. log.Error("Error getting artifacts: %v", err)
  305. ctx.HTTPError(http.StatusInternalServerError, err.Error())
  306. return
  307. }
  308. if len(artifacts) == 0 {
  309. log.Debug("[artifact] handleListArtifacts, no artifacts")
  310. ctx.HTTPError(http.StatusNotFound)
  311. return
  312. }
  313. var (
  314. items []listArtifactsResponseItem
  315. values = make(map[string]bool)
  316. )
  317. for _, art := range artifacts {
  318. if values[art.ArtifactName] {
  319. continue
  320. }
  321. artifactHash := fmt.Sprintf("%x", md5.Sum([]byte(art.ArtifactName)))
  322. item := listArtifactsResponseItem{
  323. Name: art.ArtifactName,
  324. FileContainerResourceURL: ar.buildArtifactURL(ctx, runID, artifactHash, "download_url"),
  325. }
  326. items = append(items, item)
  327. values[art.ArtifactName] = true
  328. log.Debug("[artifact] handleListArtifacts, name: %s, url: %s", item.Name, item.FileContainerResourceURL)
  329. }
  330. respData := listArtifactsResponse{
  331. Count: int64(len(items)),
  332. Value: items,
  333. }
  334. ctx.JSON(http.StatusOK, respData)
  335. }
  336. type (
  337. downloadArtifactResponse struct {
  338. Value []downloadArtifactResponseItem `json:"value"`
  339. }
  340. downloadArtifactResponseItem struct {
  341. Path string `json:"path"`
  342. ItemType string `json:"itemType"`
  343. ContentLocation string `json:"contentLocation"`
  344. }
  345. )
  346. // getDownloadArtifactURL generates download url for each artifact
  347. func (ar artifactRoutes) getDownloadArtifactURL(ctx *ArtifactContext) {
  348. _, runID, ok := validateRunID(ctx)
  349. if !ok {
  350. return
  351. }
  352. itemPath := util.PathJoinRel(ctx.Req.URL.Query().Get("itemPath"))
  353. if !validateArtifactHash(ctx, itemPath) {
  354. return
  355. }
  356. artifacts, err := db.Find[actions.ActionArtifact](ctx, actions.FindArtifactsOptions{
  357. RunID: runID,
  358. ArtifactName: itemPath,
  359. Status: int(actions.ArtifactStatusUploadConfirmed),
  360. })
  361. if err != nil {
  362. log.Error("Error getting artifacts: %v", err)
  363. ctx.HTTPError(http.StatusInternalServerError, err.Error())
  364. return
  365. }
  366. if len(artifacts) == 0 {
  367. log.Debug("[artifact] getDownloadArtifactURL, no artifacts")
  368. ctx.HTTPError(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.HTTPError(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.ServeDirect() {
  380. u, err := ar.fs.URL(artifact.StoragePath, artifact.ArtifactName, ctx.Req.Method, nil)
  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(ctx, 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.PathParamInt64("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.HTTPError(http.StatusInternalServerError, err.Error())
  415. return
  416. }
  417. if !exist {
  418. log.Error("artifact with ID %d does not exist", artifactID)
  419. ctx.HTTPError(http.StatusNotFound, fmt.Sprintf("artifact with ID %d does not exist", artifactID))
  420. return
  421. }
  422. if artifact.RunID != runID {
  423. log.Error("Error mismatch runID and artifactID, task: %v, artifact: %v", runID, artifactID)
  424. ctx.HTTPError(http.StatusBadRequest)
  425. return
  426. }
  427. if artifact.Status != actions.ArtifactStatusUploadConfirmed {
  428. log.Error("Error artifact not found: %s", artifact.Status.ToString())
  429. ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
  430. return
  431. }
  432. fd, err := ar.fs.Open(artifact.StoragePath)
  433. if err != nil {
  434. log.Error("Error opening file: %v", err)
  435. ctx.HTTPError(http.StatusInternalServerError, err.Error())
  436. return
  437. }
  438. defer fd.Close()
  439. // if artifact is compressed, set content-encoding header to gzip
  440. if artifact.ContentEncoding == "gzip" {
  441. ctx.Resp.Header().Set("Content-Encoding", "gzip")
  442. }
  443. log.Debug("[artifact] downloadArtifact, name: %s, path: %s, storage: %s, size: %d", artifact.ArtifactName, artifact.ArtifactPath, artifact.StoragePath, artifact.FileSize)
  444. ctx.ServeContent(fd, &context.ServeHeaderOptions{
  445. Filename: artifact.ArtifactName,
  446. LastModified: artifact.CreatedUnix.AsLocalTime(),
  447. })
  448. }