diff --git a/lib/api/apiUtils/object/coldStorage.js b/lib/api/apiUtils/object/coldStorage.js index dd33022c99..d66a957aeb 100644 --- a/lib/api/apiUtils/object/coldStorage.js +++ b/lib/api/apiUtils/object/coldStorage.js @@ -6,6 +6,7 @@ const { ObjectMDArchive } = require('arsenal').models; const errors = require('arsenal').errors; const errorInstances = require('arsenal').errorInstances; const { config } = require('../../../Config'); +const { isColdStorageClass } = require('./storageClass'); const { locationConstraints } = config; const { scaledMsPerDay } = config.getTimeOptions(); @@ -17,9 +18,7 @@ const { scaledMsPerDay } = config.getTimeOptions(); * @returns {string|undefined} x-amz-restore */ function getAmzRestoreResHeader(objMD) { - if (objMD.archive && - objMD.archive.restoreRequestedAt && - !objMD.archive.restoreCompletedAt) { + if (objMD.archive && objMD.archive.restoreRequestedAt && !objMD.archive.restoreCompletedAt) { // Avoid race condition by relying on the `archive` MD of the object // and return the right header after a RESTORE request. // eslint-disable-next-line @@ -48,7 +47,9 @@ function setArchiveInfoHeaders(objMD) { } if (objMD.archive) { - headers['x-amz-scal-archive-info'] = JSON.stringify(objMD.archive.archiveInfo); + if (objMD.archive.archiveInfo) { + headers['x-amz-scal-archive-info'] = JSON.stringify(objMD.archive.archiveInfo); + } if (objMD.archive.restoreRequestedAt) { headers['x-amz-scal-restore-requested-at'] = new Date(objMD.archive.restoreRequestedAt).toUTCString(); @@ -83,11 +84,10 @@ function _validateStartRestore(objectMD, log) { if (new Date(objectMD.archive?.restoreWillExpireAt) < new Date(Date.now())) { // return InvalidObjectState error if the restored object is expired // but restore info md of this object has not yet been cleared - log.debug('The restored object already expired.', - { - archive: objectMD.archive, - method: '_validateStartRestore', - }); + log.debug('The restored object already expired.', { + archive: objectMD.archive, + method: '_validateStartRestore', + }); return errors.InvalidObjectState; } @@ -96,25 +96,34 @@ function _validateStartRestore(objectMD, log) { // been reset. return undefined; } + if (isColdStorageClass(objectMD['x-amz-storage-class']) && !objectMD.archive?.archiveInfo) { + // The object was written directly to a cold location, and is still awaiting its first + // archive: its data is hot, so there is nothing to recall yet. The request is only + // recorded, and initiated once the archive completes; a repeated request simply updates + // the pending one, instead of being rejected as already in progress. + log.debug('The object is not archived yet, the restore is deferred.', { + archive: objectMD.archive, + method: '_validateStartRestore', + }); + return undefined; + } const isLocationCold = locationConstraints[objectMD.dataStoreName]?.isCold; if (!isLocationCold) { // return InvalidObjectState error if the object is not in cold storage, // not in cold storage means either location cold flag not exists or cold flag is explicit false - log.debug('The bucket of the object is not in a cold storage location.', - { - isLocationCold, - method: '_validateStartRestore', - }); + log.debug('The bucket of the object is not in a cold storage location.', { + isLocationCold, + method: '_validateStartRestore', + }); return errors.InvalidObjectState; } if (objectMD.archive?.restoreRequestedAt) { // return RestoreAlreadyInProgress error if the object is currently being restored // check if archive.restoreRequestAt exists and archive.restoreCompletedAt not yet exists - log.debug('The object is currently being restored.', - { - archive: objectMD.archive, - method: '_validateStartRestore', - }); + log.debug('The object is currently being restored.', { + archive: objectMD.archive, + method: '_validateStartRestore', + }); return errors.RestoreAlreadyInProgress; } return undefined; @@ -142,21 +151,24 @@ function validatePutVersionId(objMD, versionId, log) { const isLocationCold = locationConstraints[objMD.dataStoreName]?.isCold; if (!isLocationCold) { - log.error('The object data is not stored in a cold storage location.', - { - isLocationCold, - dataStoreName: objMD.dataStoreName, - method: 'validatePutVersionId', - }); + log.error('The object data is not stored in a cold storage location.', { + isLocationCold, + dataStoreName: objMD.dataStoreName, + method: 'validatePutVersionId', + }); return errors.InvalidObjectState; } // make sure object archive restoration is in progress // NOTE: we do not use putObjectVersion to update the restoration period. - if (!objMD.archive || !objMD.archive.restoreRequestedAt || !objMD.archive.restoreRequestedDays - || objMD.archive.restoreCompletedAt || objMD.archive.restoreWillExpireAt) { - log.error('object archive restoration is not in progress', - { method: 'validatePutVersionId', versionId }); + if ( + !objMD.archive || + !objMD.archive.restoreRequestedAt || + !objMD.archive.restoreRequestedDays || + objMD.archive.restoreCompletedAt || + objMD.archive.restoreWillExpireAt + ) { + log.error('object archive restoration is not in progress', { method: 'validatePutVersionId', versionId }); return errors.InvalidObjectState; } @@ -180,11 +192,11 @@ function _updateObjectExpirationDate(objectMD, log) { const isObjectAlreadyRestored = !!objectMD.archive.restoreCompletedAt; log.debug('The restore status of the object.', { isObjectAlreadyRestored, - method: 'isObjectAlreadyRestored' + method: 'isObjectAlreadyRestored', }); if (isObjectAlreadyRestored) { const expiryDate = new Date(objectMD.archive.restoreRequestedAt); - expiryDate.setTime(expiryDate.getTime() + (objectMD.archive.restoreRequestedDays * scaledMsPerDay)); + expiryDate.setTime(expiryDate.getTime() + objectMD.archive.restoreRequestedDays * scaledMsPerDay); /* eslint-disable no-param-reassign */ objectMD.archive.restoreWillExpireAt = expiryDate; @@ -208,22 +220,20 @@ function _updateObjectExpirationDate(objectMD, log) { * */ function _updateRestoreInfo(objectMD, restoreParam, log) { + /* eslint-disable no-param-reassign */ if (!objectMD.archive) { - log.debug('objectMD.archive doesn\'t exits', { - objectMD, - method: '_updateRestoreInfo' - }); - return errorInstances.InternalError.customizeDescription('Archive metadata is missing.'); + // an object awaiting its first archive has no archive metadata yet + objectMD.archive = {}; } - /* eslint-disable no-param-reassign */ objectMD.archive.restoreRequestedAt = new Date(); objectMD.archive.restoreRequestedDays = restoreParam.days; objectMD.originOp = 's3:ObjectRestore:Post'; /* eslint-enable no-param-reassign */ - if (!ObjectMDArchive.isValid(objectMD.archive)) { + // `ObjectMDArchive` requires `archiveInfo`, which is only set once the object is archived + if (objectMD.archive.archiveInfo && !ObjectMDArchive.isValid(objectMD.archive)) { log.debug('archive is not valid', { archive: objectMD.archive, - method: '_updateRestoreInfo' + method: '_updateRestoreInfo', }); return errorInstances.InternalError.customizeDescription('Invalid archive metadata.'); } @@ -249,7 +259,7 @@ function startRestore(objectMD, restoreParam, log, cb) { if (checkResultError) { log.debug('Restore cannot be done.', { error: checkResultError, - method: 'startRestore' + method: 'startRestore', }); return cb(checkResultError); } @@ -257,12 +267,12 @@ function startRestore(objectMD, restoreParam, log, cb) { if (updateResultError) { log.debug('Failed to update restore information.', { error: updateResultError, - method: 'startRestore' + method: 'startRestore', }); return cb(updateResultError); } log.debug('Validated and updated restore information', { - method: 'startRestore' + method: 'startRestore', }); const isObjectAlreadyRestored = _updateObjectExpirationDate(objectMD, log); return cb(null, isObjectAlreadyRestored); @@ -274,14 +284,20 @@ function startRestore(objectMD, restoreParam, log, cb) { * @returns {ArsenalError|null} error if object data is not available */ function verifyColdObjectAvailable(objMD) { + // An object written directly to a cold location has not been archived yet: its data is still + // in the hot location, and stays available even once a restore has been requested for it. + if (!objMD.archive?.archiveInfo) { + return null; + } // return error when object is cold - if (objMD.archive && - // Object is in cold backend - (!objMD.archive.restoreRequestedAt || - // Object is being restored - (objMD.archive.restoreRequestedAt && !objMD.archive.restoreCompletedAt))) { - const err = errorInstances.InvalidObjectState - .customizeDescription('The operation is not valid for the object\'s storage class'); + if ( + !objMD.archive.restoreRequestedAt || + // Object is being restored + (objMD.archive.restoreRequestedAt && !objMD.archive.restoreCompletedAt) + ) { + const err = errorInstances.InvalidObjectState.customizeDescription( + "The operation is not valid for the object's storage class", + ); return err; } return null; diff --git a/tests/unit/api/apiUtils/coldStorage.js b/tests/unit/api/apiUtils/coldStorage.js index d38a6962b8..e1d3b6d9fc 100644 --- a/tests/unit/api/apiUtils/coldStorage.js +++ b/tests/unit/api/apiUtils/coldStorage.js @@ -2,9 +2,11 @@ const assert = require('assert'); const { errors } = require('arsenal'); const { + getAmzRestoreResHeader, + setArchiveInfoHeaders, startRestore, validatePutVersionId, - verifyColdObjectAvailable + verifyColdObjectAvailable, } = require('../../../../lib/api/apiUtils/object/coldStorage'); const { DummyRequestLogger } = require('../../helpers'); const { ObjectMD, ObjectMDArchive } = require('arsenal/build/lib/models'); @@ -13,9 +15,25 @@ const { scaledMsPerDay } = config.getTimeOptions(); const log = new DummyRequestLogger(); const oneDay = 24 * 60 * 60 * 1000; -const { - LOCATION_NAME_DMF, -} = require('../../../constants'); +const { LOCATION_NAME_DMF } = require('../../../constants'); + +const archiveInfo = { + archiveId: '97a71dfe-49c1-4cca-840a-69199e0b0322', + archiveVersion: 5577006791947779, +}; + +/** + * Object written directly to a cold location: it is declared cold, but its data is still in the + * hot location and it has no archive metadata until the transition completes. + * @returns {object} the object metadata + */ +function directToColdObjectMD() { + return new ObjectMD() + .setDataStoreName('us-east-1') + .setAmzStorageClass(LOCATION_NAME_DMF) + .setTransitionInProgress(true, Date.now()) + .getValue(); +} describe('cold storage', () => { describe('validatePutVersionId', () => { @@ -74,29 +92,36 @@ describe('cold storage', () => { }, expectedRes: undefined, }, - ].forEach(testCase => it(testCase.description, () => { - const res = validatePutVersionId(testCase.objMD, testCase.versionId, log); - assert.deepStrictEqual(res, testCase.expectedRes); - })); + ].forEach(testCase => + it(testCase.description, () => { + const res = validatePutVersionId(testCase.objMD, testCase.versionId, log); + assert.deepStrictEqual(res, testCase.expectedRes); + }), + ); }); describe('verifyColdObjectAvailable', () => { [ { description: 'should return error if object is in a cold location', - objectMd: new ObjectMD() - .setArchive(new ObjectMDArchive({ + objectMd: new ObjectMD().setArchive( + new ObjectMDArchive({ archiveId: '97a71dfe-49c1-4cca-840a-69199e0b0322', - archiveVersion: 5577006791947779 - })) + archiveVersion: 5577006791947779, + }), + ), }, { description: 'should return error if object is restoring', - objectMd: new ObjectMD() - .setArchive(new ObjectMDArchive({ - archiveId: '97a71dfe-49c1-4cca-840a-69199e0b0322', - archiveVersion: 5577006791947779, - }, Date.now())) + objectMd: new ObjectMD().setArchive( + new ObjectMDArchive( + { + archiveId: '97a71dfe-49c1-4cca-840a-69199e0b0322', + archiveVersion: 5577006791947779, + }, + Date.now(), + ), + ), }, ].forEach(params => { it(`${params.description}`, () => { @@ -118,19 +143,90 @@ describe('cold storage', () => { }); it('should return null if object is restored', () => { - const objectMd = new ObjectMD().setArchive(new ObjectMDArchive( - { - archiveId: '97a71dfe-49c1-4cca-840a-69199e0b0322', - archiveVersion: 5577006791947779, - }, - /*restoreRequestedAt*/ new Date(0), - /*restoreRequestedDays*/ 5, - /*restoreCompletedAt*/ new Date(1000), - /*restoreWillExpireAt*/ new Date(1000 + 5 * oneDay), - )); + const objectMd = new ObjectMD().setArchive( + new ObjectMDArchive( + { + archiveId: '97a71dfe-49c1-4cca-840a-69199e0b0322', + archiveVersion: 5577006791947779, + }, + /*restoreRequestedAt*/ new Date(0), + /*restoreRequestedDays*/ 5, + /*restoreCompletedAt*/ new Date(1000), + /*restoreWillExpireAt*/ new Date(1000 + 5 * oneDay), + ), + ); const err = verifyColdObjectAvailable(objectMd.getValue()); assert.ifError(err); }); + + it('should return null if object is awaiting its first archive', () => { + const err = verifyColdObjectAvailable(directToColdObjectMD()); + assert.ifError(err); + }); + + it('should return null if object awaiting its first archive has a pending restore', () => { + const objectMd = directToColdObjectMD(); + objectMd.archive = { + restoreRequestedAt: new Date(), + restoreRequestedDays: 5, + }; + const err = verifyColdObjectAvailable(objectMd); + assert.ifError(err); + }); + + it('should return error if an archived object has no restore request', () => { + const objectMd = new ObjectMD().setDataStoreName(LOCATION_NAME_DMF).getValue(); + objectMd.archive = { + archiveInfo, + restoreCompletedAt: new Date(), + }; + const err = verifyColdObjectAvailable(objectMd); + assert.strictEqual(err.message, 'InvalidObjectState'); + }); + }); + + describe('getAmzRestoreResHeader', () => { + it('should report an ongoing request for an object awaiting its first archive', () => { + const objectMd = directToColdObjectMD(); + objectMd.archive = { + restoreRequestedAt: new Date(), + restoreRequestedDays: 5, + }; + assert.strictEqual(getAmzRestoreResHeader(objectMd), 'ongoing-request="true"'); + }); + + it('should not report anything for an object awaiting its first archive', () => { + assert.strictEqual(getAmzRestoreResHeader(directToColdObjectMD()), undefined); + }); + }); + + describe('setArchiveInfoHeaders', () => { + it('should not set the archive info header when the object is not archived yet', () => { + const restoreRequestedAt = new Date(); + const objectMd = directToColdObjectMD(); + objectMd.archive = { + restoreRequestedAt, + restoreRequestedDays: 5, + }; + + const headers = setArchiveInfoHeaders(objectMd); + assert.strictEqual(headers['x-amz-scal-archive-info'], undefined); + assert.strictEqual(headers['x-amz-scal-restore-requested-at'], restoreRequestedAt.toUTCString()); + assert.strictEqual(headers['x-amz-scal-restore-requested-days'], 5); + assert.strictEqual(headers['x-amz-storage-class'], LOCATION_NAME_DMF); + assert.strictEqual(headers['x-amz-scal-transition-in-progress'], true); + }); + + it('should set the archive info header of an archived object', () => { + const objectMd = new ObjectMD() + .setDataStoreName(LOCATION_NAME_DMF) + .setAmzStorageClass(LOCATION_NAME_DMF) + .setArchive(new ObjectMDArchive(archiveInfo)) + .getValue(); + + const headers = setArchiveInfoHeaders(objectMd); + assert.strictEqual(headers['x-amz-scal-archive-info'], JSON.stringify(archiveInfo)); + }); }); describe('startRestore', () => { @@ -139,21 +235,25 @@ describe('cold storage', () => { startRestore(objectMd, { days: 5 }, log, err => { assert.deepStrictEqual(err, errors.InvalidObjectState); + assert.strictEqual(objectMd.archive, undefined); done(); }); }); it('should fail when object is being restored', done => { - const objectMd = new ObjectMD().setDataStoreName( - LOCATION_NAME_DMF - ).setArchive(new ObjectMDArchive( - { - archiveId: '97a71dfe-49c1-4cca-840a-69199e0b0322', - archiveVersion: 5577006791947779, - }, - /*restoreRequestedAt*/ new Date(0), - /*restoreRequestedDays*/ 5, - )).getValue(); + const objectMd = new ObjectMD() + .setDataStoreName(LOCATION_NAME_DMF) + .setArchive( + new ObjectMDArchive( + { + archiveId: '97a71dfe-49c1-4cca-840a-69199e0b0322', + archiveVersion: 5577006791947779, + }, + /*restoreRequestedAt*/ new Date(0), + /*restoreRequestedDays*/ 5, + ), + ) + .getValue(); startRestore(objectMd, { days: 5 }, log, err => { assert.deepStrictEqual(err, errors.RestoreAlreadyInProgress); @@ -162,18 +262,21 @@ describe('cold storage', () => { }); it('should fail when restored object is expired', done => { - const objectMd = new ObjectMD().setDataStoreName( - LOCATION_NAME_DMF - ).setArchive(new ObjectMDArchive( - { - archiveId: '97a71dfe-49c1-4cca-840a-69199e0b0322', - archiveVersion: 5577006791947779, - }, - /*restoreRequestedAt*/ new Date(0), - /*restoreRequestedDays*/ 5, - /*restoreCompletedAt*/ new Date(Date.now() - 6 * oneDay), - /*restoreWillExpireAt*/ new Date(Date.now() - 1 * oneDay), - )).getValue(); + const objectMd = new ObjectMD() + .setDataStoreName(LOCATION_NAME_DMF) + .setArchive( + new ObjectMDArchive( + { + archiveId: '97a71dfe-49c1-4cca-840a-69199e0b0322', + archiveVersion: 5577006791947779, + }, + /*restoreRequestedAt*/ new Date(0), + /*restoreRequestedDays*/ 5, + /*restoreCompletedAt*/ new Date(Date.now() - 6 * oneDay), + /*restoreWillExpireAt*/ new Date(Date.now() - 1 * oneDay), + ), + ) + .getValue(); startRestore(objectMd, { days: 5 }, log, err => { assert.deepStrictEqual(err, errors.InvalidObjectState); @@ -182,12 +285,15 @@ describe('cold storage', () => { }); it('should succeed for cold object', done => { - const objectMd = new ObjectMD().setDataStoreName( - LOCATION_NAME_DMF - ).setArchive(new ObjectMDArchive({ - archiveId: '97a71dfe-49c1-4cca-840a-69199e0b0322', - archiveVersion: 5577006791947779, - })).getValue(); + const objectMd = new ObjectMD() + .setDataStoreName(LOCATION_NAME_DMF) + .setArchive( + new ObjectMDArchive({ + archiveId: '97a71dfe-49c1-4cca-840a-69199e0b0322', + archiveVersion: 5577006791947779, + }), + ) + .getValue(); const t = new Date(); startRestore(objectMd, { days: 7 }, log, (err, isObjectAlreadyRestored) => { @@ -207,22 +313,26 @@ describe('cold storage', () => { }); it('should succeed for restored object', done => { - const objectMd = new ObjectMD().setDataStoreName( - LOCATION_NAME_DMF - ).setArchive(new ObjectMDArchive( - { - archiveId: '97a71dfe-49c1-4cca-840a-69199e0b0322', - archiveVersion: 5577006791947779, - }, - /*restoreRequestedAt*/ new Date(0), - /*restoreRequestedDays*/ 2, - /*restoreCompletedAt*/ new Date(Date.now() - 1 * oneDay), - /*restoreWillExpireAt*/ new Date(Date.now() + 1 * oneDay), - )).setAmzRestore({ - 'ongoing-request': false, - 'expiry-date': new Date(Date.now() + 1 * oneDay), - 'content-md5': '12345' - }).getValue(); + const objectMd = new ObjectMD() + .setDataStoreName(LOCATION_NAME_DMF) + .setArchive( + new ObjectMDArchive( + { + archiveId: '97a71dfe-49c1-4cca-840a-69199e0b0322', + archiveVersion: 5577006791947779, + }, + /*restoreRequestedAt*/ new Date(0), + /*restoreRequestedDays*/ 2, + /*restoreCompletedAt*/ new Date(Date.now() - 1 * oneDay), + /*restoreWillExpireAt*/ new Date(Date.now() + 1 * oneDay), + ), + ) + .setAmzRestore({ + 'ongoing-request': false, + 'expiry-date': new Date(Date.now() + 1 * oneDay), + 'content-md5': '12345', + }) + .getValue(); const restoreCompletedAt = objectMd.archive.restoreCompletedAt; const t = new Date(); @@ -236,27 +346,75 @@ describe('cold storage', () => { assert.ok(objectMd.archive.restoreRequestedAt.getTime() <= new Date()); assert.strictEqual(objectMd.archive.restoreCompletedAt, restoreCompletedAt); - assert.strictEqual(objectMd.archive.restoreWillExpireAt.getTime(), - objectMd.archive.restoreRequestedAt.getTime() + (5 * scaledMsPerDay)); + assert.strictEqual( + objectMd.archive.restoreWillExpireAt.getTime(), + objectMd.archive.restoreRequestedAt.getTime() + 5 * scaledMsPerDay, + ); assert.deepEqual(objectMd['x-amz-restore'], { 'ongoing-request': false, 'expiry-date': objectMd.archive.restoreWillExpireAt, - 'content-md5': '12345' + 'content-md5': '12345', }); done(); }); }); - it('should fail if _updateRestoreInfo fails', done => { - const objectMd = new ObjectMD().setDataStoreName( - LOCATION_NAME_DMF - ).setArchive(false).getValue(); + it('should fail if the archive metadata is invalid', done => { + const objectMd = new ObjectMD().setDataStoreName(LOCATION_NAME_DMF).getValue(); + objectMd.archive = { archiveInfo: 'not an object' }; startRestore(objectMd, { days: 7 }, log, err => { assert.deepStrictEqual(err, errors.InternalError); done(); }); }); + + it('should succeed for an object awaiting its first archive', done => { + const objectMd = directToColdObjectMD(); + + const t = new Date(); + startRestore(objectMd, { days: 7 }, log, (err, isObjectAlreadyRestored) => { + assert.ifError(err); + assert.ok(!isObjectAlreadyRestored); + + // the object has not been archived, so the request is only recorded + assert.strictEqual(objectMd.archive.archiveInfo, undefined); + assert.strictEqual(objectMd.archive.restoreRequestedDays, 7); + assert.ok(objectMd.archive.restoreRequestedAt.getTime() >= t.getTime()); + assert.ok(objectMd.archive.restoreRequestedAt.getTime() <= Date.now()); + assert.strictEqual(objectMd.archive.restoreCompletedAt, undefined); + assert.strictEqual(objectMd.archive.restoreWillExpireAt, undefined); + + // the object is still declared cold, and its data still hot + assert.strictEqual(objectMd['x-amz-storage-class'], LOCATION_NAME_DMF); + assert.strictEqual(objectMd.dataStoreName, 'us-east-1'); + assert.strictEqual(objectMd['x-amz-scal-transition-in-progress'], true); + assert.strictEqual(objectMd.originOp, 's3:ObjectRestore:Post'); + + done(); + }); + }); + + it('should update the pending request of an object awaiting its first archive', done => { + const objectMd = directToColdObjectMD(); + const restoreRequestedAt = new Date(Date.now() - oneDay); + objectMd.archive = { + restoreRequestedAt, + restoreRequestedDays: 5, + }; + + startRestore(objectMd, { days: 9 }, log, (err, isObjectAlreadyRestored) => { + assert.ifError(err); + assert.ok(!isObjectAlreadyRestored); + + assert.strictEqual(objectMd.archive.archiveInfo, undefined); + assert.strictEqual(objectMd.archive.restoreRequestedDays, 9); + assert.ok(objectMd.archive.restoreRequestedAt.getTime() > restoreRequestedAt.getTime()); + assert.strictEqual(objectMd.archive.restoreCompletedAt, undefined); + + done(); + }); + }); }); }); diff --git a/tests/unit/api/directToCold.js b/tests/unit/api/directToCold.js index 4d14e33072..483c71f887 100644 --- a/tests/unit/api/directToCold.js +++ b/tests/unit/api/directToCold.js @@ -4,6 +4,9 @@ const async = require('async'); const { bucketPut } = require('../../../lib/api/bucketPut'); const objectPut = require('../../../lib/api/objectPut'); const objectCopy = require('../../../lib/api/objectCopy'); +const objectGet = require('../../../lib/api/objectGet'); +const objectHead = require('../../../lib/api/objectHead'); +const objectRestore = require('../../../lib/api/objectRestore'); const initiateMultipartUpload = require('../../../lib/api/initiateMultipartUpload'); const DummyRequest = require('../DummyRequest'); const { cleanup, DummyRequestLogger, makeAuthInfo } = require('../helpers'); @@ -65,6 +68,34 @@ function getObjectMD(key, cb) { return metadata.getObjectMD(bucketName, key, {}, log, cb); } +function getObjectRequest() { + return { + bucketName, + namespace, + objectKey, + headers: { host: `${bucketName}.s3.amazonaws.com` }, + url: `/${bucketName}/${objectKey}`, + actionImplicitDenies: false, + }; +} + +function restoreObjectRequest(days) { + return { + ...getObjectRequest(), + post: + '' + + `${days}` + + 'Standard' + + '', + }; +} + +function putDirectToColdObject(cb) { + return objectPut(authInfo, putObjectRequest({ 'x-amz-storage-class': coldLocation }), undefined, log, err => + cb(err), + ); +} + function assertDirectToCold(md) { assert.strictEqual(md['x-amz-storage-class'], coldLocation); // the data itself stays in the hot location @@ -360,4 +391,118 @@ describe('direct to cold', () => { ); }); }); + + describe('restore during the archive window', () => { + beforeEach(done => { + config.enableDirectToCold = true; + putDirectToColdObject(done); + }); + + it('should accept a restore and record it in the object metadata', done => { + const testStartTime = new Date(); + async.waterfall( + [ + next => + objectRestore(authInfo, restoreObjectRequest(5), log, (err, statusCode) => { + assert.ifError(err); + assert.strictEqual(statusCode, 202); + next(); + }), + next => getObjectMD(objectKey, next), + ], + (err, md) => { + assert.ifError(err); + // the object has not been archived, so the request is only recorded + assert.strictEqual(md.archive.archiveInfo, undefined); + assert.strictEqual(md.archive.restoreRequestedDays, 5); + assert.ok(new Date(md.archive.restoreRequestedAt) >= testStartTime); + assert.strictEqual(md.archive.restoreCompletedAt, undefined); + // the object is still declared cold, its data still hot, and it still needs + // to be transitioned + assert.strictEqual(md['x-amz-storage-class'], coldLocation); + assert.strictEqual(md.dataStoreName, hotLocation); + assert.strictEqual(md['x-amz-scal-transition-in-progress'], true); + assert.strictEqual(md.originOp, 's3:ObjectRestore:Post'); + done(); + }, + ); + }); + + it('should update the pending request on a repeated restore', done => { + async.waterfall( + [ + next => objectRestore(authInfo, restoreObjectRequest(5), log, err => next(err)), + next => getObjectMD(objectKey, next), + (md, next) => + objectRestore(authInfo, restoreObjectRequest(9), log, (err, statusCode) => { + assert.ifError(err); + assert.strictEqual(statusCode, 202); + next(null, md); + }), + (md, next) => getObjectMD(objectKey, (err, updatedMd) => next(err, md, updatedMd)), + ], + (err, md, updatedMd) => { + assert.ifError(err); + assert.strictEqual(updatedMd.archive.restoreRequestedDays, 9); + assert.ok( + new Date(updatedMd.archive.restoreRequestedAt) >= new Date(md.archive.restoreRequestedAt), + ); + assert.strictEqual(updatedMd.archive.archiveInfo, undefined); + done(); + }, + ); + }); + + it('should report an ongoing restore on HEAD', done => { + async.waterfall( + [ + next => + objectHead(authInfo, getObjectRequest(), log, (err, headers) => { + assert.ifError(err); + // before the restore request, the object simply appears cold + assert.strictEqual(headers['x-amz-storage-class'], coldLocation); + assert.strictEqual(headers['x-amz-meta-scal-s3-transition-in-progress'], true); + assert.strictEqual(headers['x-amz-restore'], undefined); + next(); + }), + next => objectRestore(authInfo, restoreObjectRequest(5), log, err => next(err)), + next => objectHead(authInfo, getObjectRequest(), log, next), + ], + (err, headers) => { + assert.ifError(err); + assert.strictEqual(headers['x-amz-restore'], 'ongoing-request="true"'); + assert.strictEqual(headers['x-amz-storage-class'], coldLocation); + assert.strictEqual(headers['x-amz-meta-scal-s3-transition-in-progress'], true); + done(); + }, + ); + }); + + it('should still allow the object to be read', done => { + async.waterfall( + [ + next => objectRestore(authInfo, restoreObjectRequest(5), log, err => next(err)), + next => + objectGet(authInfo, getObjectRequest(), false, log, (err, _, headers) => next(err, headers)), + ], + (err, headers) => { + // the data is still in the hot location, so it stays readable + assert.ifError(err); + assert.strictEqual(headers['x-amz-restore'], 'ongoing-request="true"'); + done(); + }, + ); + }); + + it('should accept a restore once direct-to-cold is disabled', done => { + // the object was created while the feature was enabled: turning it off must not make + // it unrestorable + config.enableDirectToCold = false; + objectRestore(authInfo, restoreObjectRequest(5), log, (err, statusCode) => { + assert.ifError(err); + assert.strictEqual(statusCode, 202); + done(); + }); + }); + }); });