diff --git a/src/pkg/cli/compose/fixup.go b/src/pkg/cli/compose/fixup.go index bda5cac51..c6cb6d652 100644 --- a/src/pkg/cli/compose/fixup.go +++ b/src/pkg/cli/compose/fixup.go @@ -72,6 +72,13 @@ func FixupServices(ctx context.Context, provider client.Provider, project *compo } } + _, managedS3 := svccfg.Extensions["x-defang-s3"] + if managedS3 || IsMinioRepo(repo) { + if err := fixupS3Service(&svccfg, project, provider, accountInfo, upload); err != nil { + return fmt.Errorf("service %q: %w", svccfg.Name, err) + } + } + if len(svccfg.Name) > 16 { term.Warnf("service %q: service name is longer than 16 characters, you may run into issues with resource name length", svccfg.Name) } @@ -300,6 +307,76 @@ func fixupPostgresService(svccfg *composeTypes.ServiceConfig, provider client.Pr return nil } +// fixupS3Service wires the bucket and region into every service that +// depends_on the MinIO anchor, mirroring the model-provider convention in +// wireDependentServices. +// +// Unlike postgres/redis/mongo, this deliberately adds no host port. That HACK +// exists only to earn the service a CNAME, and a CNAME cannot carry an S3 +// endpoint: bucket names are globally unique, and TLS SNI would not match a +// private name. The endpoint is the provider's to supply, because only it +// knows the shape — native S3/GCS on AWS and GCP (where the SDK's own default +// endpoint is usually right), and the s3proxy service, on its own port, on +// Azure. So the CLI never synthesizes one. +func fixupS3Service(svccfg *composeTypes.ServiceConfig, project *composeTypes.Project, provider client.Provider, accountInfo *client.AccountInfo, upload UploadMode) error { + s3Extension, managedS3 := svccfg.Extensions["x-defang-s3"] + if _, ok := provider.(*client.PlaygroundProvider); ok && managedS3 && upload != UploadModeEstimate { + term.Warnf("service %q: managed S3 is not supported in the Playground; consider using BYOC (https://s.defang.io/byoc)", svccfg.Name) + } + fixupIngressPorts(svccfg) // the anchor is not a public endpoint + + if !managedS3 { + return nil + } + + bucket, err := validateS3Store(s3Extension) + if err != nil { + return err + } + + // Inject no region when the provider didn't report one: AccountInfo may + // simply have failed (FixupServices treats that as non-fatal and carries on + // with a zero value), and a guessed region is worse than none. It would + // override the SDK's own resolution with a value that is wrong whenever the + // deployment isn't in that guess, and SigV4 then fails at runtime against a + // bucket in the real region. Absent, the SDK resolves the region itself from + // the task environment. This mirrors configureAccessGateway, which sets + // AWS_REGION only when info.Region is non-empty. + envName := strings.ToUpper(svccfg.Name) // TODO: handle characters that are not allowed in env vars, like '-' + wireS3DependentServices(project, svccfg.Name, bucket, accountInfo.Region, envName+"_BUCKET", envName+"_REGION") + return nil +} + +// wireS3DependentServices injects the bucket/region env vars into every +// service that depends_on svcName. It never overwrites a value the author +// already set, including an endpoint they point somewhere themselves. +// +// Why two variables rather than one _URL, as model providers get: an S3 +// endpoint URL cannot carry the bucket. Every mainstream S3 client takes the +// bucket as a per-call API parameter, not as client config, so it has to +// arrive as a variable of its own. The region is separate for the same +// reason — SigV4 needs it, and non-AWS clients don't read AWS_REGION by +// themselves. The endpoint is the one value the CLI does not inject at all: +// on AWS it should be absent, so the SDK uses its own default, and where a +// cloud does need one, only the provider knows it (see fixupS3Service). +func wireS3DependentServices(project *composeTypes.Project, svcName, bucket, region, bucketEnvVar, regionEnvVar string) { + for name, dependency := range project.Services { + if _, ok := dependency.DependsOn[svcName]; !ok { + continue + } + if dependency.Environment == nil { + dependency.Environment = make(composeTypes.MappingWithEquals) + } + if _, ok := dependency.Environment[bucketEnvVar]; !ok { + dependency.Environment[bucketEnvVar] = &bucket + } + if _, ok := dependency.Environment[regionEnvVar]; !ok && region != "" { + dependency.Environment[regionEnvVar] = ®ion + } + project.Services[name] = dependency + } +} + func fixupMongoService(svccfg *composeTypes.ServiceConfig, provider client.Provider, upload UploadMode) error { _, managedMongo := svccfg.Extensions["x-defang-mongodb"] if _, ok := provider.(*client.PlaygroundProvider); ok && managedMongo && upload != UploadModeEstimate { @@ -589,8 +666,16 @@ func modelWithProvider(model, prefix string) string { return prefix + "/" + model } +// GetImageRepo returns the lowercase repository of an image reference, without +// its tag or digest: minio/minio, minio/minio:latest and +// minio/minio@sha256: all yield "minio/minio", so the managed-service +// image checks below recognize a digest-pinned image too. A colon inside the +// registry host is a port, not a tag. func GetImageRepo(image string) string { - repo, _, _ := strings.Cut(image, ":") + repo, _, _ := strings.Cut(image, "@") // strip the digest, if any + if i := strings.LastIndex(repo, ":"); i > strings.LastIndex(repo, "/") { + repo = repo[:i] // strip the tag, but keep a registry port + } return strings.ToLower(repo) } @@ -634,3 +719,7 @@ func IsRedisRepo(repo string) bool { func IsMongoRepo(repo string) bool { return strings.HasSuffix(repo, "mongo") } + +func IsMinioRepo(repo string) bool { + return strings.HasSuffix(repo, "minio") +} diff --git a/src/pkg/cli/compose/fixup_test.go b/src/pkg/cli/compose/fixup_test.go index 5bb2f2339..598e35a48 100644 --- a/src/pkg/cli/compose/fixup_test.go +++ b/src/pkg/cli/compose/fixup_test.go @@ -1,11 +1,13 @@ package compose import ( + "context" "strings" "testing" "github.com/DefangLabs/defang/src/pkg" "github.com/DefangLabs/defang/src/pkg/cli/client" + "github.com/aws/smithy-go/ptr" composeTypes "github.com/compose-spec/compose-go/v2/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -359,3 +361,112 @@ func TestModelWithProvider(t *testing.T) { assert.Equal(t, "vertex_ai/gemini-2.5-flash", modelWithProvider("gemini-2.5-flash", "vertex_ai")) assert.Equal(t, "vertex_ai/gemini-2.5-flash", modelWithProvider("vertex_ai/gemini-2.5-flash", "vertex_ai")) } + +func TestGetImageRepo(t *testing.T) { + tests := []struct { + image string + want string + }{ + {image: "minio/minio", want: "minio/minio"}, + {image: "minio/minio:RELEASE.2024-01-01T00-00-00Z", want: "minio/minio"}, + {image: "minio/minio@sha256:0000000000000000000000000000000000000000000000000000000000000000", want: "minio/minio"}, + {image: "minio/minio:latest@sha256:0000000000000000000000000000000000000000000000000000000000000000", want: "minio/minio"}, + {image: "MinIO/MinIO", want: "minio/minio"}, + {image: "registry.example.com:5000/minio/minio", want: "registry.example.com:5000/minio/minio"}, + {image: "registry.example.com:5000/minio/minio:latest", want: "registry.example.com:5000/minio/minio"}, + {image: "", want: ""}, + } + for _, tt := range tests { + t.Run(tt.image, func(t *testing.T) { + if got := GetImageRepo(tt.image); got != tt.want { + t.Errorf("GetImageRepo(%q) = %q, want %q", tt.image, got, tt.want) + } + }) + } +} + +// s3RegionProvider is a MockProvider whose AccountInfo carries a region, so the +// region-present branch of fixupS3Service can be exercised: the shared +// MockProvider reports none. +type s3RegionProvider struct { + client.MockProvider + region string +} + +func (p s3RegionProvider) AccountInfo(context.Context) (*client.AccountInfo, error) { + return &client.AccountInfo{Provider: client.ProviderAWS, Region: p.region}, nil +} + +func TestFixupS3ServiceRegion(t *testing.T) { + tests := []struct { + name string + region string + preset *string + wantRegion *string // nil = the var must not be injected at all + }{ + { + name: "provider reports a region", + region: "eu-west-2", + wantRegion: ptr.String("eu-west-2"), + }, + { + // No region means AccountInfo failed or reported none. Guessing one + // would override the SDK's own resolution with a value that breaks + // SigV4 whenever the guess is wrong, so inject nothing. + name: "provider reports no region", + region: "", + wantRegion: nil, + }, + { + name: "author's own value wins over the provider's", + region: "eu-west-2", + preset: ptr.String("ap-south-1"), + wantRegion: ptr.String("ap-south-1"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dependent := composeTypes.ServiceConfig{ + Name: "app", + DependsOn: composeTypes.DependsOnConfig{"objs": composeTypes.ServiceDependency{}}, + Environment: composeTypes.MappingWithEquals{}, + } + if tt.preset != nil { + dependent.Environment["OBJS_REGION"] = tt.preset + } + project := &composeTypes.Project{Services: composeTypes.Services{"app": dependent}} + anchor := composeTypes.ServiceConfig{ + Name: "objs", + Image: "minio/minio", + Extensions: map[string]any{"x-defang-s3": map[string]any{"bucket": "buzz-media-prod"}}, + } + + provider := s3RegionProvider{region: tt.region} + info, err := provider.AccountInfo(t.Context()) + if err != nil { + t.Fatal(err) + } + if err := fixupS3Service(&anchor, project, provider, info, UploadModeIgnore); err != nil { + t.Fatal(err) + } + + env := project.Services["app"].Environment + got, ok := env["OBJS_REGION"] + if tt.wantRegion == nil { + if ok { + t.Errorf("OBJS_REGION = %q, want it not to be injected", *got) + } + } else if !ok { + t.Errorf("OBJS_REGION not injected, want %q", *tt.wantRegion) + } else if *got != *tt.wantRegion { + t.Errorf("OBJS_REGION = %q, want %q", *got, *tt.wantRegion) + } + + // The bucket is injected either way; only the region is conditional. + if bucket, ok := env["OBJS_BUCKET"]; !ok || *bucket != "buzz-media-prod" { + t.Errorf("OBJS_BUCKET = %v, want %q", bucket, "buzz-media-prod") + } + }) + } +} diff --git a/src/pkg/cli/compose/stateful.go b/src/pkg/cli/compose/stateful.go index a78213c8a..9d6f3d60a 100644 --- a/src/pkg/cli/compose/stateful.go +++ b/src/pkg/cli/compose/stateful.go @@ -32,7 +32,7 @@ var statefulImages = []string{ } func isStatefulImage(image string) bool { - repo := strings.ToLower(strings.SplitN(image, ":", 2)[0]) + repo := GetImageRepo(image) for _, statefulImage := range statefulImages { if strings.HasSuffix(repo, statefulImage) { return true diff --git a/src/pkg/cli/compose/stateful_test.go b/src/pkg/cli/compose/stateful_test.go index 335ea7657..0da3cdba4 100644 --- a/src/pkg/cli/compose/stateful_test.go +++ b/src/pkg/cli/compose/stateful_test.go @@ -28,6 +28,11 @@ func TestIsStatefulImage(t *testing.T) { image: "docker.io/redis", expected: true, }, + { + name: "Stateful image pinned by digest", + image: "minio/minio@sha256:0000000000000000000000000000000000000000000000000000000000000000", + expected: true, + }, { name: "Stateless image", image: "alpine:latest", diff --git a/src/pkg/cli/compose/validation.go b/src/pkg/cli/compose/validation.go index 99d9df5a1..94a37b0c9 100644 --- a/src/pkg/cli/compose/validation.go +++ b/src/pkg/cli/compose/validation.go @@ -375,7 +375,18 @@ func validateService(svccfg *composeTypes.ServiceConfig, project *composeTypes.P } } - if !managedRedis && !managedPostgres && !managedMongodb && isStatefulImage(svccfg.Image) { + s3Extension, managedS3 := svccfg.Extensions["x-defang-s3"] + if managedS3 { + // Ensure the repo is a valid MinIO repo + if !IsMinioRepo(repo) { + term.Warnf("service %q: managed S3 service should use a minio image", svccfg.Name) + } + if _, err = validateS3Store(s3Extension); err != nil { + return fmt.Errorf("service %q: %w", svccfg.Name, err) + } + } + + if !managedRedis && !managedPostgres && !managedMongodb && !managedS3 && isStatefulImage(svccfg.Image) { term.Warnf("service %q: stateful service will lose data on restart; use a managed service instead", svccfg.Name) } @@ -386,6 +397,7 @@ func validateService(svccfg *composeTypes.ServiceConfig, project *composeTypes.P "x-defang-redis", "x-defang-postgres", "x-defang-mongodb", + "x-defang-s3", "x-defang-llm", "x-defang-autoscaling", // Consumed by the CD provider, not the CLI, but still valid and @@ -568,6 +580,43 @@ func validateManagedStore(managedStore any) (bool, error) { } } +var bucketNameRegex = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*[a-z0-9]$`) + +// validateS3Store requires a declared bucket name: bucket names must be +// globally unique, and the CLI injects the endpoint/bucket into dependent +// services' env before anything is provisioned, so a generated name isn't +// known yet. No dots — a dot breaks the wildcard TLS cert on virtual-hosted +// S3 endpoints. +func validateS3Store(managedStore any) (string, error) { + m, ok := managedStore.(map[string]any) + if !ok { + return "", errors.New("'x-defang-s3' requires a 'bucket' name") + } + b, ok := m["bucket"] + if !ok { + return "", errors.New("'x-defang-s3' requires a 'bucket' name") + } + bucket, ok := b.(string) + if !ok { + return "", errors.New("'bucket' must be a string") + } + if len(bucket) < 3 || len(bucket) > 63 { + return "", fmt.Errorf("'bucket' %q must be between 3 and 63 characters", bucket) + } + if strings.Contains(bucket, ".") { + return "", fmt.Errorf("'bucket' %q must not contain dots", bucket) + } + if !bucketNameRegex.MatchString(bucket) { + return "", fmt.Errorf("'bucket' %q must use only lowercase letters, digits and hyphens, and start/end with a letter or digit", bucket) + } + if downtime, ok := m["allow-downtime"]; ok { + if _, ok := downtime.(bool); !ok { + return "", errors.New("'allow-downtime' must be a boolean") + } + } + return bucket, nil +} + func IsComputeService(service *composeTypes.ServiceConfig) bool { if service.Extensions == nil { return true @@ -576,5 +625,6 @@ func IsComputeService(service *composeTypes.ServiceConfig) bool { return service.Extensions["x-defang-static-files"] == nil && service.Extensions["x-defang-redis"] == nil && service.Extensions["x-defang-mongodb"] == nil && + service.Extensions["x-defang-s3"] == nil && service.Extensions["x-defang-postgres"] == nil } diff --git a/src/pkg/cli/compose/validation_test.go b/src/pkg/cli/compose/validation_test.go index 5288a69ab..0734135dc 100644 --- a/src/pkg/cli/compose/validation_test.go +++ b/src/pkg/cli/compose/validation_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "errors" + "fmt" "io" "os" "slices" @@ -417,6 +418,121 @@ func TestManagedStoreParams(t *testing.T) { } } +func TestValidateS3Store(t *testing.T) { + tests := []struct { + name string + extension any + wantBucket string + wantErr string + }{ + { + name: "sanity check", + extension: map[string]any{ + "bucket": "buzz-media-prod", + }, + wantBucket: "buzz-media-prod", + }, + { + name: "with allow-downtime", + extension: map[string]any{ + "bucket": "buzz-media-prod", + "allow-downtime": true, + }, + wantBucket: "buzz-media-prod", + }, + { + name: "missing bucket", + extension: map[string]any{}, + wantErr: "'x-defang-s3' requires a 'bucket' name", + }, + { + name: "shorthand true", + extension: true, + wantErr: "'x-defang-s3' requires a 'bucket' name", + }, + { + name: "nil", + extension: nil, + wantErr: "'x-defang-s3' requires a 'bucket' name", + }, + { + name: "non-string bucket", + extension: map[string]any{ + "bucket": 123, + }, + wantErr: "'bucket' must be a string", + }, + { + name: "too short", + extension: map[string]any{ + "bucket": "ab", + }, + wantErr: `'bucket' "ab" must be between 3 and 63 characters`, + }, + { + name: "too long", + extension: map[string]any{ + "bucket": strings.Repeat("a", 64), + }, + wantErr: fmt.Sprintf("'bucket' %q must be between 3 and 63 characters", strings.Repeat("a", 64)), + }, + { + name: "contains dot", + extension: map[string]any{ + "bucket": "buzz.media.prod", + }, + wantErr: `'bucket' "buzz.media.prod" must not contain dots`, + }, + { + name: "uppercase", + extension: map[string]any{ + "bucket": "Buzz-Media", + }, + wantErr: `'bucket' "Buzz-Media" must use only lowercase letters, digits and hyphens, and start/end with a letter or digit`, + }, + { + name: "starts with hyphen", + extension: map[string]any{ + "bucket": "-buzz-media", + }, + wantErr: `'bucket' "-buzz-media" must use only lowercase letters, digits and hyphens, and start/end with a letter or digit`, + }, + { + name: "ends with hyphen", + extension: map[string]any{ + "bucket": "buzz-media-", + }, + wantErr: `'bucket' "buzz-media-" must use only lowercase letters, digits and hyphens, and start/end with a letter or digit`, + }, + { + name: "invalid downtime", + extension: map[string]any{ + "bucket": "buzz-media-prod", + "allow-downtime": "abc", + }, + wantErr: "'allow-downtime' must be a boolean", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + bucket, err := validateS3Store(tt.extension) + if tt.wantErr != "" { + if err == nil || err.Error() != tt.wantErr { + t.Fatalf("expected error %q, got: %v", tt.wantErr, err) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if bucket != tt.wantBucket { + t.Fatalf("unexpected bucket: %v, expected: %v", bucket, tt.wantBucket) + } + }) + } +} + func TestServiceExtensionWarnings(t *testing.T) { oldTerm := term.DefaultTerm t.Cleanup(func() { term.DefaultTerm = oldTerm }) @@ -457,3 +573,36 @@ func TestServiceExtensionWarnings(t *testing.T) { }) } } + +// A managed service is not a container, so the CLI must not wait for an ECS +// service (or Cloud Run service, or container app) that the provider never +// creates for it. x-defang-s3 joins that set now that the AWS provider turns +// the MinIO anchor into a bucket (DefangLabs/pulumi-defang#518). +func TestIsComputeService(t *testing.T) { + tests := []struct { + extension string + want bool + }{ + {extension: "", want: true}, + {extension: "x-defang-llm", want: true}, + {extension: "x-defang-postgres", want: false}, + {extension: "x-defang-redis", want: false}, + {extension: "x-defang-mongodb", want: false}, + {extension: "x-defang-s3", want: false}, + {extension: "x-defang-static-files", want: false}, + } + + for _, tt := range tests { + name := tt.extension + if name == "" { + name = "no extensions" + } + t.Run(name, func(t *testing.T) { + svc := &composeTypes.ServiceConfig{Name: "svc", Image: "nginx"} + if tt.extension != "" { + svc.Extensions = composeTypes.Extensions{tt.extension: true} + } + assert.Equal(t, tt.want, IsComputeService(svc)) + }) + } +} diff --git a/src/testdata/s3/compose.yaml b/src/testdata/s3/compose.yaml new file mode 100644 index 000000000..a33d9ad34 --- /dev/null +++ b/src/testdata/s3/compose.yaml @@ -0,0 +1,45 @@ +services: + objs: + image: minio/minio + x-defang-s3: + bucket: buzz-media-prod + ports: + - target: 9000 + mode: host + + wrongimage: + image: example + x-defang-s3: + bucket: wrong-image-bucket + ports: + - target: 9000 + mode: host + + noports: + image: minio/minio + x-defang-s3: + bucket: no-ports-bucket + + # A digest-pinned MinIO anchor: proves GetImageRepo sees past the digest, so + # this port is moved to host mode instead of staying a public ingress port. + digestpin: + image: minio/minio@sha256:0000000000000000000000000000000000000000000000000000000000000000 + ports: + - target: 9000 + + noext: + image: minio/minio + ports: + - target: 9000 + mode: host + + buzz: + build: . + depends_on: [objs] + + buzzpreset: + build: . + depends_on: [objs] + environment: + OBJS_BUCKET: my-own-bucket + OBJS_URL: http://custom-endpoint:9000 diff --git a/src/testdata/s3/compose.yaml.fixup b/src/testdata/s3/compose.yaml.fixup new file mode 100644 index 000000000..d80a9f5b2 --- /dev/null +++ b/src/testdata/s3/compose.yaml.fixup @@ -0,0 +1,67 @@ +buzz: + build: + context: . + dockerfile: '*Railpack' + depends_on: + objs: + condition: service_started + required: true + environment: + OBJS_BUCKET: buzz-media-prod + networks: + default: null +buzzpreset: + build: + context: . + dockerfile: '*Railpack' + depends_on: + objs: + condition: service_started + required: true + environment: + OBJS_BUCKET: my-own-bucket + OBJS_URL: http://custom-endpoint:9000 + networks: + default: null +digestpin: + image: minio/minio@sha256:0000000000000000000000000000000000000000000000000000000000000000 + networks: + default: null + ports: + - mode: host + target: 9000 + protocol: tcp +noext: + image: minio/minio + networks: + default: null + ports: + - mode: host + target: 9000 + protocol: tcp +noports: + image: minio/minio + networks: + default: null + x-defang-s3: + bucket: no-ports-bucket +objs: + image: minio/minio + networks: + default: null + ports: + - mode: host + target: 9000 + protocol: tcp + x-defang-s3: + bucket: buzz-media-prod +wrongimage: + image: example + networks: + default: null + ports: + - mode: host + target: 9000 + protocol: tcp + x-defang-s3: + bucket: wrong-image-bucket diff --git a/src/testdata/s3/compose.yaml.golden b/src/testdata/s3/compose.yaml.golden new file mode 100644 index 000000000..58039bf01 --- /dev/null +++ b/src/testdata/s3/compose.yaml.golden @@ -0,0 +1,70 @@ +name: s3 +services: + buzz: + build: + context: . + dockerfile: Dockerfile + depends_on: + objs: + condition: service_started + required: true + networks: + default: null + buzzpreset: + build: + context: . + dockerfile: Dockerfile + depends_on: + objs: + condition: service_started + required: true + environment: + OBJS_BUCKET: my-own-bucket + OBJS_URL: http://custom-endpoint:9000 + networks: + default: null + digestpin: + image: minio/minio@sha256:0000000000000000000000000000000000000000000000000000000000000000 + networks: + default: null + ports: + - mode: ingress + target: 9000 + protocol: tcp + noext: + image: minio/minio + networks: + default: null + ports: + - mode: host + target: 9000 + protocol: tcp + noports: + image: minio/minio + networks: + default: null + x-defang-s3: + bucket: no-ports-bucket + objs: + image: minio/minio + networks: + default: null + ports: + - mode: host + target: 9000 + protocol: tcp + x-defang-s3: + bucket: buzz-media-prod + wrongimage: + image: example + networks: + default: null + ports: + - mode: host + target: 9000 + protocol: tcp + x-defang-s3: + bucket: wrong-image-bucket +networks: + default: + name: s3_default diff --git a/src/testdata/s3/compose.yaml.warnings b/src/testdata/s3/compose.yaml.warnings new file mode 100644 index 000000000..33736450a --- /dev/null +++ b/src/testdata/s3/compose.yaml.warnings @@ -0,0 +1,10 @@ + ! service "buzz": missing memory reservation; using provider-specific defaults. Specify deploy.resources.reservations.memory to avoid out-of-memory errors + ! service "buzzpreset": missing memory reservation; using provider-specific defaults. Specify deploy.resources.reservations.memory to avoid out-of-memory errors + ! service "digestpin": missing memory reservation; using provider-specific defaults. Specify deploy.resources.reservations.memory to avoid out-of-memory errors + ! service "digestpin": stateful service will lose data on restart; use a managed service instead + ! service "noext": missing memory reservation; using provider-specific defaults. Specify deploy.resources.reservations.memory to avoid out-of-memory errors + ! service "noext": stateful service will lose data on restart; use a managed service instead + ! service "noports": missing memory reservation; using provider-specific defaults. Specify deploy.resources.reservations.memory to avoid out-of-memory errors + ! service "objs": missing memory reservation; using provider-specific defaults. Specify deploy.resources.reservations.memory to avoid out-of-memory errors + ! service "wrongimage": managed S3 service should use a minio image + ! service "wrongimage": missing memory reservation; using provider-specific defaults. Specify deploy.resources.reservations.memory to avoid out-of-memory errors