Skip to content

Commit 1e6d3d4

Browse files
committed
feat(artifact): add support for artifact storage with inline and external backends
Introduces a pluggable artifact storage system supporting multiple backends (S3, GCS, SMB, SFTP, local filesystem) and inline database storage. Includes compression support (gzip), filesystem abstraction interfaces, and database schema for storing artifacts either inline or in external cloud/object storage systems. External connections can be configured via connection URL properties.
1 parent dbb8aeb commit 1e6d3d4

36 files changed

Lines changed: 2762 additions & 317 deletions

.github/workflows/test.yaml

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,13 @@ jobs:
4040
postgrest
4141
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
4242
- name: Install Go
43-
uses: buildjet/setup-go@555ce355a95ff01018ffcf8fbbd9c44654db8374 # v5.0.2
43+
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
4444
with:
4545
go-version: 1.25.x
4646
cache: false # Using custom cache action below for .bin directory
4747
- name: Checkout code
4848
uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
49-
- uses: buildjet/cache@3e70d19e31d6a8030aeddf6ed8dbe601f94d09f4 # v4.0.2
49+
- uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
5050
with:
5151
path: |
5252
~/go/pkg/mod
@@ -76,13 +76,13 @@ jobs:
7676
--health-retries 5
7777
steps:
7878
- name: Install Go
79-
uses: buildjet/setup-go@555ce355a95ff01018ffcf8fbbd9c44654db8374 # v5.0.2
79+
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
8080
with:
8181
go-version: 1.25.x
8282
cache: false # Using custom cache action below for .bin directory
8383
- name: Checkout code
8484
uses: actions/checkout@1d96c772d19495a3b5c517cd2bc0cb401ea0529f # v4.1.3
85-
- uses: buildjet/cache@3e70d19e31d6a8030aeddf6ed8dbe601f94d09f4 # v4.0.2
85+
- uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
8686
with:
8787
path: |
8888
~/go/pkg/mod
@@ -99,6 +99,30 @@ jobs:
9999
DUTY_DB_DISABLE_RLS: "true"
100100
LOKI_URL: http://localhost:3100
101101

102+
e2e-blobs:
103+
runs-on: ubuntu-latest
104+
steps:
105+
- name: Install Go
106+
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
107+
with:
108+
go-version: 1.25.x
109+
cache: false
110+
- name: Checkout code
111+
uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
112+
- uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
113+
with:
114+
path: |
115+
~/go/pkg/mod
116+
~/.cache/go-build
117+
.bin
118+
key: cache-${{ hashFiles('**/go.sum') }}-${{ hashFiles('.bin/*') }}
119+
restore-keys: |
120+
cache-
121+
- name: E2E Blob Store Tests
122+
run: make test-e2e-blobs
123+
env:
124+
DUTY_DB_DISABLE_RLS: "true"
125+
102126
migrate:
103127
runs-on: ubuntu-latest
104128
strategy:
@@ -115,11 +139,11 @@ jobs:
115139
go build main.go && ./main --db-url 'postgres://postgres:postgres@localhost:5432/test?sslmode=disable'
116140
steps:
117141
- name: Install Go
118-
uses: buildjet/setup-go@555ce355a95ff01018ffcf8fbbd9c44654db8374 # v5.0.2
142+
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
119143
with:
120144
go-version: 1.25.x
121145
cache: false # Using custom cache action below for .bin directory
122-
- uses: buildjet/cache@3e70d19e31d6a8030aeddf6ed8dbe601f94d09f4 # v4.0.2
146+
- uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
123147
with:
124148
path: |
125149
~/go/pkg/mod

Makefile

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ ginkgo:
1212
go install github.com/onsi/ginkgo/v2/ginkgo
1313

1414
test: ginkgo
15-
ginkgo -r --succinct --skip-package=tests/e2e,bench --label-filter "!e2e"
15+
ginkgo -r --succinct --skip-package=tests/e2e,tests/e2e-blobs,bench --label-filter "!e2e"
1616

1717
test-concurrent: ginkgo
1818
ginkgo -r -v --nodes=4 --skip-package=bench --label-filter "!e2e"
@@ -30,6 +30,10 @@ e2e-services: ## Run e2e test services in foreground with automatic cleanup on e
3030
trap 'docker-compose down -v && docker-compose rm -f' EXIT INT TERM && \
3131
docker-compose up --remove-orphans
3232

