Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions cloud/aws/deploy/bucket.go
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,30 @@ func (a *NitricAwsPulumiProvider) Bucket(ctx *pulumi.Context, parent pulumi.Reso

a.Buckets[name] = bucket

if len(config.CorsRules) > 0 {
corsRules := s3.BucketCorsConfigurationV2CorsRuleArray{}
for _, rule := range config.CorsRules {
corsRule := s3.BucketCorsConfigurationV2CorsRuleArgs{
AllowedOrigins: pulumi.ToStringArray(rule.AllowedOrigins),
AllowedMethods: pulumi.ToStringArray(rule.AllowedMethods),
AllowedHeaders: pulumi.ToStringArray(rule.AllowedHeaders),
ExposeHeaders: pulumi.ToStringArray(rule.ExposeHeaders),
}
if rule.MaxAgeSeconds > 0 {
corsRule.MaxAgeSeconds = pulumi.IntPtr(int(rule.MaxAgeSeconds))
}
corsRules = append(corsRules, corsRule)
}

_, err := s3.NewBucketCorsConfigurationV2(ctx, fmt.Sprintf("%s-cors", name), &s3.BucketCorsConfigurationV2Args{
Bucket: bucket.ID().ToStringOutput(),
CorsRules: corsRules,
}, opts...)
if err != nil {
return fmt.Errorf("unable to create CORS configuration for bucket %s: %w", name, err)
}
}

if len(config.Listeners) > 0 {
notificationName := fmt.Sprintf("notification-%s", name)
notification, err := createNotification(ctx, notificationName, &S3NotificationArgs{
Expand Down
205 changes: 205 additions & 0 deletions cloud/aws/deploy/bucket_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
// Copyright Nitric Pty Ltd.
//
// SPDX-License-Identifier: Apache-2.0
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at:
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package deploy

import (
"sync"
"testing"

"github.com/nitrictech/nitric/cloud/aws/common"
"github.com/pulumi/pulumi-aws/sdk/v5/go/aws/s3"
"github.com/pulumi/pulumi/sdk/v3/go/common/resource"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"

deploymentspb "github.com/nitrictech/nitric/core/pkg/proto/deployments/v1"
)

type bucketMocks struct {
mu sync.Mutex
resources []mockResource
}

type mockResource struct {
TypeToken string
Name string
Inputs resource.PropertyMap
}

func (m *bucketMocks) NewResource(args pulumi.MockResourceArgs) (string, resource.PropertyMap, error) {
m.mu.Lock()
defer m.mu.Unlock()

m.resources = append(m.resources, mockResource{
TypeToken: args.TypeToken,
Name: args.Name,
Inputs: args.Inputs,
})

// Return a fake ID and the inputs as outputs
return args.Name + "-id", args.Inputs, nil
}

func (m *bucketMocks) Call(args pulumi.MockCallArgs) (resource.PropertyMap, error) {
return resource.PropertyMap{}, nil
}

func (m *bucketMocks) findResources(typeToken string) []mockResource {
m.mu.Lock()
defer m.mu.Unlock()

var found []mockResource
for _, r := range m.resources {
if r.TypeToken == typeToken {
found = append(found, r)
}
}
return found
}

func TestBucket_WithCorsRules_CreatesS3CorsConfiguration(t *testing.T) {
mocks := &bucketMocks{}

err := pulumi.RunErr(func(ctx *pulumi.Context) error {
provider := &NitricAwsPulumiProvider{
StackId: "test-stack",
AwsConfig: &common.AwsConfig{},
Buckets: map[string]*s3.Bucket{},
BucketNotifications: map[string]*s3.BucketNotification{},
}

return provider.Bucket(ctx, nil, "test-bucket", &deploymentspb.Bucket{
CorsRules: []*deploymentspb.BucketCorsRule{
{
AllowedOrigins: []string{"https://example.com"},
AllowedMethods: []string{"GET", "POST"},
AllowedHeaders: []string{"Content-Type", "Authorization"},
ExposeHeaders: []string{"ETag"},
MaxAgeSeconds: 3600,
},
},
})
}, pulumi.WithMocks("test", "test", mocks))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

// Verify an S3 bucket was created
buckets := mocks.findResources("aws:s3/bucket:Bucket")
if len(buckets) != 1 {
t.Fatalf("expected 1 S3 bucket, got %d", len(buckets))
}

// Verify a CORS configuration was created
corsConfigs := mocks.findResources("aws:s3/bucketCorsConfigurationV2:BucketCorsConfigurationV2")
if len(corsConfigs) != 1 {
t.Fatalf("expected 1 CORS configuration, got %d", len(corsConfigs))
}

// Verify the CORS rule field values
corsRules, ok := corsConfigs[0].Inputs["corsRules"]
if !ok {
t.Fatal("expected CORS configuration to have 'corsRules' input")
}
rules := corsRules.ArrayValue()
if len(rules) != 1 {
t.Fatalf("expected 1 CORS rule, got %d", len(rules))
}
rule := rules[0].ObjectValue()

origins := rule["allowedOrigins"].ArrayValue()
if len(origins) != 1 || origins[0].StringValue() != "https://example.com" {
t.Fatalf("unexpected allowedOrigins: %v", origins)
}
methods := rule["allowedMethods"].ArrayValue()
if len(methods) != 2 || methods[0].StringValue() != "GET" {
t.Fatalf("unexpected allowedMethods: %v", methods)
}
headers := rule["allowedHeaders"].ArrayValue()
if len(headers) != 2 || headers[0].StringValue() != "Content-Type" {
t.Fatalf("unexpected allowedHeaders: %v", headers)
}
expose := rule["exposeHeaders"].ArrayValue()
if len(expose) != 1 || expose[0].StringValue() != "ETag" {
t.Fatalf("unexpected exposeHeaders: %v", expose)
}
maxAge, ok := rule["maxAgeSeconds"]
if !ok || maxAge.NumberValue() != 3600 {
t.Fatalf("unexpected maxAgeSeconds: %v", maxAge)
}
}

func TestBucket_WithoutCorsRules_NoCorsConfiguration(t *testing.T) {
mocks := &bucketMocks{}

err := pulumi.RunErr(func(ctx *pulumi.Context) error {
provider := &NitricAwsPulumiProvider{
StackId: "test-stack",
AwsConfig: &common.AwsConfig{},
Buckets: map[string]*s3.Bucket{},
BucketNotifications: map[string]*s3.BucketNotification{},
}

return provider.Bucket(ctx, nil, "test-bucket", &deploymentspb.Bucket{})
}, pulumi.WithMocks("test", "test", mocks))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

// Verify no CORS configuration was created
corsConfigs := mocks.findResources("aws:s3/bucketCorsConfigurationV2:BucketCorsConfigurationV2")
if len(corsConfigs) != 0 {
t.Fatalf("expected 0 CORS configurations, got %d", len(corsConfigs))
}
}

func TestBucket_MultipleCorsRules(t *testing.T) {
mocks := &bucketMocks{}

err := pulumi.RunErr(func(ctx *pulumi.Context) error {
provider := &NitricAwsPulumiProvider{
StackId: "test-stack",
AwsConfig: &common.AwsConfig{},
Buckets: map[string]*s3.Bucket{},
BucketNotifications: map[string]*s3.BucketNotification{},
}

return provider.Bucket(ctx, nil, "test-bucket", &deploymentspb.Bucket{
CorsRules: []*deploymentspb.BucketCorsRule{
{
AllowedOrigins: []string{"https://example.com"},
AllowedMethods: []string{"GET"},
MaxAgeSeconds: 300,
},
{
AllowedOrigins: []string{"https://other.com"},
AllowedMethods: []string{"PUT", "POST"},
AllowedHeaders: []string{"*"},
MaxAgeSeconds: 600,
},
},
})
}, pulumi.WithMocks("test", "test", mocks))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

// Verify exactly one CORS configuration resource (containing both rules)
corsConfigs := mocks.findResources("aws:s3/bucketCorsConfigurationV2:BucketCorsConfigurationV2")
if len(corsConfigs) != 1 {
t.Fatalf("expected 1 CORS configuration, got %d", len(corsConfigs))
}
}
17 changes: 17 additions & 0 deletions cloud/aws/deploytf/.nitric/modules/bucket/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,23 @@ resource "aws_s3_bucket" "bucket" {
}
}

# CORS configuration for the bucket
resource "aws_s3_bucket_cors_configuration" "cors" {
count = length(var.cors_rules) > 0 ? 1 : 0
bucket = aws_s3_bucket.bucket.id

dynamic "cors_rule" {
for_each = var.cors_rules
content {
allowed_origins = cors_rule.value.allowed_origins
allowed_methods = cors_rule.value.allowed_methods
allowed_headers = cors_rule.value.allowed_headers
expose_headers = cors_rule.value.expose_headers
max_age_seconds = cors_rule.value.max_age_seconds
}
}
}

# Deploy bucket lambda invocation permissions
resource "aws_lambda_permission" "allow_bucket" {
for_each = var.notification_targets
Expand Down
12 changes: 12 additions & 0 deletions cloud/aws/deploytf/.nitric/modules/bucket/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,15 @@ variable "notification_targets" {
events = list(string)
}))
}

variable "cors_rules" {
description = "CORS rules for the bucket"
type = list(object({
allowed_origins = list(string)
allowed_methods = list(string)
allowed_headers = list(string)
expose_headers = list(string)
max_age_seconds = number
}))
default = []
}
12 changes: 12 additions & 0 deletions cloud/aws/deploytf/bucket.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,22 @@ func (n *NitricAwsTerraformProvider) Bucket(stack cdktf.TerraformStack, name str
}
}

