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
3 changes: 3 additions & 0 deletions .docker/docker-compose-infra-multigres-override.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ services:
# 2 cells is the minimum: the shard bootstraps with an AtLeastN(2)
# durability policy, so a single pooler can never elect a leader.
MULTIGRES_NUM_CELLS: "2"
# Storage migrations contain trusted dynamic PL/pgSQL that the gateway's
# unsafe-statement analyzer intentionally rejects by default.
MT_UNSAFE_POOLER_MODE: "true"
# Bootstrapping a cluster takes longer than starting a bare postgres image,
# so allow a generous start period before the gateway answers queries.
healthcheck:
Expand Down
148 changes: 148 additions & 0 deletions migrations/tenant/0065-bucket-lifecycle-configuration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
-- Add lifecycle configuration.

ALTER TABLE storage.buckets
ADD COLUMN IF NOT EXISTS lifecycle_configuration jsonb,
ADD COLUMN IF NOT EXISTS lifecycle_configuration_generation uuid;

DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_catalog.pg_constraint
WHERE conrelid = 'storage.buckets'::regclass
AND conname = 'buckets_lifecycle_configuration_pair_check'
) THEN
ALTER TABLE storage.buckets
ADD CONSTRAINT buckets_lifecycle_configuration_pair_check CHECK (
(lifecycle_configuration IS NULL) =
(lifecycle_configuration_generation IS NULL)
);
END IF;

IF NOT EXISTS (
SELECT 1
FROM pg_catalog.pg_constraint
WHERE conrelid = 'storage.buckets'::regclass
AND conname = 'buckets_lifecycle_configuration_shape_check'
) THEN
ALTER TABLE storage.buckets
ADD CONSTRAINT buckets_lifecycle_configuration_shape_check CHECK (
lifecycle_configuration IS NULL
OR (
jsonb_typeof(lifecycle_configuration) = 'object'
AND lifecycle_configuration ? 'rules'
AND CASE
WHEN jsonb_typeof(lifecycle_configuration -> 'rules') = 'array'
THEN jsonb_array_length(lifecycle_configuration -> 'rules') BETWEEN 1 AND 1000
ELSE false
END
)
);
END IF;

IF NOT EXISTS (
SELECT 1
FROM pg_catalog.pg_constraint
WHERE conrelid = 'storage.buckets'::regclass
AND conname = 'buckets_lifecycle_configuration_standard_only_check'
) THEN
ALTER TABLE storage.buckets
ADD CONSTRAINT buckets_lifecycle_configuration_standard_only_check CHECK (
type = 'STANDARD'
OR (
lifecycle_configuration IS NULL
AND lifecycle_configuration_generation IS NULL
)
);
END IF;
END;
$$;

CREATE OR REPLACE FUNCTION storage.protect_bucket_control_columns()
RETURNS trigger
LANGUAGE plpgsql
SET search_path = pg_catalog
AS $$
DECLARE
service_role text = TG_ARGV[0];
current_operation text = COALESCE(current_setting('storage.operation', true), '');
configuration_changed boolean;
BEGIN
IF TG_OP = 'INSERT' THEN
IF NEW.lifecycle_configuration IS NOT NULL
OR NEW.lifecycle_configuration_generation IS NOT NULL THEN
RAISE EXCEPTION 'bucket control columns must use their protected defaults on insert'
USING ERRCODE = '42501';
END IF;

RETURN NEW;
END IF;

configuration_changed =
OLD.lifecycle_configuration IS DISTINCT FROM NEW.lifecycle_configuration
OR OLD.lifecycle_configuration_generation IS DISTINCT FROM NEW.lifecycle_configuration_generation;

IF NOT configuration_changed THEN
RETURN NEW;
END IF;

IF NEW.type IS DISTINCT FROM 'STANDARD' THEN
RAISE EXCEPTION 'bucket versioning and lifecycle controls require a Standard bucket'
USING ERRCODE = '0A000';
END IF;

IF current_user::text IS DISTINCT FROM service_role THEN
RAISE EXCEPTION 'bucket control columns may only be changed by the configured storage service role'
USING ERRCODE = '42501';
END IF;

IF NEW.lifecycle_configuration IS NULL
AND NEW.lifecycle_configuration_generation IS NULL THEN
IF current_operation NOT IN (
'storage.s3.bucket.delete_lifecycle',
'storage.bucket.delete_lifecycle'
) THEN
RAISE EXCEPTION 'invalid operation for lifecycle configuration deletion'
USING ERRCODE = '42501';
END IF;

RETURN NEW;
END IF;

IF current_operation NOT IN (
'storage.s3.bucket.put_lifecycle',
'storage.bucket.put_lifecycle'
) THEN
RAISE EXCEPTION 'invalid operation for lifecycle configuration update'
USING ERRCODE = '42501';
END IF;

IF NEW.lifecycle_configuration IS NULL
OR NEW.lifecycle_configuration_generation IS NULL
OR OLD.lifecycle_configuration IS NOT DISTINCT FROM NEW.lifecycle_configuration
OR OLD.lifecycle_configuration_generation IS NOT DISTINCT FROM NEW.lifecycle_configuration_generation THEN
RAISE EXCEPTION 'a changed lifecycle policy requires a new non-null generation'
USING ERRCODE = '22023';
END IF;

