diff --git a/packages/api/internal/api/api.gen.go b/packages/api/internal/api/api.gen.go index 5e3ea3eb8d..ec6337222b 100644 --- a/packages/api/internal/api/api.gen.go +++ b/packages/api/internal/api/api.gen.go @@ -1335,6 +1335,9 @@ type TemplateBuild struct { // TemplateBuildFileUpload defines model for TemplateBuildFileUpload. type TemplateBuildFileUpload struct { + // Headers Request headers that must be sent with the upload request + Headers *map[string]string `json:"headers,omitempty"` + // Present Whether the file is already present in the cache Present bool `json:"present"` @@ -19622,117 +19625,118 @@ var swaggerSpec = []string{ "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", + "tmWSrz5f/Z8AAAD//w==", } // decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, diff --git a/packages/api/internal/handlers/template_layer_files_upload.go b/packages/api/internal/handlers/template_layer_files_upload.go index a583cafd11..906e16fd4d 100644 --- a/packages/api/internal/handlers/template_layer_files_upload.go +++ b/packages/api/internal/handlers/template_layer_files_upload.go @@ -68,8 +68,13 @@ func (a *APIStore) GetTemplatesTemplateIDFilesHash(c *gin.Context, templateID ap return } - c.JSON(http.StatusCreated, &api.TemplateBuildFileUpload{ + upload := api.TemplateBuildFileUpload{ Present: resp.GetPresent(), Url: resp.Url, - }) + } + if headers := resp.GetUploadHeaders(); len(headers) > 0 { + upload.Headers = &headers + } + + c.JSON(http.StatusCreated, &upload) } diff --git a/packages/api/internal/handlers/template_layer_files_upload_test.go b/packages/api/internal/handlers/template_layer_files_upload_test.go new file mode 100644 index 0000000000..7192ecf8b2 --- /dev/null +++ b/packages/api/internal/handlers/template_layer_files_upload_test.go @@ -0,0 +1,40 @@ +package handlers + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/e2b-dev/infra/packages/api/internal/api" +) + +// Pre-headers clients must keep parsing the new response; header-less providers must keep producing the old bytes exactly. +func TestTemplateBuildFileUploadHeadersAreAdditive(t *testing.T) { + t.Parallel() + + t.Run("no headers keeps the response bytes unchanged", func(t *testing.T) { + t.Parallel() + + body, err := json.Marshal(api.TemplateBuildFileUpload{Present: false, Url: new("https://bucket.example/signed")}) + require.NoError(t, err) + assert.JSONEq(t, `{"present":false,"url":"https://bucket.example/signed"}`, string(body)) + }) + + t.Run("a client ignoring headers still parses the response", func(t *testing.T) { + t.Parallel() + + var legacy struct { + Present bool `json:"present"` + Url *string `json:"url"` + } + require.NoError(t, json.Unmarshal( + []byte(`{"present":false,"url":"https://account.example/signed","headers":{"x-ms-blob-type":"BlockBlob"}}`), + &legacy)) + + assert.False(t, legacy.Present) + require.NotNil(t, legacy.Url) + assert.Equal(t, "https://account.example/signed", *legacy.Url) + }) +} diff --git a/packages/orchestrator/pkg/sandbox/template/peerclient/storage.go b/packages/orchestrator/pkg/sandbox/template/peerclient/storage.go index 09699b957c..78b7962e0e 100644 --- a/packages/orchestrator/pkg/sandbox/template/peerclient/storage.go +++ b/packages/orchestrator/pkg/sandbox/template/peerclient/storage.go @@ -94,7 +94,7 @@ func (p *routingProvider) DeleteObjectsWithPrefix(ctx context.Context, prefix st return p.base.DeleteObjectsWithPrefix(ctx, prefix) } -func (p *routingProvider) UploadSignedURL(ctx context.Context, path string, ttl time.Duration) (string, error) { +func (p *routingProvider) UploadSignedURL(ctx context.Context, path string, ttl time.Duration) (storage.UploadURL, error) { return p.base.UploadSignedURL(ctx, path, ttl) } @@ -168,7 +168,7 @@ func (p *peerStorageProvider) DeleteObjectsWithPrefix(ctx context.Context, prefi return p.base.DeleteObjectsWithPrefix(ctx, prefix) } -func (p *peerStorageProvider) UploadSignedURL(ctx context.Context, path string, ttl time.Duration) (string, error) { +func (p *peerStorageProvider) UploadSignedURL(ctx context.Context, path string, ttl time.Duration) (storage.UploadURL, error) { return p.base.UploadSignedURL(ctx, path, ttl) } diff --git a/packages/orchestrator/pkg/template/server/upload_layer_files_template.go b/packages/orchestrator/pkg/template/server/upload_layer_files_template.go index a02d01e0f2..79d6ecf94e 100644 --- a/packages/orchestrator/pkg/template/server/upload_layer_files_template.go +++ b/packages/orchestrator/pkg/template/server/upload_layer_files_template.go @@ -36,7 +36,7 @@ func (s *ServerStore) InitLayerFileUpload(ctx context.Context, in *templatemanag return nil, fmt.Errorf("failed to check if layer files exists: %w", err) } - signedUrl, err := s.buildStorage.UploadSignedURL(ctx, path, signedUrlExpiration) + upload, err := s.buildStorage.UploadSignedURL(ctx, path, signedUrlExpiration) if err != nil { // A cache hit needs no upload URL, so a provider that cannot sign one is fatal // only on a miss. @@ -48,7 +48,8 @@ func (s *ServerStore) InitLayerFileUpload(ctx context.Context, in *templatemanag } return &templatemanager.InitLayerFileUploadResponse{ - Present: exists, - Url: &signedUrl, + Present: exists, + Url: &upload.URL, + UploadHeaders: upload.Headers, }, nil } diff --git a/packages/orchestrator/pkg/template/server/upload_layer_files_template_test.go b/packages/orchestrator/pkg/template/server/upload_layer_files_template_test.go index 8e62f6f39c..d6aa589416 100644 --- a/packages/orchestrator/pkg/template/server/upload_layer_files_template_test.go +++ b/packages/orchestrator/pkg/template/server/upload_layer_files_template_test.go @@ -21,7 +21,7 @@ const ( testFilesHash = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" ) -func newInitLayerFileUploadServer(t *testing.T, exists bool, signedURL string, signErr error) *ServerStore { +func newInitLayerFileUploadServer(t *testing.T, exists bool, upload storage.UploadURL, signErr error) *ServerStore { t.Helper() blob := storage.NewMockBlob(t) @@ -30,7 +30,7 @@ func newInitLayerFileUploadServer(t *testing.T, exists bool, signedURL string, s provider := storage.NewMockStorageProvider(t) path := paths.GetLayerFilesCachePath(testTemplateID, testFilesHash) provider.EXPECT().OpenBlob(mock.Anything, path).Return(blob, nil) - provider.EXPECT().UploadSignedURL(mock.Anything, path, signedUrlExpiration).Return(signedURL, signErr) + provider.EXPECT().UploadSignedURL(mock.Anything, path, signedUrlExpiration).Return(upload, signErr) return &ServerStore{buildStorage: provider} } @@ -50,7 +50,7 @@ func TestInitLayerFileUploadUnsignableProvider(t *testing.T) { t.Run("cache hit reports present without a url", func(t *testing.T) { t.Parallel() - s := newInitLayerFileUploadServer(t, true, "", unsupported) + s := newInitLayerFileUploadServer(t, true, storage.UploadURL{}, unsupported) resp, err := s.InitLayerFileUpload(t.Context(), initLayerFileUploadRequest()) require.NoError(t, err) @@ -61,7 +61,7 @@ func TestInitLayerFileUploadUnsignableProvider(t *testing.T) { t.Run("cache miss still fails", func(t *testing.T) { t.Parallel() - s := newInitLayerFileUploadServer(t, false, "", unsupported) + s := newInitLayerFileUploadServer(t, false, storage.UploadURL{}, unsupported) _, err := s.InitLayerFileUpload(t.Context(), initLayerFileUploadRequest()) require.ErrorIs(t, err, storage.ErrSignedUploadURLUnsupported) @@ -74,7 +74,7 @@ func TestInitLayerFileUploadSigningErrorOnCacheHit(t *testing.T) { t.Parallel() signErr := errors.New("failed to parse GCP service account") - s := newInitLayerFileUploadServer(t, true, "", signErr) + s := newInitLayerFileUploadServer(t, true, storage.UploadURL{}, signErr) _, err := s.InitLayerFileUpload(t.Context(), initLayerFileUploadRequest()) require.ErrorIs(t, err, signErr) @@ -89,12 +89,24 @@ func TestInitLayerFileUploadKeepsURLOnCacheHit(t *testing.T) { t.Run(fmt.Sprintf("exists=%v", exists), func(t *testing.T) { t.Parallel() - s := newInitLayerFileUploadServer(t, exists, "https://bucket.example/signed", nil) + s := newInitLayerFileUploadServer(t, exists, storage.UploadURL{URL: "https://bucket.example/signed"}, nil) resp, err := s.InitLayerFileUpload(t.Context(), initLayerFileUploadRequest()) require.NoError(t, err) assert.Equal(t, exists, resp.GetPresent()) assert.Equal(t, "https://bucket.example/signed", resp.GetUrl()) + assert.Empty(t, resp.GetUploadHeaders(), "providers that need no request headers must not send any") }) } } + +func TestInitLayerFileUploadForwardsUploadHeaders(t *testing.T) { + t.Parallel() + + headers := map[string]string{"x-ms-blob-type": "BlockBlob"} + s := newInitLayerFileUploadServer(t, false, storage.UploadURL{URL: "https://account.example/signed", Headers: headers}, nil) + + resp, err := s.InitLayerFileUpload(t.Context(), initLayerFileUploadRequest()) + require.NoError(t, err) + assert.Equal(t, headers, resp.GetUploadHeaders()) +} diff --git a/packages/orchestrator/template-manager.proto b/packages/orchestrator/template-manager.proto index b05540ec13..9c098df30e 100644 --- a/packages/orchestrator/template-manager.proto +++ b/packages/orchestrator/template-manager.proto @@ -16,6 +16,8 @@ message InitLayerFileUploadRequest { message InitLayerFileUploadResponse{ bool present = 1; optional string url = 2; + // Request headers the upload client must send on the PUT to `url`. + map uploadHeaders = 3; } message TemplateStep { diff --git a/packages/shared/pkg/grpc/template-manager/template-manager.pb.go b/packages/shared/pkg/grpc/template-manager/template-manager.pb.go index 69f816c1fb..551218a5f0 100644 --- a/packages/shared/pkg/grpc/template-manager/template-manager.pb.go +++ b/packages/shared/pkg/grpc/template-manager/template-manager.pb.go @@ -232,9 +232,11 @@ func (x *InitLayerFileUploadRequest) GetCacheScope() string { } type InitLayerFileUploadResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Present bool `protobuf:"varint,1,opt,name=present,proto3" json:"present,omitempty"` - Url *string `protobuf:"bytes,2,opt,name=url,proto3,oneof" json:"url,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + Present bool `protobuf:"varint,1,opt,name=present,proto3" json:"present,omitempty"` + Url *string `protobuf:"bytes,2,opt,name=url,proto3,oneof" json:"url,omitempty"` + // Request headers the upload client must send on the PUT to `url`. + UploadHeaders map[string]string `protobuf:"bytes,3,rep,name=uploadHeaders,proto3" json:"uploadHeaders,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -283,6 +285,13 @@ func (x *InitLayerFileUploadResponse) GetUrl() string { return "" } +func (x *InitLayerFileUploadResponse) GetUploadHeaders() map[string]string { + if x != nil { + return x.UploadHeaders + } + return nil +} + type TemplateStep struct { state protoimpl.MessageState `protogen:"open.v1"` Type string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"` @@ -1370,10 +1379,14 @@ const file_template_manager_proto_rawDesc = "" + "\n" + "cacheScope\x18\x03 \x01(\tH\x00R\n" + "cacheScope\x88\x01\x01B\r\n" + - "\v_cacheScope\"V\n" + + "\v_cacheScope\"\xef\x01\n" + "\x1bInitLayerFileUploadResponse\x12\x18\n" + "\apresent\x18\x01 \x01(\bR\apresent\x12\x15\n" + - "\x03url\x18\x02 \x01(\tH\x00R\x03url\x88\x01\x01B\x06\n" + + "\x03url\x18\x02 \x01(\tH\x00R\x03url\x88\x01\x01\x12U\n" + + "\ruploadHeaders\x18\x03 \x03(\v2/.InitLayerFileUploadResponse.UploadHeadersEntryR\ruploadHeaders\x1a@\n" + + "\x12UploadHeadersEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\x06\n" + "\x04_url\"\x8c\x01\n" + "\fTemplateStep\x12\x12\n" + "\x04type\x18\x01 \x01(\tR\x04type\x12\x12\n" + @@ -1517,7 +1530,7 @@ func file_template_manager_proto_rawDescGZIP() []byte { } var file_template_manager_proto_enumTypes = make([]protoimpl.EnumInfo, 3) -var file_template_manager_proto_msgTypes = make([]protoimpl.MessageInfo, 17) +var file_template_manager_proto_msgTypes = make([]protoimpl.MessageInfo, 18) var file_template_manager_proto_goTypes = []any{ (LogLevel)(0), // 0: LogLevel (LogsDirection)(0), // 1: LogsDirection @@ -1538,44 +1551,46 @@ var file_template_manager_proto_goTypes = []any{ (*TemplateBuildLogEntry)(nil), // 16: TemplateBuildLogEntry (*TemplateBuildStatusReason)(nil), // 17: TemplateBuildStatusReason (*TemplateBuildStatusResponse)(nil), // 18: TemplateBuildStatusResponse - nil, // 19: TemplateBuildLogEntry.FieldsEntry - (*timestamppb.Timestamp)(nil), // 20: google.protobuf.Timestamp - (*orchestrator.SchedulingMetadata)(nil), // 21: SchedulingMetadata - (*emptypb.Empty)(nil), // 22: google.protobuf.Empty + nil, // 19: InitLayerFileUploadResponse.UploadHeadersEntry + nil, // 20: TemplateBuildLogEntry.FieldsEntry + (*timestamppb.Timestamp)(nil), // 21: google.protobuf.Timestamp + (*orchestrator.SchedulingMetadata)(nil), // 22: SchedulingMetadata + (*emptypb.Empty)(nil), // 23: google.protobuf.Empty } var file_template_manager_proto_depIdxs = []int32{ - 7, // 0: FromImageRegistry.aws:type_name -> AWSRegistry - 8, // 1: FromImageRegistry.gcp:type_name -> GCPRegistry - 9, // 2: FromImageRegistry.general:type_name -> GeneralRegistry - 5, // 3: TemplateConfig.steps:type_name -> TemplateStep - 6, // 4: TemplateConfig.fromTemplate:type_name -> FromTemplateConfig - 10, // 5: TemplateConfig.fromImageRegistry:type_name -> FromImageRegistry - 11, // 6: TemplateCreateRequest.template:type_name -> TemplateConfig - 0, // 7: TemplateStatusRequest.level:type_name -> LogLevel - 20, // 8: TemplateStatusRequest.start:type_name -> google.protobuf.Timestamp - 20, // 9: TemplateStatusRequest.end:type_name -> google.protobuf.Timestamp - 1, // 10: TemplateStatusRequest.direction:type_name -> LogsDirection - 21, // 11: TemplateBuildMetadata.schedulingMetadata:type_name -> SchedulingMetadata - 20, // 12: TemplateBuildLogEntry.timestamp:type_name -> google.protobuf.Timestamp - 0, // 13: TemplateBuildLogEntry.level:type_name -> LogLevel - 19, // 14: TemplateBuildLogEntry.fields:type_name -> TemplateBuildLogEntry.FieldsEntry - 2, // 15: TemplateBuildStatusResponse.status:type_name -> TemplateBuildState - 15, // 16: TemplateBuildStatusResponse.metadata:type_name -> TemplateBuildMetadata - 16, // 17: TemplateBuildStatusResponse.logEntries:type_name -> TemplateBuildLogEntry - 17, // 18: TemplateBuildStatusResponse.reason:type_name -> TemplateBuildStatusReason - 12, // 19: TemplateService.TemplateCreate:input_type -> TemplateCreateRequest - 13, // 20: TemplateService.TemplateBuildStatus:input_type -> TemplateStatusRequest - 14, // 21: TemplateService.TemplateBuildDelete:input_type -> TemplateBuildDeleteRequest - 3, // 22: TemplateService.InitLayerFileUpload:input_type -> InitLayerFileUploadRequest - 22, // 23: TemplateService.TemplateCreate:output_type -> google.protobuf.Empty - 18, // 24: TemplateService.TemplateBuildStatus:output_type -> TemplateBuildStatusResponse - 22, // 25: TemplateService.TemplateBuildDelete:output_type -> google.protobuf.Empty - 4, // 26: TemplateService.InitLayerFileUpload:output_type -> InitLayerFileUploadResponse - 23, // [23:27] is the sub-list for method output_type - 19, // [19:23] is the sub-list for method input_type - 19, // [19:19] is the sub-list for extension type_name - 19, // [19:19] is the sub-list for extension extendee - 0, // [0:19] is the sub-list for field type_name + 19, // 0: InitLayerFileUploadResponse.uploadHeaders:type_name -> InitLayerFileUploadResponse.UploadHeadersEntry + 7, // 1: FromImageRegistry.aws:type_name -> AWSRegistry + 8, // 2: FromImageRegistry.gcp:type_name -> GCPRegistry + 9, // 3: FromImageRegistry.general:type_name -> GeneralRegistry + 5, // 4: TemplateConfig.steps:type_name -> TemplateStep + 6, // 5: TemplateConfig.fromTemplate:type_name -> FromTemplateConfig + 10, // 6: TemplateConfig.fromImageRegistry:type_name -> FromImageRegistry + 11, // 7: TemplateCreateRequest.template:type_name -> TemplateConfig + 0, // 8: TemplateStatusRequest.level:type_name -> LogLevel + 21, // 9: TemplateStatusRequest.start:type_name -> google.protobuf.Timestamp + 21, // 10: TemplateStatusRequest.end:type_name -> google.protobuf.Timestamp + 1, // 11: TemplateStatusRequest.direction:type_name -> LogsDirection + 22, // 12: TemplateBuildMetadata.schedulingMetadata:type_name -> SchedulingMetadata + 21, // 13: TemplateBuildLogEntry.timestamp:type_name -> google.protobuf.Timestamp + 0, // 14: TemplateBuildLogEntry.level:type_name -> LogLevel + 20, // 15: TemplateBuildLogEntry.fields:type_name -> TemplateBuildLogEntry.FieldsEntry + 2, // 16: TemplateBuildStatusResponse.status:type_name -> TemplateBuildState + 15, // 17: TemplateBuildStatusResponse.metadata:type_name -> TemplateBuildMetadata + 16, // 18: TemplateBuildStatusResponse.logEntries:type_name -> TemplateBuildLogEntry + 17, // 19: TemplateBuildStatusResponse.reason:type_name -> TemplateBuildStatusReason + 12, // 20: TemplateService.TemplateCreate:input_type -> TemplateCreateRequest + 13, // 21: TemplateService.TemplateBuildStatus:input_type -> TemplateStatusRequest + 14, // 22: TemplateService.TemplateBuildDelete:input_type -> TemplateBuildDeleteRequest + 3, // 23: TemplateService.InitLayerFileUpload:input_type -> InitLayerFileUploadRequest + 23, // 24: TemplateService.TemplateCreate:output_type -> google.protobuf.Empty + 18, // 25: TemplateService.TemplateBuildStatus:output_type -> TemplateBuildStatusResponse + 23, // 26: TemplateService.TemplateBuildDelete:output_type -> google.protobuf.Empty + 4, // 27: TemplateService.InitLayerFileUpload:output_type -> InitLayerFileUploadResponse + 24, // [24:28] is the sub-list for method output_type + 20, // [20:24] is the sub-list for method input_type + 20, // [20:20] is the sub-list for extension type_name + 20, // [20:20] is the sub-list for extension extendee + 0, // [0:20] is the sub-list for field type_name } func init() { file_template_manager_proto_init() } @@ -1605,7 +1620,7 @@ func file_template_manager_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_template_manager_proto_rawDesc), len(file_template_manager_proto_rawDesc)), NumEnums: 3, - NumMessages: 17, + NumMessages: 18, NumExtensions: 0, NumServices: 1, }, diff --git a/packages/shared/pkg/storage/mock_storageprovider.go b/packages/shared/pkg/storage/mock_storageprovider.go index d40a0f402e..9d887b570d 100644 --- a/packages/shared/pkg/storage/mock_storageprovider.go +++ b/packages/shared/pkg/storage/mock_storageprovider.go @@ -276,22 +276,22 @@ func (_c *MockStorageProvider_OpenSeekable_Call) RunAndReturn(run func(ctx conte } // UploadSignedURL provides a mock function for the type MockStorageProvider -func (_mock *MockStorageProvider) UploadSignedURL(ctx context.Context, path string, ttl time.Duration) (string, error) { +func (_mock *MockStorageProvider) UploadSignedURL(ctx context.Context, path string, ttl time.Duration) (UploadURL, error) { ret := _mock.Called(ctx, path, ttl) if len(ret) == 0 { panic("no return value specified for UploadSignedURL") } - var r0 string + var r0 UploadURL var r1 error - if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Duration) (string, error)); ok { + if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Duration) (UploadURL, error)); ok { return returnFunc(ctx, path, ttl) } - if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Duration) string); ok { + if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Duration) UploadURL); ok { r0 = returnFunc(ctx, path, ttl) } else { - r0 = ret.Get(0).(string) + r0 = ret.Get(0).(UploadURL) } if returnFunc, ok := ret.Get(1).(func(context.Context, string, time.Duration) error); ok { r1 = returnFunc(ctx, path, ttl) @@ -337,12 +337,12 @@ func (_c *MockStorageProvider_UploadSignedURL_Call) Run(run func(ctx context.Con return _c } -func (_c *MockStorageProvider_UploadSignedURL_Call) Return(s string, err error) *MockStorageProvider_UploadSignedURL_Call { - _c.Call.Return(s, err) +func (_c *MockStorageProvider_UploadSignedURL_Call) Return(uploadURL UploadURL, err error) *MockStorageProvider_UploadSignedURL_Call { + _c.Call.Return(uploadURL, err) return _c } -func (_c *MockStorageProvider_UploadSignedURL_Call) RunAndReturn(run func(ctx context.Context, path string, ttl time.Duration) (string, error)) *MockStorageProvider_UploadSignedURL_Call { +func (_c *MockStorageProvider_UploadSignedURL_Call) RunAndReturn(run func(ctx context.Context, path string, ttl time.Duration) (UploadURL, error)) *MockStorageProvider_UploadSignedURL_Call { _c.Call.Return(run) return _c } diff --git a/packages/shared/pkg/storage/storage.go b/packages/shared/pkg/storage/storage.go index 75d57003b5..f517dfdede 100644 --- a/packages/shared/pkg/storage/storage.go +++ b/packages/shared/pkg/storage/storage.go @@ -65,9 +65,15 @@ func (t SeekableObjectType) String() string { } } +// UploadURL is a signed upload target for an external client; Headers go verbatim on the PUT (a SAS can pin response headers but never require a request one). +type UploadURL struct { + URL string + Headers map[string]string +} + type StorageProvider interface { DeleteObjectsWithPrefix(ctx context.Context, prefix string) error - UploadSignedURL(ctx context.Context, path string, ttl time.Duration) (string, error) + UploadSignedURL(ctx context.Context, path string, ttl time.Duration) (UploadURL, error) OpenBlob(ctx context.Context, path string) (Blob, error) OpenSeekable(ctx context.Context, path string) (Seekable, error) GetDetails() string diff --git a/packages/shared/pkg/storage/storage_aws.go b/packages/shared/pkg/storage/storage_aws.go index e42108e492..d5191b4fdf 100644 --- a/packages/shared/pkg/storage/storage_aws.go +++ b/packages/shared/pkg/storage/storage_aws.go @@ -163,7 +163,7 @@ func (s *awsStorage) GetDetails() string { return fmt.Sprintf("[AWS Storage, bucket set to %s]", s.bucketName) } -func (s *awsStorage) UploadSignedURL(ctx context.Context, path string, ttl time.Duration) (string, error) { +func (s *awsStorage) UploadSignedURL(ctx context.Context, path string, ttl time.Duration) (UploadURL, error) { input := &s3.PutObjectInput{ Bucket: aws.String(s.bucketName), Key: aws.String(path), @@ -172,10 +172,10 @@ func (s *awsStorage) UploadSignedURL(ctx context.Context, path string, ttl time. opts.Expires = ttl }) if err != nil { - return "", fmt.Errorf("failed to presign PUT URL: %w", err) + return UploadURL{}, fmt.Errorf("failed to presign PUT URL: %w", err) } - return resp.URL, nil + return UploadURL{URL: resp.URL}, nil } func (s *awsStorage) OpenSeekable(_ context.Context, path string) (Seekable, error) { diff --git a/packages/shared/pkg/storage/storage_aws_test.go b/packages/shared/pkg/storage/storage_aws_test.go index 295206bd0a..e98844ae35 100644 --- a/packages/shared/pkg/storage/storage_aws_test.go +++ b/packages/shared/pkg/storage/storage_aws_test.go @@ -690,3 +690,39 @@ func TestAWSDeleteObjectsWithPrefixRejectsEmptyPrefix(t *testing.T) { require.Error(t, err) require.Contains(t, err.Error(), "empty prefix") } + +// An Azure-shaped header map leaking into the S3/GCS response would break any upload that echoed it into the presigned signature. +func TestS3UploadSignedURLNeedsNoRequestHeaders(t *testing.T) { + t.Parallel() + + backend := testBackend(t) + client := backend.newClient(t, nil) + provider := &awsStorage{ + client: client, + presignClient: s3.NewPresignClient(client), + bucketName: backend.bucket, + } + + key := testKey("presigned-upload.bin") + obj := backend.object(t, client, key) + + upload, err := provider.UploadSignedURL(t.Context(), key, time.Hour) + require.NoError(t, err) + require.NotEmpty(t, upload.URL) + assert.Nil(t, upload.Headers) + + body := []byte("presigned-upload-body") + req, err := http.NewRequestWithContext(t.Context(), http.MethodPut, upload.URL, bytes.NewReader(body)) + require.NoError(t, err) + req.ContentLength = int64(len(body)) + + res, err := http.DefaultClient.Do(req) + require.NoError(t, err) + require.NoError(t, res.Body.Close()) + require.Equal(t, http.StatusOK, res.StatusCode) + + var read bytes.Buffer + _, err = obj.WriteTo(t.Context(), &read) + require.NoError(t, err) + assert.Equal(t, body, read.Bytes()) +} diff --git a/packages/shared/pkg/storage/storage_azure.go b/packages/shared/pkg/storage/storage_azure.go index efef082668..5fc5cef29a 100644 --- a/packages/shared/pkg/storage/storage_azure.go +++ b/packages/shared/pkg/storage/storage_azure.go @@ -22,6 +22,8 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/bloberror" "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/blockblob" "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/container" + "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/sas" + "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/service" "go.uber.org/zap" "github.com/e2b-dev/infra/packages/shared/pkg/consts" @@ -35,6 +37,9 @@ const ( azureReadTimeout = 15 * time.Second azureUploadBlockSize = 10 * 1024 * 1024 // 10 MB + + // Backdated SAS start time, so clock skew cannot reject a just-issued token. + azureSASClockSkew = 5 * time.Minute ) type azureStorage struct { @@ -42,6 +47,10 @@ type azureStorage struct { container *container.Client containerName string limiter *limit.Limiter + + // A shared key signs a SAS locally, a token credential fetches a user delegation key; SAS-only auth can do neither. + sharedKey *azblob.SharedKeyCredential + canDelegate bool } var _ StorageProvider = (*azureStorage)(nil) @@ -66,19 +75,26 @@ var ( // key (AZURE_STORAGE_ACCOUNT_KEY) when present, falling back to // azidentity.NewDefaultAzureCredential. func newAzureStorage(ctx context.Context, containerName string, limiter *limit.Limiter) (*azureStorage, error) { - var client *azblob.Client + var ( + client *azblob.Client + sharedKey *azblob.SharedKeyCredential + canDelegate bool + ) if connectionString := consts.AzureStorageConnectionString(); connectionString != "" { - // Any connection string the SDK accepts is accepted here, including a - // least-privilege SAS one (BlobEndpoint=...;SharedAccessSignature=...): - // every reachable operation works without a shared key. Signed upload - // URLs would have needed one, but they are unsupported on Azure anyway - // (see UploadSignedURL). var err error client, err = azblob.NewClientFromConnectionString(connectionString, nil) if err != nil { return nil, fmt.Errorf("failed to create Azure client from connection string: %w", err) } + + // The SDK keeps the credential it parsed private, so signing needs its own copy. + if accountName, accountKey, ok := parseConnectionStringSharedKey(connectionString); ok { + sharedKey, err = azblob.NewSharedKeyCredential(accountName, accountKey) + if err != nil { + return nil, fmt.Errorf("failed to create Azure shared key credential: %w", err) + } + } } else { accountName := consts.AzureStorageAccountName() if accountName == "" { @@ -88,7 +104,8 @@ func newAzureStorage(ctx context.Context, containerName string, limiter *limit.L serviceURL := fmt.Sprintf("https://%s.blob.core.windows.net/", accountName) if accountKey := consts.AzureStorageAccountKey(); accountKey != "" { - sharedKey, err := azblob.NewSharedKeyCredential(accountName, accountKey) + var err error + sharedKey, err = azblob.NewSharedKeyCredential(accountName, accountKey) if err != nil { return nil, fmt.Errorf("failed to create Azure shared key credential: %w", err) } @@ -107,25 +124,45 @@ func newAzureStorage(ctx context.Context, containerName string, limiter *limit.L if err != nil { return nil, fmt.Errorf("failed to create Azure client: %w", err) } + + canDelegate = true } } - // A stated property of the provider, surfaced at construction rather than - // discovered in staging: template layer-file uploads go through - // UploadSignedURL, which is unsupported on Azure (see its doc comment), so - // template builds fail at the get-signed-URL step until the proxied-upload - // follow-up lands. - logger.L().Warn(ctx, "Azure storage does not support signed upload URLs: template layer-file uploads are unavailable until uploads are proxied through the orchestrator", - zap.String("container", containerName)) + if sharedKey == nil && !canDelegate { + logger.L().Warn(ctx, "Azure storage cannot sign upload URLs with a SAS-only connection string: template layer-file uploads will fail on a cache miss", + zap.String("container", containerName)) + } return &azureStorage{ client: client, container: client.ServiceClient().NewContainerClient(containerName), containerName: containerName, limiter: limiter, + sharedKey: sharedKey, + canDelegate: canDelegate, }, nil } +// ok is false for a connection string that authenticates with a SAS instead of a key. +func parseConnectionStringSharedKey(connectionString string) (accountName, accountKey string, ok bool) { + for part := range strings.SplitSeq(connectionString, ";") { + key, value, found := strings.Cut(part, "=") + if !found { + continue + } + + switch strings.TrimSpace(key) { + case "AccountName": + accountName = value + case "AccountKey": + accountKey = value + } + } + + return accountName, accountKey, accountName != "" && accountKey != "" +} + func (s *azureStorage) DeleteObjectsWithPrefix(ctx context.Context, prefix string) error { // An empty prefix would match, and delete, every blob in the container. if prefix == "" { @@ -174,23 +211,60 @@ func (s *azureStorage) GetDetails() string { return fmt.Sprintf("[Azure Storage, container set to %s]", s.containerName) } -// UploadSignedURL is deliberately unsupported on Azure, and fails loudly rather than -// handing back a URL the caller cannot use. -// -// Azure's Put Blob requires the request header "x-ms-blob-type: BlockBlob". S3 and GCS -// presigned PUTs have no equivalent, and a SAS cannot carry a required REQUEST header -- -// SAS only pins response headers (rsct/rscd). The URL therefore reaches an external -// client (the public API returns only {present, url}, and the PUT is performed by the SDK -// or CLI, not by this repo) which does not send the header, so the upload fails with -// MissingRequiredHeader while the same code path works on the other two providers. -// -// Returning an error keeps that failure at the API boundary, where it names its cause, -// instead of surfacing as an opaque Azure 4xx in someone's build. Azure uploads will -// instead be proxied through the orchestrator in a follow-up to this stack (the same -// shape as the filesystem provider's local upload path, and no proto or public-API -// change); SAS PUT URLs are deliberately never issued. -func (s *azureStorage) UploadSignedURL(_ context.Context, path string, _ time.Duration) (string, error) { - return "", fmt.Errorf("%w: Azure (%q) Put Blob requires the x-ms-blob-type request header, which a SAS cannot carry and external upload clients do not send", ErrSignedUploadURLUnsupported, path) +// Put Blob also requires the "x-ms-blob-type" request header, which a SAS cannot carry, so it travels back in Headers for the external client to send. +func (s *azureStorage) UploadSignedURL(ctx context.Context, path string, ttl time.Duration) (UploadURL, error) { + blobURL := s.container.NewBlobClient(path).URL() + + now := time.Now().UTC() + values := sas.BlobSignatureValues{ + Protocol: sasProtocolFor(blobURL), + StartTime: now.Add(-azureSASClockSkew), + ExpiryTime: now.Add(ttl), + Permissions: (&sas.BlobPermissions{Create: true, Write: true}).String(), + ContainerName: s.containerName, + BlobName: path, + } + + var ( + params sas.QueryParameters + err error + ) + + switch { + case s.sharedKey != nil: + params, err = values.SignWithSharedKey(s.sharedKey) + case s.canDelegate: + var credential *service.UserDelegationCredential + credential, err = s.client.ServiceClient().GetUserDelegationCredential(ctx, service.KeyInfo{ + Start: new(values.StartTime.Format(sas.TimeFormat)), + Expiry: new(values.ExpiryTime.Format(sas.TimeFormat)), + }, nil) + if err != nil { + return UploadURL{}, fmt.Errorf("failed to get Azure user delegation key for %q: %w", path, err) + } + + params, err = values.SignWithUserDelegation(credential) + default: + return UploadURL{}, fmt.Errorf("%w: Azure (%q) needs AZURE_STORAGE_ACCOUNT_KEY, an AccountKey in AZURE_STORAGE_CONNECTION_STRING, or a credential allowed to fetch a user delegation key", ErrSignedUploadURLUnsupported, path) + } + + if err != nil { + return UploadURL{}, fmt.Errorf("failed to sign Azure upload SAS for %q: %w", path, err) + } + + return UploadURL{ + URL: blobURL + "?" + params.Encode(), + Headers: map[string]string{"x-ms-blob-type": string(blob.BlobTypeBlockBlob)}, + }, nil +} + +// HTTPS-only against a real account; the storage emulator serves plain HTTP. +func sasProtocolFor(blobURL string) sas.Protocol { + if strings.HasPrefix(blobURL, "https://") { + return sas.ProtocolHTTPS + } + + return sas.ProtocolHTTPSandHTTP } func (s *azureStorage) OpenSeekable(_ context.Context, path string) (Seekable, error) { diff --git a/packages/shared/pkg/storage/storage_azure_integration_test.go b/packages/shared/pkg/storage/storage_azure_integration_test.go index 6927562aaf..d755d59f76 100644 --- a/packages/shared/pkg/storage/storage_azure_integration_test.go +++ b/packages/shared/pkg/storage/storage_azure_integration_test.go @@ -11,6 +11,7 @@ import ( "fmt" "io" "math/rand" + "net/http" "os" "path/filepath" "testing" @@ -335,12 +336,29 @@ func TestAzureIntegration(t *testing.T) { require.ErrorContains(t, provider.DeleteObjectsWithPrefix(ctx, ""), "empty prefix") }) - t.Run("UploadSignedURLIsRefused", func(t *testing.T) { - // The interface method must fail loudly rather than return a URL an external - // client cannot use; see its doc comment. - _, err := provider.UploadSignedURL(ctx, "signed/refused.bin", time.Hour) - require.ErrorIs(t, err, ErrSignedUploadURLUnsupported) - assert.Contains(t, err.Error(), "x-ms-blob-type") + t.Run("UploadSignedURLRequiresTheReturnedHeaders", func(t *testing.T) { + path := "signed/upload.bin" + body := []byte("signed-upload-body") + + upload, err := provider.UploadSignedURL(ctx, path, time.Hour) + require.NoError(t, err) + require.Equal(t, map[string]string{"x-ms-blob-type": "BlockBlob"}, upload.Headers) + + // Azurite rejects the header-less PUT with a bare 400 where real Azure names MissingRequiredHeader. + status, _, err := putSignedURL(ctx, t, upload.URL, nil, body) + require.NoError(t, err) + assert.Equal(t, http.StatusBadRequest, status) + + status, responseBody, err := putSignedURL(ctx, t, upload.URL, upload.Headers, body) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, status, responseBody) + + object, err := provider.OpenBlob(ctx, path) + require.NoError(t, err) + var read bytes.Buffer + _, err = object.WriteTo(ctx, &read) + require.NoError(t, err) + assert.Equal(t, body, read.Bytes()) }) t.Run("DeleteNonexistentIsIdempotent", func(t *testing.T) { @@ -400,3 +418,31 @@ func TestAzureIntegrationMissingContainerIsNotObjectNotExist(t *testing.T) { assert.NotErrorIs(t, err, ErrObjectNotExist) }) } + +// putSignedURL PUTs body to a signed upload URL with exactly the given headers. +func putSignedURL(ctx context.Context, t *testing.T, url string, headers map[string]string, body []byte) (int, string, error) { + t.Helper() + + req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, bytes.NewReader(body)) + if err != nil { + return 0, "", err + } + + req.ContentLength = int64(len(body)) + for name, value := range headers { + req.Header.Set(name, value) + } + + res, err := http.DefaultClient.Do(req) + if err != nil { + return 0, "", err + } + defer res.Body.Close() + + responseBody, err := io.ReadAll(res.Body) + if err != nil { + return 0, "", err + } + + return res.StatusCode, string(responseBody), nil +} diff --git a/packages/shared/pkg/storage/storage_azure_test.go b/packages/shared/pkg/storage/storage_azure_test.go index 8277aae99f..ba0b71476b 100644 --- a/packages/shared/pkg/storage/storage_azure_test.go +++ b/packages/shared/pkg/storage/storage_azure_test.go @@ -1,10 +1,19 @@ package storage import ( + "context" "encoding/base64" + "io" "math" + "net/http" + "net/url" + "strings" "testing" + "time" + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -155,10 +164,7 @@ func TestAzurePartUploaderBlockIDsAreUploadScoped(t *testing.T) { } func TestNewAzureStorageAcceptsKeylessConnectionString(t *testing.T) { - // A least-privilege SAS connection string (no AccountKey) is a working config for - // every reachable operation — nothing in the provider needs a shared key. (Signed - // upload URLs would have, but UploadSignedURL is hard-disabled on Azure; the AAD - // path is likewise accepted without one.) + // A SAS-only connection string (no AccountKey) works for everything except minting an upload SAS. t.Setenv("AZURE_STORAGE_CONNECTION_STRING", "BlobEndpoint=https://myaccount.blob.core.windows.net;SharedAccessSignature=sv=2022-11-02&ss=b&sig=fake") @@ -220,3 +226,170 @@ func TestClampAzureUploadConcurrency(t *testing.T) { }) } } + +func TestNewAzureStorageSigningCredential(t *testing.T) { + t.Run("connection string with an account key can sign locally", func(t *testing.T) { + t.Setenv("AZURE_STORAGE_CONNECTION_STRING", + "DefaultEndpointsProtocol=https;AccountName=myaccount;AccountKey=ZmFrZS1hY2NvdW50LWtleS1mb3ItdGVzdHMtb25seS1ub3QtYS1jcmVkZW50aWFs;EndpointSuffix=core.windows.net") + + s, err := newAzureStorage(t.Context(), "fc-templates", nil) + require.NoError(t, err) + assert.NotNil(t, s.sharedKey) + assert.False(t, s.canDelegate) + + upload, err := s.UploadSignedURL(t.Context(), "templates/abc/layer.tar", 30*time.Minute) + require.NoError(t, err) + assert.Equal(t, map[string]string{"x-ms-blob-type": "BlockBlob"}, upload.Headers) + assert.Contains(t, upload.URL, "sp=cw", "the SAS must grant exactly create+write") + }) + + t.Run("SAS-only connection string cannot sign", func(t *testing.T) { + t.Setenv("AZURE_STORAGE_CONNECTION_STRING", + "BlobEndpoint=https://myaccount.blob.core.windows.net;SharedAccessSignature=sv=2022-11-02&ss=b&sig=fake") + + s, err := newAzureStorage(t.Context(), "fc-templates", nil) + require.NoError(t, err) + assert.Nil(t, s.sharedKey) + assert.False(t, s.canDelegate) + + _, err = s.UploadSignedURL(t.Context(), "templates/abc/layer.tar", 30*time.Minute) + require.ErrorIs(t, err, ErrSignedUploadURLUnsupported) + }) + + t.Run("account name and key env pair signs locally", func(t *testing.T) { + t.Setenv("AZURE_STORAGE_ACCOUNT_NAME", "myaccount") + t.Setenv("AZURE_STORAGE_ACCOUNT_KEY", "ZmFrZS1hY2NvdW50LWtleS1mb3ItdGVzdHMtb25seS1ub3QtYS1jcmVkZW50aWFs") + + s, err := newAzureStorage(t.Context(), "fc-templates", nil) + require.NoError(t, err) + assert.NotNil(t, s.sharedKey) + + upload, err := s.UploadSignedURL(t.Context(), "templates/abc/layer.tar", 30*time.Minute) + require.NoError(t, err) + assert.True(t, strings.HasPrefix(upload.URL, "https://myaccount.blob.core.windows.net/"), upload.URL) + assert.Contains(t, upload.URL, "spr=https", "a real account must only accept HTTPS") + }) +} + +func TestParseConnectionStringSharedKey(t *testing.T) { + t.Parallel() + + // Base64 keys end in '=' padding, so the value must be split on the first '=' only. + const key = "ZmFrZS1hY2NvdW50LWtleS1mb3ItdGVzdHMtb25seS1ub3QtYS1jcmVk==" + + for _, tt := range []struct { + name string + connectionString string + wantName string + wantKey string + wantOK bool + }{ + { + name: "account key with base64 padding", + connectionString: "DefaultEndpointsProtocol=https;AccountName=myaccount;AccountKey=" + key + ";EndpointSuffix=core.windows.net", + wantName: "myaccount", + wantKey: key, + wantOK: true, + }, + { + name: "sas only", + connectionString: "BlobEndpoint=https://myaccount.blob.core.windows.net;SharedAccessSignature=sv=2022-11-02&ss=b&sig=fake", + wantOK: false, + }, + { + name: "account name without a key", + connectionString: "AccountName=myaccount;SharedAccessSignature=sig=fake", + wantName: "myaccount", + wantOK: false, + }, + { + name: "empty", + connectionString: "", + wantOK: false, + }, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + accountName, accountKey, ok := parseConnectionStringSharedKey(tt.connectionString) + assert.Equal(t, tt.wantOK, ok) + assert.Equal(t, tt.wantName, accountName) + assert.Equal(t, tt.wantKey, accountKey) + }) + } +} + +// staticTokenCredential stands in for a managed identity — the pipeline demands a bearer token before it talks to the fake transport. +type staticTokenCredential struct{} + +func (staticTokenCredential) GetToken(context.Context, policy.TokenRequestOptions) (azcore.AccessToken, error) { + return azcore.AccessToken{Token: "fake-token", ExpiresOn: time.Now().Add(time.Hour)}, nil +} + +// userDelegationKeyTransport answers Get User Delegation Key with a canned key and records the request, so its shape asserts without an AAD-backed account. +type userDelegationKeyTransport struct { + query url.Values + calls int +} + +func (t *userDelegationKeyTransport) Do(req *http.Request) (*http.Response, error) { + t.calls++ + t.query = req.URL.Query() + + // Azure's own documented example key value; it only ever signs in this test. + body := ` + + 11111111-1111-1111-1111-111111111111 + 22222222-2222-2222-2222-222222222222 + 2026-09-11T09:00:00Z + 2026-09-11T09:30:00Z + b + 2026-06-06 + ZmFrZS11c2VyLWRlbGVnYXRpb24ta2V5LWZvci10ZXN0cy1vbmx5 +` + + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/xml"}}, + Body: io.NopCloser(strings.NewReader(body)), + Request: req, + }, nil +} + +// Covers the managed-identity signing path; only the RBAC grant behind the key fetch cannot be exercised here. +func TestAzureUploadSignedURLSignsWithUserDelegation(t *testing.T) { + t.Parallel() + + transport := &userDelegationKeyTransport{} + client, err := azblob.NewClient("https://myaccount.blob.core.windows.net/", staticTokenCredential{}, + &azblob.ClientOptions{ClientOptions: azcore.ClientOptions{Transport: transport}}) + require.NoError(t, err) + + provider := &azureStorage{ + client: client, + container: client.ServiceClient().NewContainerClient("fc-templates"), + containerName: "fc-templates", + canDelegate: true, + } + + upload, err := provider.UploadSignedURL(t.Context(), "templates/abc/layer.tar", 30*time.Minute) + require.NoError(t, err) + + assert.Equal(t, 1, transport.calls) + assert.Equal(t, "userdelegationkey", transport.query.Get("comp")) + assert.Equal(t, "service", transport.query.Get("restype")) + + assert.Equal(t, map[string]string{"x-ms-blob-type": "BlockBlob"}, upload.Headers) + + signed, err := url.Parse(upload.URL) + require.NoError(t, err) + assert.Equal(t, "/fc-templates/templates/abc/layer.tar", signed.Path) + + params := signed.Query() + assert.Equal(t, "11111111-1111-1111-1111-111111111111", params.Get("skoid"), "a user-delegation SAS is identified by skoid/sktid") + assert.Equal(t, "22222222-2222-2222-2222-222222222222", params.Get("sktid")) + assert.Equal(t, "cw", params.Get("sp")) + assert.Equal(t, "b", params.Get("sr")) + assert.Equal(t, "https", params.Get("spr")) + assert.NotEmpty(t, params.Get("sig")) +} diff --git a/packages/shared/pkg/storage/storage_cache.go b/packages/shared/pkg/storage/storage_cache.go index 61982f3ee7..71a3832c5d 100644 --- a/packages/shared/pkg/storage/storage_cache.go +++ b/packages/shared/pkg/storage/storage_cache.go @@ -81,7 +81,7 @@ func (c cache) DeleteObjectsWithPrefix(ctx context.Context, prefix string) error return c.inner.DeleteObjectsWithPrefix(ctx, prefix) } -func (c cache) UploadSignedURL(ctx context.Context, path string, ttl time.Duration) (string, error) { +func (c cache) UploadSignedURL(ctx context.Context, path string, ttl time.Duration) (UploadURL, error) { return c.inner.UploadSignedURL(ctx, path, ttl) } diff --git a/packages/shared/pkg/storage/storage_fs.go b/packages/shared/pkg/storage/storage_fs.go index ca71381622..9ed0303e23 100644 --- a/packages/shared/pkg/storage/storage_fs.go +++ b/packages/shared/pkg/storage/storage_fs.go @@ -58,9 +58,9 @@ func (s *fsStorage) GetDetails() string { return fmt.Sprintf("[Local file storage, base path set to %s]", s.basePath) } -func (s *fsStorage) UploadSignedURL(_ context.Context, path string, ttl time.Duration) (string, error) { +func (s *fsStorage) UploadSignedURL(_ context.Context, path string, ttl time.Duration) (UploadURL, error) { if s.uploadURL == "" || s.hmacKey == nil { - return "", errors.New("file system storage does not support signed URLs (no local upload endpoint configured)") + return UploadURL{}, errors.New("file system storage does not support signed URLs (no local upload endpoint configured)") } expiresSec := time.Now().Add(ttl).Unix() @@ -69,7 +69,7 @@ func (s *fsStorage) UploadSignedURL(_ context.Context, path string, ttl time.Dur u := fmt.Sprintf("%s/upload?path=%s&expires=%d&token=%s", s.uploadURL, url.QueryEscape(path), expiresSec, url.QueryEscape(token)) - return u, nil + return UploadURL{URL: u}, nil } func (s *fsStorage) OpenSeekable(_ context.Context, path string) (Seekable, error) { diff --git a/packages/shared/pkg/storage/storage_google.go b/packages/shared/pkg/storage/storage_google.go index 24e1e78f74..bf23cd196e 100644 --- a/packages/shared/pkg/storage/storage_google.go +++ b/packages/shared/pkg/storage/storage_google.go @@ -122,10 +122,10 @@ func (s *gcpStorage) GetDetails() string { return fmt.Sprintf("[GCP Storage, bucket set to %s]", s.bucket.BucketName()) } -func (s *gcpStorage) UploadSignedURL(_ context.Context, path string, ttl time.Duration) (string, error) { +func (s *gcpStorage) UploadSignedURL(_ context.Context, path string, ttl time.Duration) (UploadURL, error) { token, err := parseServiceAccountBase64(consts.GoogleServiceAccountSecret) if err != nil { - return "", fmt.Errorf("failed to parse GCP service account: %w", err) + return UploadURL{}, fmt.Errorf("failed to parse GCP service account: %w", err) } opts := &storage.SignedURLOptions{ @@ -137,10 +137,10 @@ func (s *gcpStorage) UploadSignedURL(_ context.Context, path string, ttl time.Du url, err := storage.SignedURL(s.bucket.BucketName(), path, opts) if err != nil { - return "", fmt.Errorf("failed to create signed URL for GCS object (%s): %w", path, err) + return UploadURL{}, fmt.Errorf("failed to create signed URL for GCS object (%s): %w", path, err) } - return url, nil + return UploadURL{URL: url}, nil } func (s *gcpStorage) OpenSeekable(_ context.Context, path string) (Seekable, error) { diff --git a/packages/shared/pkg/storage/storage_localupload_test.go b/packages/shared/pkg/storage/storage_localupload_test.go index 86c759978b..99e79e3b2c 100644 --- a/packages/shared/pkg/storage/storage_localupload_test.go +++ b/packages/shared/pkg/storage/storage_localupload_test.go @@ -151,9 +151,10 @@ func TestUploadSignedURL(t *testing.T) { signedURL, err := p.UploadSignedURL(t.Context(), "templates/abc/layer.tar", 5*time.Minute) require.NoError(t, err) + assert.Nil(t, signedURL.Headers) // Parse the URL and verify structure. - u, err := url.Parse(signedURL) + u, err := url.Parse(signedURL.URL) require.NoError(t, err) assert.Equal(t, "http", u.Scheme) @@ -184,7 +185,7 @@ func TestUploadSignedURL(t *testing.T) { signedURL, err := p.UploadSignedURL(t.Context(), "path with spaces/file name.tar", 5*time.Minute) require.NoError(t, err) - u, err := url.Parse(signedURL) + u, err := url.Parse(signedURL.URL) require.NoError(t, err) // Query().Get() returns the decoded value. @@ -214,7 +215,7 @@ func TestUploadSignedURL(t *testing.T) { require.NoError(t, err) // Parse the signed URL as a client would receive it. - u, err := url.Parse(signedURL) + u, err := url.Parse(signedURL.URL) require.NoError(t, err) q := u.Query() diff --git a/spec/openapi.yml b/spec/openapi.yml index 385ce9d9c2..748aeecb16 100644 --- a/spec/openapi.yml +++ b/spec/openapi.yml @@ -1549,6 +1549,11 @@ components: url: description: Url where the file should be uploaded to type: string + headers: + description: Request headers that must be sent with the upload request + type: object + additionalProperties: + type: string LogLevel: type: string diff --git a/tests/integration/internal/api/generated.go b/tests/integration/internal/api/generated.go index eabb34585b..ce30b75cd6 100644 --- a/tests/integration/internal/api/generated.go +++ b/tests/integration/internal/api/generated.go @@ -1330,6 +1330,9 @@ type TemplateBuild struct { // TemplateBuildFileUpload defines model for TemplateBuildFileUpload. type TemplateBuildFileUpload struct { + // Headers Request headers that must be sent with the upload request + Headers *map[string]string `json:"headers,omitempty"` + // Present Whether the file is already present in the cache Present bool `json:"present"`