Skip to content

Commit

Permalink
feat: add artifact service (#53)
Browse files Browse the repository at this point in the history
* extend github workflow context

* add artifact service

* fix mage comments

* fix linter warnings
  • Loading branch information
aweris authored Aug 3, 2023
1 parent 12d3624 commit a5bc4b3
Show file tree
Hide file tree
Showing 24 changed files with 1,357 additions and 22 deletions.
21 changes: 21 additions & 0 deletions .github/workflows/release-main.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,24 @@ jobs:

- name: Publish artifact-service
run: ./hack/mage tools:ghx:publish main

artifact-service:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0

- name: Set up Go
uses: actions/setup-go@v4

- name: Login to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Publish artifact-service
run: ./hack/mage services:artifact:publish main
21 changes: 21 additions & 0 deletions .github/workflows/release-tag.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,24 @@ jobs:

- name: Publish artifact-service
run: ./hack/mage tools:ghx:publish ${{ github.ref }}

artifact-service:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0

- name: Set up Go
uses: actions/setup-go@v4

- name: Login to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Publish artifact-service
run: ./hack/mage services:artifact:publish ${{ github.ref }}
1 change: 1 addition & 0 deletions .golangci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,4 @@ linters-settings:
- github.com/google/uuid
- github.com/rhysd/actionlint
- github.com/magefile/mage/sh
- github.com/julienschmidt/httprouter
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ require (
dagger.io/dagger v0.7.4
github.com/cli/go-gh/v2 v2.1.0
github.com/google/uuid v1.3.0
github.com/julienschmidt/httprouter v1.3.0
github.com/magefile/mage v1.15.0
github.com/rhysd/actionlint v1.6.25
github.com/spf13/cobra v1.7.0
github.com/spf13/pflag v1.0.5
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,16 @@ github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSAS
github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U=
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg=
github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
Expand Down
7 changes: 1 addition & 6 deletions go.work.sum
Original file line number Diff line number Diff line change
Expand Up @@ -129,10 +129,6 @@ github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i
github.com/matryer/moq v0.2.3 h1:Q06vEqnBYjjfx5KKgHfYRKE/lvlRu+Nj+xodG4YdHnU=
github.com/matryer/moq v0.2.7 h1:RtpiPUM8L7ZSCbSwK+QcZH/E9tgqAkFjKQxsRs25b4w=
github.com/matryer/moq v0.2.7/go.mod h1:kITsx543GOENm48TUAQyJ9+SAvFSr7iGQXPoth/VUBk=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI=
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/microcosm-cc/bluemonday v1.0.20 h1:flpzsq4KU3QIYAYGV/szUat7H+GPOXR0B2JU5A1Wp8Y=
Expand Down Expand Up @@ -175,6 +171,7 @@ github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsr
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.5.4 h1:2uY/xC0roWy8IBEGLgB1ywIoEJFGmRrX21YQcvGZzjU=
github.com/yuin/goldmark-emoji v1.0.1 h1:ctuWEyzGBwiucEqxzwe0SOYDXPAucOrE9NQC18Wa1os=
github.com/yuin/goldmark-emoji v1.0.1/go.mod h1:2w1E6FEWLcDQkoTE+7HU6QF1F6SLlNGjRIBbIZQFqkQ=
go.etcd.io/etcd/api/v3 v3.5.9 h1:4wSsluwyTbGGmyjJktOf3wFQoTBIURXHnq9n/G/JQHs=
Expand Down Expand Up @@ -220,9 +217,7 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
Expand Down
28 changes: 20 additions & 8 deletions internal/core/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,17 +131,25 @@ type GithubFilesContext struct {

// GithubWorkflowContext is a context that contains information about the workflow.
type GithubWorkflowContext struct {
Workflow string `json:"workflow"` // Workflow is the name of the workflow. If the workflow file doesn't specify a name, the value of this property is the full path of the workflow file in the repository.
WorkflowRef string `json:"workflow_ref"` // WorkflowRef is the ref path to the workflow. For example, octocat/hello-world/.github/workflows/my-workflow.yml@refs/heads/my_branch.
WorkflowSHA string `json:"workflow_sha"` // WorkflowSHA is the commit SHA for the workflow file.
Workflow string `json:"workflow"` // Workflow is the name of the workflow. If the workflow file doesn't specify a name, the value of this property is the full path of the workflow file in the repository.
WorkflowRef string `json:"workflow_ref"` // WorkflowRef is the ref path to the workflow. For example, octocat/hello-world/.github/workflows/my-workflow.yml@refs/heads/my_branch.
WorkflowSHA string `json:"workflow_sha"` // WorkflowSHA is the commit SHA for the workflow file.
RunID string `json:"run_id"` // RunID is a unique number for each workflow run within a repository. This number does not change if you re-run the workflow run.
RunNumber string `json:"run_number"` // RunNumber is a unique number for each run of a particular workflow in a repository. This number begins at 1 for the workflow's first run, and increments with each new run. This number does not change if you re-run the workflow run.
RunAttempt string `json:"run_attempt"` // RunAttempt is a unique number for each attempt of a particular workflow run in a repository. This number begins at 1 for the workflow run's first attempt, and increments with each re-run.
RetentionDays string `json:"retention_days"` // RetentionDays is the number of days that workflow run logs and artifacts are kept.
}

// NewGithubWorkflowContext creates a new GithubWorkflowContext from the given workflow.
func NewGithubWorkflowContext(repo *Repository, workflow *Workflow) GithubWorkflowContext {
func NewGithubWorkflowContext(repo *Repository, workflow *Workflow, runID string) GithubWorkflowContext {
return GithubWorkflowContext{
Workflow: workflow.Name,
WorkflowRef: fmt.Sprintf("%s/%s@%s", repo.NameWithOwner, workflow.Path, repo.CurrentRef),
WorkflowSHA: workflow.SHA,
Workflow: workflow.Name,
WorkflowRef: fmt.Sprintf("%s/%s@%s", repo.NameWithOwner, workflow.Path, repo.CurrentRef),
WorkflowSHA: workflow.SHA,
RunID: runID,
RunNumber: "1", // TODO: fill this value
RunAttempt: "1", // TODO: fill this value
RetentionDays: "0", // TODO: fill this value
}
}

Expand All @@ -150,7 +158,11 @@ func (c GithubWorkflowContext) Apply(container *dagger.Container) *dagger.Contai
return container.
WithEnvVariable("GITHUB_WORKFLOW", c.Workflow).
WithEnvVariable("GITHUB_WORKFLOW_REF", c.WorkflowRef).
WithEnvVariable("GITHUB_WORKFLOW_SHA", c.WorkflowSHA)
WithEnvVariable("GITHUB_WORKFLOW_SHA", c.WorkflowSHA).
WithEnvVariable("GITHUB_RUN_ID", c.RunID).
WithEnvVariable("GITHUB_RUN_NUMBER", c.RunNumber).
WithEnvVariable("GITHUB_RUN_ATTEMPT", c.RunAttempt).
WithEnvVariable("GITHUB_RETENTION_DAYS", c.RetentionDays)
}

// GithubJobInfoContext is a context that contains information about the job.
Expand Down
72 changes: 72 additions & 0 deletions internal/dagger/services/artifact_service.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package services

import (
"fmt"
"path/filepath"

"dagger.io/dagger"

"github.com/aweris/gale/internal/config"
"github.com/aweris/gale/internal/version"
)

// ArtifactService is the dagger service definitions for the services/artifact directory.
type ArtifactService struct {
client *dagger.Client
container *dagger.Container
artifacts *dagger.CacheVolume
alias string
port string
}

// NewArtifactService creates a new artifact service.
func NewArtifactService() *ArtifactService {
v := version.GetVersion()

tag := v.GitVersion

container := config.Client().Container().From("ghcr.io/aweris/gale/services/artifact:" + tag)

// port configuration
container = container.WithEnvVariable("PORT", "8080").WithExposedPort(8080)

// stateful data configuration

cache := config.Client().CacheVolume("gale-artifact-service")
container = container.WithMountedCache("/artifacts", cache).WithEnvVariable("ARTIFACTS_DIR", "/artifacts")

return &ArtifactService{
client: config.Client(),
container: container,
artifacts: cache,
alias: "artifacts",
port: "8080",
}
}

// Container returns the container of the artifact service.
func (a *ArtifactService) Container() *dagger.Container {
return a.container
}

// ServiceBinding returns a container with the artifact service binding and all necessary configurations. The method
// signature is compatible with the dagger.WithContainerFunc type. It can be used to as
// container.With(service.ServiceBinding) to bind the service to the container.
func (a *ArtifactService) ServiceBinding(container *dagger.Container) *dagger.Container {
container = container.WithServiceBinding(a.alias, a.container)
container = container.WithEnvVariable("ACTIONS_RUNTIME_URL", fmt.Sprintf("http://%s:%s/", a.alias, a.port))
container = container.WithEnvVariable("ACTIONS_RUNTIME_TOKEN", "token") // dummy token, not used by service

return container
}

// Artifacts returns a artifact directory for the given run ID.
func (a *ArtifactService) Artifacts(runID string) *dagger.Directory {
// This is a bit of a hack. We need to copy the artifacts from the cache volume to a directory. Without this
// copy, we're not able to export the artifacts from the container.
return a.client.Container().
From("alpine:latest").
WithMountedCache("/artifacts", a.artifacts).
WithExec([]string{"cp", "-r", filepath.Clean(filepath.Join("artifacts", runID)), "/out"}).
Directory("/out")
}
146 changes: 146 additions & 0 deletions internal/fs/multipart_filewriter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package fs

import (
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
)

// MultipartFileWriter allows writing a file in chunks first and then merging the chunks into a single file.
// This is useful for uploading large files in chunks while ensuring files are not corrupted in case of network errors
// or not ordered chunk uploads.
type MultipartFileWriter struct {
root string
}

// NewMultipartFileWriter creates a new MultipartFileWriter that writes files to the specified root directory.
func NewMultipartFileWriter(root string) (*MultipartFileWriter, error) {
if err := os.MkdirAll(root, 0755); err != nil {
return nil, err
}

return &MultipartFileWriter{root: root}, nil
}

// Write saves the uploaded part data as separate files in the same directory as the specified file.
func (w *MultipartFileWriter) Write(filePath string, offset int, data io.Reader) error {
// Ensure absolute path for the file directory exists.
fileDir := filepath.Join(w.root, filepath.Dir(filePath))
if err := os.MkdirAll(fileDir, 0755); err != nil {
return err
}

// Construct the part file name with offset
partFileName := fmt.Sprintf("%s.part.%d", filepath.Base(filePath), offset)

// Create the part file path in the same directory
partFilePath := filepath.Join(fileDir, partFileName)

// Write the part data to the part file
partFile, err := os.Create(partFilePath)
if err != nil {
return err
}
defer partFile.Close()

_, err = io.Copy(partFile, data)
if err != nil {
return err
}

return nil
}

// Merge combines the uploaded part files into separate intended files and removes the part files.
func (w *MultipartFileWriter) Merge() error {
// Find all the part files in the root directory
parts, err := findPartialFiles(w.root)
if err != nil {
return err
}

for target, parts := range parts {
// Ensure that the part files are sorted by offset
sort.Slice(parts, func(i, j int) bool { return parseOffset(parts[i]) < parseOffset(parts[j]) })

// Create the target file
targetFile, err := os.Create(target)
if err != nil {
return err
}

// Merge the part files into the target file
for _, part := range parts {
file, err := os.Open(part)
if err != nil {
return err
}
defer file.Close()

// Copy the part file data to the target file
_, err = io.Copy(targetFile, file)
if err != nil {
return err
}

// Remove the part file
os.Remove(part)
}
}
return err
}

// findPartialFiles finds all the part files in the root directory and returns a map of target file paths and their part file paths.
func findPartialFiles(root string) (map[string][]string, error) {
parts := make(map[string][]string)

err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}

// Skip the root directory
if path == root {
return nil
}

// Check if the directory contains .part files
if !d.IsDir() && strings.Contains(path, ".part.") {
// Get the directory and filename from the provided file path
dir := filepath.Dir(path)
base := filepath.Base(path)

// Get the original filename without the .part extension
filename := strings.TrimSuffix(base, fmt.Sprintf(".part%s", filepath.Ext(base)))

targetPath := filepath.Join(dir, filename)

if _, ok := parts[targetPath]; !ok {
parts[targetPath] = []string{}
}

// append the part file to the list
parts[targetPath] = append(parts[targetPath], path)
}

return nil
})
if err != nil {
return nil, err
}

return parts, nil
}

// parseOffset parses the offset from the part file name. The part file name is of the format <filename>.<offset>.part
// and this function returns the offset.
func parseOffset(filePath string) int64 {
ext := filepath.Ext(filePath)
offset, _ := strconv.ParseInt(strings.TrimPrefix(ext, "."), 10, 64)
return offset
}
Loading

0 comments on commit a5bc4b3

Please sign in to comment.