初始提交: Gitea 项目代码

This commit is contained in:
root
2026-05-30 22:47:36 +08:00
commit f288f76350
6116 changed files with 776822 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"net/http"
"gitea.dev/modules/web"
"gitea.dev/routers/api/actions/ping"
"gitea.dev/routers/api/actions/runner"
)
func Routes(prefix string) *web.Router {
m := web.NewRouter()
path, handler := ping.NewPingServiceHandler()
m.Post(path+"*", http.StripPrefix(prefix, handler).ServeHTTP)
path, handler = runner.NewRunnerServiceHandler()
m.Post(path+"*", http.StripPrefix(prefix, handler).ServeHTTP)
return m
}
+831
View File
@@ -0,0 +1,831 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v7.34.0
// source: artifact.proto
package actions
import (
reflect "reflect"
sync "sync"
unsafe "unsafe"
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
timestamppb "google.golang.org/protobuf/types/known/timestamppb"
wrapperspb "google.golang.org/protobuf/types/known/wrapperspb"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type CreateArtifactRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
WorkflowRunBackendId string `protobuf:"bytes,1,opt,name=workflow_run_backend_id,json=workflowRunBackendId,proto3" json:"workflow_run_backend_id,omitempty"`
WorkflowJobRunBackendId string `protobuf:"bytes,2,opt,name=workflow_job_run_backend_id,json=workflowJobRunBackendId,proto3" json:"workflow_job_run_backend_id,omitempty"`
Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"`
ExpiresAt *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"`
Version int32 `protobuf:"varint,5,opt,name=version,proto3" json:"version,omitempty"`
MimeType *wrapperspb.StringValue `protobuf:"bytes,6,opt,name=mime_type,json=mimeType,proto3" json:"mime_type,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *CreateArtifactRequest) Reset() {
*x = CreateArtifactRequest{}
mi := &file_artifact_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *CreateArtifactRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CreateArtifactRequest) ProtoMessage() {}
func (x *CreateArtifactRequest) ProtoReflect() protoreflect.Message {
mi := &file_artifact_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use CreateArtifactRequest.ProtoReflect.Descriptor instead.
func (*CreateArtifactRequest) Descriptor() ([]byte, []int) {
return file_artifact_proto_rawDescGZIP(), []int{0}
}
func (x *CreateArtifactRequest) GetWorkflowRunBackendId() string {
if x != nil {
return x.WorkflowRunBackendId
}
return ""
}
func (x *CreateArtifactRequest) GetWorkflowJobRunBackendId() string {
if x != nil {
return x.WorkflowJobRunBackendId
}
return ""
}
func (x *CreateArtifactRequest) GetName() string {
if x != nil {
return x.Name
}
return ""
}
func (x *CreateArtifactRequest) GetExpiresAt() *timestamppb.Timestamp {
if x != nil {
return x.ExpiresAt
}
return nil
}
func (x *CreateArtifactRequest) GetVersion() int32 {
if x != nil {
return x.Version
}
return 0
}
func (x *CreateArtifactRequest) GetMimeType() *wrapperspb.StringValue {
if x != nil {
return x.MimeType
}
return nil
}
type CreateArtifactResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"`
SignedUploadUrl string `protobuf:"bytes,2,opt,name=signed_upload_url,json=signedUploadUrl,proto3" json:"signed_upload_url,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *CreateArtifactResponse) Reset() {
*x = CreateArtifactResponse{}
mi := &file_artifact_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *CreateArtifactResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CreateArtifactResponse) ProtoMessage() {}
func (x *CreateArtifactResponse) ProtoReflect() protoreflect.Message {
mi := &file_artifact_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use CreateArtifactResponse.ProtoReflect.Descriptor instead.
func (*CreateArtifactResponse) Descriptor() ([]byte, []int) {
return file_artifact_proto_rawDescGZIP(), []int{1}
}
func (x *CreateArtifactResponse) GetOk() bool {
if x != nil {
return x.Ok
}
return false
}
func (x *CreateArtifactResponse) GetSignedUploadUrl() string {
if x != nil {
return x.SignedUploadUrl
}
return ""
}
type FinalizeArtifactRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
WorkflowRunBackendId string `protobuf:"bytes,1,opt,name=workflow_run_backend_id,json=workflowRunBackendId,proto3" json:"workflow_run_backend_id,omitempty"`
WorkflowJobRunBackendId string `protobuf:"bytes,2,opt,name=workflow_job_run_backend_id,json=workflowJobRunBackendId,proto3" json:"workflow_job_run_backend_id,omitempty"`
Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"`
Size int64 `protobuf:"varint,4,opt,name=size,proto3" json:"size,omitempty"`
Hash *wrapperspb.StringValue `protobuf:"bytes,5,opt,name=hash,proto3" json:"hash,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *FinalizeArtifactRequest) Reset() {
*x = FinalizeArtifactRequest{}
mi := &file_artifact_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *FinalizeArtifactRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*FinalizeArtifactRequest) ProtoMessage() {}
func (x *FinalizeArtifactRequest) ProtoReflect() protoreflect.Message {
mi := &file_artifact_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use FinalizeArtifactRequest.ProtoReflect.Descriptor instead.
func (*FinalizeArtifactRequest) Descriptor() ([]byte, []int) {
return file_artifact_proto_rawDescGZIP(), []int{2}
}
func (x *FinalizeArtifactRequest) GetWorkflowRunBackendId() string {
if x != nil {
return x.WorkflowRunBackendId
}
return ""
}
func (x *FinalizeArtifactRequest) GetWorkflowJobRunBackendId() string {
if x != nil {
return x.WorkflowJobRunBackendId
}
return ""
}
func (x *FinalizeArtifactRequest) GetName() string {
if x != nil {
return x.Name
}
return ""
}
func (x *FinalizeArtifactRequest) GetSize() int64 {
if x != nil {
return x.Size
}
return 0
}
func (x *FinalizeArtifactRequest) GetHash() *wrapperspb.StringValue {
if x != nil {
return x.Hash
}
return nil
}
type FinalizeArtifactResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"`
ArtifactId int64 `protobuf:"varint,2,opt,name=artifact_id,json=artifactId,proto3" json:"artifact_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *FinalizeArtifactResponse) Reset() {
*x = FinalizeArtifactResponse{}
mi := &file_artifact_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *FinalizeArtifactResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*FinalizeArtifactResponse) ProtoMessage() {}
func (x *FinalizeArtifactResponse) ProtoReflect() protoreflect.Message {
mi := &file_artifact_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use FinalizeArtifactResponse.ProtoReflect.Descriptor instead.
func (*FinalizeArtifactResponse) Descriptor() ([]byte, []int) {
return file_artifact_proto_rawDescGZIP(), []int{3}
}
func (x *FinalizeArtifactResponse) GetOk() bool {
if x != nil {
return x.Ok
}
return false
}
func (x *FinalizeArtifactResponse) GetArtifactId() int64 {
if x != nil {
return x.ArtifactId
}
return 0
}
type ListArtifactsRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
WorkflowRunBackendId string `protobuf:"bytes,1,opt,name=workflow_run_backend_id,json=workflowRunBackendId,proto3" json:"workflow_run_backend_id,omitempty"`
WorkflowJobRunBackendId string `protobuf:"bytes,2,opt,name=workflow_job_run_backend_id,json=workflowJobRunBackendId,proto3" json:"workflow_job_run_backend_id,omitempty"`
NameFilter *wrapperspb.StringValue `protobuf:"bytes,3,opt,name=name_filter,json=nameFilter,proto3" json:"name_filter,omitempty"`
IdFilter *wrapperspb.Int64Value `protobuf:"bytes,4,opt,name=id_filter,json=idFilter,proto3" json:"id_filter,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ListArtifactsRequest) Reset() {
*x = ListArtifactsRequest{}
mi := &file_artifact_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ListArtifactsRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ListArtifactsRequest) ProtoMessage() {}
func (x *ListArtifactsRequest) ProtoReflect() protoreflect.Message {
mi := &file_artifact_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ListArtifactsRequest.ProtoReflect.Descriptor instead.
func (*ListArtifactsRequest) Descriptor() ([]byte, []int) {
return file_artifact_proto_rawDescGZIP(), []int{4}
}
func (x *ListArtifactsRequest) GetWorkflowRunBackendId() string {
if x != nil {
return x.WorkflowRunBackendId
}
return ""
}
func (x *ListArtifactsRequest) GetWorkflowJobRunBackendId() string {
if x != nil {
return x.WorkflowJobRunBackendId
}
return ""
}
func (x *ListArtifactsRequest) GetNameFilter() *wrapperspb.StringValue {
if x != nil {
return x.NameFilter
}
return nil
}
func (x *ListArtifactsRequest) GetIdFilter() *wrapperspb.Int64Value {
if x != nil {
return x.IdFilter
}
return nil
}
type ListArtifactsResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Artifacts []*ListArtifactsResponse_MonolithArtifact `protobuf:"bytes,1,rep,name=artifacts,proto3" json:"artifacts,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ListArtifactsResponse) Reset() {
*x = ListArtifactsResponse{}
mi := &file_artifact_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ListArtifactsResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ListArtifactsResponse) ProtoMessage() {}
func (x *ListArtifactsResponse) ProtoReflect() protoreflect.Message {
mi := &file_artifact_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ListArtifactsResponse.ProtoReflect.Descriptor instead.
func (*ListArtifactsResponse) Descriptor() ([]byte, []int) {
return file_artifact_proto_rawDescGZIP(), []int{5}
}
func (x *ListArtifactsResponse) GetArtifacts() []*ListArtifactsResponse_MonolithArtifact {
if x != nil {
return x.Artifacts
}
return nil
}
type ListArtifactsResponse_MonolithArtifact struct {
state protoimpl.MessageState `protogen:"open.v1"`
WorkflowRunBackendId string `protobuf:"bytes,1,opt,name=workflow_run_backend_id,json=workflowRunBackendId,proto3" json:"workflow_run_backend_id,omitempty"`
WorkflowJobRunBackendId string `protobuf:"bytes,2,opt,name=workflow_job_run_backend_id,json=workflowJobRunBackendId,proto3" json:"workflow_job_run_backend_id,omitempty"`
DatabaseId int64 `protobuf:"varint,3,opt,name=database_id,json=databaseId,proto3" json:"database_id,omitempty"`
Name string `protobuf:"bytes,4,opt,name=name,proto3" json:"name,omitempty"`
Size int64 `protobuf:"varint,5,opt,name=size,proto3" json:"size,omitempty"`
CreatedAt *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ListArtifactsResponse_MonolithArtifact) Reset() {
*x = ListArtifactsResponse_MonolithArtifact{}
mi := &file_artifact_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ListArtifactsResponse_MonolithArtifact) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ListArtifactsResponse_MonolithArtifact) ProtoMessage() {}
func (x *ListArtifactsResponse_MonolithArtifact) ProtoReflect() protoreflect.Message {
mi := &file_artifact_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ListArtifactsResponse_MonolithArtifact.ProtoReflect.Descriptor instead.
func (*ListArtifactsResponse_MonolithArtifact) Descriptor() ([]byte, []int) {
return file_artifact_proto_rawDescGZIP(), []int{6}
}
func (x *ListArtifactsResponse_MonolithArtifact) GetWorkflowRunBackendId() string {
if x != nil {
return x.WorkflowRunBackendId
}
return ""
}
func (x *ListArtifactsResponse_MonolithArtifact) GetWorkflowJobRunBackendId() string {
if x != nil {
return x.WorkflowJobRunBackendId
}
return ""
}
func (x *ListArtifactsResponse_MonolithArtifact) GetDatabaseId() int64 {
if x != nil {
return x.DatabaseId
}
return 0
}
func (x *ListArtifactsResponse_MonolithArtifact) GetName() string {
if x != nil {
return x.Name
}
return ""
}
func (x *ListArtifactsResponse_MonolithArtifact) GetSize() int64 {
if x != nil {
return x.Size
}
return 0
}
func (x *ListArtifactsResponse_MonolithArtifact) GetCreatedAt() *timestamppb.Timestamp {
if x != nil {
return x.CreatedAt
}
return nil
}
type GetSignedArtifactURLRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
WorkflowRunBackendId string `protobuf:"bytes,1,opt,name=workflow_run_backend_id,json=workflowRunBackendId,proto3" json:"workflow_run_backend_id,omitempty"`
WorkflowJobRunBackendId string `protobuf:"bytes,2,opt,name=workflow_job_run_backend_id,json=workflowJobRunBackendId,proto3" json:"workflow_job_run_backend_id,omitempty"`
Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetSignedArtifactURLRequest) Reset() {
*x = GetSignedArtifactURLRequest{}
mi := &file_artifact_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetSignedArtifactURLRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetSignedArtifactURLRequest) ProtoMessage() {}
func (x *GetSignedArtifactURLRequest) ProtoReflect() protoreflect.Message {
mi := &file_artifact_proto_msgTypes[7]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetSignedArtifactURLRequest.ProtoReflect.Descriptor instead.
func (*GetSignedArtifactURLRequest) Descriptor() ([]byte, []int) {
return file_artifact_proto_rawDescGZIP(), []int{7}
}
func (x *GetSignedArtifactURLRequest) GetWorkflowRunBackendId() string {
if x != nil {
return x.WorkflowRunBackendId
}
return ""
}
func (x *GetSignedArtifactURLRequest) GetWorkflowJobRunBackendId() string {
if x != nil {
return x.WorkflowJobRunBackendId
}
return ""
}
func (x *GetSignedArtifactURLRequest) GetName() string {
if x != nil {
return x.Name
}
return ""
}
type GetSignedArtifactURLResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
SignedUrl string `protobuf:"bytes,1,opt,name=signed_url,json=signedUrl,proto3" json:"signed_url,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetSignedArtifactURLResponse) Reset() {
*x = GetSignedArtifactURLResponse{}
mi := &file_artifact_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetSignedArtifactURLResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetSignedArtifactURLResponse) ProtoMessage() {}
func (x *GetSignedArtifactURLResponse) ProtoReflect() protoreflect.Message {
mi := &file_artifact_proto_msgTypes[8]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetSignedArtifactURLResponse.ProtoReflect.Descriptor instead.
func (*GetSignedArtifactURLResponse) Descriptor() ([]byte, []int) {
return file_artifact_proto_rawDescGZIP(), []int{8}
}
func (x *GetSignedArtifactURLResponse) GetSignedUrl() string {
if x != nil {
return x.SignedUrl
}
return ""
}
type DeleteArtifactRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
WorkflowRunBackendId string `protobuf:"bytes,1,opt,name=workflow_run_backend_id,json=workflowRunBackendId,proto3" json:"workflow_run_backend_id,omitempty"`
WorkflowJobRunBackendId string `protobuf:"bytes,2,opt,name=workflow_job_run_backend_id,json=workflowJobRunBackendId,proto3" json:"workflow_job_run_backend_id,omitempty"`
Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *DeleteArtifactRequest) Reset() {
*x = DeleteArtifactRequest{}
mi := &file_artifact_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *DeleteArtifactRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*DeleteArtifactRequest) ProtoMessage() {}
func (x *DeleteArtifactRequest) ProtoReflect() protoreflect.Message {
mi := &file_artifact_proto_msgTypes[9]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use DeleteArtifactRequest.ProtoReflect.Descriptor instead.
func (*DeleteArtifactRequest) Descriptor() ([]byte, []int) {
return file_artifact_proto_rawDescGZIP(), []int{9}
}
func (x *DeleteArtifactRequest) GetWorkflowRunBackendId() string {
if x != nil {
return x.WorkflowRunBackendId
}
return ""
}
func (x *DeleteArtifactRequest) GetWorkflowJobRunBackendId() string {
if x != nil {
return x.WorkflowJobRunBackendId
}
return ""
}
func (x *DeleteArtifactRequest) GetName() string {
if x != nil {
return x.Name
}
return ""
}
type DeleteArtifactResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"`
ArtifactId int64 `protobuf:"varint,2,opt,name=artifact_id,json=artifactId,proto3" json:"artifact_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *DeleteArtifactResponse) Reset() {
*x = DeleteArtifactResponse{}
mi := &file_artifact_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *DeleteArtifactResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*DeleteArtifactResponse) ProtoMessage() {}
func (x *DeleteArtifactResponse) ProtoReflect() protoreflect.Message {
mi := &file_artifact_proto_msgTypes[10]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use DeleteArtifactResponse.ProtoReflect.Descriptor instead.
func (*DeleteArtifactResponse) Descriptor() ([]byte, []int) {
return file_artifact_proto_rawDescGZIP(), []int{10}
}
func (x *DeleteArtifactResponse) GetOk() bool {
if x != nil {
return x.Ok
}
return false
}
func (x *DeleteArtifactResponse) GetArtifactId() int64 {
if x != nil {
return x.ArtifactId
}
return 0
}
var File_artifact_proto protoreflect.FileDescriptor
const file_artifact_proto_rawDesc = "" +
"\n" +
"\x0eartifact.proto\x12\x1dgithub.actions.results.api.v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1egoogle/protobuf/wrappers.proto\"\xb0\x02\n" +
"\x15CreateArtifactRequest\x125\n" +
"\x17workflow_run_backend_id\x18\x01 \x01(\tR\x14workflowRunBackendId\x12<\n" +
"\x1bworkflow_job_run_backend_id\x18\x02 \x01(\tR\x17workflowJobRunBackendId\x12\x12\n" +
"\x04name\x18\x03 \x01(\tR\x04name\x129\n" +
"\n" +
"expires_at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\texpiresAt\x12\x18\n" +
"\aversion\x18\x05 \x01(\x05R\aversion\x129\n" +
"\tmime_type\x18\x06 \x01(\v2\x1c.google.protobuf.StringValueR\bmimeType\"T\n" +
"\x16CreateArtifactResponse\x12\x0e\n" +
"\x02ok\x18\x01 \x01(\bR\x02ok\x12*\n" +
"\x11signed_upload_url\x18\x02 \x01(\tR\x0fsignedUploadUrl\"\xe8\x01\n" +
"\x17FinalizeArtifactRequest\x125\n" +
"\x17workflow_run_backend_id\x18\x01 \x01(\tR\x14workflowRunBackendId\x12<\n" +
"\x1bworkflow_job_run_backend_id\x18\x02 \x01(\tR\x17workflowJobRunBackendId\x12\x12\n" +
"\x04name\x18\x03 \x01(\tR\x04name\x12\x12\n" +
"\x04size\x18\x04 \x01(\x03R\x04size\x120\n" +
"\x04hash\x18\x05 \x01(\v2\x1c.google.protobuf.StringValueR\x04hash\"K\n" +
"\x18FinalizeArtifactResponse\x12\x0e\n" +
"\x02ok\x18\x01 \x01(\bR\x02ok\x12\x1f\n" +
"\vartifact_id\x18\x02 \x01(\x03R\n" +
"artifactId\"\x84\x02\n" +
"\x14ListArtifactsRequest\x125\n" +
"\x17workflow_run_backend_id\x18\x01 \x01(\tR\x14workflowRunBackendId\x12<\n" +
"\x1bworkflow_job_run_backend_id\x18\x02 \x01(\tR\x17workflowJobRunBackendId\x12=\n" +
"\vname_filter\x18\x03 \x01(\v2\x1c.google.protobuf.StringValueR\n" +
"nameFilter\x128\n" +
"\tid_filter\x18\x04 \x01(\v2\x1b.google.protobuf.Int64ValueR\bidFilter\"|\n" +
"\x15ListArtifactsResponse\x12c\n" +
"\tartifacts\x18\x01 \x03(\v2E.github.actions.results.api.v1.ListArtifactsResponse_MonolithArtifactR\tartifacts\"\xa1\x02\n" +
"&ListArtifactsResponse_MonolithArtifact\x125\n" +
"\x17workflow_run_backend_id\x18\x01 \x01(\tR\x14workflowRunBackendId\x12<\n" +
"\x1bworkflow_job_run_backend_id\x18\x02 \x01(\tR\x17workflowJobRunBackendId\x12\x1f\n" +
"\vdatabase_id\x18\x03 \x01(\x03R\n" +
"databaseId\x12\x12\n" +
"\x04name\x18\x04 \x01(\tR\x04name\x12\x12\n" +
"\x04size\x18\x05 \x01(\x03R\x04size\x129\n" +
"\n" +
"created_at\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\"\xa6\x01\n" +
"\x1bGetSignedArtifactURLRequest\x125\n" +
"\x17workflow_run_backend_id\x18\x01 \x01(\tR\x14workflowRunBackendId\x12<\n" +
"\x1bworkflow_job_run_backend_id\x18\x02 \x01(\tR\x17workflowJobRunBackendId\x12\x12\n" +
"\x04name\x18\x03 \x01(\tR\x04name\"=\n" +
"\x1cGetSignedArtifactURLResponse\x12\x1d\n" +
"\n" +
"signed_url\x18\x01 \x01(\tR\tsignedUrl\"\xa0\x01\n" +
"\x15DeleteArtifactRequest\x125\n" +
"\x17workflow_run_backend_id\x18\x01 \x01(\tR\x14workflowRunBackendId\x12<\n" +
"\x1bworkflow_job_run_backend_id\x18\x02 \x01(\tR\x17workflowJobRunBackendId\x12\x12\n" +
"\x04name\x18\x03 \x01(\tR\x04name\"I\n" +
"\x16DeleteArtifactResponse\x12\x0e\n" +
"\x02ok\x18\x01 \x01(\bR\x02ok\x12\x1f\n" +
"\vartifact_id\x18\x02 \x01(\x03R\n" +
"artifactIdB\x1fZ\x1dgitea.dev/routers/api/actionsb\x06proto3"
var (
file_artifact_proto_rawDescOnce sync.Once
file_artifact_proto_rawDescData []byte
)
func file_artifact_proto_rawDescGZIP() []byte {
file_artifact_proto_rawDescOnce.Do(func() {
file_artifact_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_artifact_proto_rawDesc), len(file_artifact_proto_rawDesc)))
})
return file_artifact_proto_rawDescData
}
var file_artifact_proto_msgTypes = make([]protoimpl.MessageInfo, 11)
var file_artifact_proto_goTypes = []any{
(*CreateArtifactRequest)(nil), // 0: github.actions.results.api.v1.CreateArtifactRequest
(*CreateArtifactResponse)(nil), // 1: github.actions.results.api.v1.CreateArtifactResponse
(*FinalizeArtifactRequest)(nil), // 2: github.actions.results.api.v1.FinalizeArtifactRequest
(*FinalizeArtifactResponse)(nil), // 3: github.actions.results.api.v1.FinalizeArtifactResponse
(*ListArtifactsRequest)(nil), // 4: github.actions.results.api.v1.ListArtifactsRequest
(*ListArtifactsResponse)(nil), // 5: github.actions.results.api.v1.ListArtifactsResponse
(*ListArtifactsResponse_MonolithArtifact)(nil), // 6: github.actions.results.api.v1.ListArtifactsResponse_MonolithArtifact
(*GetSignedArtifactURLRequest)(nil), // 7: github.actions.results.api.v1.GetSignedArtifactURLRequest
(*GetSignedArtifactURLResponse)(nil), // 8: github.actions.results.api.v1.GetSignedArtifactURLResponse
(*DeleteArtifactRequest)(nil), // 9: github.actions.results.api.v1.DeleteArtifactRequest
(*DeleteArtifactResponse)(nil), // 10: github.actions.results.api.v1.DeleteArtifactResponse
(*timestamppb.Timestamp)(nil), // 11: google.protobuf.Timestamp
(*wrapperspb.StringValue)(nil), // 12: google.protobuf.StringValue
(*wrapperspb.Int64Value)(nil), // 13: google.protobuf.Int64Value
}
var file_artifact_proto_depIdxs = []int32{
11, // 0: github.actions.results.api.v1.CreateArtifactRequest.expires_at:type_name -> google.protobuf.Timestamp
12, // 1: github.actions.results.api.v1.CreateArtifactRequest.mime_type:type_name -> google.protobuf.StringValue
12, // 2: github.actions.results.api.v1.FinalizeArtifactRequest.hash:type_name -> google.protobuf.StringValue
12, // 3: github.actions.results.api.v1.ListArtifactsRequest.name_filter:type_name -> google.protobuf.StringValue
13, // 4: github.actions.results.api.v1.ListArtifactsRequest.id_filter:type_name -> google.protobuf.Int64Value
6, // 5: github.actions.results.api.v1.ListArtifactsResponse.artifacts:type_name -> github.actions.results.api.v1.ListArtifactsResponse_MonolithArtifact
11, // 6: github.actions.results.api.v1.ListArtifactsResponse_MonolithArtifact.created_at:type_name -> google.protobuf.Timestamp
7, // [7:7] is the sub-list for method output_type
7, // [7:7] is the sub-list for method input_type
7, // [7:7] is the sub-list for extension type_name
7, // [7:7] is the sub-list for extension extendee
0, // [0:7] is the sub-list for field type_name
}
func init() { file_artifact_proto_init() }
func file_artifact_proto_init() {
if File_artifact_proto != nil {
return
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_artifact_proto_rawDesc), len(file_artifact_proto_rawDesc)),
NumEnums: 0,
NumMessages: 11,
NumExtensions: 0,
NumServices: 0,
},
GoTypes: file_artifact_proto_goTypes,
DependencyIndexes: file_artifact_proto_depIdxs,
MessageInfos: file_artifact_proto_msgTypes,
}.Build()
File_artifact_proto = out.File
file_artifact_proto_goTypes = nil
file_artifact_proto_depIdxs = nil
}
+76
View File
@@ -0,0 +1,76 @@
syntax = "proto3";
import "google/protobuf/timestamp.proto";
import "google/protobuf/wrappers.proto";
package github.actions.results.api.v1;
option go_package = "gitea.dev/routers/api/actions";
message CreateArtifactRequest {
string workflow_run_backend_id = 1;
string workflow_job_run_backend_id = 2;
string name = 3;
google.protobuf.Timestamp expires_at = 4;
int32 version = 5;
google.protobuf.StringValue mime_type = 6;
}
message CreateArtifactResponse {
bool ok = 1;
string signed_upload_url = 2;
}
message FinalizeArtifactRequest {
string workflow_run_backend_id = 1;
string workflow_job_run_backend_id = 2;
string name = 3;
int64 size = 4;
google.protobuf.StringValue hash = 5;
}
message FinalizeArtifactResponse {
bool ok = 1;
int64 artifact_id = 2;
}
message ListArtifactsRequest {
string workflow_run_backend_id = 1;
string workflow_job_run_backend_id = 2;
google.protobuf.StringValue name_filter = 3;
google.protobuf.Int64Value id_filter = 4;
}
message ListArtifactsResponse {
repeated ListArtifactsResponse_MonolithArtifact artifacts = 1;
}
message ListArtifactsResponse_MonolithArtifact {
string workflow_run_backend_id = 1;
string workflow_job_run_backend_id = 2;
int64 database_id = 3;
string name = 4;
int64 size = 5;
google.protobuf.Timestamp created_at = 6;
}
message GetSignedArtifactURLRequest {
string workflow_run_backend_id = 1;
string workflow_job_run_backend_id = 2;
string name = 3;
}
message GetSignedArtifactURLResponse {
string signed_url = 1;
}
message DeleteArtifactRequest {
string workflow_run_backend_id = 1;
string workflow_job_run_backend_id = 2;
string name = 3;
}
message DeleteArtifactResponse {
bool ok = 1;
int64 artifact_id = 2;
}
+511
View File
@@ -0,0 +1,511 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
// GitHub Actions Artifacts API Simple Description
//
// 1. Upload artifact
// 1.1. Post upload url
// Post: /api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts?api-version=6.0-preview
// Request:
// {
// "Type": "actions_storage",
// "Name": "artifact"
// }
// Response:
// {
// "fileContainerResourceUrl":"/api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts/{artifact_id}/upload"
// }
// it acquires an upload url for artifact upload
// 1.2. Upload artifact
// PUT: /api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts/{artifact_id}/upload?itemPath=artifact%2Ffilename
// it upload chunk with headers:
// x-tfs-filelength: 1024 // total file length
// content-length: 1024 // chunk length
// x-actions-results-md5: md5sum // md5sum of chunk
// content-range: bytes 0-1023/1024 // chunk range
// we save all chunks to one storage directory after md5sum check
// 1.3. Confirm upload
// PATCH: /api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts/{artifact_id}/upload?itemPath=artifact%2Ffilename
// it confirm upload and merge all chunks to one file, save this file to storage
//
// 2. Download artifact
// 2.1 list artifacts
// GET: /api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts?api-version=6.0-preview
// Response:
// {
// "count": 1,
// "value": [
// {
// "name": "artifact",
// "fileContainerResourceUrl": "/api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts/{artifact_id}/path"
// }
// ]
// }
// 2.2 download artifact
// GET: /api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts/{artifact_id}/path?api-version=6.0-preview
// Response:
// {
// "value": [
// {
// "contentLocation": "/api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts/{artifact_id}/download",
// "path": "artifact/filename",
// "itemType": "file"
// }
// ]
// }
// 2.3 download artifact file
// GET: /api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts/{artifact_id}/download?itemPath=artifact%2Ffilename
// Response:
// download file
//
import (
"crypto/md5"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"gitea.dev/models/actions"
"gitea.dev/models/db"
"gitea.dev/modules/httplib"
"gitea.dev/modules/json"
"gitea.dev/modules/log"
"gitea.dev/modules/optional"
"gitea.dev/modules/setting"
"gitea.dev/modules/storage"
"gitea.dev/modules/util"
"gitea.dev/modules/web"
web_types "gitea.dev/modules/web/types"
actions_service "gitea.dev/services/actions"
"gitea.dev/services/context"
)
const artifactRouteBase = "/_apis/pipelines/workflows/{run_id}/artifacts"
type artifactContextKeyType struct{}
var artifactContextKey = artifactContextKeyType{}
type ArtifactContext struct {
*context.Base
ActionTask *actions.ActionTask
}
func init() {
web.RegisterResponseStatusProvider[*ArtifactContext](func(req *http.Request) web_types.ResponseStatusProvider {
return req.Context().Value(artifactContextKey).(*ArtifactContext)
})
}
func ArtifactsRoutes(prefix string) *web.Router {
m := web.NewRouter()
m.AfterRouting(ArtifactContexter())
r := artifactRoutes{
prefix: prefix,
fs: storage.ActionsArtifacts,
}
m.Group(artifactRouteBase, func() {
// retrieve, list and confirm artifacts
m.Combo("").Get(r.listArtifacts).Post(r.getUploadArtifactURL).Patch(r.confirmUploadArtifact)
// handle container artifacts list and download
m.Put("/{artifact_hash}/upload", r.uploadArtifact)
// handle artifacts download
m.Get("/{artifact_hash}/download_url", r.getDownloadArtifactURL)
m.Get("/{artifact_id}/download", r.downloadArtifact)
})
return m
}
func ArtifactContexter() func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
base := context.NewBaseContext(resp, req)
ctx := &ArtifactContext{Base: base}
ctx.SetContextValue(artifactContextKey, ctx)
// action task call server api with Bearer ACTIONS_RUNTIME_TOKEN
// we should verify the ACTIONS_RUNTIME_TOKEN
authHeader := req.Header.Get("Authorization")
if len(authHeader) == 0 || !strings.HasPrefix(authHeader, "Bearer ") {
ctx.HTTPError(http.StatusUnauthorized, "Bad authorization header")
return
}
// New act_runner uses jwt to authenticate
tID, err := actions_service.ParseAuthorizationToken(req)
var task *actions.ActionTask
if err == nil {
task, err = actions.GetTaskByID(req.Context(), tID)
if err != nil {
log.Error("Error runner api getting task by ID: %v", err)
ctx.HTTPError(http.StatusInternalServerError, "Error runner api getting task by ID")
return
}
if task.Status != actions.StatusRunning {
log.Error("Error runner api getting task: task is not running")
ctx.HTTPError(http.StatusInternalServerError, "Error runner api getting task: task is not running")
return
}
} else {
// Old act_runner uses GITEA_TOKEN to authenticate
authToken := strings.TrimPrefix(authHeader, "Bearer ")
task, err = actions.GetRunningTaskByToken(req.Context(), authToken)
if err != nil {
log.Error("Error runner api getting task: %v", err)
ctx.HTTPError(http.StatusInternalServerError, "Error runner api getting task")
return
}
}
if err := task.LoadJob(req.Context()); err != nil {
log.Error("Error runner api getting job: %v", err)
ctx.HTTPError(http.StatusInternalServerError, "Error runner api getting job")
return
}
ctx.ActionTask = task
next.ServeHTTP(ctx.Resp, ctx.Req)
})
}
}
type artifactRoutes struct {
prefix string
fs storage.ObjectStorage
}
func (ar artifactRoutes) buildArtifactURL(ctx *ArtifactContext, runID int64, artifactHash, suffix string) string {
uploadURL := strings.TrimSuffix(httplib.GuessCurrentAppURL(ctx), "/") + strings.TrimSuffix(ar.prefix, "/") +
strings.ReplaceAll(artifactRouteBase, "{run_id}", strconv.FormatInt(runID, 10)) +
"/" + artifactHash + "/" + suffix
return uploadURL
}
type getUploadArtifactRequest struct {
Type string
Name string
RetentionDays int64
}
type getUploadArtifactResponse struct {
FileContainerResourceURL string `json:"fileContainerResourceUrl"`
}
// getUploadArtifactURL generates a URL for uploading an artifact
func (ar artifactRoutes) getUploadArtifactURL(ctx *ArtifactContext) {
_, runID, ok := validateRunID(ctx)
if !ok {
return
}
var req getUploadArtifactRequest
if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil {
log.Error("Error decode request body: %v", err)
ctx.HTTPError(http.StatusInternalServerError, "Error decode request body")
return
}
// set retention days
retentionQuery := ""
if req.RetentionDays > 0 {
retentionQuery = fmt.Sprintf("?retentionDays=%d", req.RetentionDays)
}
// use md5(artifact_name) to create upload url
artifactHash := fmt.Sprintf("%x", md5.Sum([]byte(req.Name)))
resp := getUploadArtifactResponse{
FileContainerResourceURL: ar.buildArtifactURL(ctx, runID, artifactHash, "upload"+retentionQuery),
}
log.Debug("[artifact] get upload url: %s", resp.FileContainerResourceURL)
ctx.JSON(http.StatusOK, resp)
}
func (ar artifactRoutes) uploadArtifact(ctx *ArtifactContext) {
task, runID, ok := validateRunID(ctx)
if !ok {
return
}
artifactName, artifactPath, ok := parseArtifactItemPath(ctx)
if !ok {
return
}
// get upload file size
fileRealTotalSize := getUploadFileSize(ctx)
// get artifact retention days
expiredDays := setting.Actions.ArtifactRetentionDays
if queryRetentionDays := ctx.Req.URL.Query().Get("retentionDays"); queryRetentionDays != "" {
var err error
expiredDays, err = strconv.ParseInt(queryRetentionDays, 10, 64)
if err != nil {
log.Error("Error parse retention days: %v", err)
ctx.HTTPError(http.StatusBadRequest, "Error parse retention days")
return
}
}
log.Debug("[artifact] upload chunk, name: %s, path: %s, size: %d, retention days: %d",
artifactName, artifactPath, fileRealTotalSize, expiredDays)
// create or get artifact with name and path
artifact, err := actions.CreateArtifact(ctx, task, artifactName, artifactPath, expiredDays)
if err != nil {
log.Error("Error create or get artifact: %v", err)
ctx.HTTPError(http.StatusInternalServerError, "Error create or get artifact")
return
}
// save chunk to storage, if success, return chunks total size
// if artifact is not gzip when uploading, chunksTotalSize == fileRealTotalSize
// if artifact is gzip when uploading, chunksTotalSize < fileRealTotalSize
chunksTotalSize, err := saveUploadChunkV3GetTotalSize(ar.fs, ctx, artifact, runID)
if err != nil {
log.Error("Error save upload chunk: %v", err)
ctx.HTTPError(http.StatusInternalServerError, "Error save upload chunk")
return
}
// update artifact size if zero or not match, overwrite artifact size
if artifact.FileSize == 0 ||
artifact.FileCompressedSize == 0 ||
artifact.FileSize != fileRealTotalSize ||
artifact.FileCompressedSize != chunksTotalSize {
artifact.FileSize = fileRealTotalSize
artifact.FileCompressedSize = chunksTotalSize
artifact.ContentEncodingOrType = ctx.Req.Header.Get("Content-Encoding")
if err := actions.UpdateArtifactByID(ctx, artifact.ID, artifact); err != nil {
log.Error("Error update artifact: %v", err)
ctx.HTTPError(http.StatusInternalServerError, "Error update artifact")
return
}
log.Debug("[artifact] update artifact size, artifact_id: %d, size: %d, compressed size: %d",
artifact.ID, artifact.FileSize, artifact.FileCompressedSize)
}
ctx.JSON(http.StatusOK, map[string]string{
"message": "success",
})
}
// confirmUploadArtifact confirm upload artifact.
// if all chunks are uploaded, merge them to one file.
func (ar artifactRoutes) confirmUploadArtifact(ctx *ArtifactContext) {
_, runID, ok := validateRunID(ctx)
if !ok {
return
}
artifactName := ctx.Req.URL.Query().Get("artifactName")
if artifactName == "" {
log.Error("Error artifact name is empty")
ctx.HTTPError(http.StatusBadRequest, "Error artifact name is empty")
return
}
if err := mergeChunksForRun(ctx, ar.fs, runID, ctx.ActionTask.Job.RunAttemptID, artifactName); err != nil {
log.Error("Error merge chunks: %v", err)
ctx.HTTPError(http.StatusInternalServerError, "Error merge chunks")
return
}
ctx.JSON(http.StatusOK, map[string]string{
"message": "success",
})
}
type (
listArtifactsResponse struct {
Count int64 `json:"count"`
Value []listArtifactsResponseItem `json:"value"`
}
listArtifactsResponseItem struct {
Name string `json:"name"`
FileContainerResourceURL string `json:"fileContainerResourceUrl"`
}
)
func (ar artifactRoutes) listArtifacts(ctx *ArtifactContext) {
_, runID, ok := validateRunID(ctx)
if !ok {
return
}
artifacts, err := db.Find[actions.ActionArtifact](ctx, actions.FindArtifactsOptions{
RunID: runID,
RunAttemptID: optional.Some(ctx.ActionTask.Job.RunAttemptID),
Status: int(actions.ArtifactStatusUploadConfirmed),
})
if err != nil {
log.Error("Error getting artifacts: %v", err)
ctx.HTTPError(http.StatusInternalServerError, err.Error())
return
}
if len(artifacts) == 0 {
log.Debug("[artifact] handleListArtifacts, no artifacts")
ctx.HTTPError(http.StatusNotFound)
return
}
var (
items []listArtifactsResponseItem
values = make(map[string]bool)
)
for _, art := range artifacts {
if values[art.ArtifactName] {
continue
}
artifactHash := fmt.Sprintf("%x", md5.Sum([]byte(art.ArtifactName)))
item := listArtifactsResponseItem{
Name: art.ArtifactName,
FileContainerResourceURL: ar.buildArtifactURL(ctx, runID, artifactHash, "download_url"),
}
items = append(items, item)
values[art.ArtifactName] = true
log.Debug("[artifact] handleListArtifacts, name: %s, url: %s", item.Name, item.FileContainerResourceURL)
}
respData := listArtifactsResponse{
Count: int64(len(items)),
Value: items,
}
ctx.JSON(http.StatusOK, respData)
}
type (
downloadArtifactResponse struct {
Value []downloadArtifactResponseItem `json:"value"`
}
downloadArtifactResponseItem struct {
Path string `json:"path"`
ItemType string `json:"itemType"`
ContentLocation string `json:"contentLocation"`
}
)
// getDownloadArtifactURL generates download url for each artifact
func (ar artifactRoutes) getDownloadArtifactURL(ctx *ArtifactContext) {
_, runID, ok := validateRunID(ctx)
if !ok {
return
}
itemPath := util.PathJoinRel(ctx.Req.URL.Query().Get("itemPath"))
if !validateArtifactHash(ctx, itemPath) {
return
}
artifacts, err := db.Find[actions.ActionArtifact](ctx, actions.FindArtifactsOptions{
RunID: runID,
RunAttemptID: optional.Some(ctx.ActionTask.Job.RunAttemptID),
ArtifactName: itemPath,
Status: int(actions.ArtifactStatusUploadConfirmed),
})
if err != nil {
log.Error("Error getting artifacts: %v", err)
ctx.HTTPError(http.StatusInternalServerError, err.Error())
return
}
if len(artifacts) == 0 {
log.Debug("[artifact] getDownloadArtifactURL, no artifacts")
ctx.HTTPError(http.StatusNotFound)
return
}
if itemPath != artifacts[0].ArtifactName {
log.Error("Error mismatch artifact name, itemPath: %v, artifact: %v", itemPath, artifacts[0].ArtifactName)
ctx.HTTPError(http.StatusBadRequest, "Error mismatch artifact name")
return
}
var items []downloadArtifactResponseItem
for _, artifact := range artifacts {
var downloadURL string
if setting.Actions.ArtifactStorage.ServeDirect() {
u, err := ar.fs.ServeDirectURL(artifact.StoragePath, artifact.ArtifactName, ctx.Req.Method, nil)
if err != nil && !errors.Is(err, storage.ErrURLNotSupported) {
log.Error("Error getting serve direct url: %v", err)
}
if u != nil {
downloadURL = u.String()
}
}
if downloadURL == "" {
downloadURL = ar.buildArtifactURL(ctx, runID, strconv.FormatInt(artifact.ID, 10), "download")
}
item := downloadArtifactResponseItem{
Path: util.PathJoinRel(itemPath, artifact.ArtifactPath),
ItemType: "file",
ContentLocation: downloadURL,
}
log.Debug("[artifact] getDownloadArtifactURL, path: %s, url: %s", item.Path, item.ContentLocation)
items = append(items, item)
}
respData := downloadArtifactResponse{
Value: items,
}
ctx.JSON(http.StatusOK, respData)
}
// downloadArtifact downloads artifact content
func (ar artifactRoutes) downloadArtifact(ctx *ArtifactContext) {
_, runID, ok := validateRunID(ctx)
if !ok {
return
}
artifactID := ctx.PathParamInt64("artifact_id")
artifact, exist, err := db.GetByID[actions.ActionArtifact](ctx, artifactID)
if err != nil {
log.Error("Error getting artifact: %v", err)
ctx.HTTPError(http.StatusInternalServerError, err.Error())
return
}
if !exist {
log.Error("artifact with ID %d does not exist", artifactID)
ctx.HTTPError(http.StatusNotFound, fmt.Sprintf("artifact with ID %d does not exist", artifactID))
return
}
if artifact.RunID != runID {
log.Error("Error mismatch runID and artifactID, task: %v, artifact: %v", runID, artifactID)
ctx.HTTPError(http.StatusBadRequest)
return
}
if ctx.ActionTask.Job.RunAttemptID > 0 && artifact.RunAttemptID != ctx.ActionTask.Job.RunAttemptID {
log.Error("Error mismatch runAttemptID and artifactID, task: %v, artifact: %v", ctx.ActionTask.Job.RunAttemptID, artifactID)
ctx.HTTPError(http.StatusBadRequest)
return
}
if artifact.Status != actions.ArtifactStatusUploadConfirmed {
log.Error("Error artifact not found: %s", artifact.Status.ToString())
ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
return
}
fd, err := ar.fs.Open(artifact.StoragePath)
if err != nil {
log.Error("Error opening file: %v", err)
ctx.HTTPError(http.StatusInternalServerError, err.Error())
return
}
defer fd.Close()
// if artifact is compressed, set content-encoding header to gzip
if artifact.ContentEncodingOrType == actions.ContentEncodingV3Gzip {
ctx.Resp.Header().Set("Content-Encoding", "gzip")
}
log.Debug("[artifact] downloadArtifact, name: %s, path: %s, storage: %s, size: %d", artifact.ArtifactName, artifact.ArtifactPath, artifact.StoragePath, artifact.FileSize)
ctx.ServeContent(fd, context.ServeHeaderOptions{
Filename: artifact.ArtifactName,
LastModified: artifact.CreatedUnix.AsLocalTime(),
})
}
+395
View File
@@ -0,0 +1,395 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"crypto/md5"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"hash"
"io"
"path"
"sort"
"strings"
"time"
"gitea.dev/models/actions"
"gitea.dev/models/db"
"gitea.dev/modules/log"
"gitea.dev/modules/optional"
"gitea.dev/modules/setting"
"gitea.dev/modules/storage"
)
type saveUploadChunkOptions struct {
start int64
end *int64
checkMd5 bool
}
func makeTmpPathNameV3(runID int64) string {
return fmt.Sprintf("tmp-upload/run-%d", runID)
}
func makeTmpPathNameV4(runID int64) string {
return fmt.Sprintf("tmp-upload/run-%d-v4", runID)
}
func makeChunkFilenameV3(runID, artifactID, start int64, endPtr *int64) string {
var end int64
if endPtr != nil {
end = *endPtr
}
return fmt.Sprintf("%d-%d-%d-%d.chunk", runID, artifactID, start, end)
}
func parseChunkFileItemV3(st storage.ObjectStorage, fpath string) (*chunkFileItem, error) {
baseName := path.Base(fpath)
if !strings.HasSuffix(baseName, ".chunk") {
return nil, errSkipChunkFile
}
var item chunkFileItem
var unusedRunID int64
if _, err := fmt.Sscanf(baseName, "%d-%d-%d-%d.chunk", &unusedRunID, &item.ArtifactID, &item.Start, &item.End); err != nil {
return nil, err
}
item.Path = fpath
if item.End == 0 {
fi, err := st.Stat(item.Path)
if err != nil {
return nil, err
}
item.Size = fi.Size()
item.End = item.Start + item.Size - 1
} else {
item.Size = item.End - item.Start + 1
}
return &item, nil
}
func saveUploadChunkV3(st storage.ObjectStorage, ctx *ArtifactContext, artifact *actions.ActionArtifact,
runID int64, opts saveUploadChunkOptions,
) (writtenSize int64, retErr error) {
// build chunk store path
storagePath := fmt.Sprintf("%s/%s", makeTmpPathNameV3(runID), makeChunkFilenameV3(runID, artifact.ID, opts.start, opts.end))
// "end" is optional, so "contentSize=-1" means read until EOF
contentSize := int64(-1)
if opts.end != nil {
contentSize = *opts.end - opts.start + 1
}
var r io.Reader = ctx.Req.Body
var hasher hash.Hash
if opts.checkMd5 {
// use io.TeeReader to avoid reading all body to md5 sum.
// it writes data to hasher after reading end
// if hash is not matched, delete the read-end result
hasher = md5.New()
r = io.TeeReader(r, hasher)
}
// save chunk to storage
writtenSize, err := st.Save(storagePath, r, contentSize)
if err != nil {
return 0, fmt.Errorf("save chunk to storage error: %v", err)
}
defer func() {
if retErr != nil {
if err := st.Delete(storagePath); err != nil {
log.Error("Error deleting chunk: %s, %v", storagePath, err)
}
}
}()
if contentSize != -1 && writtenSize != contentSize {
return writtenSize, fmt.Errorf("writtenSize %d does not match contentSize %d", writtenSize, contentSize)
}
if opts.checkMd5 {
// check md5
reqMd5String := ctx.Req.Header.Get(artifactXActionsResultsMD5Header)
chunkMd5String := base64.StdEncoding.EncodeToString(hasher.Sum(nil))
log.Debug("[artifact] check chunk md5, sum: %s, header: %s", chunkMd5String, reqMd5String)
// if md5 not match, delete the chunk
if reqMd5String != chunkMd5String {
return writtenSize, errors.New("md5 not match")
}
}
log.Debug("[artifact] save chunk %s, size: %d, artifact id: %d, start: %d, size: %d", storagePath, writtenSize, artifact.ID, opts.start, contentSize)
return writtenSize, nil
}
func saveUploadChunkV3GetTotalSize(st storage.ObjectStorage, ctx *ArtifactContext, artifact *actions.ActionArtifact, runID int64) (totalSize int64, _ error) {
// parse content-range header, format: bytes 0-1023/146515
contentRange := ctx.Req.Header.Get("Content-Range")
var start, end int64
if _, err := fmt.Sscanf(contentRange, "bytes %d-%d/%d", &start, &end, &totalSize); err != nil {
return 0, fmt.Errorf("parse content range error: %v", err)
}
_, err := saveUploadChunkV3(st, ctx, artifact, runID, saveUploadChunkOptions{start: start, end: &end, checkMd5: true})
if err != nil {
return 0, err
}
return totalSize, nil
}
// Returns uploaded length
func appendUploadChunkV3(st storage.ObjectStorage, ctx *ArtifactContext, artifact *actions.ActionArtifact, runID, start int64) (int64, error) {
opts := saveUploadChunkOptions{start: start}
if ctx.Req.ContentLength > 0 {
end := start + ctx.Req.ContentLength - 1
opts.end = &end
}
return saveUploadChunkV3(st, ctx, artifact, runID, opts)
}
type chunkFileItem struct {
ArtifactID int64
Path string
// these offset/size related fields might be missing when parsing, they will be filled in the listing functions
Size int64
Start int64
End int64 // inclusive: Size=10, Start=0, End=9
ChunkName string // v4 only
}
func listV3UnorderedChunksMapByRunID(st storage.ObjectStorage, runID int64) (map[int64][]*chunkFileItem, error) {
storageDir := makeTmpPathNameV3(runID)
var chunks []*chunkFileItem
if err := st.IterateObjects(storageDir, func(fpath string, obj storage.Object) error {
item, err := parseChunkFileItemV3(st, fpath)
if errors.Is(err, errSkipChunkFile) {
return nil
} else if err != nil {
return fmt.Errorf("unable to parse chunk name: %v", fpath)
}
chunks = append(chunks, item)
return nil
}); err != nil {
return nil, err
}
// chunks group by artifact id
chunksMap := make(map[int64][]*chunkFileItem)
for _, c := range chunks {
chunksMap[c.ArtifactID] = append(chunksMap[c.ArtifactID], c)
}
return chunksMap, nil
}
func listOrderedChunksForArtifact(st storage.ObjectStorage, runID, artifactID int64, blist *BlockList) ([]*chunkFileItem, error) {
emptyListAsError := func(chunks []*chunkFileItem) ([]*chunkFileItem, error) {
if len(chunks) == 0 {
return nil, fmt.Errorf("no chunk found for artifact id: %d", artifactID)
}
return chunks, nil
}
storageDir := makeTmpPathNameV4(runID)
var chunks []*chunkFileItem
var chunkMapV4 map[string]*chunkFileItem
if blist != nil {
// make a dummy map for quick lookup of chunk names, the values are nil now and will be filled after iterating storage objects
chunkMapV4 = map[string]*chunkFileItem{}
for _, name := range blist.Latest {
chunkMapV4[name] = nil
}
}
if err := st.IterateObjects(storageDir, func(fpath string, obj storage.Object) error {
item, err := parseChunkFileItemV4(st, artifactID, fpath)
if errors.Is(err, errSkipChunkFile) {
return nil
} else if err != nil {
return fmt.Errorf("unable to parse chunk name: %v", fpath)
}
// Single chunk upload with block id
if _, ok := chunkMapV4[item.ChunkName]; ok {
chunkMapV4[item.ChunkName] = item
} else if chunkMapV4 == nil {
if chunks != nil {
return errors.New("blockmap is required for chunks > 1")
}
chunks = []*chunkFileItem{item}
}
return nil
}); err != nil {
return nil, err
}
if blist == nil && chunks == nil {
chunkUnorderedItemsMapV3, err := listV3UnorderedChunksMapByRunID(st, runID)
if err != nil {
return nil, err
}
chunks = chunkUnorderedItemsMapV3[artifactID]
sort.Slice(chunks, func(i, j int) bool {
return chunks[i].Start < chunks[j].Start
})
return emptyListAsError(chunks)
}
if len(chunks) == 0 && blist != nil {
for i, name := range blist.Latest {
chunk := chunkMapV4[name]
if chunk == nil {
return nil, fmt.Errorf("missing chunk (%d/%d): %s", i, len(blist.Latest), name)
}
chunks = append(chunks, chunk)
}
}
for i, chunk := range chunks {
if i == 0 {
chunk.End += chunk.Size - 1
} else {
chunk.Start = chunkMapV4[blist.Latest[i-1]].End + 1
chunk.End = chunk.Start + chunk.Size - 1
}
}
return emptyListAsError(chunks)
}
func mergeChunksForRun(ctx *ArtifactContext, st storage.ObjectStorage, runID, runAttemptID int64, artifactName string) error {
// read all db artifacts by name
artifacts, err := db.Find[actions.ActionArtifact](ctx, actions.FindArtifactsOptions{
RunID: runID,
RunAttemptID: optional.Some(runAttemptID),
ArtifactName: artifactName,
})
if err != nil {
return err
}
// read all uploading chunks from storage
unorderedChunksMap, err := listV3UnorderedChunksMapByRunID(st, runID)
if err != nil {
return err
}
// range db artifacts to merge chunks
for _, art := range artifacts {
chunks, ok := unorderedChunksMap[art.ID]
if !ok {
log.Debug("artifact %d chunks not found", art.ID)
continue
}
if err := mergeChunksForArtifact(ctx, chunks, st, art, ""); err != nil {
return err
}
}
return nil
}
func generateArtifactStoragePath(artifact *actions.ActionArtifact) string {
// if chunk is gzip, use gz as extension
// download-artifact action will use content-encoding header to decide if it should decompress the file
extension := "chunk"
if artifact.ContentEncodingOrType == actions.ContentEncodingV3Gzip {
extension = "chunk.gz"
}
return fmt.Sprintf("%d/%d/%d.%s", artifact.RunID%255, artifact.ID%255, time.Now().UnixNano(), extension)
}
func mergeChunksForArtifact(ctx *ArtifactContext, chunks []*chunkFileItem, st storage.ObjectStorage, artifact *actions.ActionArtifact, checksum string) error {
sort.Slice(chunks, func(i, j int) bool {
return chunks[i].Start < chunks[j].Start
})
allChunks := make([]*chunkFileItem, 0)
startAt := int64(-1)
// check if all chunks are uploaded and in order and clean repeated chunks
for _, c := range chunks {
// startAt is -1 means this is the first chunk
// previous c.ChunkEnd + 1 == c.ChunkStart means this chunk is in order
// StartAt is not -1 and c.ChunkStart is not startAt + 1 means there is a chunk missing
if c.Start == (startAt + 1) {
allChunks = append(allChunks, c)
startAt = c.End
}
}
// if the last chunk.End + 1 is not equal to chunk.ChunkLength, means chunks are not uploaded completely
if startAt+1 != artifact.FileCompressedSize {
log.Debug("[artifact] chunks are not uploaded completely, artifact_id: %d", artifact.ID)
return nil
}
// use multiReader
readers := make([]io.Reader, 0, len(allChunks))
closeReaders := func() {
for _, r := range readers {
_ = r.(io.Closer).Close() // it guarantees to be io.Closer by the following loop's Open function
}
readers = nil
}
defer closeReaders()
for _, c := range allChunks {
var readCloser io.ReadCloser
var err error
if readCloser, err = st.Open(c.Path); err != nil {
return fmt.Errorf("open chunk error: %v, %s", err, c.Path)
}
readers = append(readers, readCloser)
}
mergedReader := io.MultiReader(readers...)
shaPrefix := "sha256:"
var hashSha256 hash.Hash
if strings.HasPrefix(checksum, shaPrefix) {
hashSha256 = sha256.New()
} else if checksum != "" {
setting.PanicInDevOrTesting("unsupported checksum format: %s, will skip the checksum verification", checksum)
}
if hashSha256 != nil {
mergedReader = io.TeeReader(mergedReader, hashSha256)
}
// save merged file
storagePath := generateArtifactStoragePath(artifact)
written, err := st.Save(storagePath, mergedReader, artifact.FileCompressedSize)
if err != nil {
return fmt.Errorf("save merged file error: %v", err)
}
if written != artifact.FileCompressedSize {
return errors.New("merged file size is not equal to chunk length")
}
defer func() {
closeReaders() // close before delete
// drop chunks
for _, c := range chunks {
if err := st.Delete(c.Path); err != nil {
log.Warn("Error deleting chunk: %s, %v", c.Path, err)
}
}
}()
if hashSha256 != nil {
rawChecksum := hashSha256.Sum(nil)
actualChecksum := hex.EncodeToString(rawChecksum)
if !strings.HasSuffix(checksum, actualChecksum) {
return fmt.Errorf("update artifact error checksum is invalid %v vs %v", checksum, actualChecksum)
}
}
// save storage path to artifact
log.Debug("[artifact] merge chunks to artifact: %d, %s, old:%s", artifact.ID, storagePath, artifact.StoragePath)
// if artifact is already uploaded, delete the old file
if artifact.StoragePath != "" {
if err := st.Delete(artifact.StoragePath); err != nil {
log.Warn("Error deleting old artifact: %s, %v", artifact.StoragePath, err)
}
}
artifact.StoragePath = storagePath
artifact.Status = actions.ArtifactStatusUploadConfirmed
if err := actions.UpdateArtifactByID(ctx, artifact.ID, artifact); err != nil {
return fmt.Errorf("update artifact error: %v", err)
}
return nil
}
+93
View File
@@ -0,0 +1,93 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"crypto/md5"
"fmt"
"net/http"
"strconv"
"strings"
"gitea.dev/models/actions"
"gitea.dev/modules/log"
"gitea.dev/modules/util"
)
const (
artifactXTfsFileLengthHeader = "x-tfs-filelength"
artifactXActionsResultsMD5Header = "x-actions-results-md5"
)
// The rules are from https://github.com/actions/toolkit/blob/main/packages/artifact/src/internal/upload/path-and-artifact-name-validation.ts
const invalidArtifactNameChars = "\\/\":<>|*?\r\n"
func validateArtifactName(ctx *ArtifactContext, artifactName string) bool {
if strings.ContainsAny(artifactName, invalidArtifactNameChars) {
log.Error("Error checking artifact name contains invalid character")
ctx.HTTPError(http.StatusBadRequest, "Error checking artifact name contains invalid character")
return false
}
return true
}
func validateRunID(ctx *ArtifactContext) (*actions.ActionTask, int64, bool) {
task := ctx.ActionTask
runID := ctx.PathParamInt64("run_id")
if task.Job.RunID != runID {
log.Error("Error runID not match")
ctx.HTTPError(http.StatusBadRequest, "run-id does not match")
return nil, 0, false
}
return task, runID, true
}
func validateRunIDV4(ctx *ArtifactContext, rawRunID string) (*actions.ActionTask, int64, bool) { //nolint:unparam // ActionTask is never used
task := ctx.ActionTask
runID, err := strconv.ParseInt(rawRunID, 10, 64)
if err != nil || task.Job.RunID != runID {
log.Error("Error runID not match")
ctx.HTTPError(http.StatusBadRequest, "run-id does not match")
return nil, 0, false
}
return task, runID, true
}
func validateArtifactHash(ctx *ArtifactContext, artifactName string) bool {
paramHash := ctx.PathParam("artifact_hash")
// use artifact name to create upload url
artifactHash := fmt.Sprintf("%x", md5.Sum([]byte(artifactName)))
if paramHash == artifactHash {
return true
}
log.Error("Invalid artifact hash: %s", paramHash)
ctx.HTTPError(http.StatusBadRequest, "Invalid artifact hash")
return false
}
func parseArtifactItemPath(ctx *ArtifactContext) (string, string, bool) {
// itemPath is generated from upload-artifact action
// it's formatted as {artifact_name}/{artfict_path_in_runner}
// act_runner in host mode on Windows, itemPath is joined by Windows slash '\'
itemPath := util.PathJoinRelX(ctx.Req.URL.Query().Get("itemPath"))
artifactName := strings.Split(itemPath, "/")[0]
artifactPath := strings.TrimPrefix(itemPath, artifactName+"/")
if !validateArtifactHash(ctx, artifactName) {
return "", "", false
}
if !validateArtifactName(ctx, artifactName) {
return "", "", false
}
return artifactName, artifactPath, true
}
// getUploadFileSize returns the size of the file to be uploaded.
// The raw size is the size of the file as reported by the header X-TFS-FileLength.
func getUploadFileSize(ctx *ArtifactContext) int64 {
xTfsLength, _ := strconv.ParseInt(ctx.Req.Header.Get(artifactXTfsFileLengthHeader), 10, 64)
if xTfsLength > 0 {
return xTfsLength
}
return ctx.Req.ContentLength
}
+726
View File
@@ -0,0 +1,726 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
// GitHub Actions Artifacts V4 API Simple Description
//
// 1. Upload artifact
// 1.1. CreateArtifact
// Post: /twirp/github.actions.results.api.v1.ArtifactService/CreateArtifact
// Request:
// {
// "workflow_run_backend_id": "21",
// "workflow_job_run_backend_id": "49",
// "name": "test",
// "version": 4
// }
// Response:
// {
// "ok": true,
// "signedUploadUrl": "http://localhost:3000/twirp/github.actions.results.api.v1.ArtifactService/UploadArtifact?sig=mO7y35r4GyjN7fwg0DTv3-Fv1NDXD84KLEgLpoPOtDI=&expires=2024-01-23+21%3A48%3A37.20833956+%2B0100+CET&artifactName=test&taskID=75"
// }
// 1.2. Upload Zip Content to Blobstorage (unauthenticated request)
// PUT: http://localhost:3000/twirp/github.actions.results.api.v1.ArtifactService/UploadArtifact?sig=mO7y35r4GyjN7fwg0DTv3-Fv1NDXD84KLEgLpoPOtDI=&expires=2024-01-23+21%3A48%3A37.20833956+%2B0100+CET&artifactName=test&taskID=75&comp=block
// 1.3. Continue Upload Zip Content to Blobstorage (unauthenticated request), repeat until everything is uploaded
// PUT: http://localhost:3000/twirp/github.actions.results.api.v1.ArtifactService/UploadArtifact?sig=mO7y35r4GyjN7fwg0DTv3-Fv1NDXD84KLEgLpoPOtDI=&expires=2024-01-23+21%3A48%3A37.20833956+%2B0100+CET&artifactName=test&taskID=75&comp=appendBlock
// 1.4. BlockList xml payload to Blobstorage (unauthenticated request)
// Files of about 800MB are parallel in parallel and / or out of order, this file is needed to ensure the correct order
// PUT: http://localhost:3000/twirp/github.actions.results.api.v1.ArtifactService/UploadArtifact?sig=mO7y35r4GyjN7fwg0DTv3-Fv1NDXD84KLEgLpoPOtDI=&expires=2024-01-23+21%3A48%3A37.20833956+%2B0100+CET&artifactName=test&taskID=75&comp=blockList
// Request
// <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
// <BlockList>
// <Latest>blockId1</Latest>
// <Latest>blockId2</Latest>
// </BlockList>
// 1.5. FinalizeArtifact
// Post: /twirp/github.actions.results.api.v1.ArtifactService/FinalizeArtifact
// Request
// {
// "workflow_run_backend_id": "21",
// "workflow_job_run_backend_id": "49",
// "name": "test",
// "size": "2097",
// "hash": "sha256:b6325614d5649338b87215d9536b3c0477729b8638994c74cdefacb020a2cad4"
// }
// Response
// {
// "ok": true,
// "artifactId": "4"
// }
// 2. Download artifact
// 2.1. ListArtifacts and optionally filter by artifact exact name or id
// Post: /twirp/github.actions.results.api.v1.ArtifactService/ListArtifacts
// Request
// {
// "workflow_run_backend_id": "21",
// "workflow_job_run_backend_id": "49",
// "name_filter": "test"
// }
// Response
// {
// "artifacts": [
// {
// "workflowRunBackendId": "21",
// "workflowJobRunBackendId": "49",
// "databaseId": "4",
// "name": "test",
// "size": "2093",
// "createdAt": "2024-01-23T00:13:28Z"
// }
// ]
// }
// 2.2. GetSignedArtifactURL get the URL to download the artifact zip file of a specific artifact
// Post: /twirp/github.actions.results.api.v1.ArtifactService/GetSignedArtifactURL
// Request
// {
// "workflow_run_backend_id": "21",
// "workflow_job_run_backend_id": "49",
// "name": "test"
// }
// Response
// {
// "signedUrl": "http://localhost:3000/twirp/github.actions.results.api.v1.ArtifactService/DownloadArtifact?sig=wHzFOwpF-6220-5CA0CIRmAX9VbiTC2Mji89UOqo1E8=&expires=2024-01-23+21%3A51%3A56.872846295+%2B0100+CET&artifactName=test&taskID=76"
// }
// 2.3. Download Zip from Blobstorage (unauthenticated request)
// GET: http://localhost:3000/twirp/github.actions.results.api.v1.ArtifactService/DownloadArtifact?sig=wHzFOwpF-6220-5CA0CIRmAX9VbiTC2Mji89UOqo1E8=&expires=2024-01-23+21%3A51%3A56.872846295+%2B0100+CET&artifactName=test&taskID=76
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/xml"
"errors"
"fmt"
"io"
"mime"
"net/http"
"net/url"
"path"
"strconv"
"strings"
"time"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
actions_module "gitea.dev/modules/actions"
"gitea.dev/modules/httplib"
"gitea.dev/modules/log"
"gitea.dev/modules/optional"
"gitea.dev/modules/setting"
"gitea.dev/modules/storage"
"gitea.dev/modules/util"
"gitea.dev/modules/web"
"gitea.dev/services/actions"
"gitea.dev/services/context"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/reflect/protoreflect"
"google.golang.org/protobuf/types/known/timestamppb"
"xorm.io/builder"
)
const ArtifactV4RouteBase = "/twirp/github.actions.results.api.v1.ArtifactService"
type artifactV4Routes struct {
prefix string
fs storage.ObjectStorage
}
func ArtifactV4Contexter() func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
base := context.NewBaseContext(resp, req)
ctx := &ArtifactContext{Base: base}
ctx.SetContextValue(artifactContextKey, ctx)
next.ServeHTTP(ctx.Resp, ctx.Req)
})
}
}
func ArtifactsV4Routes(prefix string) *web.Router {
m := web.NewRouter()
r := artifactV4Routes{
prefix: prefix,
fs: storage.ActionsArtifacts,
}
m.Group("", func() {
m.Post("CreateArtifact", r.createArtifact)
m.Post("FinalizeArtifact", r.finalizeArtifact)
m.Post("ListArtifacts", r.listArtifacts)
m.Post("GetSignedArtifactURL", r.getSignedArtifactURL)
m.Post("DeleteArtifact", r.deleteArtifact)
}, ArtifactContexter())
m.Group("", func() {
m.Put("UploadArtifact", r.uploadArtifact)
m.Get("DownloadArtifact", r.downloadArtifact)
}, ArtifactV4Contexter())
return m
}
func (r *artifactV4Routes) buildSignature(endpoint, expires, artifactName string, taskID, artifactID int64) []byte {
return actions_module.BuildSignature("v4", endpoint, expires, artifactName, strconv.FormatInt(taskID, 10), strconv.FormatInt(artifactID, 10))
}
func (r *artifactV4Routes) buildArtifactURL(ctx *ArtifactContext, endpoint, artifactName string, taskID, artifactID int64) string {
expires := time.Now().Add(60 * time.Minute).Format("2006-01-02 15:04:05.999999999 -0700 MST")
uploadURL := strings.TrimSuffix(httplib.GuessCurrentAppURL(ctx), "/") + strings.TrimSuffix(r.prefix, "/") +
"/" + endpoint +
"?sig=" + base64.RawURLEncoding.EncodeToString(r.buildSignature(endpoint, expires, artifactName, taskID, artifactID)) +
"&expires=" + url.QueryEscape(expires) +
"&artifactName=" + url.QueryEscape(artifactName) +
"&taskID=" + strconv.FormatInt(taskID, 10) +
"&artifactID=" + strconv.FormatInt(artifactID, 10)
return uploadURL
}
func makeBlockFilenameV4(runID, artifactID, size int64, blockID string) string {
sizeInName := max(size, 0) // do not use "-1" in filename
return fmt.Sprintf("block-%d-%d-%d-%s", runID, artifactID, sizeInName, base64.URLEncoding.EncodeToString([]byte(blockID)))
}
var errSkipChunkFile = errors.New("skip this chunk file")
func parseChunkFileItemV4(st storage.ObjectStorage, artifactID int64, fpath string) (*chunkFileItem, error) {
baseName := path.Base(fpath)
if !strings.HasPrefix(baseName, "block-") {
return nil, errSkipChunkFile
}
var item chunkFileItem
var unusedRunID int64
var b64chunkName string
_, err := fmt.Sscanf(baseName, "block-%d-%d-%d-%s", &unusedRunID, &item.ArtifactID, &item.Size, &b64chunkName)
if err != nil {
return nil, err
}
if item.ArtifactID != artifactID {
return nil, errSkipChunkFile
}
chunkName, err := base64.URLEncoding.DecodeString(b64chunkName)
if err != nil {
return nil, err
}
item.ChunkName = string(chunkName)
item.Path = fpath
if item.Size <= 0 {
fi, err := st.Stat(item.Path)
if err != nil {
return nil, err
}
item.Size = fi.Size()
}
return &item, nil
}
func (r *artifactV4Routes) verifySignature(ctx *ArtifactContext, endp string) (*actions_model.ActionTask, string, bool) {
rawTaskID := ctx.Req.URL.Query().Get("taskID")
rawArtifactID := ctx.Req.URL.Query().Get("artifactID")
sig := ctx.Req.URL.Query().Get("sig")
expires := ctx.Req.URL.Query().Get("expires")
artifactName := ctx.Req.URL.Query().Get("artifactName")
dsig, errSig := base64.RawURLEncoding.DecodeString(sig)
taskID, errTask := strconv.ParseInt(rawTaskID, 10, 64)
artifactID, errArtifactID := strconv.ParseInt(rawArtifactID, 10, 64)
err := errors.Join(errSig, errTask, errArtifactID)
if err != nil {
log.Error("Error decoding signature values: %v", err)
ctx.HTTPError(http.StatusBadRequest, "Error decoding signature values")
return nil, "", false
}
expecedsig := r.buildSignature(endp, expires, artifactName, taskID, artifactID)
if !hmac.Equal(dsig, expecedsig) {
log.Error("Error unauthorized")
ctx.HTTPError(http.StatusUnauthorized, "Error unauthorized")
return nil, "", false
}
t, err := time.Parse("2006-01-02 15:04:05.999999999 -0700 MST", expires)
if err != nil || t.Before(time.Now()) {
log.Error("Error link expired")
ctx.HTTPError(http.StatusUnauthorized, "Error link expired")
return nil, "", false
}
task, err := actions_model.GetTaskByID(ctx, taskID)
if err != nil {
log.Error("Error runner api getting task by ID: %v", err)
ctx.HTTPError(http.StatusInternalServerError, "Error runner api getting task by ID")
return nil, "", false
}
if task.Status != actions_model.StatusRunning {
log.Error("Error runner api getting task: task is not running")
ctx.HTTPError(http.StatusInternalServerError, "Error runner api getting task: task is not running")
return nil, "", false
}
if err := task.LoadJob(ctx); err != nil {
log.Error("Error runner api getting job: %v", err)
ctx.HTTPError(http.StatusInternalServerError, "Error runner api getting job")
return nil, "", false
}
return task, artifactName, true
}
func (r *artifactV4Routes) getArtifactByName(ctx *ArtifactContext, runID, runAttemptID int64, name string) (*actions_model.ActionArtifact, error) {
var art actions_model.ActionArtifact
has, err := db.GetEngine(ctx).Where(builder.Eq{"run_id": runID, "run_attempt_id": runAttemptID, "artifact_name": name}, builder.Like{"content_encoding", "%/%"}).Get(&art)
if err != nil {
return nil, err
} else if !has {
return nil, util.ErrNotExist
}
return &art, nil
}
func (r *artifactV4Routes) parseProtobufBody(ctx *ArtifactContext, req protoreflect.ProtoMessage) bool {
body, err := io.ReadAll(ctx.Req.Body)
if err != nil {
log.Error("Error decode request body: %v", err)
ctx.HTTPError(http.StatusInternalServerError, "Error decode request body")
return false
}
err = protojson.Unmarshal(body, req)
if err != nil {
log.Error("Error decode request body: %v", err)
ctx.HTTPError(http.StatusInternalServerError, "Error decode request body")
return false
}
return true
}
func (r *artifactV4Routes) sendProtobufBody(ctx *ArtifactContext, req protoreflect.ProtoMessage) {
resp, err := protojson.Marshal(req)
if err != nil {
log.Error("Error encode response body: %v", err)
ctx.HTTPError(http.StatusInternalServerError, "Error encode response body")
return
}
ctx.Resp.Header().Set("Content-Type", "application/json;charset=utf-8")
ctx.Resp.WriteHeader(http.StatusOK)
_, _ = ctx.Resp.Write(resp)
}
func (r *artifactV4Routes) createArtifact(ctx *ArtifactContext) {
var req CreateArtifactRequest
if ok := r.parseProtobufBody(ctx, &req); !ok {
return
}
_, _, ok := validateRunIDV4(ctx, req.WorkflowRunBackendId)
if !ok {
return
}
artifactName := req.Name
retentionDays := setting.Actions.ArtifactRetentionDays
if req.ExpiresAt != nil {
retentionDays = int64(time.Until(req.ExpiresAt.AsTime()).Hours() / 24)
}
encoding := req.GetMimeType().GetValue()
// Validate media type
if encoding != "" {
encoding, _, _ = mime.ParseMediaType(encoding)
}
fileName := artifactName
if !strings.Contains(encoding, "/") || strings.EqualFold(encoding, actions_model.ContentTypeZip) && !strings.HasSuffix(fileName, ".zip") {
encoding = actions_model.ContentTypeZip
fileName = artifactName + ".zip"
}
// create or get artifact with name and path
artifact, err := actions_model.CreateArtifact(ctx, ctx.ActionTask, artifactName, fileName, retentionDays)
if err != nil {
log.Error("Error create or get artifact: %v", err)
ctx.HTTPError(http.StatusInternalServerError, "Error create or get artifact")
return
}
artifact.ContentEncodingOrType = encoding
artifact.FileSize = 0
artifact.FileCompressedSize = 0
var respData CreateArtifactResponse
if setting.Actions.ArtifactStorage.ServeDirect() && setting.Actions.ArtifactStorage.Type == setting.AzureBlobStorageType {
storagePath := generateArtifactStoragePath(artifact)
if artifact.StoragePath != "" {
_ = storage.ActionsArtifacts.Delete(artifact.StoragePath)
}
artifact.StoragePath = storagePath
artifact.Status = actions_model.ArtifactStatusUploadPending
u, err := storage.ActionsArtifacts.ServeDirectURL(artifact.StoragePath, artifact.ArtifactPath, http.MethodPut, nil)
if err != nil {
log.Error("Error ServeDirectURL: %v", err)
ctx.HTTPError(http.StatusInternalServerError, "Error ServeDirectURL")
return
}
respData = CreateArtifactResponse{
Ok: true,
SignedUploadUrl: u.String(),
}
} else {
respData = CreateArtifactResponse{
Ok: true,
SignedUploadUrl: r.buildArtifactURL(ctx, "UploadArtifact", artifactName, ctx.ActionTask.ID, artifact.ID),
}
}
if err := actions_model.UpdateArtifactByID(ctx, artifact.ID, artifact); err != nil {
log.Error("Error UpdateArtifactByID: %v", err)
ctx.HTTPError(http.StatusInternalServerError, "Error UpdateArtifactByID")
return
}
r.sendProtobufBody(ctx, &respData)
}
func (r *artifactV4Routes) uploadArtifact(ctx *ArtifactContext) {
task, artifactName, ok := r.verifySignature(ctx, "UploadArtifact")
if !ok {
return
}
comp := ctx.Req.URL.Query().Get("comp")
switch comp {
case "block", "appendBlock":
// get artifact by name
artifact, err := r.getArtifactByName(ctx, task.Job.RunID, task.Job.RunAttemptID, artifactName)
if err != nil {
log.Error("Error artifact not found: %v", err)
ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
return
}
blockID := ctx.Req.URL.Query().Get("blockid")
if blockID == "" {
uploadedLength, err := appendUploadChunkV3(r.fs, ctx, artifact, artifact.RunID, artifact.FileSize)
if err != nil {
log.Error("Error appending chunk %v", err)
ctx.HTTPError(http.StatusInternalServerError, "Error appending Chunk")
return
}
artifact.FileCompressedSize += uploadedLength
artifact.FileSize += uploadedLength
if err := actions_model.UpdateArtifactByID(ctx, artifact.ID, artifact); err != nil {
log.Error("Error UpdateArtifactByID: %v", err)
ctx.HTTPError(http.StatusInternalServerError, "Error UpdateArtifactByID")
return
}
} else {
blockFilename := makeBlockFilenameV4(task.Job.RunID, artifact.ID, ctx.Req.ContentLength, blockID)
_, err := r.fs.Save(fmt.Sprintf("%s/%s", makeTmpPathNameV4(task.Job.RunID), blockFilename), ctx.Req.Body, ctx.Req.ContentLength)
if err != nil {
log.Error("Error uploading block blob %v", err)
ctx.HTTPError(http.StatusInternalServerError, "Error uploading block blob")
return
}
}
ctx.JSON(http.StatusCreated, "appended")
case "blocklist":
rawArtifactID := ctx.Req.URL.Query().Get("artifactID")
artifactID, _ := strconv.ParseInt(rawArtifactID, 10, 64)
_, err := r.fs.Save(fmt.Sprintf("%s/%d-%d-blocklist", makeTmpPathNameV4(task.Job.RunID), task.Job.RunID, artifactID), ctx.Req.Body, -1)
if err != nil {
log.Error("Error uploading blocklist %v", err)
ctx.HTTPError(http.StatusInternalServerError, "Error uploading blocklist")
return
}
ctx.JSON(http.StatusCreated, "created")
}
}
type BlockList struct {
Latest []string `xml:"Latest"`
}
type Latest struct {
Value string `xml:",chardata"`
}
func (r *artifactV4Routes) readBlockList(runID, artifactID int64) (*BlockList, error) {
blockListName := fmt.Sprintf("%s/%d-%d-blocklist", makeTmpPathNameV4(runID), runID, artifactID)
s, err := r.fs.Open(blockListName)
if err != nil {
return nil, err
}
xdec := xml.NewDecoder(s)
blockList := &BlockList{}
err = xdec.Decode(blockList)
_ = s.Close()
delerr := r.fs.Delete(blockListName)
if delerr != nil {
log.Warn("Failed to delete blockList %s: %v", blockListName, delerr)
}
if err != nil {
return nil, err
}
return blockList, nil
}
func (r *artifactV4Routes) finalizeArtifact(ctx *ArtifactContext) {
var req FinalizeArtifactRequest
if ok := r.parseProtobufBody(ctx, &req); !ok {
return
}
_, runID, ok := validateRunIDV4(ctx, req.WorkflowRunBackendId)
if !ok {
return
}
// get artifact by name
artifact, err := r.getArtifactByName(ctx, runID, ctx.ActionTask.Job.RunAttemptID, req.Name)
if err != nil {
log.Error("Error artifact not found: %v", err)
ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
return
}
if setting.Actions.ArtifactStorage.ServeDirect() && setting.Actions.ArtifactStorage.Type == setting.AzureBlobStorageType {
r.finalizeAzureServeDirect(ctx, &req, artifact)
} else {
r.finalizeDefaultArtifact(ctx, &req, artifact, runID)
}
// Return on finalize error
if ctx.Written() {
return
}
respData := FinalizeArtifactResponse{
Ok: true,
ArtifactId: artifact.ID,
}
r.sendProtobufBody(ctx, &respData)
}
func (r *artifactV4Routes) finalizeDefaultArtifact(ctx *ArtifactContext, req *FinalizeArtifactRequest, artifact *actions_model.ActionArtifact, runID int64) {
blockList, blockListErr := r.readBlockList(runID, artifact.ID)
chunks, err := listOrderedChunksForArtifact(r.fs, runID, artifact.ID, blockList)
if err != nil {
log.Error("Error list chunks: %v", errors.Join(blockListErr, err))
ctx.HTTPError(http.StatusInternalServerError, "Error list chunks")
return
}
artifact.FileSize = chunks[len(chunks)-1].End + 1
artifact.FileCompressedSize = chunks[len(chunks)-1].End + 1
if req.Size != artifact.FileSize {
log.Error("Error merge chunks size mismatch")
ctx.HTTPError(http.StatusInternalServerError, "Error merge chunks size mismatch")
return
}
if err := mergeChunksForArtifact(ctx, chunks, r.fs, artifact, req.GetHash().GetValue()); err != nil {
log.Error("Error merge chunks: %v", err)
ctx.HTTPError(http.StatusInternalServerError, "Error merge chunks")
return
}
}
func (r *artifactV4Routes) finalizeAzureServeDirect(ctx *ArtifactContext, req *FinalizeArtifactRequest, artifact *actions_model.ActionArtifact) {
checksumValue, hasSha256Checksum := strings.CutPrefix(req.GetHash().GetValue(), "sha256:")
var actualLength int64
if hasSha256Checksum {
hashSha256 := sha256.New()
obj, err := storage.ActionsArtifacts.Open(artifact.StoragePath)
if err != nil {
log.Error("Error read block: %v", err)
ctx.HTTPError(http.StatusInternalServerError, "Error read block")
return
}
defer obj.Close()
actualLength, err = io.Copy(hashSha256, obj)
if err != nil {
log.Error("Error read block: %v", err)
ctx.HTTPError(http.StatusInternalServerError, "Error read block")
return
}
rawChecksum := hashSha256.Sum(nil)
actualChecksum := hex.EncodeToString(rawChecksum)
if checksumValue != actualChecksum {
log.Error("Error merge chunks: checksum mismatch")
ctx.HTTPError(http.StatusInternalServerError, "Error merge chunks: checksum mismatch")
return
}
} else {
fi, err := storage.ActionsArtifacts.Stat(artifact.StoragePath)
if err != nil {
log.Error("Error stat block: %v", err)
ctx.HTTPError(http.StatusInternalServerError, "Error stat block")
return
}
actualLength = fi.Size()
}
if req.Size != actualLength {
log.Error("Error merge chunks: length mismatch")
ctx.HTTPError(http.StatusInternalServerError, "Error merge chunks: length mismatch")
return
}
// Update artifact metadata and status now that the upload is confirmed.
artifact.FileSize = actualLength
artifact.FileCompressedSize = actualLength
artifact.Status = actions_model.ArtifactStatusUploadConfirmed
if err := actions_model.UpdateArtifactByID(ctx, artifact.ID, artifact); err != nil {
log.Error("Error UpdateArtifactByID: %v", err)
ctx.HTTPError(http.StatusInternalServerError, "Error UpdateArtifactByID")
return
}
}
func (r *artifactV4Routes) listArtifacts(ctx *ArtifactContext) {
var req ListArtifactsRequest
if ok := r.parseProtobufBody(ctx, &req); !ok {
return
}
_, runID, ok := validateRunIDV4(ctx, req.WorkflowRunBackendId)
if !ok {
return
}
artifacts, err := db.Find[actions_model.ActionArtifact](ctx, actions_model.FindArtifactsOptions{
RunID: runID,
RunAttemptID: optional.Some(ctx.ActionTask.Job.RunAttemptID),
Status: int(actions_model.ArtifactStatusUploadConfirmed),
FinalizedArtifactsV4: true,
})
if err != nil {
log.Error("Error getting artifacts: %v", err)
ctx.HTTPError(http.StatusInternalServerError, err.Error())
return
}
list := []*ListArtifactsResponse_MonolithArtifact{}
table := map[string]*ListArtifactsResponse_MonolithArtifact{}
for _, artifact := range artifacts {
if _, ok := table[artifact.ArtifactName]; ok || req.IdFilter != nil && artifact.ID != req.IdFilter.Value || req.NameFilter != nil && artifact.ArtifactName != req.NameFilter.Value {
table[artifact.ArtifactName] = nil
continue
}
table[artifact.ArtifactName] = &ListArtifactsResponse_MonolithArtifact{
Name: artifact.ArtifactName,
CreatedAt: timestamppb.New(artifact.CreatedUnix.AsTime()),
DatabaseId: artifact.ID,
WorkflowRunBackendId: req.WorkflowRunBackendId,
WorkflowJobRunBackendId: req.WorkflowJobRunBackendId,
Size: artifact.FileSize,
}
}
for _, artifact := range table {
if artifact != nil {
list = append(list, artifact)
}
}
respData := ListArtifactsResponse{
Artifacts: list,
}
r.sendProtobufBody(ctx, &respData)
}
func (r *artifactV4Routes) getSignedArtifactURL(ctx *ArtifactContext) {
var req GetSignedArtifactURLRequest
if ok := r.parseProtobufBody(ctx, &req); !ok {
return
}
_, runID, ok := validateRunIDV4(ctx, req.WorkflowRunBackendId)
if !ok {
return
}
artifactName := req.Name
// get artifact by name
artifact, err := r.getArtifactByName(ctx, runID, ctx.ActionTask.Job.RunAttemptID, artifactName)
if err != nil {
log.Error("Error artifact not found: %v", err)
ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
return
}
if artifact.Status != actions_model.ArtifactStatusUploadConfirmed {
log.Error("Error artifact not found: %s", artifact.Status.ToString())
ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
return
}
respData := GetSignedArtifactURLResponse{}
if setting.Actions.ArtifactStorage.ServeDirect() {
// DO NOT USE the http POST method coming from the getSignedArtifactURL endpoint
u, err := actions.GetArtifactV4ServeDirectURL(artifact, http.MethodGet)
if err == nil {
respData.SignedUrl = u
}
}
if respData.SignedUrl == "" {
respData.SignedUrl = r.buildArtifactURL(ctx, "DownloadArtifact", artifactName, ctx.ActionTask.ID, artifact.ID)
}
r.sendProtobufBody(ctx, &respData)
}
func (r *artifactV4Routes) downloadArtifact(ctx *ArtifactContext) {
task, artifactName, ok := r.verifySignature(ctx, "DownloadArtifact")
if !ok {
return
}
// get artifact by name
artifact, err := r.getArtifactByName(ctx, task.Job.RunID, task.Job.RunAttemptID, artifactName)
if err != nil {
log.Error("Error artifact not found: %v", err)
ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
return
}
if artifact.Status != actions_model.ArtifactStatusUploadConfirmed {
log.Error("Error artifact not found: %s", artifact.Status.ToString())
ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
return
}
err = actions.DownloadArtifactV4ReadStorage(ctx.Base, artifact)
if err != nil {
log.Error("Error serve artifact: %v", err)
ctx.HTTPError(http.StatusInternalServerError, "failed to download artifact")
}
}
func (r *artifactV4Routes) deleteArtifact(ctx *ArtifactContext) {
var req DeleteArtifactRequest
if ok := r.parseProtobufBody(ctx, &req); !ok {
return
}
_, runID, ok := validateRunIDV4(ctx, req.WorkflowRunBackendId)
if !ok {
return
}
// get artifact by name
artifact, err := r.getArtifactByName(ctx, runID, ctx.ActionTask.Job.RunAttemptID, req.Name)
if err != nil {
log.Error("Error artifact not found: %v", err)
ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
return
}
err = actions_model.SetArtifactNeedDeleteByRunAttempt(ctx, runID, ctx.ActionTask.Job.RunAttemptID, req.Name)
if err != nil {
log.Error("Error deleting artifacts: %v", err)
ctx.HTTPError(http.StatusInternalServerError, err.Error())
return
}
respData := DeleteArtifactResponse{
Ok: true,
ArtifactId: artifact.ID,
}
r.sendProtobufBody(ctx, &respData)
}
+36
View File
@@ -0,0 +1,36 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package ping
import (
"context"
"fmt"
"net/http"
pingv1 "gitea.dev/actions-proto-go/ping/v1"
"gitea.dev/actions-proto-go/ping/v1/pingv1connect"
"gitea.dev/modules/log"
"connectrpc.com/connect"
)
func NewPingServiceHandler() (string, http.Handler) {
return pingv1connect.NewPingServiceHandler(&Service{})
}
var _ pingv1connect.PingServiceHandler = (*Service)(nil)
type Service struct{}
func (s *Service) Ping(
ctx context.Context,
req *connect.Request[pingv1.PingRequest],
) (*connect.Response[pingv1.PingResponse], error) {
log.Trace("Content-Type: %s", req.Header().Get("Content-Type"))
log.Trace("User-Agent: %s", req.Header().Get("User-Agent"))
res := connect.NewResponse(&pingv1.PingResponse{
Data: fmt.Sprintf("Hello, %s!", req.Msg.Data),
})
return res, nil
}
+61
View File
@@ -0,0 +1,61 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package ping
import (
"net/http"
"net/http/httptest"
"testing"
pingv1 "gitea.dev/actions-proto-go/ping/v1"
"gitea.dev/actions-proto-go/ping/v1/pingv1connect"
"connectrpc.com/connect"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestService(t *testing.T) {
mux := http.NewServeMux()
mux.Handle(pingv1connect.NewPingServiceHandler(
&Service{},
))
MainServiceTest(t, mux)
}
func MainServiceTest(t *testing.T, h http.Handler) {
t.Parallel()
server := httptest.NewUnstartedServer(h)
server.EnableHTTP2 = true
server.StartTLS()
defer server.Close()
connectClient := pingv1connect.NewPingServiceClient(
server.Client(),
server.URL,
)
grpcClient := pingv1connect.NewPingServiceClient(
server.Client(),
server.URL,
connect.WithGRPC(),
)
grpcWebClient := pingv1connect.NewPingServiceClient(
server.Client(),
server.URL,
connect.WithGRPCWeb(),
)
clients := []pingv1connect.PingServiceClient{connectClient, grpcClient, grpcWebClient}
t.Run("ping request", func(t *testing.T) {
for _, client := range clients {
result, err := client.Ping(t.Context(), connect.NewRequest(&pingv1.PingRequest{
Data: "foobar",
}))
require.NoError(t, err)
assert.Equal(t, "Hello, foobar!", result.Msg.Data)
}
})
}
+80
View File
@@ -0,0 +1,80 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package runner
import (
"context"
"crypto/subtle"
"errors"
"strings"
actions_model "gitea.dev/models/actions"
auth_model "gitea.dev/models/auth"
"gitea.dev/modules/log"
"gitea.dev/modules/timeutil"
"gitea.dev/modules/util"
"connectrpc.com/connect"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
const (
uuidHeaderKey = "x-runner-uuid"
tokenHeaderKey = "x-runner-token"
)
var withRunner = connect.WithInterceptors(connect.UnaryInterceptorFunc(func(unaryFunc connect.UnaryFunc) connect.UnaryFunc {
return func(ctx context.Context, request connect.AnyRequest) (connect.AnyResponse, error) {
methodName := getMethodName(request)
if methodName == "Register" {
return unaryFunc(ctx, request)
}
uuid := request.Header().Get(uuidHeaderKey)
token := request.Header().Get(tokenHeaderKey)
runner, err := actions_model.GetRunnerByUUID(ctx, uuid)
if err != nil {
if errors.Is(err, util.ErrNotExist) {
return nil, status.Error(codes.Unauthenticated, "unregistered runner")
}
return nil, status.Error(codes.Internal, err.Error())
}
if subtle.ConstantTimeCompare([]byte(runner.TokenHash), []byte(auth_model.HashToken(token, runner.TokenSalt))) != 1 {
return nil, status.Error(codes.Unauthenticated, "unregistered runner")
}
cols := []string{"last_online"}
runner.LastOnline = timeutil.TimeStampNow()
if methodName == "UpdateTask" || methodName == "UpdateLog" {
runner.LastActive = timeutil.TimeStampNow()
cols = append(cols, "last_active")
}
if err := actions_model.UpdateRunner(ctx, runner, cols...); err != nil {
log.Error("can't update runner status: %v", err)
}
ctx = context.WithValue(ctx, runnerCtxKey{}, runner)
return unaryFunc(ctx, request)
}
}))
func getMethodName(req connect.AnyRequest) string {
splits := strings.Split(req.Spec().Procedure, "/")
if len(splits) > 0 {
return splits[len(splits)-1]
}
return ""
}
type runnerCtxKey struct{}
func GetRunner(ctx context.Context) *actions_model.ActionRunner {
if v := ctx.Value(runnerCtxKey{}); v != nil {
if r, ok := v.(*actions_model.ActionRunner); ok {
return r
}
}
return nil
}
+362
View File
@@ -0,0 +1,362 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package runner
import (
"context"
"errors"
"net/http"
"slices"
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
"gitea.dev/actions-proto-go/runner/v1/runnerv1connect"
actions_model "gitea.dev/models/actions"
repo_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
"gitea.dev/modules/actions"
"gitea.dev/modules/log"
"gitea.dev/modules/util"
actions_service "gitea.dev/services/actions"
"connectrpc.com/connect"
gouuid "github.com/google/uuid"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/proto"
)
func NewRunnerServiceHandler() (string, http.Handler) {
return runnerv1connect.NewRunnerServiceHandler(
&Service{},
connect.WithCompressMinBytes(1024),
withRunner,
)
}
var _ runnerv1connect.RunnerServiceClient = (*Service)(nil)
type Service struct{}
// Register for new runner.
func (s *Service) Register(
ctx context.Context,
req *connect.Request[runnerv1.RegisterRequest],
) (*connect.Response[runnerv1.RegisterResponse], error) {
if req.Msg.Token == "" || req.Msg.Name == "" {
return nil, errors.New("missing runner token, name")
}
runnerToken, err := actions_model.GetRunnerToken(ctx, req.Msg.Token)
if err != nil {
return nil, errors.New("runner registration token not found")
}
if !runnerToken.IsActive {
return nil, errors.New("runner registration token has been invalidated, please use the latest one")
}
if runnerToken.OwnerID > 0 {
if _, err := user_model.GetUserByID(ctx, runnerToken.OwnerID); err != nil {
return nil, errors.New("owner of the token not found")
}
}
if runnerToken.RepoID > 0 {
if _, err := repo_model.GetRepositoryByID(ctx, runnerToken.RepoID); err != nil {
return nil, errors.New("repository of the token not found")
}
}
labels := req.Msg.Labels
hasCancellingSupport, _ := runnerRequestHasCancellingCapability(req.Msg)
// create new runner
name := util.EllipsisDisplayString(req.Msg.Name, 255)
runner := &actions_model.ActionRunner{
UUID: gouuid.New().String(),
Name: name,
OwnerID: runnerToken.OwnerID,
RepoID: runnerToken.RepoID,
Version: req.Msg.Version,
AgentLabels: labels,
Ephemeral: req.Msg.Ephemeral,
HasCancellingSupport: hasCancellingSupport,
}
runner.GenerateAndFillToken()
// create new runner
if err := actions_model.CreateRunner(ctx, runner); err != nil {
return nil, errors.New("can't create new runner")
}
// update token status
runnerToken.IsActive = true
if err := actions_model.UpdateRunnerToken(ctx, runnerToken, "is_active"); err != nil {
return nil, errors.New("can't update runner token status")
}
res := connect.NewResponse(&runnerv1.RegisterResponse{
Runner: &runnerv1.Runner{
Id: runner.ID,
Uuid: runner.UUID,
Token: runner.Token,
Name: runner.Name,
Version: runner.Version,
Labels: runner.AgentLabels,
Ephemeral: runner.Ephemeral,
},
})
return res, nil
}
// runnerCapabilityCancelling is the wire string the runner advertises in its
// capabilities list to indicate it understands the transitional cancelling
// state and will run post-step cleanup before finalizing the task.
const runnerCapabilityCancelling = "cancelling"
type capabilityGetter interface {
GetCapabilities() []string
}
type declareRequest interface {
proto.Message
GetVersion() string
GetLabels() []string
}
func runnerRequestHasCancellingCapability(req proto.Message) (bool, bool) {
if req == nil {
return false, false
}
if typedReq, ok := any(req).(capabilityGetter); ok {
return slices.Contains(typedReq.GetCapabilities(), runnerCapabilityCancelling), true
}
return false, false
}
func applyDeclareRequestToRunner(runner *actions_model.ActionRunner, req declareRequest) []string {
runner.AgentLabels = req.GetLabels()
runner.Version = req.GetVersion()
cols := []string{"agent_labels", "version"}
hasCancellingSupport, capabilityStateKnown := runnerRequestHasCancellingCapability(req)
if capabilityStateKnown && runner.HasCancellingSupport != hasCancellingSupport {
runner.HasCancellingSupport = hasCancellingSupport
cols = append(cols, "has_cancelling_support")
}
return cols
}
func (s *Service) Declare(
ctx context.Context,
req *connect.Request[runnerv1.DeclareRequest],
) (*connect.Response[runnerv1.DeclareResponse], error) {
runner := GetRunner(ctx)
if err := actions_model.UpdateRunner(ctx, runner, applyDeclareRequestToRunner(runner, req.Msg)...); err != nil {
return nil, status.Errorf(codes.Internal, "update runner: %v", err)
}
return connect.NewResponse(&runnerv1.DeclareResponse{
Runner: &runnerv1.Runner{
Id: runner.ID,
Uuid: runner.UUID,
Token: runner.Token,
Name: runner.Name,
Version: runner.Version,
Labels: runner.AgentLabels,
},
}), nil
}
// FetchTask assigns a task to the runner
func (s *Service) FetchTask(
ctx context.Context,
req *connect.Request[runnerv1.FetchTaskRequest],
) (*connect.Response[runnerv1.FetchTaskResponse], error) {
runner := GetRunner(ctx)
var task *runnerv1.Task
tasksVersion := req.Msg.TasksVersion // task version from runner
latestVersion, err := actions_model.GetTasksVersionByScope(ctx, runner.OwnerID, runner.RepoID)
if err != nil {
return nil, status.Errorf(codes.Internal, "query tasks version failed: %v", err)
} else if latestVersion == 0 {
if err := actions_model.IncreaseTaskVersion(ctx, runner.OwnerID, runner.RepoID); err != nil {
return nil, status.Errorf(codes.Internal, "fail to increase task version: %v", err)
}
// if we don't increase the value of `latestVersion` here,
// the response of FetchTask will return tasksVersion as zero.
// and the runner will treat it as an old version of Gitea.
latestVersion++
}
if tasksVersion != latestVersion {
// Re-load runner from DB so task assignment uses current IsDisabled state
// (avoids race where disable commits while this request still has stale runner).
freshRunner, err := actions_model.GetRunnerByUUID(ctx, runner.UUID)
if err != nil {
return nil, status.Errorf(codes.Internal, "get runner: %v", err)
}
// if the task version in request is not equal to the version in db,
// it means there may still be some tasks that haven't been assigned.
// try to pick a task for the runner that send the request.
if t, ok, err := actions_service.PickTask(ctx, freshRunner); err != nil {
log.Error("pick task failed: %v", err)
return nil, status.Errorf(codes.Internal, "pick task: %v", err)
} else if ok {
task = t
}
}
res := connect.NewResponse(&runnerv1.FetchTaskResponse{
Task: task,
TasksVersion: latestVersion,
})
return res, nil
}
// UpdateTask updates the task status.
func (s *Service) UpdateTask(
ctx context.Context,
req *connect.Request[runnerv1.UpdateTaskRequest],
) (*connect.Response[runnerv1.UpdateTaskResponse], error) {
runner := GetRunner(ctx)
task, err := actions_model.UpdateTaskByState(ctx, runner.ID, req.Msg.State)
if err != nil {
return nil, status.Errorf(codes.Internal, "update task: %v", err)
}
for k, v := range req.Msg.Outputs {
if len(k) > 255 {
log.Warn("Ignore the output of task %d because the key is too long: %q", task.ID, k)
continue
}
// The value can be a maximum of 1 MB
if l := len(v); l > 1024*1024 {
log.Warn("Ignore the output %q of task %d because the value is too long: %v", k, task.ID, l)
continue
}
// There's another limitation on GitHub that the total of all outputs in a workflow run can be a maximum of 50 MB.
// We don't check the total size here because it's not easy to do, and it doesn't really worth it.
// See https://docs.github.com/en/actions/using-jobs/defining-outputs-for-jobs
if err := actions_model.InsertTaskOutputIfNotExist(ctx, task.ID, k, v); err != nil {
log.Warn("Failed to insert the output %q of task %d: %v", k, task.ID, err)
// It's ok not to return errors, the runner will resend the outputs.
}
}
sentOutputs, err := actions_model.FindTaskOutputKeyByTaskID(ctx, task.ID)
if err != nil {
log.Warn("Failed to find the sent outputs of task %d: %v", task.ID, err)
// It's not to return errors, it can be handled when the runner resends sent outputs.
}
if err := task.LoadJob(ctx); err != nil {
return nil, status.Errorf(codes.Internal, "load job: %v", err)
}
if err := task.Job.LoadAttributes(ctx); err != nil {
return nil, status.Errorf(codes.Internal, "load run: %v", err)
}
actions_service.CreateCommitStatusForRunJobs(ctx, task.Job.Run, task.Job)
if task.Status.IsDone() {
actions_service.NotifyWorkflowJobStatusUpdateWithTask(ctx, task.Job, task)
}
if req.Msg.State.Result != runnerv1.Result_RESULT_UNSPECIFIED {
if err := actions_service.EmitJobsIfReadyByRun(task.Job.RunID); err != nil {
log.Error("Emit ready jobs of run %d: %v", task.Job.RunID, err)
}
if task.Job.Run.Status.IsDone() {
actions_service.NotifyWorkflowRunStatusUpdateWithReload(ctx, task.Job.RepoID, task.Job.RunID)
}
}
return connect.NewResponse(&runnerv1.UpdateTaskResponse{
State: &runnerv1.TaskState{
Id: req.Msg.State.Id,
Result: task.Status.AsResult(),
},
SentOutputs: sentOutputs,
}), nil
}
// UpdateLog uploads log of the task.
func (s *Service) UpdateLog(
ctx context.Context,
req *connect.Request[runnerv1.UpdateLogRequest],
) (*connect.Response[runnerv1.UpdateLogResponse], error) {
runner := GetRunner(ctx)
res := connect.NewResponse(&runnerv1.UpdateLogResponse{})
task, err := actions_model.GetTaskByID(ctx, req.Msg.TaskId)
if err != nil {
return nil, status.Errorf(codes.Internal, "get task: %v", err)
} else if runner.ID != task.RunnerID {
return nil, status.Errorf(codes.Internal, "invalid runner for task")
}
ack := task.LogLength
// Trim rows the runner already had acked.
var rows []*runnerv1.LogRow
if req.Msg.Index <= ack && int64(len(req.Msg.Rows))+req.Msg.Index > ack {
rows = req.Msg.Rows[ack-req.Msg.Index:]
}
// Ack a re-sent finalize idempotently. Appending new rows past the seal errors.
if task.LogInStorage {
if len(rows) > 0 {
return nil, status.Errorf(codes.AlreadyExists, "log file has been archived")
}
res.Msg.AckIndex = ack
return res, nil
}
// Bail unless we have new rows or a NoMore to finalize. Even with
// NoMore, bail when the runner has outrun the server — archiving a
// log with a gap is worse than asking it to retry.
if len(rows) == 0 && (!req.Msg.NoMore || req.Msg.Index > ack) {
res.Msg.AckIndex = ack
return res, nil
}
// WriteLogs is called even with no rows: with offset==0 it bootstraps
// an empty DBFS file so TransferLogs below has something to read when
// the runner finalizes a task that produced no log output.
ns, err := actions.WriteLogs(ctx, task.LogFilename, task.LogSize, rows)
if err != nil {
return nil, status.Errorf(codes.Internal, "unable to append logs to dbfs file: %v", err)
}
task.LogLength += int64(len(rows))
for _, n := range ns {
task.LogIndexes = append(task.LogIndexes, task.LogSize)
task.LogSize += int64(n)
}
res.Msg.AckIndex = task.LogLength
var remove func()
if req.Msg.NoMore {
task.LogInStorage = true
remove, err = actions.TransferLogs(ctx, task.LogFilename)
if err != nil {
return nil, status.Errorf(codes.Internal, "transfer logs: %v", err)
}
}
if err := actions_model.UpdateTask(ctx, task, "log_indexes", "log_length", "log_size", "log_in_storage"); err != nil {
return nil, status.Errorf(codes.Internal, "update task: %v", err)
}
if remove != nil {
remove()
}
return res, nil
}
+86
View File
@@ -0,0 +1,86 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package runner
import (
"testing"
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
actions_model "gitea.dev/models/actions"
"github.com/stretchr/testify/assert"
)
type capabilityRegisterRequest struct {
*runnerv1.RegisterRequest
capabilities []string
}
func (r *capabilityRegisterRequest) GetCapabilities() []string {
return r.capabilities
}
type capabilityDeclareRequest struct {
*runnerv1.DeclareRequest
capabilities []string
}
func (r *capabilityDeclareRequest) GetCapabilities() []string {
return r.capabilities
}
func TestRunnerRequestHasCancellingCapabilityTypedAccessor(t *testing.T) {
registerReq := &capabilityRegisterRequest{
RegisterRequest: &runnerv1.RegisterRequest{},
capabilities: []string{runnerCapabilityCancelling, "other"},
}
hasCapability, known := runnerRequestHasCancellingCapability(registerReq)
assert.True(t, hasCapability)
assert.True(t, known)
declareReq := &capabilityDeclareRequest{
DeclareRequest: &runnerv1.DeclareRequest{},
capabilities: nil,
}
hasCapability, known = runnerRequestHasCancellingCapability(declareReq)
assert.False(t, hasCapability)
assert.True(t, known)
hasCapability, known = runnerRequestHasCancellingCapability(nil)
assert.False(t, hasCapability)
assert.False(t, known)
}
func TestApplyDeclareRequestToRunnerPreservesUnknownCapabilityState(t *testing.T) {
runner := &actions_model.ActionRunner{
HasCancellingSupport: true,
}
req := &runnerv1.DeclareRequest{
Version: "1.2.3",
Labels: []string{"linux"},
}
cols := applyDeclareRequestToRunner(runner, req)
assert.Equal(t, []string{"agent_labels", "version"}, cols)
assert.True(t, runner.HasCancellingSupport)
assert.Equal(t, "1.2.3", runner.Version)
assert.Equal(t, []string{"linux"}, runner.AgentLabels)
}
func TestApplyDeclareRequestToRunnerUpdatesTypedCapabilityState(t *testing.T) {
runner := &actions_model.ActionRunner{
HasCancellingSupport: true,
}
req := &capabilityDeclareRequest{
DeclareRequest: &runnerv1.DeclareRequest{
Version: "1.2.3",
Labels: []string{"linux"},
},
capabilities: []string{},
}
cols := applyDeclareRequestToRunner(runner, req)
assert.Equal(t, []string{"agent_labels", "version", "has_cancelling_support"}, cols)
assert.False(t, runner.HasCancellingSupport)
}