Skip to content

Commit a6bd90a

Browse files
authored
feat(driver/s3): Add OSS Archive Support (#9350)
* feat(s3): Add support for S3 object storage classes Introduces a new 'storage_class' configuration option for S3 providers. Users can now specify the desired storage class (e.g., Standard, GLACIER, DEEP_ARCHIVE) for objects uploaded to S3-compatible services like AWS S3 and Tencent COS. The input storage class string is normalized to match AWS SDK constants, supporting various common aliases. If an unknown storage class is provided, it will be used as a raw value with a warning. This enhancement provides greater control over storage costs and data access patterns. * feat(storage): Support for displaying file storage classes Adds storage class information to file metadata and API responses. This change introduces the ability to store file storage classes in file metadata and display them in API responses. This allows users to view a file's storage tier (e.g., S3 Standard, Glacier), enhancing data management capabilities. Implementation details include: - Introducing the StorageClassProvider interface and the ObjWrapStorageClass structure to uniformly handle and communicate object storage class information. - Updated file metadata structures (e.g., ArchiveObj, FileInfo, RespFile) to include a StorageClass field. - Modified relevant API response functions (e.g., GetFileInfo, GetFileList) to populate and return storage classes. - Integrated functionality for retrieving object storage classes from underlying storage systems (e.g., S3) and wrapping them in lists. * feat(driver/s3): Added the "Other" interface and implemented it by the S3 driver. A new `driver.Other` interface has been added and defined in the `other.go` file. The S3 driver has been updated to implement this new interface, extending its functionality. * feat(s3): Add S3 object archive and thaw task management This commit introduces comprehensive support for S3 object archive and thaw operations, managed asynchronously through a new task system. - **S3 Transition Task System**: - Adds a new `S3Transition` task configuration, including workers, max retries, and persistence options. - Initializes `S3TransitionTaskManager` to handle asynchronous S3 archive/thaw requests. - Registers dedicated API routes for monitoring S3 transition tasks. - **Integrate S3 Archive/Thaw with Other API**: - Modifies the `Other` API handler to intercept `archive` and `thaw` methods for S3 storage drivers. - Dispatches these operations as `S3TransitionTask` instances to the task manager for background processing. - Returns a task ID to the client for tracking the status of the dispatched operation. - **Refactor `other` package for improved API consistency**: - Exports previously internal structs such as `archiveRequest`, `thawRequest`, `objectDescriptor`, `archiveResponse`, `thawResponse`, and `restoreStatus` by making their names public. - Makes helper functions like `decodeOtherArgs`, `normalizeStorageClass`, and `normalizeRestoreTier` public. - Introduces new constants for various S3 `Other` API methods.
1 parent 35d3224 commit a6bd90a

File tree

13 files changed

+807
-65
lines changed

13 files changed

+807
-65
lines changed

drivers/s3/driver.go

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"github.com/alist-org/alist/v3/internal/stream"
1616
"github.com/alist-org/alist/v3/pkg/cron"
1717
"github.com/alist-org/alist/v3/server/common"
18+
"github.com/aws/aws-sdk-go/aws"
1819
"github.com/aws/aws-sdk-go/aws/session"
1920
"github.com/aws/aws-sdk-go/service/s3"
2021
"github.com/aws/aws-sdk-go/service/s3/s3manager"
@@ -32,6 +33,33 @@ type S3 struct {
3233
cron *cron.Cron
3334
}
3435

36+
var storageClassLookup = map[string]string{
37+
"standard": s3.ObjectStorageClassStandard,
38+
"reduced_redundancy": s3.ObjectStorageClassReducedRedundancy,
39+
"glacier": s3.ObjectStorageClassGlacier,
40+
"standard_ia": s3.ObjectStorageClassStandardIa,
41+
"onezone_ia": s3.ObjectStorageClassOnezoneIa,
42+
"intelligent_tiering": s3.ObjectStorageClassIntelligentTiering,
43+
"deep_archive": s3.ObjectStorageClassDeepArchive,
44+
"outposts": s3.ObjectStorageClassOutposts,
45+
"glacier_ir": s3.ObjectStorageClassGlacierIr,
46+
"snow": s3.ObjectStorageClassSnow,
47+
"express_onezone": s3.ObjectStorageClassExpressOnezone,
48+
}
49+
50+
func (d *S3) resolveStorageClass() *string {
51+
value := strings.TrimSpace(d.StorageClass)
52+
if value == "" {
53+
return nil
54+
}
55+
normalized := strings.ToLower(strings.ReplaceAll(value, "-", "_"))
56+
if v, ok := storageClassLookup[normalized]; ok {
57+
return aws.String(v)
58+
}
59+
log.Warnf("s3: unknown storage class %q, using raw value", d.StorageClass)
60+
return aws.String(value)
61+
}
62+
3563
func (d *S3) Config() driver.Config {
3664
return d.config
3765
}
@@ -179,8 +207,14 @@ func (d *S3) Put(ctx context.Context, dstDir model.Obj, s model.FileStreamer, up
179207
}),
180208
ContentType: &contentType,
181209
}
210+
if storageClass := d.resolveStorageClass(); storageClass != nil {
211+
input.StorageClass = storageClass
212+
}
182213
_, err := uploader.UploadWithContext(ctx, input)
183214
return err
184215
}
185216

