Skip to content
Merged
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
12 changes: 9 additions & 3 deletions lib/errors/arsenalErrors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ export const InvalidTag: ErrorFormat = {
export const InvalidTargetBucketForLogging: ErrorFormat = {
code: 400,
description:
'The target bucket for logging does not exist, is not owned by you, '+
'The target bucket for logging does not exist, is not owned by you, '+
'or does not have the appropriate grants for the log-delivery group.',
};

Expand Down Expand Up @@ -402,11 +402,17 @@ export const ObjectLockConfigurationNotFoundError: ErrorFormat = {
description: 'The object lock configuration was not found',
};


export const ServerSideEncryptionConfigurationNotFoundError: ErrorFormat = {
code: 404,
description: 'The server side encryption configuration was not found',
};

export const NoSuchRateLimitConfig: ErrorFormat = {
code: 404,
description: 'The bucket rate limit configuration does not exist.',
};

export const NotImplemented: ErrorFormat = {
code: 501,
description:
Expand Down Expand Up @@ -483,8 +489,8 @@ export const SignatureDoesNotMatch: ErrorFormat = {
description:
'The request signature we calculated does not match the signature you provided.',
};
// "This is an AWS S3 specific error. We are opting to use the more general 'ServiceUnavailable'
// error used throughout AWS (IAM/EC2) to have uniformity of error messages even though we are
// "This is an AWS S3 specific error. We are opting to use the more general 'ServiceUnavailable'
// error used throughout AWS (IAM/EC2) to have uniformity of error messages even though we are
// potentially compromising S3 compatibility.",

// export const ServiceUnavailable: ErrorFormat = {
Expand Down
43 changes: 31 additions & 12 deletions lib/models/BucketInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,12 @@ import { areTagsValid, BucketTag } from '../s3middleware/tagging';
import { VeeamCapability, VeeamSOSApiSchema, VeeamSOSApiSerializable } from './Veeam';
import { AzureInfoMetadata } from './BucketAzureInfo';
import BucketLoggingStatus from './BucketLoggingStatus';
import RateLimitConfiguration, { RateLimitConfigurationMetadata } from './RateLimitConfiguration';

// WHEN UPDATING THIS NUMBER, UPDATE BucketInfoModelVersion.md CHANGELOG
// BucketInfoModelVersion.md can be found in documentation/ at the root
// of this repository
const modelVersion = 18;
const modelVersion = 19;

export type CORS = {
id: string;
Expand Down Expand Up @@ -80,6 +81,7 @@ export type BucketMetadata = {
capabilities?: Capabilities,
quotaMax: bigint | number,
bucketLoggingStatus?: BucketLoggingStatus,
rateLimitConfiguration?: RateLimitConfigurationMetadata,
};

export type BucketMetadataJSON = Omit<BucketMetadata, 'quotaMax' | 'capabilities'> & {
Expand Down Expand Up @@ -118,6 +120,7 @@ export default class BucketInfo implements BucketMetadata {
private _capabilities?: Capabilities;
private _quotaMax: bigint;
private _bucketLoggingStatus?: BucketLoggingStatus;
private _rateLimitConfiguration?: RateLimitConfiguration;

/**
* Represents all bucket information.
Expand Down Expand Up @@ -205,6 +208,7 @@ export default class BucketInfo implements BucketMetadata {
capabilities?: Capabilities,
quotaMax?: bigint | number,
bucketLoggingStatus?: BucketLoggingStatus,
rateLimitConfiguration?: RateLimitConfiguration,
) {
assert.strictEqual(typeof name, 'string');
assert.strictEqual(typeof owner, 'string');
Expand All @@ -228,7 +232,7 @@ export default class BucketInfo implements BucketMetadata {
assert.strictEqual(typeof cryptoScheme, 'number');
assert.strictEqual(typeof algorithm, 'string');
assert.strictEqual(typeof mandatory, 'boolean');
assert.ok(masterKeyId !== undefined || configuredMasterKeyId !== undefined,
assert.ok(masterKeyId !== undefined || configuredMasterKeyId !== undefined,
'At least one of masterKeyId or configuredMasterKeyId must be defined');
if (masterKeyId !== undefined) {
assert.strictEqual(typeof masterKeyId, 'string', 'masterKeyId must be a string');
Expand Down Expand Up @@ -368,8 +372,9 @@ export default class BucketInfo implements BucketMetadata {
VeeamSOSApi: capabilities.VeeamSOSApi &&
VeeamCapability.toBigInt(capabilities.VeeamSOSApi),
};

this._quotaMax = BigInt(quotaMax || 0n);
this._rateLimitConfiguration = rateLimitConfiguration;
return this;
}

Expand Down Expand Up @@ -411,6 +416,7 @@ export default class BucketInfo implements BucketMetadata {
},
quotaMax: this._quotaMax.toString(),
bucketLoggingStatus: this._bucketLoggingStatus,
rateLimitConfiguration: this._rateLimitConfiguration?.getData(),
};
const final = this._websiteConfiguration
? {
Expand Down Expand Up @@ -445,6 +451,8 @@ export default class BucketInfo implements BucketMetadata {
new WebsiteConfiguration(obj.websiteConfiguration) : undefined;
const bucketLoggingStatus = obj.bucketLoggingStatus ?
new BucketLoggingStatus((obj.bucketLoggingStatus as any)._loggingEnabled) : undefined;
const rateLimitConfiguration = obj.rateLimitConfiguration ?
new RateLimitConfiguration(obj.rateLimitConfiguration) : undefined;
return new BucketInfo(obj.name, obj.owner, obj.ownerDisplayName,
obj.creationDate, obj.mdBucketModelVersion, obj.acl,
obj.transient, obj.deleted, obj.serverSideEncryption,
Expand All @@ -453,7 +461,7 @@ export default class BucketInfo implements BucketMetadata {
obj.bucketPolicy, obj.uid, obj.readLocationConstraint, obj.isNFS,
obj.ingestion, obj.azureInfo, obj.objectLockEnabled,
obj.objectLockConfiguration, obj.notificationConfiguration, obj.tags,
capabilities, BigInt(obj.quotaMax || 0n), bucketLoggingStatus);
capabilities, BigInt(obj.quotaMax || 0n), bucketLoggingStatus, rateLimitConfiguration);
}

/**
Expand Down Expand Up @@ -486,7 +494,8 @@ export default class BucketInfo implements BucketMetadata {
data._isNFS, data._ingestion, data._azureInfo,
data._objectLockEnabled, data._objectLockConfiguration,
data._notificationConfiguration, data._tags, capabilities,
BigInt(data._quotaMax || 0n), data._bucketLoggingStatus);
BigInt(data._quotaMax || 0n), data._bucketLoggingStatus,
data._rateLimitConfiguration);
}

/**
Expand All @@ -498,6 +507,8 @@ export default class BucketInfo implements BucketMetadata {
static fromJson(data: BucketMetadataJSON) {
const bucketLoggingStatus = data.bucketLoggingStatus ?
new BucketLoggingStatus((data.bucketLoggingStatus as any)._loggingEnabled) : undefined;
const rateLimitConfiguration = data.rateLimitConfiguration ?
new RateLimitConfiguration(data.rateLimitConfiguration) : undefined;
return new BucketInfo(data.name, data.owner, data.ownerDisplayName,
data.creationDate, data.mdBucketModelVersion, data.acl,
data.transient, data.deleted, data.serverSideEncryption,
Expand All @@ -511,7 +522,7 @@ export default class BucketInfo implements BucketMetadata {
...data.capabilities,
VeeamSOSApi: data.capabilities?.VeeamSOSApi &&
VeeamCapability.parse(data.capabilities?.VeeamSOSApi),
}, BigInt(data.quotaMax || 0n), bucketLoggingStatus);
}, BigInt(data.quotaMax || 0n), bucketLoggingStatus, rateLimitConfiguration);
}

/**
Expand Down Expand Up @@ -738,9 +749,9 @@ export default class BucketInfo implements BucketMetadata {

/**
* Checks if the default encryption is set at the account level instead of the legacy bucket level.
* This method helps to prevent deletion of the account-level master encryption key when deleting buckets.
* This method helps to prevent deletion of the account-level master encryption key when deleting buckets.
*
* @returns {boolean} - Returns true if account-level default encryption is enabled,
* @returns {boolean} - Returns true if account-level default encryption is enabled,
* false if it uses the legacy bucket level.
*/
isAccountEncryptionEnabled() {
Expand Down Expand Up @@ -1027,7 +1038,7 @@ export default class BucketInfo implements BucketMetadata {
getTags() {
return this._tags;
}

/**
* Set bucket tags
* @return - bucket info instance
Expand All @@ -1047,7 +1058,7 @@ export default class BucketInfo implements BucketMetadata {

/**
* Get a specific bucket capability
*
*
* @param capability? - if provided, will return a specific capacity
* @return - capability of the bucket
*/
Expand All @@ -1057,7 +1068,7 @@ export default class BucketInfo implements BucketMetadata {
}
return undefined;
}

/**
* Set bucket capabilities
* @return - bucket info instance
Expand All @@ -1074,7 +1085,7 @@ export default class BucketInfo implements BucketMetadata {
getQuota() {
return this._quotaMax;
}

/**
* Set bucket quota
* @param quota - quota to be set
Expand Down Expand Up @@ -1102,4 +1113,12 @@ export default class BucketInfo implements BucketMetadata {
this._bucketLoggingStatus = bucketLoggingStatus;
return this;
}

getRateLimitConfiguration(): RateLimitConfiguration | undefined {
return this._rateLimitConfiguration;
}

setRateLimitConfiguration(value: RateLimitConfiguration) {
this._rateLimitConfiguration = value;
}
}
38 changes: 38 additions & 0 deletions lib/models/RateLimitConfiguration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@

export type LimitConfiguration = {
Limit: number;
};

export type RateLimitConfigurationMetadata = {
RequestsPerSecond?: LimitConfiguration
};

export default class RateLimitConfiguration {
private readonly _data: RateLimitConfigurationMetadata;

constructor(obj: RateLimitConfigurationMetadata) {
this._data = obj;
}

getRequestsPerSecondLimit(): number | undefined {
return this._data.RequestsPerSecond?.Limit;
}

setRequestsPerSecondLimit(value: number): RateLimitConfiguration {
this._data.RequestsPerSecond = {
...(this._data.RequestsPerSecond || {}),
Limit: value,
};

return this;
}

removeRequestsPerSecondLimit(): RateLimitConfiguration {
delete this._data.RequestsPerSecond;
return this;
}

getData(): RateLimitConfigurationMetadata {
return this._data;
}
}
1 change: 1 addition & 0 deletions lib/models/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,5 @@ export { default as ObjectMDAzureInfo } from './ObjectMDAzureInfo';
export { default as ObjectMDLocation } from './ObjectMDLocation';
export { default as ReplicationConfiguration } from './ReplicationConfiguration';
export { default as BucketLoggingStatus } from './BucketLoggingStatus';
export { default as RateLimitConfiguration } from './RateLimitConfiguration';
export * as WebsiteConfiguration from './WebsiteConfiguration';
4 changes: 3 additions & 1 deletion lib/s3routes/routes/routeDELETE.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ export default function routeDELETE(
return call('bucketDeleteTagging');
} else if (query?.quota !== undefined) {
return call('bucketDeleteQuota');
} else if (query?.['rate-limit'] !== undefined) {
return call('bucketDeleteRateLimit');
}
return call('bucketDelete');
} else {
Expand All @@ -57,7 +59,7 @@ export default function routeDELETE(
* be sent back as a response.
*/
if (err && (
!(err instanceof ArsenalError) ||
!(err instanceof ArsenalError) ||
(!err.is.NoSuchKey && !err.is.NoSuchVersion)
)) {
return routesUtils.responseNoBody(err, corsHeaders,
Expand Down
2 changes: 2 additions & 0 deletions lib/s3routes/routes/routeGET.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ export default function routerGET(
call('bucketGetQuota');
} else if (query.logging !== undefined) {
call('bucketGetLogging');
} else if (query['rate-limit'] !== undefined) {
call('bucketGetRateLimit');
} else {
// GET bucket
call('bucketGet');
Expand Down
7 changes: 7 additions & 0 deletions lib/s3routes/routes/routePUT.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,13 @@ export default function routePUT(
return routesUtils.responseNoBody(err, resHeaders, response,
200, log);
});
} else if (query['rate-limit'] !== undefined) {
api.callApiMethod('bucketPutRateLimit', request, response,
log, (err, resHeaders) => {
routesUtils.statsReport500(err, statsClient);
return routesUtils.responseNoBody(err, resHeaders, response,
200, log);
});
} else {
// PUT bucket
return api.callApiMethod('bucketPut', request, response, log,
Expand Down
55 changes: 55 additions & 0 deletions tests/unit/models/RateLimitConfiguration.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import assert from 'assert';

import RateLimitConfiguration from '../../../lib/models/RateLimitConfiguration';

describe('Test RateLimitConfiguration', () => {
it('should create an empty RateLimitConfiguration', () => {
const rlc = new RateLimitConfiguration({});
assert.deepStrictEqual(rlc.getData(), {});
});

it('should create a RateLimitConfiguration with a RequestsPerSecond limit', () => {
const rlc = new RateLimitConfiguration({
RequestsPerSecond: {
Limit: 1000,
},
});
assert.deepStrictEqual(rlc.getData(), {
RequestsPerSecond: {
Limit: 1000,
},
});
});

it('should return RequestsPerSecond.Limit if set', () => {
const rlc = new RateLimitConfiguration({
RequestsPerSecond: {
Limit: 1000,
},
});
assert.strictEqual(rlc.getRequestsPerSecondLimit(), 1000);
});

it('should return undefined if RequestsPerSecond.Limit is not set', () => {
const rlc = new RateLimitConfiguration({});
assert.strictEqual(rlc.getRequestsPerSecondLimit(), undefined);
});

it('should set RequestsPerSecond.Limit', () => {
const rlc = new RateLimitConfiguration({});
assert.strictEqual(rlc.getRequestsPerSecondLimit(), undefined);
rlc.setRequestsPerSecondLimit(1000);
assert.strictEqual(rlc.getRequestsPerSecondLimit(), 1000);
});

it('should remove RequestsPerSecond.Limit', () => {
const rlc = new RateLimitConfiguration({
RequestsPerSecond: {
Limit: 1000,
},
});
assert.strictEqual(rlc.getRequestsPerSecondLimit(), 1000);
rlc.removeRequestsPerSecondLimit();
assert.strictEqual(rlc.getRequestsPerSecondLimit(), undefined);
});
});
11 changes: 11 additions & 0 deletions tests/unit/s3routes/routeDELETE.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -138,4 +138,15 @@ describe('routeDELETE', () => {
otherError, {}, response, undefined, log,
);
});

it('should call bucketDeleteRateLimit if query.rate-limit is set', () => {
request.query = { 'rate-limit': '' };
request.objectKey = undefined;

routeDELETE(request, response, api, log, statsClient);

expect(api.callApiMethod).toHaveBeenCalledWith(
'bucketDeleteRateLimit', request, response, log, expect.any(Function),
);
});
});
11 changes: 11 additions & 0 deletions tests/unit/s3routes/routeGET.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,17 @@ describe('routerGET', () => {
);
});

it('should call bucketGetRateLimit when query.rate-limit is present', () => {
request.bucketName = 'bucketName';
request.query = { 'rate-limit': '' };

routerGET(request, response, api, log, statsClient, dataRetrievalParams);

expect(api.callApiMethod).toHaveBeenCalledWith(
'bucketGetRateLimit', request, response, log, expect.any(Function),
);
});

it('should handle objectGet with responseStreamData when no query is present for an object', () => {
request.bucketName = 'bucketName';
request.objectKey = 'objectKey';
Expand Down
Loading
Loading