33+
.PHONY: test-e2e-blobs
34+
test-e2e-blobs: ginkgo
35+
ginkgo -v --label-filter="e2e" ./tests/e2e-blobs/
36+
3337
.PHONY: bench
3438
bench:
3539
go test -run ^$$ -bench=. -benchtime=10s -timeout 30m github.com/flanksource/duty/bench

artifact/blob_store.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package artifact
2+
3+
import (
4+
"bytes"
5+
"crypto/sha256"
6+
"encoding/hex"
7+
"fmt"
8+
"io"
9+
10+
"github.com/flanksource/duty/models"
11+
"github.com/google/uuid"
12+
"gorm.io/gorm"
13+
)
14+
15+
type BlobStore interface {
16+
Write(data Data, artifact *models.Artifact) (*models.Artifact, error)
17+
Read(artifactID uuid.UUID) (*Data, error)
18+
io.Closer
19+
}
20+
21+
type blobStore struct {
22+
fs FilesystemRW
23+
db *gorm.DB
24+
backend string
25+
}
26+
27+
func NewBlobStore(fs FilesystemRW, db *gorm.DB, backend string) BlobStore {
28+
return &blobStore{fs: fs, db: db, backend: backend}
29+
}
30+
31+
func (s *blobStore) Write(data Data, a *models.Artifact) (*models.Artifact, error) {
32+
if data.Content == nil {
33+
return nil, fmt.Errorf("artifact data content is nil")
34+
}
35+
defer func() { _ = data.Content.Close() }()
36+
37+
checksum := sha256.New()
38+
mimeReader := io.TeeReader(data.Content, checksum)
39+
40+
mw := &mimeWriter{Max: maxBytesForMimeDetection}
41+
fileReader := io.TeeReader(mimeReader, mw)
42+
43+
info, err := s.fs.Write(s.db.Statement.Context, data.Filename, fileReader)
44+
if err != nil {
45+
return nil, fmt.Errorf("writing artifact %s: %w", data.Filename, err)
46+
}
47+
48+
if data.ContentType == "" {
49+
data.ContentType = mw.Detect().String()
50+
}
51+
52+
// For inline store, the artifact already has content set
53+
if inlineArt := InlineArtifact(info); inlineArt != nil {
54+
a.Content = inlineArt.Content
55+
a.CompressionType = inlineArt.CompressionType
56+
}
57+
58+
a.Path = data.Filename
59+
a.Filename = info.Name()
60+
a.Size = info.Size()
61+
a.ContentType = data.ContentType
62+
a.Checksum = hex.EncodeToString(checksum.Sum(nil))
63+
64+
if err := s.db.Create(a).Error; err != nil {
65+
return nil, fmt.Errorf("saving artifact to db: %w", err)
66+
}
67+
68+
return a, nil
69+
}
70+
71+
func (s *blobStore) Read(artifactID uuid.UUID) (*Data, error) {
72+
var a models.Artifact
73+
if err := s.db.Where("id = ?", artifactID).First(&a).Error; err != nil {
74+
return nil, fmt.Errorf("finding artifact %s: %w", artifactID, err)
75+
}
76+
77+
if a.IsInline() {
78+
content, err := a.GetContent()
79+
if err != nil {
80+
return nil, fmt.Errorf("decompressing inline artifact %s: %w", artifactID, err)
81+
}
82+
return &Data{
83+
Content: io.NopCloser(bytes.NewReader(content)),
84+
ContentLength: a.Size,
85+
Checksum: a.Checksum,
86+
ContentType: a.ContentType,
87+
Filename: a.Filename,
88+
}, nil
89+
}
90+
91+
r, err := s.fs.Read(s.db.Statement.Context, a.Path)
92+
if err != nil {
93+
return nil, fmt.Errorf("reading artifact %s from %s: %w", artifactID, a.Path, err)
94+
}
95+
96+
return &Data{
97+
Content: r,
98+
ContentLength: a.Size,
99+
Checksum: a.Checksum,
100+
ContentType: a.ContentType,
101+
Filename: a.Filename,
102+
}, nil
103+
}
104+
105+
func (s *blobStore) Close() error {
106+
return s.fs.Close()
107+
}

artifact/clients/aws/doc.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
package aws