186-
var _ driver.Driver = (*S3)(nil)
217+
var (
218+
_ driver.Driver = (*S3)(nil)
219+
_ driver.Other = (*S3)(nil)
220+
)

drivers/s3/meta.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ type Addition struct {
2121
ListObjectVersion string `json:"list_object_version" type:"select" options:"v1,v2" default:"v1"`
2222
RemoveBucket bool `json:"remove_bucket" help:"Remove bucket name from path when using custom host."`
2323
AddFilenameToDisposition bool `json:"add_filename_to_disposition" help:"Add filename to Content-Disposition header."`
24+
StorageClass string `json:"storage_class" type:"select" options:",standard,standard_ia,onezone_ia,intelligent_tiering,glacier,glacier_ir,deep_archive,archive" help:"Storage class for new objects. AWS and Tencent COS support different subsets (COS uses ARCHIVE/DEEP_ARCHIVE)."`
2425
}
2526

2627
func init() {

drivers/s3/other.go

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
package s3
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"net/url"
8+
"strings"
9+
"time"
10+
11+
"github.com/alist-org/alist/v3/internal/errs"
12+
"github.com/alist-org/alist/v3/internal/model"
13+
"github.com/aws/aws-sdk-go/aws"
14+
"github.com/aws/aws-sdk-go/service/s3"
15+
)
16+
17+
const (
18+
OtherMethodArchive = "archive"
19+
OtherMethodArchiveStatus = "archive_status"
20+
OtherMethodThaw = "thaw"
21+
OtherMethodThawStatus = "thaw_status"
22+
)
23+
24+
type ArchiveRequest struct {
25+
StorageClass string `json:"storage_class"`
26+
}
27+
28+
type ThawRequest struct {
29+
Days int64 `json:"days"`
30+
Tier string `json:"tier"`
31+
}
32+
33+
type ObjectDescriptor struct {
34+
Path string `json:"path"`
35+
Bucket string `json:"bucket"`
36+
Key string `json:"key"`
37+
}
38+
39+
type ArchiveResponse struct {
40+
Action string `json:"action"`
41+
Object ObjectDescriptor `json:"object"`
42+
StorageClass string `json:"storage_class"`
43+
RequestID string `json:"request_id,omitempty"`
44+
VersionID string `json:"version_id,omitempty"`
45+
ETag string `json:"etag,omitempty"`
46+
LastModified string `json:"last_modified,omitempty"`
47+
}
48+
49+
type ThawResponse struct {
50+
Action string `json:"action"`
51+
Object ObjectDescriptor `json:"object"`
52+
RequestID string `json:"request_id,omitempty"`
53+
Status *RestoreStatus `json:"status,omitempty"`
54+
}
55+
56+
type RestoreStatus struct {
57+
Ongoing bool `json:"ongoing"`
58+
Expiry string `json:"expiry,omitempty"`
59+
Raw string `json:"raw"`
60+
}
61+
62+
func (d *S3) Other(ctx context.Context, args model.OtherArgs) (interface{}, error) {
63+
if args.Obj == nil {
64+
return nil, fmt.Errorf("missing object reference")
65+
}
66+
if args.Obj.IsDir() {
67+
return nil, errs.NotSupport
68+
}
69+
70+
switch strings.ToLower(strings.TrimSpace(args.Method)) {
71+
case "archive":
72+
return d.archive(ctx, args)
73+
case "archive_status":
74+
return d.archiveStatus(ctx, args)
75+
case "thaw":
76+
return d.thaw(ctx, args)
77+
case "thaw_status":
78+
return d.thawStatus(ctx, args)
79+
default:
80+
return nil, errs.NotSupport
81+
}
82+
}
83+
84+
func (d *S3) archive(ctx context.Context, args model.OtherArgs) (interface{}, error) {
85+
key := getKey(args.Obj.GetPath(), false)
86+
payload := ArchiveRequest{}
87+
if err := DecodeOtherArgs(args.Data, &payload); err != nil {
88+
return nil, fmt.Errorf("parse archive request: %w", err)
89+
}
90+
if payload.StorageClass == "" {
91+
return nil, fmt.Errorf("storage_class is required")
92+
}
93+
storageClass := NormalizeStorageClass(payload.StorageClass)
94+
input := &s3.CopyObjectInput{
95+
Bucket: &d.Bucket,
96+
Key: &key,
97+
CopySource: aws.String(url.PathEscape(d.Bucket + "/" + key)),
98+
MetadataDirective: aws.String(s3.MetadataDirectiveCopy),
99+
StorageClass: aws.String(storageClass),
100+
}
101+
copyReq, output := d.client.CopyObjectRequest(input)
102+
copyReq.SetContext(ctx)
103+
if err := copyReq.Send(); err != nil {
104+
return nil, err
105+
}
106+
107+
resp := ArchiveResponse{
108+
Action: "archive",
109+
Object: d.describeObject(args.Obj, key),
110+
StorageClass: storageClass,
111+
RequestID: copyReq.RequestID,
112+
}
113+
if output.VersionId != nil {
114+
resp.VersionID = aws.StringValue(output.VersionId)
115+
}
116+
if result := output.CopyObjectResult; result != nil {
117+
resp.ETag = aws.StringValue(result.ETag)
118+
if result.LastModified != nil {
119+
resp.LastModified = result.LastModified.UTC().Format(time.RFC3339)
120+
}
121+
}
122+
if status, err := d.describeObjectStatus(ctx, key); err == nil {
123+
if status.StorageClass != "" {
124+
resp.StorageClass = status.StorageClass
125+
}
126+
}
127+
return resp, nil
128+
}
129+
130+
func (d *S3) archiveStatus(ctx context.Context, args model.OtherArgs) (interface{}, error) {
131+
key := getKey(args.Obj.GetPath(), false)
132+
status, err := d.describeObjectStatus(ctx, key)
133+
if err != nil {
134+
return nil, err
135+
}
136+
return ArchiveResponse{
137+
Action: "archive_status",
138+
Object: d.describeObject(args.Obj, key),
139+
StorageClass: status.StorageClass,
140+
}, nil
141+
}
142+
143+
func (d *S3) thaw(ctx context.Context, args model.OtherArgs) (interface{}, error) {
144+
key := getKey(args.Obj.GetPath(), false)
145+
payload := ThawRequest{Days: 1}
146+
if err := DecodeOtherArgs(args.Data, &payload); err != nil {
147+
return nil, fmt.Errorf("parse thaw request: %w", err)
148+
}
149+
if payload.Days <= 0 {
150+
payload.Days = 1
151+
}
152+
restoreRequest := &s3.RestoreRequest{
153+
Days: aws.Int64(payload.Days),
154+
}
155+
if tier := NormalizeRestoreTier(payload.Tier); tier != "" {
156+
restoreRequest.GlacierJobParameters = &s3.GlacierJobParameters{Tier: aws.String(tier)}
157+
}
158+
input := &s3.RestoreObjectInput{
159+
Bucket: &d.Bucket,
160+
Key: &key,
161+
RestoreRequest: restoreRequest,
162+
}
163+
restoreReq, _ := d.client.RestoreObjectRequest(input)
164+
restoreReq.SetContext(ctx)
165+
if err := restoreReq.Send(); err != nil {
166+
return nil, err
167+
}
168+
status, _ := d.describeObjectStatus(ctx, key)
169+
resp := ThawResponse{
170+
Action: "thaw",
171+
Object: d.describeObject(args.Obj, key),
172+
RequestID: restoreReq.RequestID,
173+
}
174+
if status != nil {
175+
resp.Status = status.Restore
176+
}
177+
return resp, nil
178+
}
179+
180+
func (d *S3) thawStatus(ctx context.Context, args model.OtherArgs) (interface{}, error) {
181+
key := getKey(args.Obj.GetPath(), false)
182+
status, err := d.describeObjectStatus(ctx, key)
183+
if err != nil {
184+
return nil, err
185+
}
186+
return ThawResponse{
187+
Action: "thaw_status",
188+
Object: d.describeObject(args.Obj, key),
189+
Status: status.Restore,
190+
}, nil
191+
}
192+
193+
func (d *S3) describeObject(obj model.Obj, key string) ObjectDescriptor {
194+
return ObjectDescriptor{
195+
Path: obj.GetPath(),
196+
Bucket: d.Bucket,
197+
Key: key,
198+
}
199+
}
200+
201+
type objectStatus struct {
202+
StorageClass string
203+
Restore *RestoreStatus
204+
}
205+
206+
func (d *S3) describeObjectStatus(ctx context.Context, key string) (*objectStatus, error) {
207+
head, err := d.client.HeadObjectWithContext(ctx, &s3.HeadObjectInput{Bucket: &d.Bucket, Key: &key})
208+
if err != nil {
209+
return nil, err
210+
}
211+
status := &objectStatus{
212+
StorageClass: aws.StringValue(head.StorageClass),
213+
Restore: parseRestoreHeader(head.Restore),
214+
}
215+
return status, nil
216+
}
217+
218+
func parseRestoreHeader(header *string) *RestoreStatus {
219+
if header == nil {
220+
return nil
221+
}
222+
value := strings.TrimSpace(*header)
223+
if value == "" {
224+
return nil
225+
}
226+
status := &RestoreStatus{Raw: value}
227+
parts := strings.Split(value, ",")
228+
for _, part := range parts {
229+
part = strings.TrimSpace(part)
230+
if part == "" {
231+
continue
232+
}
233+
if strings.HasPrefix(part, "ongoing-request=") {
234+
status.Ongoing = strings.Contains(part, "\"true\"")
235+
}
236+
if strings.HasPrefix(part, "expiry-date=") {
237+
expiry := strings.Trim(part[len("expiry-date="):], "\"")
238+
if expiry != "" {
239+
if t, err := time.Parse(time.RFC1123, expiry); err == nil {
240+
status.Expiry = t.UTC().Format(time.RFC3339)
241+
} else {
242+
status.Expiry = expiry
243+
}
244+
}
245+
}
246+
}
247+
return status
248+
}
249+
250+
func DecodeOtherArgs(data interface{}, target interface{}) error {
251+
if data == nil {
252+
return nil
253+
}
254+
raw, err := json.Marshal(data)
255+
if err != nil {
256+
return err
257+
}
258+
return json.Unmarshal(raw, target)
259+
}
260+
261+
func NormalizeStorageClass(value string) string {
262+
normalized := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(value, "-", "_")))
263+
if normalized == "" {
264+
return value
265+
}
266+
if v, ok := storageClassLookup[normalized]; ok {
267+
return v
268+
}
269+
return value
270+
}
271+
272+
func NormalizeRestoreTier(value string) string {
273+
normalized := strings.ToLower(strings.TrimSpace(value))
274+
switch normalized {
275+
case "", "default":
276+
return ""
277+
case "bulk":
278+
return s3.TierBulk
279+
case "standard":
280+
return s3.TierStandard
281+
case "expedited":
282+
return s3.TierExpedited
283+
default:
284+
return value
285+
}
286+
}