RETURN NEW;
END;
$$;

DO $$
DECLARE
service_role text = COALESCE(current_setting('storage.service_role', true), 'service_role');
BEGIN
DROP TRIGGER IF EXISTS protect_bucket_control_insert ON storage.buckets;
EXECUTE format(
'CREATE TRIGGER protect_bucket_control_insert BEFORE INSERT ON storage.buckets FOR EACH ROW EXECUTE FUNCTION storage.protect_bucket_control_columns(%L)',
service_role
);
Comment on lines +137 to +140

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@GuptaManan100 this was working a week ago but now it's failing with

EXECUTE of a runtime-built statement inside a PL/pgSQL body is not supported: 
the statement text is not a constant, so it cannot be checked for unsafe 
session-state changes

is it expected?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MT_UNSAFE_POOLER_MODE: "true" seems silencing it but does it have any drawbacks to be aware of?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For completeness, it fails its first example in https://github.com/supabase/storage/blob/master/migrations/tenant/0002-storage-schema.sql but we can't change old migrations.

This commit seems working, 64dd33d


DROP TRIGGER IF EXISTS protect_bucket_control_update ON storage.buckets;
EXECUTE format(
'CREATE TRIGGER protect_bucket_control_update BEFORE UPDATE OF lifecycle_configuration, lifecycle_configuration_generation ON storage.buckets FOR EACH ROW EXECUTE FUNCTION storage.protect_bucket_control_columns(%L)',
service_role
);
END;
$$;
93 changes: 93 additions & 0 deletions src/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,4 +82,97 @@ describe('public app', () => {
await app.close()
}
})