artifact/clients/aws/fileinfo.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
//go:build !fast
2+
3+
package aws
4+
5+
import (
6+
"io/fs"
7+
"strings"
8+
"time"
9+
10+
"github.com/aws/aws-sdk-go-v2/service/s3/types"
11+
"github.com/flanksource/commons/utils"
12+
"github.com/samber/lo"
13+
)
14+
15+
type S3FileInfo struct {
16+
Object types.Object
17+
}
18+
19+
func (obj S3FileInfo) Name() string {
20+
if obj.Object.Key == nil {
21+
return ""
22+
}
23+
return *obj.Object.Key
24+
}
25+
26+
func (obj S3FileInfo) Size() int64 {
27+
return utils.Deref(obj.Object.Size)
28+
}
29+
30+
func (obj S3FileInfo) Mode() fs.FileMode {
31+
return fs.FileMode(0644)
32+
}
33+
34+
func (obj S3FileInfo) ModTime() time.Time {
35+
return lo.FromPtr(obj.Object.LastModified)
36+
}
37+
38+
func (obj S3FileInfo) FullPath() string {
39+
return *obj.Object.Key
40+
}
41+
42+
func (obj S3FileInfo) IsDir() bool {
43+
return strings.HasSuffix(obj.Name(), "/")
44+
}
45+
46+
func (obj S3FileInfo) Sys() interface{} {
47+
return obj.Object
48+
}

artifact/clients/azure/fileinfo.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package azure
2+
3+
import (
4+
"io/fs"
5+
"time"
6+
)
7+
8+
type BlobFileInfo struct {
9+
BlobName string
10+
BlobSize int64
11+
LastMod time.Time
12+
ContentType string
13+
}
14+
15+
func (f BlobFileInfo) Name() string { return f.BlobName }
16+
func (f BlobFileInfo) Size() int64 { return f.BlobSize }
17+
func (f BlobFileInfo) Mode() fs.FileMode { return 0644 }
18+
func (f BlobFileInfo) ModTime() time.Time { return f.LastMod }
19+
func (f BlobFileInfo) IsDir() bool { return false }
20+
func (f BlobFileInfo) Sys() any { return nil }
21+
func (f BlobFileInfo) FullPath() string { return f.BlobName }

artifact/clients/gcp/fileinfo.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package gcp
2+
3+
import (
4+
"io/fs"
5+
"time"
6+
7+
gcs "cloud.google.com/go/storage"
8+
)
9+
10+
type GCSFileInfo struct {
11+
Object *gcs.ObjectAttrs
12+
}
13+
14+
func (GCSFileInfo) IsDir() bool {
15+
return false
16+
}
17+
18+
func (obj GCSFileInfo) ModTime() time.Time {
19+
return obj.Object.Updated
20+
}
21+
22+
func (obj GCSFileInfo) Mode() fs.FileMode {
23+
return fs.FileMode(0644)
24+
}
25+
26+
func (obj GCSFileInfo) Name() string {
27+
return obj.Object.Name
28+
}
29+
30+
func (obj GCSFileInfo) Size() int64 {
31+
return obj.Object.Size
32+
}
33+
34+
func (obj GCSFileInfo) Sys() interface{} {
35+
return obj.Object
36+
}
37+
38+
func (obj GCSFileInfo) FullPath() string {
39+
return obj.Object.Name
40+
}

artifact/clients/sftp/sftp.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package sftp
2+
3+
import (
4+
"github.com/pkg/sftp"
5+
"golang.org/x/crypto/ssh"
6+
)
7+
8+
// SSHConnect creates an SFTP client connection.
9+
// NOTE: Uses InsecureIgnoreHostKey because artifact storage targets are
10+
// configured by admins via trusted connection objects, not user input.
11+
func SSHConnect(host, user, password string) (*sftp.Client, error) {
12+
config := &ssh.ClientConfig{
13+
User: user,
14+
Auth: []ssh.AuthMethod{
15+
ssh.Password(password),
16+
},
17+
HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec
18+
}
19+
20+
conn, err := ssh.Dial("tcp", host, config)
21+
if err != nil {
22+
return nil, err
23+
}
24+
25+
client, err := sftp.NewClient(conn)
26+
if err != nil {
27+
conn.Close()
28+
return nil, err
29+
}
30+
31+
return client, nil
32+
}

0 commit comments

Comments
 (0)