var corsRules []map[string]interface{}
for _, rule := range config.CorsRules {
corsRules = append(corsRules, map[string]interface{}{
"allowed_origins": jsii.Strings(rule.AllowedOrigins...),
"allowed_methods": jsii.Strings(rule.AllowedMethods...),
"allowed_headers": jsii.Strings(rule.AllowedHeaders...),
"expose_headers": jsii.Strings(rule.ExposeHeaders...),
"max_age_seconds": jsii.Number(float64(rule.MaxAgeSeconds)),
})
}

n.Buckets[name] = bucket.NewBucket(stack, jsii.Sprintf("bucket_%s", name), &bucket.BucketConfig{
BucketName: &name,
StackId: n.Stack.StackIdOutput(),
NotificationTargets: &notificationTargets,
CorsRules: &corsRules,
})

return nil
Expand Down
Binary file modified cloud/aws/deploytf/generated/api/jsii/api-0.0.0.tgz
Binary file not shown.
23 changes: 23 additions & 0 deletions cloud/aws/deploytf/generated/bucket/Bucket.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ type Bucket interface {
CdktfStack() cdktf.TerraformStack
// Experimental.
ConstructNodeMetadata() *map[string]interface{}
CorsRules() interface{}
SetCorsRules(val interface{})
// Experimental.
DependsOn() *[]*string
// Experimental.
Expand Down Expand Up @@ -120,6 +122,16 @@ func (j *jsiiProxy_Bucket) ConstructNodeMetadata() *map[string]interface{} {
return returns
}

func (j *jsiiProxy_Bucket) CorsRules() interface{} {
var returns interface{}
_jsii_.Get(
j,
"corsRules",
&returns,
)
return returns
}

func (j *jsiiProxy_Bucket) DependsOn() *[]*string {
var returns *[]*string
_jsii_.Get(
Expand Down Expand Up @@ -279,6 +291,17 @@ func (j *jsiiProxy_Bucket)SetBucketName(val *string) {
)
}

func (j *jsiiProxy_Bucket)SetCorsRules(val interface{}) {
if err := j.validateSetCorsRulesParameters(val); err != nil {
panic(err)
}
_jsii_.Set(
j,
"corsRules",
val,
)
}

func (j *jsiiProxy_Bucket)SetDependsOn(val *[]*string) {
_jsii_.Set(
j,
Expand Down
2 changes: 2 additions & 0 deletions cloud/aws/deploytf/generated/bucket/BucketConfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,7 @@ type BucketConfig struct {
NotificationTargets interface{} `field:"required" json:"notificationTargets" yaml:"notificationTargets"`
// The ID of the Nitric stack.
StackId *string `field:"required" json:"stackId" yaml:"stackId"`
// CORS rules for the bucket.
CorsRules interface{} `field:"optional" json:"corsRules" yaml:"corsRules"`
}

8 changes: 8 additions & 0 deletions cloud/aws/deploytf/generated/bucket/Bucket__checks.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,14 @@ func (j *jsiiProxy_Bucket) validateSetBucketNameParameters(val *string) error {
return nil
}

func (j *jsiiProxy_Bucket) validateSetCorsRulesParameters(val interface{}) error {
if val == nil {
return fmt.Errorf("parameter val is required, but nil was provided")
}

return nil
}

func (j *jsiiProxy_Bucket) validateSetNotificationTargetsParameters(val interface{}) error {
if val == nil {
return fmt.Errorf("parameter val is required, but nil was provided")
Expand Down
4 changes: 4 additions & 0 deletions cloud/aws/deploytf/generated/bucket/Bucket__no_checks.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ func (j *jsiiProxy_Bucket) validateSetBucketNameParameters(val *string) error {
return nil
}

func (j *jsiiProxy_Bucket) validateSetCorsRulesParameters(val interface{}) error {
return nil
}

func (j *jsiiProxy_Bucket) validateSetNotificationTargetsParameters(val interface{}) error {
return nil
}
Expand Down
Binary file modified cloud/aws/deploytf/generated/bucket/jsii/bucket-0.0.0.tgz
Binary file not shown.
1 change: 1 addition & 0 deletions cloud/aws/deploytf/generated/bucket/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ func init() {
_jsii_.MemberProperty{JsiiProperty: "bucketName", GoGetter: "BucketName"},
_jsii_.MemberProperty{JsiiProperty: "cdktfStack", GoGetter: "CdktfStack"},
_jsii_.MemberProperty{JsiiProperty: "constructNodeMetadata", GoGetter: "ConstructNodeMetadata"},
_jsii_.MemberProperty{JsiiProperty: "corsRules", GoGetter: "CorsRules"},
_jsii_.MemberProperty{JsiiProperty: "dependsOn", GoGetter: "DependsOn"},
_jsii_.MemberProperty{JsiiProperty: "forEach", GoGetter: "ForEach"},
_jsii_.MemberProperty{JsiiProperty: "fqn", GoGetter: "Fqn"},
Expand Down
Binary file modified cloud/aws/deploytf/generated/cdn/jsii/cdn-0.0.0.tgz
Binary file not shown.
Binary file modified cloud/aws/deploytf/generated/http_proxy/jsii/http_proxy-0.0.0.tgz
Binary file not shown.
Binary file modified cloud/aws/deploytf/generated/keyvalue/jsii/keyvalue-0.0.0.tgz
Binary file not shown.
Binary file modified cloud/aws/deploytf/generated/parameter/jsii/parameter-0.0.0.tgz
Binary file not shown.
Binary file modified cloud/aws/deploytf/generated/policy/jsii/policy-0.0.0.tgz
Binary file not shown.
Binary file modified cloud/aws/deploytf/generated/queue/jsii/queue-0.0.0.tgz
Binary file not shown.
Binary file modified cloud/aws/deploytf/generated/rds/jsii/rds-0.0.0.tgz
Binary file not shown.
Binary file modified cloud/aws/deploytf/generated/schedule/jsii/schedule-0.0.0.tgz
Binary file not shown.
Binary file modified cloud/aws/deploytf/generated/secret/jsii/secret-0.0.0.tgz
Binary file not shown.
Binary file modified cloud/aws/deploytf/generated/service/jsii/service-0.0.0.tgz
Binary file not shown.
Binary file modified cloud/aws/deploytf/generated/sql/jsii/sql-0.0.0.tgz
Binary file not shown.
Binary file modified cloud/aws/deploytf/generated/stack/jsii/stack-0.0.0.tgz
Binary file not shown.
Binary file modified cloud/aws/deploytf/generated/topic/jsii/topic-0.0.0.tgz
Binary file not shown.
Binary file modified cloud/aws/deploytf/generated/vpc/jsii/vpc-0.0.0.tgz
Binary file not shown.
Binary file modified cloud/aws/deploytf/generated/website/jsii/website-0.0.0.tgz
Binary file not shown.
Binary file modified cloud/aws/deploytf/generated/websocket/jsii/websocket-0.0.0.tgz
Binary file not shown.
5 changes: 5 additions & 0 deletions cloud/azure/deploy/bucket.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,11 @@ func (p *NitricAzurePulumiProvider) Bucket(ctx *pulumi.Context, parent pulumi.Re
var err error
opts := []pulumi.ResourceOption{pulumi.Parent(parent)}

if len(config.CorsRules) > 0 {
return fmt.Errorf("bucket %q has CORS rules configured, but Azure does not support per-container CORS. "+
"CORS must be configured at the Storage Account level", name)
}

p.Buckets[name], err = storage.NewBlobContainer(ctx, ResourceName(ctx, name, StorageContainerRT), &storage.BlobContainerArgs{
ResourceGroupName: p.ResourceGroup.Name,
AccountName: p.StorageAccount.Name,
Expand Down
Loading