Skip to content

Commit

Permalink
ZENKO-4789: implement quota tests and logic
Browse files Browse the repository at this point in the history
  • Loading branch information
williamlardier committed May 7, 2024
1 parent ffd54bf commit 58327b8
Show file tree
Hide file tree
Showing 7 changed files with 369 additions and 35 deletions.
139 changes: 132 additions & 7 deletions tests/ctst/common/common.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,56 @@
import { Given, setDefaultTimeout, Then } from '@cucumber/cucumber';
import { Given, setDefaultTimeout, Then, When } from '@cucumber/cucumber';
import { Constants, S3, Utils } from 'cli-testing';
import Zenko from 'world/Zenko';
import { extractPropertyFromResults } from './utils';
import assert from 'assert';
import { Admin, Kafka } from 'kafkajs';
import { createBucketWithConfiguration, putObject } from 'steps/utils/utils';
import { createBucketWithConfiguration, putObject, runActionAgainstBucket } from 'steps/utils/utils';
import { ActionPermissionsType } from 'steps/bucket-policies/utils';

setDefaultTimeout(Constants.DEFAULT_TIMEOUT);

async function getTopicsOffsets(topics:string[], kafkaAdmin:Admin) {
/**
* @param {Zenko} this world object
* @param {string} objectName object name
* @returns {string} the object name based on the backend flakyness
*/
function getObjectNameWithBackendFlakiness(this: Zenko, objectName: string) {

Check warning on line 16 in tests/ctst/common/common.ts

View workflow job for this annotation

GitHub Actions / lint-and-build-ctst

'getObjectNameWithBackendFlakiness' is defined but never used
let objectNameFinal;
const backendFlakinessRetryNumber = this.getSaved<string>('backendFlakinessRetryNumber');
const backendFlakiness = this.getSaved<string>('backendFlakiness');

if (!backendFlakiness || !backendFlakinessRetryNumber || !objectName) {
return objectName;
}

switch (backendFlakiness) {
case 'command':
objectNameFinal = `${objectName}.scal-retry-command-${backendFlakinessRetryNumber}`;
break;
case 'archive':
case 'restore':
objectNameFinal = `${objectName}.scal-retry-${backendFlakiness}-job-${backendFlakinessRetryNumber}`;
break;
default:
process.stdout.write(`Unknown backend flakyness ${backendFlakiness}\n`);
return objectName;
}
return objectNameFinal;
}

async function getTopicsOffsets(topics: string[], kafkaAdmin: Admin) {
const offsets = [];
for (const topic of topics) {
const partitions: ({ high: string; low: string; })[] =
await kafkaAdmin.fetchTopicOffsets(topic);
await kafkaAdmin.fetchTopicOffsets(topic);
offsets.push({ topic, partitions });
}
return offsets;
}

Given('an account', async function (this: Zenko) {
await this.createAccount();
});

Given('a {string} bucket', async function (this: Zenko, versioning: string) {
this.resetCommand();
const preName = this.parameters.AccountName || Constants.ACCOUNT_NAME;
Expand Down Expand Up @@ -49,7 +82,7 @@ Then('kafka consumed messages should not take too much place on disk',
const kafkaAdmin = new Kafka({ brokers: [this.parameters.KafkaHosts] }).admin();
const topics: string[] = (await kafkaAdmin.listTopics())
.filter(t => (t.includes(this.parameters.InstanceID) &&
!ignoredTopics.some(e => t.includes(e))));
!ignoredTopics.some(e => t.includes(e))));
const previousOffsets = await getTopicsOffsets(topics, kafkaAdmin);

const seconds = parseInt(this.parameters.KafkaCleanerInterval);
Expand All @@ -59,7 +92,7 @@ Then('kafka consumed messages should not take too much place on disk',
// verify that the timestamp is not older than last kafkacleaner run
// Instead of waiting for a fixed amount of time,
// we could also check for metrics to see last kafkacleaner run

// 10 seconds added to be sure kafkacleaner had time to process
await Utils.sleep(seconds * 1000 + 10000);

Expand All @@ -85,3 +118,95 @@ Given('an object {string} that {string}', async function (this: Zenko, objectNam
await putObject(this, objectName);
}
});

When('the user tries to perform the current S3 action on the bucket {int} times with a {int} ms delay',
async function (this: Zenko, numberOfRuns: number, delay: number) {
this.setAuthMode('test_identity');
const action = {
...this.getSaved<ActionPermissionsType>('currentAction'),
};
if (action.action === 'ListObjectVersions') {
action.action = 'ListObjects';
this.addToSaved('currentAction', action);
}
if (action.action.includes('Version') && !action.action.includes('Versioning')) {
action.action = action.action.replace('Version', '');
this.addToSaved('currentAction', action);
}
for (let i = 0; i < numberOfRuns; i++) {
// For repeated WRITE actions, we want to change the object name
if (action.action === 'PutObject') {
this.addToSaved('objectName', `objectrepeat-${Utils.randomString()}`);
} else if (action.action === 'CopyObject' || action.action === 'UploadPartCopy') {
this.addToSaved('copyObject', `objectrepeatcopy-${Utils.randomString()}`);
}
await runActionAgainstBucket(this, this.getSaved<ActionPermissionsType>('currentAction').action);
if (this.getResult().err) {
// stop at any error, the error will be evaluated in a separated step
return;
}
await Utils.sleep(delay);
}
});

Then('the API should {string} with {string}', function (this: Zenko, result: string, expected: string) {
this.cleanupEntity();
const action = this.getSaved<ActionPermissionsType>('currentAction');
switch (result) {
case 'success':
if (action.expectedResultOnAllowTest) {
assert.strictEqual(
this.getResult().err?.includes(action.expectedResultOnAllowTest) ||
this.getResult().stdout?.includes(action.expectedResultOnAllowTest) ||
this.getResult().err === null, true);
} else {
assert.strictEqual(!!this.getResult().err, false);
}
break;
case 'fail':
assert.strictEqual(this.getResult().err?.includes(expected), true);
break;
}
});

Then('the operation finished without error', function (this: Zenko) {
this.cleanupEntity();
assert.strictEqual(!!this.getResult().err, false);
});

Given('an upload size of {int} B for the object {string}', async function (
this: Zenko,
size: number,
objectName: string
) {
this.addToSaved('objectSize', size);
if (this.getSaved<boolean>('preExistingObject')) {
if (objectName) {
this.addToSaved('objectName', objectName);
} else {
this.addToSaved('objectName', `object-${Utils.randomString()}`);
}
await putObject(this, this.getSaved<string>('objectName'));
}
});

When('I PUT an object with size {int}', async function (this: Zenko, size: number) {
if (size > 0) {
this.addToSaved('objectSize', size);
}
this.addToSaved('objectName', `object-${Utils.randomString()}`);
const result = await putObject(this, this.getSaved<string>('objectName'));
this.setResult(result);
});

When('i delete object {string}', async function (this: Zenko, objectName: string) {
const objName = objectName || this.getSaved<string>('objectName');
this.resetCommand();
this.addCommandParameter({ bucket: this.getSaved<string>('bucketName') });
this.addCommandParameter({ key: objName });
const versionId = this.getSaved<Map<string, string>>('createdObjects')?.get(objName);
if (versionId) {
this.addCommandParameter({ versionId });
}
await S3.deleteObject(this.getCommandParameters());
});
4 changes: 2 additions & 2 deletions tests/ctst/steps/bucket-policies/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,7 @@ Given('an environment setup for the API', async function (this: Zenko) {
roleName: this.getSaved<string>('identityName'),
});
assert.ifError(result.stderr || result.err);
} else {
} else if (identityType === EntityType.IAM_USER) { // accounts do not have any policy
const result = await IAM.attachUserPolicy({
policyArn,
userName: this.getSaved<string>('identityName'),
Expand Down Expand Up @@ -397,7 +397,7 @@ Given('an environment setup for the API', async function (this: Zenko) {
roleName: this.getSaved<string>('identityName'),
});
assert.ifError(result.stderr || result.err);
} else {
} else if (identityType === EntityType.IAM_USER) { // accounts do not have any policy
const detachResult = await IAM.detachUserPolicy({
policyArn,
userName: this.getSaved<string>('identityName'),
Expand Down
11 changes: 9 additions & 2 deletions tests/ctst/steps/bucket-policies/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const needObjectLock = [
];

const needObject = [
'CopyObject',
'PutObjectLegalHold',
'PutObjectRetention',
'PutObjectTagging',
Expand All @@ -46,6 +47,7 @@ const needObject = [
'PutObjectVersionTagging',
'PutObjectVersionRetention',
'PutObjectVersionLegalHold',
'RestoreObject',
];

const needVersioning = [
Expand Down Expand Up @@ -325,12 +327,12 @@ const actionPermissions: ActionPermissionsType[] = [
{
action: 'UploadPart',
permissions: ['s3:PutObject'],
expectedResultOnAllowTest: 'NoSuchUpload',
needsSetup: true,
},
{
action: 'UploadPartCopy',
permissions: ['s3:PutObject', 's3:GetObject'],
expectedResultOnAllowTest: 'NoSuchUpload',
needsSetup: true,
},
{
action: 'CopyObject',
Expand Down Expand Up @@ -362,6 +364,11 @@ const actionPermissions: ActionPermissionsType[] = [
permissions: ['s3:ListBucketVersions', 's3:ListBucket'],
excludePermissionOnBucketObjects: true,
},
{
action: 'RestoreObject',
permissions: ['s3:RestoreObject'],
expectedResultOnAllowTest: 'InvalidObjectState',
},
{
action: 'MetadataSearch',
permissions: ['s3:MetadataSearch'],
Expand Down
143 changes: 143 additions & 0 deletions tests/ctst/steps/quotas/quotas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
/* eslint-disable no-case-declarations */
import fs from 'fs';
import lockFile from 'proper-lockfile';
import { createHash } from 'crypto';
import { Given, Before, When } from '@cucumber/cucumber';
import Zenko from '../../world/Zenko';
import { Scality, Command, CacheHelper, Constants, Utils } from 'cli-testing';
import { createJobAndWaitForCompletion } from 'steps/utils/kubernetes';
import { createBucketWithConfiguration, putObject } from 'steps/utils/utils';

function hashStringAndKeepFirst40Characters(input: string) {
return createHash('sha256').update(input).digest('hex').slice(0, 40);
}

/**
* The objective of this hook is to prepare all the buckets and accounts
* we use during quota checks, so that we avoid running the job multiple
* times, which affects the performance of the tests.
* The steps arer: create an account, then create a simple bucket
*/
Before({tags: '@Quotas'}, async function ({ gherkinDocument, pickle }) {
let initiated = false;
let releaseLock: (() => Promise<void>) | false = false;
const output: { [key: string]: { AccessKey: string, SecretKey: string }} = {};
const world = this as Zenko;

await Zenko.init(world.parameters);

const featureName = gherkinDocument.feature?.name?.replace(/ /g, '-').toLowerCase() || 'quotas';
const filePath = `/tmp/${featureName}`;

if (!fs.existsSync(filePath)) {
fs.writeFileSync(filePath, JSON.stringify({
ready: false,
}));
} else {
initiated = true;
}

if (!initiated) {
try {
releaseLock = await lockFile.lock(filePath, { stale: Constants.DEFAULT_TIMEOUT / 2 });
} catch (err) {
releaseLock = false;
}
}

if (releaseLock) {
const isBucketNonVersioned = gherkinDocument.feature?.tags?.find(
tag => tag.name === 'NonVersioned') === undefined;
for (const scenario of gherkinDocument.feature?.children || []) {
for (const example of scenario.scenario?.examples || []) {
for (const values of example.tableBody || []) {
const scenarioWithExampleID = hashStringAndKeepFirst40Characters(`${values.id}`);
await world.createAccount(scenarioWithExampleID);
await createBucketWithConfiguration(world, scenarioWithExampleID,
isBucketNonVersioned ? '' : 'with');
await putObject(world);
output[scenarioWithExampleID] = {
AccessKey: CacheHelper.parameters?.AccessKey || Constants.DEFAULT_ACCESS_KEY,
SecretKey: CacheHelper.parameters?.SecretKey || Constants.DEFAULT_SECRET_KEY,
};
}
}
}

await createJobAndWaitForCompletion(world, 'end2end-ops-count-items', 'quotas-setup');
await Utils.sleep(2000);
fs.writeFileSync(filePath, JSON.stringify({
ready: true,
...output,
}));

await releaseLock();
} else {
while (!fs.existsSync(filePath)) {
await Utils.sleep(100);
}

let configuration: { ready: boolean } = JSON.parse(fs.readFileSync(filePath, 'utf8')) as { ready: boolean };
while (!configuration.ready) {
await Utils.sleep(100);
configuration = JSON.parse(fs.readFileSync(filePath, 'utf8')) as { ready: boolean };
}
}

const configuration: typeof output = JSON.parse(fs.readFileSync(`/tmp/${featureName}`, 'utf8')) as typeof output;
const key = hashStringAndKeepFirst40Characters(`${pickle.astNodeIds[1]}`);
world.parameters.logger?.debug('Scenario key', { key, from: `${pickle.astNodeIds[1]}`, configuration });
const config = configuration[key];
world.resetGlobalType();
Zenko.saveAccountAccessKeys(config.AccessKey, config.SecretKey);
world.addToSaved('bucketName', key);
});

Given('a bucket quota set to {int} B', async function (this: Zenko, quota: number) {
if (quota === 0) {
return;
}
this.addCommandParameter({
quota: String(quota),
});
this.addCommandParameter({
bucket: this.getSaved<string>('bucketName'),
});
const result: Command = await Scality.updateBucketQuota(
this.parameters,
this.getCliMode(),
this.getCommandParameters());

this.parameters.logger?.debug('UpdateBucketQuota result', {
result,
});

if (result.err) {
throw new Error(result.err);
}
});

Given('an account quota set to {int} B', async function (this: Zenko, quota: number) {
if (quota === 0) {
return;
}
this.addCommandParameter({
quotaMax: String(quota),
});
const result: Command = await Scality.updateAccountQuota(
this.parameters,
this.getCliMode(),
this.getCommandParameters());

this.parameters.logger?.debug('UpdateAccountQuota result', {
result,
});

if (result.err) {
throw new Error(result.err);
}
});

When('I wait {int} seconds', async function (this: Zenko, seconds: number) {

Check warning on line 141 in tests/ctst/steps/quotas/quotas.ts

View workflow job for this annotation

GitHub Actions / lint-and-build-ctst

Unexpected function expression
await Utils.sleep(seconds * 1000);
});
Loading

0 comments on commit 58327b8

Please sign in to comment.