it('documents lifecycle configuration as a dedicated bucket subresource', async () => {
const app = buildApp({ exposeDocs: true })

try {
await app.ready()
const spec = app.swagger()
const lifecycleSchema = {
content: {
'application/json': {
schema: {
properties: {
rules: { type: 'array' },
},
},
},
},
}
const lifecycleRequestProperty = {
requestBody: {
content: {
'application/json': {
schema: {
properties: {
lifecycle_configuration: expect.anything(),
},
},
},
},
},
}

expect(spec.paths?.['/bucket/']?.post).toBeDefined()
expect(spec.paths?.['/bucket/']?.post).not.toMatchObject(lifecycleRequestProperty)
expect(spec.paths?.['/bucket/{bucketId}']?.put).toBeDefined()
expect(spec.paths?.['/bucket/{bucketId}']?.put).not.toMatchObject(lifecycleRequestProperty)
expect(spec.paths?.['/bucket/{bucketId}/lifecycle']?.put).toMatchObject({
description:
'The full configuration replaces any existing policy. Semantic validation failures use the REST InvalidParameter error contract.',
requestBody: lifecycleSchema,
})
expect(spec.paths?.['/bucket/{bucketId}/lifecycle']?.get).toMatchObject({
responses: { 200: lifecycleSchema },
})
expect(spec.paths?.['/bucket/{bucketId}/lifecycle']?.delete).toMatchObject({
responses: {
200: {
content: {
'application/json': {
schema: {
properties: {
message: { type: 'string' },
},
required: ['message'],
},
},
},
},
},
})
expect(spec.paths?.['/bucket/{bucketId}/lifecycle']?.delete?.responses).not.toHaveProperty(
'204'
)
expect(spec.paths?.['/bucket/{bucketId}']?.get?.parameters).toEqual(
expect.arrayContaining([
expect.objectContaining({
in: 'query',
name: 'include',
description:
'Include lifecycle_configuration when lifecycle support is available. The field is omitted when the feature is disabled.',
}),
])
)
expect(spec.paths?.['/bucket/{bucketId}']?.get?.responses?.['200']).toMatchObject({
content: {
'application/json': {
schema: {
properties: {
lifecycle_configuration: {
type: 'object',
nullable: true,
description:
'Returned only for include=lifecycle when lifecycle support is enabled. Null means no policy is stored or the tenant schema is not yet available.',
},
},
},
},
},
})
} finally {
await app.close()
}
})
})
10 changes: 10 additions & 0 deletions src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const CONFIG_ENV_KEYS = [
'OTEL_EXPORTER_OTLP_ENDPOINT',
'OTEL_EXPORTER_OTLP_METRICS_ENDPOINT',
'REQUEST_HARD_LIMITS_ENABLED',
'STORAGE_LIFECYCLE_ENABLED',
'STORAGE_S3_REQUEST_CHECKSUM_CALCULATION',
'STORAGE_S3_RESPONSE_CHECKSUM_VALIDATION',
'GLOBAL_S3_BUCKET',
Expand Down Expand Up @@ -83,6 +84,15 @@ describe('tenant pool cache config parsing', () => {
expect(config.tenantPoolCacheMaxEntries).toBe(16_384)
expect(config.databasePoolDrainTimeout).toBe(30_000)
expect(config.requestHardLimitsEnabled).toBe(false)
expect(config.storageLifecycleEnabled).toBe(false)
})

test('requires explicit opt-in for lifecycle configuration routes', async () => {
setConfigEnv({ STORAGE_LIFECYCLE_ENABLED: 'true' })

const { getConfig } = await import('./config')

expect(getConfig({ reload: true }).storageLifecycleEnabled).toBe(true)
})

test('uses the general OTLP endpoint as the metrics endpoint fallback', async () => {
Expand Down
2 changes: 2 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ type StorageConfigType = {
storageS3ForcePathStyle?: boolean
storageS3Region: string
storageS3ClientTimeout: number
storageLifecycleEnabled: boolean
isMultitenant: boolean
jwtSecret: string
jwtAlgorithm: JwtAlgorithm
Expand Down Expand Up @@ -464,6 +465,7 @@ export function getConfig(options?: { reload?: boolean }): StorageConfigType {
// Storage
storageBackendType: getOptionalConfigFromEnv('STORAGE_BACKEND') as StorageBackendType,
emptyBucketMax: parseInt(getOptionalConfigFromEnv('STORAGE_EMPTY_BUCKET_MAX') || '200000', 10),
storageLifecycleEnabled: getOptionalConfigFromEnv('STORAGE_LIFECYCLE_ENABLED') === 'true',

// Storage - File
storageFilePath: getOptionalConfigFromEnv(
Expand Down
38 changes: 38 additions & 0 deletions src/http/finite-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { setErrorHandler } from './error-handler'
import { withFiniteAjv } from './finite'
import createBucket from './routes/bucket/createBucket'
import getAllBuckets from './routes/bucket/getAllBuckets'
import bucketLifecycle from './routes/bucket/lifecycle'
import updateBucket from './routes/bucket/updateBucket'
import icebergBuckets from './routes/iceberg/bucket'
import icebergNamespaces from './routes/iceberg/namespace'
Expand Down Expand Up @@ -205,6 +206,43 @@ const cases: Array<{
plugin: renderPublicImage,
request: { method: 'GET', url: '/public/avatars/cat.png?y_offset=-Infinity' },
},
{
name: 'bucket lifecycle noncurrent days body',
plugin: bucketLifecycle,
request: {
method: 'PUT',
url: '/avatars/lifecycle',
payload: {
rules: [
{
status: 'Enabled',
filter: {},
noncurrentVersionExpiration: { noncurrentDays: 'Infinity' },
},
],
},
},
},
{
name: 'bucket lifecycle newer noncurrent versions body',
plugin: bucketLifecycle,
request: {
method: 'PUT',
url: '/avatars/lifecycle',
payload: {
rules: [
{
status: 'Enabled',
filter: {},
noncurrentVersionExpiration: {
noncurrentDays: 1,
newerNoncurrentVersions: '1e999',
},
},
],
},
},
},
{
name: 'Iceberg bucket offset query',
plugin: icebergBuckets,
Expand Down
28 changes: 28 additions & 0 deletions src/http/plugins/xml.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ async function buildXmlApp(
return { body: req.body }
})

app.put('/lifecycle', async (req) => req.body)
Comment thread
ferhatelmas marked this conversation as resolved.
Dismissed

app.get('/xml', async () => {
return {
ListBucketResult: {
Expand Down Expand Up @@ -78,6 +80,32 @@ async function buildXmlApp(
}

describe('xmlParser plugin', () => {
it('round-trips lifecycle rules with the S3 namespace and repeated Rule elements', async () => {
const app = await buildXmlApp(
['LifecycleConfiguration.Rule'],
'http://s3.amazonaws.com/doc/2006-03-01/'
)

try {
const payload =
'<LifecycleConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Rule><ID>first</ID><Status>Enabled</Status><Filter/><NoncurrentVersionExpiration><NoncurrentDays>30</NoncurrentDays><NewerNoncurrentVersions>2</NewerNoncurrentVersions></NoncurrentVersionExpiration></Rule><Rule><ID>second</ID><Status>Disabled</Status><Filter/><NoncurrentVersionExpiration><NoncurrentDays>7</NoncurrentDays></NoncurrentVersionExpiration></Rule></LifecycleConfiguration>'
const response = await app.inject({
method: 'PUT',
url: '/lifecycle',
headers: { 'content-type': 'application/xml', accept: 'application/xml' },
payload,
})

expect(response.statusCode).toBe(200)
expect(response.body).toContain('<LifecycleConfiguration')
expect(response.body.match(/<Rule>/g)).toHaveLength(2)
expect(response.body).toContain('<NewerNoncurrentVersions>2</NewerNoncurrentVersions>')
expect(response.body).toContain('xmlns="http://s3.amazonaws.com/doc/2006-03-01/"')
} finally {
await app.close()
}
})

it.each([
'application/xml',
'text/xml',
Expand Down
1 change: 1 addition & 0 deletions src/http/routes/bucket/createBucket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const createBucketBodySchema = {
},
},
required: ['name'],
not: { required: ['lifecycle_configuration'] },
} as const

const successResponseSchema = {
Expand Down
Loading