drivers/s3/util.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -109,13 +109,13 @@ func (d *S3) listV1(prefix string, args model.ListArgs) ([]model.Obj, error) {
109109
if !args.S3ShowPlaceholder && (name == getPlaceholderName(d.Placeholder) || name == d.Placeholder) {
110110
continue
111111
}
112-
file := model.Object{
112+
file := &model.Object{
113113
//Id: *object.Key,
114114
Name: name,
115115
Size: *object.Size,
116116
Modified: *object.LastModified,
117117
}
118-
files = append(files, &file)
118+
files = append(files, model.WrapObjStorageClass(file, aws.StringValue(object.StorageClass)))
119119
}
120120
if listObjectsResult.IsTruncated == nil {
121121
return nil, errors.New("IsTruncated nil")
@@ -164,13 +164,13 @@ func (d *S3) listV2(prefix string, args model.ListArgs) ([]model.Obj, error) {
164164
if !args.S3ShowPlaceholder && (name == getPlaceholderName(d.Placeholder) || name == d.Placeholder) {
165165
continue
166166
}
167-
file := model.Object{
167+
file := &model.Object{
168168
//Id: *object.Key,
169169
Name: name,
170170
Size: *object.Size,
171171
Modified: *object.LastModified,
172172
}
173-
files = append(files, &file)
173+
files = append(files, model.WrapObjStorageClass(file, aws.StringValue(object.StorageClass)))
174174
}
175175
if !aws.BoolValue(listObjectsResult.IsTruncated) {
176176
break
@@ -202,6 +202,9 @@ func (d *S3) copyFile(ctx context.Context, src string, dst string) error {
202202
CopySource: aws.String(url.PathEscape(d.Bucket + "/" + srcKey)),
203203
Key: &dstKey,
204204
}
205+
if storageClass := d.resolveStorageClass(); storageClass != nil {
206+
input.StorageClass = storageClass
207+
}
205208
_, err := d.client.CopyObject(input)
206209
return err
207210
}

0 commit comments

Comments
 (0)