Skip to content

Commit 7f2ce8b

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 7f2ce8b

37 files changed

Lines changed: 2775 additions & 318 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: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
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 a == nil {
33+
a = &models.Artifact{}
34+
}
35+
if data.Content == nil {
36+
return nil, fmt.Errorf("artifact data content is nil")
37+
}
38+
defer func() { _ = data.Content.Close() }()
39+
40+
checksum := sha256.New()
41+
mimeReader := io.TeeReader(data.Content, checksum)
42+
43+
mw := &mimeWriter{Max: maxBytesForMimeDetection}
44+
fileReader := io.TeeReader(mimeReader, mw)
45+
46+
info, err := s.fs.Write(s.db.Statement.Context, data.Filename, fileReader)
47+
if err != nil {
48+
return nil, fmt.Errorf("writing artifact %s: %w", data.Filename, err)
49+
}
50+
51+
if data.ContentType == "" {
52+
data.ContentType = mw.Detect().String()
53+
}
54+
55+
// For inline store, the artifact already has content set
56+
if inlineArt := InlineArtifact(info); inlineArt != nil {
57+
a.Content = inlineArt.Content
58+
a.CompressionType = inlineArt.CompressionType
59+
}
60+
61+
a.Path = data.Filename
62+
a.Filename = info.Name()
63+
a.Size = info.Size()
64+
a.ContentType = data.ContentType
65+
a.Checksum = hex.EncodeToString(checksum.Sum(nil))
66+
67+
if err := s.db.Create(a).Error; err != nil {
68+
return nil, fmt.Errorf("saving artifact to db: %w", err)
69+
}
70+
71+
return a, nil
72+
}
73+
74+
func (s *blobStore) Read(artifactID uuid.UUID) (*Data, error) {
75+
var a models.Artifact
76+
if err := s.db.Where("id = ?", artifactID).First(&a).Error; err != nil {
77+
return nil, fmt.Errorf("finding artifact %s: %w", artifactID, err)
78+
}
79+
80+
if a.IsInline() {
81+
content, err := a.GetContent()
82+
if err != nil {
83+
return nil, fmt.Errorf("decompressing inline artifact %s: %w", artifactID, err)
84+
}
85+
return &Data{
86+
Content: io.NopCloser(bytes.NewReader(content)),
87+
ContentLength: a.Size,
88+
Checksum: a.Checksum,
89+
ContentType: a.ContentType,
90+
Filename: a.Filename,
91+
}, nil
92+
}
93+
94+
r, err := s.fs.Read(s.db.Statement.Context, a.Path)
95+
if err != nil {
96+
return nil, fmt.Errorf("reading artifact %s from %s: %w", artifactID, a.Path, err)
97+
}
98+
99+
return &Data{
100+
Content: r,
101+
ContentLength: a.Size,
102+
Checksum: a.Checksum,
103+
ContentType: a.ContentType,
104+
Filename: a.Filename,
105+
}, nil
106+
}
107+
108+
func (s *blobStore) Close() error {
109+
return s.fs.Close()
110+
}

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

0 commit comments

Comments
 (0)