feat(remotes): AWS S3 backup storage (#5037)
This commit is contained in:
parent
ffa431a3cd
commit
407586e2d5
@ -25,6 +25,7 @@
|
|||||||
"@marsaud/smb2": "^0.15.0",
|
"@marsaud/smb2": "^0.15.0",
|
||||||
"@sindresorhus/df": "^3.1.1",
|
"@sindresorhus/df": "^3.1.1",
|
||||||
"@xen-orchestra/async-map": "^0.0.0",
|
"@xen-orchestra/async-map": "^0.0.0",
|
||||||
|
"aws-sdk": "^2.686.0",
|
||||||
"decorator-synchronized": "^0.5.0",
|
"decorator-synchronized": "^0.5.0",
|
||||||
"execa": "^4.0.2",
|
"execa": "^4.0.2",
|
||||||
"fs-extra": "^9.0.0",
|
"fs-extra": "^9.0.0",
|
||||||
|
@ -5,7 +5,7 @@ import getStream from 'get-stream'
|
|||||||
|
|
||||||
import asyncMap from '@xen-orchestra/async-map'
|
import asyncMap from '@xen-orchestra/async-map'
|
||||||
import limit from 'limit-concurrency-decorator'
|
import limit from 'limit-concurrency-decorator'
|
||||||
import path from 'path'
|
import path, { basename } from 'path'
|
||||||
import synchronized from 'decorator-synchronized'
|
import synchronized from 'decorator-synchronized'
|
||||||
import { fromCallback, fromEvent, ignoreErrors, timeout } from 'promise-toolbox'
|
import { fromCallback, fromEvent, ignoreErrors, timeout } from 'promise-toolbox'
|
||||||
import { parse } from 'xo-remote-parser'
|
import { parse } from 'xo-remote-parser'
|
||||||
@ -121,6 +121,7 @@ export default class RemoteHandlerAbstract {
|
|||||||
await this.__closeFile(fd)
|
await this.__closeFile(fd)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: remove method
|
||||||
async createOutputStream(
|
async createOutputStream(
|
||||||
file: File,
|
file: File,
|
||||||
{ checksum = false, ...options }: Object = {}
|
{ checksum = false, ...options }: Object = {}
|
||||||
@ -221,19 +222,15 @@ export default class RemoteHandlerAbstract {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
createWriteStream(
|
// write a stream to a file using a temporary file
|
||||||
file: File,
|
async outputStream(
|
||||||
options: { end?: number, flags?: string, start?: number } = {}
|
input: Readable | Promise<Readable>,
|
||||||
): Promise<LaxWritable> {
|
path: string,
|
||||||
return timeout.call(
|
{ checksum = true }: { checksum?: boolean } = {}
|
||||||
this._createWriteStream(
|
): Promise<void> {
|
||||||
typeof file === 'string' ? normalizePath(file) : file,
|
path = normalizePath(path)
|
||||||
{
|
input = await input
|
||||||
flags: 'wx',
|
return this._outputStream(await input, normalizePath(path), { checksum })
|
||||||
...options,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Free the resources possibly dedicated to put the remote at work, when it
|
// Free the resources possibly dedicated to put the remote at work, when it
|
||||||
@ -321,18 +318,6 @@ export default class RemoteHandlerAbstract {
|
|||||||
return this._readFile(normalizePath(file), { flags })
|
return this._readFile(normalizePath(file), { flags })
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshChecksum(path: string): Promise<void> {
|
|
||||||
path = normalizePath(path)
|
|
||||||
|
|
||||||
const stream = (await this._createReadStream(path, { flags: 'r' })).pipe(
|
|
||||||
createChecksumStream()
|
|
||||||
)
|
|
||||||
stream.resume() // start reading the whole file
|
|
||||||
await this._outputFile(checksumFile(path), await stream.checksum, {
|
|
||||||
flags: 'wx',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async rename(
|
async rename(
|
||||||
oldPath: string,
|
oldPath: string,
|
||||||
newPath: string,
|
newPath: string,
|
||||||
@ -548,6 +533,22 @@ export default class RemoteHandlerAbstract {
|
|||||||
return this._outputFile(file, data, options)
|
return this._outputFile(file, data, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async _outputStream(input, path, { checksum }) {
|
||||||
|
const tmpPath = `${dirname(path)}/.${basename(path)}`
|
||||||
|
const output = await this.createOutputStream(tmpPath, { checksum })
|
||||||
|
try {
|
||||||
|
input.pipe(output)
|
||||||
|
await fromEvent(output, 'finish')
|
||||||
|
await output.checksumWritten
|
||||||
|
// $FlowFixMe
|
||||||
|
await input.task
|
||||||
|
await this.rename(tmpPath, path, { checksum })
|
||||||
|
} catch (error) {
|
||||||
|
await this.unlink(tmpPath, { checksum })
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_read(
|
_read(
|
||||||
file: File,
|
file: File,
|
||||||
buffer: Buffer,
|
buffer: Buffer,
|
||||||
|
@ -42,18 +42,6 @@ describe('createOutputStream()', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('createReadStream()', () => {
|
|
||||||
it(`throws in case of timeout`, async () => {
|
|
||||||
const testHandler = new TestHandler({
|
|
||||||
createReadStream: () => new Promise(() => {}),
|
|
||||||
})
|
|
||||||
|
|
||||||
const promise = testHandler.createReadStream('file')
|
|
||||||
jest.advanceTimersByTime(TIMEOUT)
|
|
||||||
await expect(promise).rejects.toThrowError(TimeoutError)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('getInfo()', () => {
|
describe('getInfo()', () => {
|
||||||
it('throws in case of timeout', async () => {
|
it('throws in case of timeout', async () => {
|
||||||
const testHandler = new TestHandler({
|
const testHandler = new TestHandler({
|
||||||
|
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import 'dotenv/config'
|
import 'dotenv/config'
|
||||||
import asyncIteratorToStream from 'async-iterator-to-stream'
|
import asyncIteratorToStream from 'async-iterator-to-stream'
|
||||||
import getStream from 'get-stream'
|
|
||||||
import { forOwn, random } from 'lodash'
|
import { forOwn, random } from 'lodash'
|
||||||
import { fromCallback } from 'promise-toolbox'
|
import { fromCallback } from 'promise-toolbox'
|
||||||
import { pipeline } from 'readable-stream'
|
import { pipeline } from 'readable-stream'
|
||||||
@ -91,31 +90,6 @@ handlers.forEach(url => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('#createReadStream()', () => {
|
|
||||||
beforeEach(() => handler.outputFile('file', TEST_DATA))
|
|
||||||
|
|
||||||
testWithFileDescriptor('file', 'r', async ({ file, flags }) => {
|
|
||||||
await expect(
|
|
||||||
await getStream.buffer(
|
|
||||||
await handler.createReadStream(file, { flags })
|
|
||||||
)
|
|
||||||
).toEqual(TEST_DATA)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('#createWriteStream()', () => {
|
|
||||||
testWithFileDescriptor('file', 'wx', async ({ file, flags }) => {
|
|
||||||
const stream = await handler.createWriteStream(file, { flags })
|
|
||||||
await fromCallback(pipeline, createTestDataStream(), stream)
|
|
||||||
await expect(await handler.readFile('file')).toEqual(TEST_DATA)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('fails if parent dir is missing', async () => {
|
|
||||||
const error = await rejectionOf(handler.createWriteStream('dir/file'))
|
|
||||||
expect(error.code).toBe('ENOENT')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('#getInfo()', () => {
|
describe('#getInfo()', () => {
|
||||||
let info
|
let info
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
|
@ -4,6 +4,7 @@ import execa from 'execa'
|
|||||||
import type RemoteHandler from './abstract'
|
import type RemoteHandler from './abstract'
|
||||||
import RemoteHandlerLocal from './local'
|
import RemoteHandlerLocal from './local'
|
||||||
import RemoteHandlerNfs from './nfs'
|
import RemoteHandlerNfs from './nfs'
|
||||||
|
import RemoteHandlerS3 from './s3'
|
||||||
import RemoteHandlerSmb from './smb'
|
import RemoteHandlerSmb from './smb'
|
||||||
import RemoteHandlerSmbMount from './smb-mount'
|
import RemoteHandlerSmbMount from './smb-mount'
|
||||||
|
|
||||||
@ -13,6 +14,7 @@ export type Remote = { url: string }
|
|||||||
const HANDLERS = {
|
const HANDLERS = {
|
||||||
file: RemoteHandlerLocal,
|
file: RemoteHandlerLocal,
|
||||||
nfs: RemoteHandlerNfs,
|
nfs: RemoteHandlerNfs,
|
||||||
|
s3: RemoteHandlerS3,
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
253
@xen-orchestra/fs/src/s3.js
Normal file
253
@xen-orchestra/fs/src/s3.js
Normal file
@ -0,0 +1,253 @@
|
|||||||
|
import AWS from 'aws-sdk'
|
||||||
|
import { parse } from 'xo-remote-parser'
|
||||||
|
|
||||||
|
import RemoteHandlerAbstract from './abstract'
|
||||||
|
import { createChecksumStream } from './checksum'
|
||||||
|
|
||||||
|
// endpoints https://docs.aws.amazon.com/general/latest/gr/s3.html
|
||||||
|
|
||||||
|
export default class S3Handler extends RemoteHandlerAbstract {
|
||||||
|
constructor(remote, _opts) {
|
||||||
|
super(remote)
|
||||||
|
const { host, path, username, password } = parse(remote.url)
|
||||||
|
|
||||||
|
// https://www.zenko.io/blog/first-things-first-getting-started-scality-s3-server/
|
||||||
|
this._s3 = new AWS.S3({
|
||||||
|
accessKeyId: username,
|
||||||
|
apiVersion: '2006-03-01',
|
||||||
|
endpoint: host,
|
||||||
|
s3ForcePathStyle: true,
|
||||||
|
secretAccessKey: password,
|
||||||
|
signatureVersion: 'v4',
|
||||||
|
})
|
||||||
|
const splitPath = path.split('/').filter(s => s.length)
|
||||||
|
this._bucket = splitPath.shift()
|
||||||
|
this._dir = splitPath.join('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
get type() {
|
||||||
|
return 's3'
|
||||||
|
}
|
||||||
|
|
||||||
|
_createParams(file) {
|
||||||
|
return { Bucket: this._bucket, Key: this._dir + file }
|
||||||
|
}
|
||||||
|
|
||||||
|
async _outputStream(input, path, { checksum }) {
|
||||||
|
let inputStream = input
|
||||||
|
if (checksum) {
|
||||||
|
const checksumStream = createChecksumStream()
|
||||||
|
const forwardError = error => {
|
||||||
|
checksumStream.emit('error', error)
|
||||||
|
}
|
||||||
|
input.pipe(checksumStream)
|
||||||
|
input.on('error', forwardError)
|
||||||
|
inputStream = checksumStream
|
||||||
|
}
|
||||||
|
const upload = this._s3.upload({
|
||||||
|
...this._createParams(path),
|
||||||
|
Body: inputStream,
|
||||||
|
})
|
||||||
|
await upload.promise()
|
||||||
|
if (checksum) {
|
||||||
|
const checksum = await inputStream.checksum
|
||||||
|
const params = {
|
||||||
|
...this._createParams(path + '.checksum'),
|
||||||
|
Body: checksum,
|
||||||
|
}
|
||||||
|
await this._s3.upload(params).promise()
|
||||||
|
}
|
||||||
|
await input.task
|
||||||
|
}
|
||||||
|
|
||||||
|
async _writeFile(file, data, options) {
|
||||||
|
return this._s3
|
||||||
|
.putObject({ ...this._createParams(file), Body: data })
|
||||||
|
.promise()
|
||||||
|
}
|
||||||
|
|
||||||
|
async _createReadStream(file, options) {
|
||||||
|
return this._s3.getObject(this._createParams(file)).createReadStream()
|
||||||
|
}
|
||||||
|
|
||||||
|
async _unlink(file) {
|
||||||
|
return this._s3.deleteObject(this._createParams(file)).promise()
|
||||||
|
}
|
||||||
|
|
||||||
|
async _list(dir) {
|
||||||
|
function splitPath(path) {
|
||||||
|
return path.split('/').filter(d => d.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefix = [this._dir, dir].join('/')
|
||||||
|
const splitPrefix = splitPath(prefix)
|
||||||
|
const request = this._s3.listObjectsV2({
|
||||||
|
Bucket: this._bucket,
|
||||||
|
Prefix: splitPrefix.join('/'),
|
||||||
|
})
|
||||||
|
const result = await request.promise()
|
||||||
|
const uniq = new Set()
|
||||||
|
for (const entry of result.Contents) {
|
||||||
|
const line = splitPath(entry.Key)
|
||||||
|
if (line.length > splitPrefix.length) {
|
||||||
|
uniq.add(line[splitPrefix.length])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...uniq]
|
||||||
|
}
|
||||||
|
|
||||||
|
async _rename(oldPath, newPath) {
|
||||||
|
const params = {
|
||||||
|
...this._createParams(newPath),
|
||||||
|
CopySource: `/${this._bucket}/${this._dir}${oldPath}`,
|
||||||
|
}
|
||||||
|
await this._s3.copyObject(params).promise()
|
||||||
|
await this._s3.deleteObject(this._createParams(oldPath)).promise()
|
||||||
|
}
|
||||||
|
|
||||||
|
async _getSize(file) {
|
||||||
|
if (typeof file !== 'string') {
|
||||||
|
file = file.fd
|
||||||
|
}
|
||||||
|
const result = await this._s3.headObject(this._createParams(file)).promise()
|
||||||
|
return +result.ContentLength
|
||||||
|
}
|
||||||
|
|
||||||
|
async _read(file, buffer, position = 0) {
|
||||||
|
if (typeof file !== 'string') {
|
||||||
|
file = file.fd
|
||||||
|
}
|
||||||
|
const params = this._createParams(file)
|
||||||
|
params.Range = `bytes=${position}-${position + buffer.length - 1}`
|
||||||
|
const result = await this._s3.getObject(params).promise()
|
||||||
|
result.Body.copy(buffer)
|
||||||
|
return { bytesRead: result.Body.length, buffer }
|
||||||
|
}
|
||||||
|
|
||||||
|
async _write(file, buffer, position) {
|
||||||
|
const MIN_FRAGMENT_SIZE = 1024 * 1024 * 5 // 5Mo
|
||||||
|
if (typeof file !== 'string') {
|
||||||
|
file = file.fd
|
||||||
|
}
|
||||||
|
const uploadParams = this._createParams(file)
|
||||||
|
const fileSize = +(await this._s3.headObject(uploadParams).promise())
|
||||||
|
.ContentLength
|
||||||
|
if (fileSize < MIN_FRAGMENT_SIZE) {
|
||||||
|
const resultBuffer = Buffer.alloc(
|
||||||
|
Math.max(fileSize, position + buffer.length)
|
||||||
|
)
|
||||||
|
const fileContent = (await this._s3.getObject(uploadParams).promise())
|
||||||
|
.Body
|
||||||
|
fileContent.copy(resultBuffer)
|
||||||
|
buffer.copy(resultBuffer, position)
|
||||||
|
await this._s3
|
||||||
|
.putObject({ ...uploadParams, Body: resultBuffer })
|
||||||
|
.promise()
|
||||||
|
return { buffer, bytesWritten: buffer.length }
|
||||||
|
} else {
|
||||||
|
// using this trick: https://stackoverflow.com/a/38089437/72637
|
||||||
|
// multipart fragments have a minimum size of 5Mo unless they are last
|
||||||
|
// splitting the file in 3 parts: [prefix, edit, suffix]
|
||||||
|
// if `prefix` is bigger than 5Mo, it will be sourced from uploadPartCopy()
|
||||||
|
// otherwise otherwise it will be downloaded, concatenated to `edit`
|
||||||
|
// `edit` will always be an upload part
|
||||||
|
// `suffix` will ways be sourced from uploadPartCopy()
|
||||||
|
const multipartParams = await this._s3
|
||||||
|
.createMultipartUpload(uploadParams)
|
||||||
|
.promise()
|
||||||
|
try {
|
||||||
|
const parts = []
|
||||||
|
const prefixSize = position
|
||||||
|
let suffixOffset = prefixSize + buffer.length
|
||||||
|
let suffixSize = Math.max(0, fileSize - suffixOffset)
|
||||||
|
let hasSuffix = suffixSize > 0
|
||||||
|
let editBuffer = buffer
|
||||||
|
let editBufferOffset = position
|
||||||
|
let partNumber = 1
|
||||||
|
const prefixRange = `bytes=0-${prefixSize - 1}`
|
||||||
|
if (prefixSize < MIN_FRAGMENT_SIZE) {
|
||||||
|
const downloadParams = { ...uploadParams, Range: prefixRange }
|
||||||
|
const prefixBuffer =
|
||||||
|
prefixSize > 0
|
||||||
|
? (await this._s3.getObject(downloadParams).promise()).Body
|
||||||
|
: Buffer.alloc(0)
|
||||||
|
editBuffer = Buffer.concat([prefixBuffer, buffer])
|
||||||
|
editBufferOffset = 0
|
||||||
|
} else {
|
||||||
|
const copyPrefixParams = {
|
||||||
|
...multipartParams,
|
||||||
|
PartNumber: partNumber++,
|
||||||
|
CopySource: `/${this._bucket}/${this._dir + file}`,
|
||||||
|
CopySourceRange: prefixRange,
|
||||||
|
}
|
||||||
|
const prefixPart = (
|
||||||
|
await this._s3.uploadPartCopy(copyPrefixParams).promise()
|
||||||
|
).CopyPartResult
|
||||||
|
parts.push({
|
||||||
|
ETag: prefixPart.ETag,
|
||||||
|
PartNumber: copyPrefixParams.PartNumber,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (hasSuffix && editBuffer.length < MIN_FRAGMENT_SIZE) {
|
||||||
|
// the edit fragment is too short and is not the last fragment
|
||||||
|
// let's steal from the suffix fragment to reach the minimum size
|
||||||
|
// the suffix might be too short and itself entirely absorbed in the edit fragment, making it the last one.
|
||||||
|
const complementSize = Math.min(
|
||||||
|
MIN_FRAGMENT_SIZE - editBuffer.length,
|
||||||
|
suffixSize
|
||||||
|
)
|
||||||
|
const complementOffset = editBufferOffset + editBuffer.length
|
||||||
|
suffixOffset += complementSize
|
||||||
|
suffixSize -= complementSize
|
||||||
|
hasSuffix = suffixSize > 0
|
||||||
|
const prefixRange = `bytes=${complementOffset}-${
|
||||||
|
complementOffset + complementSize - 1
|
||||||
|
}`
|
||||||
|
const downloadParams = { ...uploadParams, Range: prefixRange }
|
||||||
|
const complementBuffer = (
|
||||||
|
await this._s3.getObject(downloadParams).promise()
|
||||||
|
).Body
|
||||||
|
editBuffer = Buffer.concat([editBuffer, complementBuffer])
|
||||||
|
}
|
||||||
|
const editParams = {
|
||||||
|
...multipartParams,
|
||||||
|
Body: editBuffer,
|
||||||
|
PartNumber: partNumber++,
|
||||||
|
}
|
||||||
|
const editPart = await this._s3.uploadPart(editParams).promise()
|
||||||
|
parts.push({ ETag: editPart.ETag, PartNumber: editParams.PartNumber })
|
||||||
|
if (hasSuffix) {
|
||||||
|
const suffixRange = `bytes=${suffixOffset}-${fileSize - 1}`
|
||||||
|
const copySuffixParams = {
|
||||||
|
...multipartParams,
|
||||||
|
PartNumber: partNumber++,
|
||||||
|
CopySource: `/${this._bucket}/${this._dir + file}`,
|
||||||
|
CopySourceRange: suffixRange,
|
||||||
|
}
|
||||||
|
const suffixPart = (
|
||||||
|
await this._s3.uploadPartCopy(copySuffixParams).promise()
|
||||||
|
).CopyPartResult
|
||||||
|
parts.push({
|
||||||
|
ETag: suffixPart.ETag,
|
||||||
|
PartNumber: copySuffixParams.PartNumber,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
await this._s3
|
||||||
|
.completeMultipartUpload({
|
||||||
|
...multipartParams,
|
||||||
|
MultipartUpload: { Parts: parts },
|
||||||
|
})
|
||||||
|
.promise()
|
||||||
|
} catch (e) {
|
||||||
|
await this._s3.abortMultipartUpload(multipartParams).promise()
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _openFile(path, flags) {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
async _closeFile(fd) {}
|
||||||
|
}
|
@ -7,6 +7,8 @@
|
|||||||
|
|
||||||
> Users must be able to say: “Nice enhancement, I'm eager to test it”
|
> Users must be able to say: “Nice enhancement, I'm eager to test it”
|
||||||
|
|
||||||
|
- [Remotes] Add AWS S3 as a backup storage
|
||||||
|
|
||||||
### Bug fixes
|
### Bug fixes
|
||||||
|
|
||||||
> Users must be able to say: “I had this issue, happy to know it's fixed”
|
> Users must be able to say: “I had this issue, happy to know it's fixed”
|
||||||
@ -30,5 +32,8 @@
|
|||||||
>
|
>
|
||||||
> In case of conflict, the highest (lowest in previous list) `$version` wins.
|
> In case of conflict, the highest (lowest in previous list) `$version` wins.
|
||||||
|
|
||||||
xo-web patch
|
- xo-server-sdn-controller patch
|
||||||
xo-server-sdn-controller patch
|
- xo-remote-parser minor
|
||||||
|
- @xen-orchestra/fs minor
|
||||||
|
- xo-web minor
|
||||||
|
- xo-server minor
|
||||||
|
@ -26,7 +26,8 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lodash": "^4.13.1"
|
"lodash": "^4.13.1",
|
||||||
|
"url-parse": "^1.4.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/cli": "^7.0.0",
|
"@babel/cli": "^7.0.0",
|
||||||
|
@ -2,6 +2,7 @@ import filter from 'lodash/filter'
|
|||||||
import map from 'lodash/map'
|
import map from 'lodash/map'
|
||||||
import trim from 'lodash/trim'
|
import trim from 'lodash/trim'
|
||||||
import trimStart from 'lodash/trimStart'
|
import trimStart from 'lodash/trimStart'
|
||||||
|
import Url from 'url-parse'
|
||||||
|
|
||||||
const NFS_RE = /^([^:]+):(?:(\d+):)?([^:]+)$/
|
const NFS_RE = /^([^:]+):(?:(\d+):)?([^:]+)$/
|
||||||
const SMB_RE = /^([^:]+):(.+)@([^@]+)\\\\([^\0]+)(?:\0(.*))?$/
|
const SMB_RE = /^([^:]+):(.+)@([^@]+)\\\\([^\0]+)(?:\0(.*))?$/
|
||||||
@ -39,6 +40,13 @@ export const parse = string => {
|
|||||||
object.domain = domain
|
object.domain = domain
|
||||||
object.username = username
|
object.username = username
|
||||||
object.password = password
|
object.password = password
|
||||||
|
} else if (type === 's3') {
|
||||||
|
const parsed = new Url(string)
|
||||||
|
object.type = 's3'
|
||||||
|
object.host = parsed.host
|
||||||
|
object.path = parsed.pathname
|
||||||
|
object.username = parsed.username
|
||||||
|
object.password = decodeURIComponent(parsed.password)
|
||||||
}
|
}
|
||||||
return object
|
return object
|
||||||
}
|
}
|
||||||
@ -60,6 +68,9 @@ export const format = ({
|
|||||||
if (type === 'smb') {
|
if (type === 'smb') {
|
||||||
string += `${username}:${password}@${domain}\\\\${host}`
|
string += `${username}:${password}@${domain}\\\\${host}`
|
||||||
}
|
}
|
||||||
|
if (type === 's3') {
|
||||||
|
string += `${username}:${encodeURIComponent(password)}@${host}`
|
||||||
|
}
|
||||||
path = sanitizePath(path)
|
path = sanitizePath(path)
|
||||||
if (type === 'smb') {
|
if (type === 'smb') {
|
||||||
path = path.split('/')
|
path = path.split('/')
|
||||||
|
@ -44,6 +44,17 @@ const data = deepFreeze({
|
|||||||
path: '/media/nfs',
|
path: '/media/nfs',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
S3: {
|
||||||
|
string:
|
||||||
|
's3://AKIAS:XSuBupZ0mJlu%2B@s3-us-west-2.amazonaws.com/test-bucket/dir',
|
||||||
|
object: {
|
||||||
|
type: 's3',
|
||||||
|
host: 's3-us-west-2.amazonaws.com',
|
||||||
|
path: '/test-bucket/dir',
|
||||||
|
username: 'AKIAS',
|
||||||
|
password: 'XSuBupZ0mJlu+',
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const parseData = deepFreeze({
|
const parseData = deepFreeze({
|
||||||
|
@ -8,7 +8,7 @@ import defer from 'golike-defer'
|
|||||||
import limitConcurrency from 'limit-concurrency-decorator'
|
import limitConcurrency from 'limit-concurrency-decorator'
|
||||||
import safeTimeout from 'strict-timeout/safe'
|
import safeTimeout from 'strict-timeout/safe'
|
||||||
import { type Pattern, createPredicate } from 'value-matcher'
|
import { type Pattern, createPredicate } from 'value-matcher'
|
||||||
import { type Readable, PassThrough } from 'stream'
|
import { PassThrough } from 'stream'
|
||||||
import { AssertionError } from 'assert'
|
import { AssertionError } from 'assert'
|
||||||
import { basename, dirname } from 'path'
|
import { basename, dirname } from 'path'
|
||||||
import { decorateWith } from '@vates/decorate-with'
|
import { decorateWith } from '@vates/decorate-with'
|
||||||
@ -29,13 +29,7 @@ import {
|
|||||||
sum,
|
sum,
|
||||||
values,
|
values,
|
||||||
} from 'lodash'
|
} from 'lodash'
|
||||||
import {
|
import { CancelToken, ignoreErrors, pFinally, timeout } from 'promise-toolbox'
|
||||||
CancelToken,
|
|
||||||
ignoreErrors,
|
|
||||||
pFinally,
|
|
||||||
pFromEvent,
|
|
||||||
timeout,
|
|
||||||
} from 'promise-toolbox'
|
|
||||||
import Vhd, {
|
import Vhd, {
|
||||||
chainVhd,
|
chainVhd,
|
||||||
checkVhdChain,
|
checkVhdChain,
|
||||||
@ -326,31 +320,6 @@ const parseVmBackupId = (id: string) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// write a stream to a file using a temporary file
|
|
||||||
//
|
|
||||||
// TODO: merge into RemoteHandlerAbstract
|
|
||||||
const writeStream = async (
|
|
||||||
input: Readable | Promise<Readable>,
|
|
||||||
handler: RemoteHandler,
|
|
||||||
path: string,
|
|
||||||
{ checksum = true }: { checksum?: boolean } = {}
|
|
||||||
): Promise<void> => {
|
|
||||||
input = await input
|
|
||||||
const tmpPath = `${dirname(path)}/.${basename(path)}`
|
|
||||||
const output = await handler.createOutputStream(tmpPath, { checksum })
|
|
||||||
try {
|
|
||||||
input.pipe(output)
|
|
||||||
await pFromEvent(output, 'finish')
|
|
||||||
await output.checksumWritten
|
|
||||||
// $FlowFixMe
|
|
||||||
await input.task
|
|
||||||
await handler.rename(tmpPath, path, { checksum })
|
|
||||||
} catch (error) {
|
|
||||||
await handler.unlink(tmpPath, { checksum })
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const wrapTask = async <T>(opts: any, task: Promise<T>): Promise<T> => {
|
const wrapTask = async <T>(opts: any, task: Promise<T>): Promise<T> => {
|
||||||
const { data, logger, message, parentId, result } = opts
|
const { data, logger, message, parentId, result } = opts
|
||||||
|
|
||||||
@ -1507,7 +1476,7 @@ export default class BackupNg {
|
|||||||
parentId: taskId,
|
parentId: taskId,
|
||||||
result: () => ({ size: xva.size }),
|
result: () => ({ size: xva.size }),
|
||||||
},
|
},
|
||||||
writeStream(fork, handler, dataFilename)
|
handler.outputStream(fork, dataFilename)
|
||||||
)
|
)
|
||||||
|
|
||||||
if (handler._getFilePath !== undefined) {
|
if (handler._getFilePath !== undefined) {
|
||||||
@ -1876,9 +1845,8 @@ export default class BackupNg {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// FIXME: should only be renamed after the metadata file has been written
|
// FIXME: should only be renamed after the metadata file has been written
|
||||||
await writeStream(
|
await handler.outputStream(
|
||||||
fork.streams[`${id}.vhd`](),
|
fork.streams[`${id}.vhd`](),
|
||||||
handler,
|
|
||||||
path,
|
path,
|
||||||
{
|
{
|
||||||
// no checksum for VHDs, because they will be invalidated by
|
// no checksum for VHDs, because they will be invalidated by
|
||||||
|
@ -547,6 +547,7 @@ const messages = {
|
|||||||
remoteTypeLocal: 'Local',
|
remoteTypeLocal: 'Local',
|
||||||
remoteTypeNfs: 'NFS',
|
remoteTypeNfs: 'NFS',
|
||||||
remoteTypeSmb: 'SMB',
|
remoteTypeSmb: 'SMB',
|
||||||
|
remoteTypeS3: 'Amazon Web Services S3 (beta)',
|
||||||
remoteType: 'Type',
|
remoteType: 'Type',
|
||||||
remoteSmbWarningMessage:
|
remoteSmbWarningMessage:
|
||||||
'SMB remotes are meant to work with Windows Server. For other systems (Linux Samba, which means almost all NAS), please use NFS.',
|
'SMB remotes are meant to work with Windows Server. For other systems (Linux Samba, which means almost all NAS), please use NFS.',
|
||||||
@ -593,6 +594,8 @@ const messages = {
|
|||||||
remoteSmbPlaceHolderDomain: 'Domain',
|
remoteSmbPlaceHolderDomain: 'Domain',
|
||||||
remoteSmbPlaceHolderAddressShare: '<address>\\\\<share>',
|
remoteSmbPlaceHolderAddressShare: '<address>\\\\<share>',
|
||||||
remoteSmbPlaceHolderOptions: 'Custom mount options',
|
remoteSmbPlaceHolderOptions: 'Custom mount options',
|
||||||
|
remoteS3PlaceHolderBucket: 'AWS S3 bucket name',
|
||||||
|
remoteS3PlaceHolderDirectory: 'directory',
|
||||||
remotePlaceHolderPassword: 'Password(fill to edit)',
|
remotePlaceHolderPassword: 'Password(fill to edit)',
|
||||||
|
|
||||||
// ------ New Storage -----
|
// ------ New Storage -----
|
||||||
|
@ -294,6 +294,58 @@ const COLUMNS_SMB_REMOTE = [
|
|||||||
COLUMN_PROXY,
|
COLUMN_PROXY,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const COLUMNS_S3_REMOTE = [
|
||||||
|
COLUMN_NAME,
|
||||||
|
{
|
||||||
|
itemRenderer: (remote, { formatMessage }) => (
|
||||||
|
<Text
|
||||||
|
data-element='host'
|
||||||
|
data-remote={remote}
|
||||||
|
onChange={_changeUrlElement}
|
||||||
|
placeholder='AWS endpoint'
|
||||||
|
value={remote.host}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
name: 'AWS S3 Endpoint',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
itemRenderer: (remote, { formatMessage }) => (
|
||||||
|
<Text
|
||||||
|
data-element='path'
|
||||||
|
data-remote={remote}
|
||||||
|
onChange={_changeUrlElement}
|
||||||
|
placeholder='bucket placeholder'
|
||||||
|
value={remote.path}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
name: 'Bucket',
|
||||||
|
},
|
||||||
|
COLUMN_STATE,
|
||||||
|
{
|
||||||
|
itemRenderer: (remote, { formatMessage }) => (
|
||||||
|
<span>
|
||||||
|
<Text
|
||||||
|
data-element='username'
|
||||||
|
data-remote={remote}
|
||||||
|
onChange={_changeUrlElement}
|
||||||
|
value={remote.username}
|
||||||
|
/>
|
||||||
|
:
|
||||||
|
<Password
|
||||||
|
data-element='password'
|
||||||
|
data-remote={remote}
|
||||||
|
onChange={_changeUrlElement}
|
||||||
|
placeholder='Click to change Secret Key'
|
||||||
|
value=''
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
name: 'Key',
|
||||||
|
},
|
||||||
|
COLUMN_SPEED,
|
||||||
|
COLUMN_PROXY,
|
||||||
|
]
|
||||||
|
|
||||||
const GROUPED_ACTIONS = [
|
const GROUPED_ACTIONS = [
|
||||||
{
|
{
|
||||||
handler: deleteRemotes,
|
handler: deleteRemotes,
|
||||||
@ -443,6 +495,23 @@ export default decorate([
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!isEmpty(state.remoteWithInfo.s3) && (
|
||||||
|
<div>
|
||||||
|
<h2>{_('remoteTypeS3')}</h2>
|
||||||
|
<SortedTable
|
||||||
|
collection={state.remoteWithInfo.s3}
|
||||||
|
columns={COLUMNS_S3_REMOTE}
|
||||||
|
data-editRemote={effects.editRemote}
|
||||||
|
data-formatMessage={formatMessage}
|
||||||
|
data-reset={effects.reset}
|
||||||
|
filters={FILTERS}
|
||||||
|
groupedActions={GROUPED_ACTIONS}
|
||||||
|
individualActions={INDIVIDUAL_ACTIONS}
|
||||||
|
stateUrlParam='s3'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Remote formatMessage={formatMessage} key={state.formKey} />
|
<Remote formatMessage={formatMessage} key={state.formKey} />
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
|
@ -18,6 +18,7 @@ const remoteTypes = {
|
|||||||
file: 'remoteTypeLocal',
|
file: 'remoteTypeLocal',
|
||||||
nfs: 'remoteTypeNfs',
|
nfs: 'remoteTypeNfs',
|
||||||
smb: 'remoteTypeSmb',
|
smb: 'remoteTypeSmb',
|
||||||
|
s3: 'remoteTypeS3',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default decorate([
|
export default decorate([
|
||||||
@ -36,6 +37,8 @@ export default decorate([
|
|||||||
proxyId: undefined,
|
proxyId: undefined,
|
||||||
type: undefined,
|
type: undefined,
|
||||||
username: undefined,
|
username: undefined,
|
||||||
|
directory: undefined,
|
||||||
|
bucket: undefined,
|
||||||
}),
|
}),
|
||||||
effects: {
|
effects: {
|
||||||
linkState,
|
linkState,
|
||||||
@ -93,6 +96,8 @@ export default decorate([
|
|||||||
path,
|
path,
|
||||||
port,
|
port,
|
||||||
proxyId,
|
proxyId,
|
||||||
|
bucket,
|
||||||
|
directory,
|
||||||
type = 'nfs',
|
type = 'nfs',
|
||||||
username,
|
username,
|
||||||
} = state
|
} = state
|
||||||
@ -103,6 +108,9 @@ export default decorate([
|
|||||||
port,
|
port,
|
||||||
type,
|
type,
|
||||||
}
|
}
|
||||||
|
if (type === 's3') {
|
||||||
|
urlParams.path = bucket + '/' + directory
|
||||||
|
}
|
||||||
username && (urlParams.username = username)
|
username && (urlParams.username = username)
|
||||||
password && (urlParams.password = password)
|
password && (urlParams.password = password)
|
||||||
domain && (urlParams.domain = domain)
|
domain && (urlParams.domain = domain)
|
||||||
@ -142,6 +150,10 @@ export default decorate([
|
|||||||
password = remote.password || '',
|
password = remote.password || '',
|
||||||
parsedPath,
|
parsedPath,
|
||||||
path = parsedPath || '',
|
path = parsedPath || '',
|
||||||
|
parsedBucket = parsedPath && parsedPath.split('/')[0],
|
||||||
|
bucket = parsedBucket || '',
|
||||||
|
parsedDirectory,
|
||||||
|
directory = parsedDirectory || '',
|
||||||
port = remote.port,
|
port = remote.port,
|
||||||
proxyId = remote.proxy,
|
proxyId = remote.proxy,
|
||||||
type = remote.type || 'nfs',
|
type = remote.type || 'nfs',
|
||||||
@ -170,6 +182,11 @@ export default decorate([
|
|||||||
{type === 'smb' && (
|
{type === 'smb' && (
|
||||||
<em className='text-warning'>{_('remoteSmbWarningMessage')}</em>
|
<em className='text-warning'>{_('remoteSmbWarningMessage')}</em>
|
||||||
)}
|
)}
|
||||||
|
{type === 's3' && (
|
||||||
|
<em className='text-warning'>
|
||||||
|
Backup to Amazon S3 is a BETA feature
|
||||||
|
</em>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className='form-group'>
|
<div className='form-group'>
|
||||||
<input
|
<input
|
||||||
@ -332,6 +349,73 @@ export default decorate([
|
|||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
)}
|
)}
|
||||||
|
{type === 's3' && (
|
||||||
|
<fieldset className='form-group form-group'>
|
||||||
|
<div className='input-group '>
|
||||||
|
<input
|
||||||
|
className='form-control'
|
||||||
|
name='host'
|
||||||
|
onChange={effects.linkState}
|
||||||
|
// pattern='^[^\\/]+\\[^\\/]+$'
|
||||||
|
placeholder='AWS S3 endpoint (ex: s3.us-east-2.amazonaws.com)'
|
||||||
|
required
|
||||||
|
type='text'
|
||||||
|
value={host}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='input-group '>
|
||||||
|
<input
|
||||||
|
className='form-control'
|
||||||
|
name='bucket'
|
||||||
|
onChange={effects.linkState}
|
||||||
|
// https://stackoverflow.com/a/58248645/72637
|
||||||
|
pattern='(?!^(\d{1,3}\.){3}\d{1,3}$)(^[a-z0-9]([a-z0-9-]*(\.[a-z0-9])?)*$)'
|
||||||
|
placeholder={formatMessage(
|
||||||
|
messages.remoteS3PlaceHolderBucket
|
||||||
|
)}
|
||||||
|
required
|
||||||
|
type='text'
|
||||||
|
value={bucket}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='input-group form-group'>
|
||||||
|
<input
|
||||||
|
className='form-control'
|
||||||
|
name='directory'
|
||||||
|
onChange={effects.linkState}
|
||||||
|
pattern='^(([^/]+)+(/[^/]+)*)?$'
|
||||||
|
placeholder={formatMessage(
|
||||||
|
messages.remoteS3PlaceHolderDirectory
|
||||||
|
)}
|
||||||
|
required
|
||||||
|
type='text'
|
||||||
|
value={directory}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='input-group'>
|
||||||
|
<input
|
||||||
|
className='form-control'
|
||||||
|
name='username'
|
||||||
|
onChange={effects.linkState}
|
||||||
|
placeholder='Access key ID'
|
||||||
|
required
|
||||||
|
type='text'
|
||||||
|
value={username}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='input-group'>
|
||||||
|
<input
|
||||||
|
className='form-control'
|
||||||
|
name='password'
|
||||||
|
onChange={effects.linkState}
|
||||||
|
placeholder='Secret access key'
|
||||||
|
required
|
||||||
|
type='text'
|
||||||
|
value={password}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
)}
|
||||||
<div className='form-group'>
|
<div className='form-group'>
|
||||||
<ActionButton
|
<ActionButton
|
||||||
btnStyle='primary'
|
btnStyle='primary'
|
||||||
|
57
yarn.lock
57
yarn.lock
@ -2843,6 +2843,21 @@ autoprefixer@^9.5.1, autoprefixer@^9.6.1:
|
|||||||
postcss "^7.0.30"
|
postcss "^7.0.30"
|
||||||
postcss-value-parser "^4.1.0"
|
postcss-value-parser "^4.1.0"
|
||||||
|
|
||||||
|
aws-sdk@^2.686.0:
|
||||||
|
version "2.687.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/aws-sdk/-/aws-sdk-2.687.0.tgz#664019f43a697b9697a5b90789a5ecbbb9e2b90f"
|
||||||
|
integrity sha512-gUIL4YXWdg3dv1QJMs0PCLpaHRe4DtPIYgRh3PPLJHnbIETLQdiA98co6qthAXIIWKx2hET4d1BhnAlAp77zQw==
|
||||||
|
dependencies:
|
||||||
|
buffer "4.9.2"
|
||||||
|
events "1.1.1"
|
||||||
|
ieee754 "1.1.13"
|
||||||
|
jmespath "0.15.0"
|
||||||
|
querystring "0.2.0"
|
||||||
|
sax "1.2.1"
|
||||||
|
url "0.10.3"
|
||||||
|
uuid "3.3.2"
|
||||||
|
xml2js "0.4.19"
|
||||||
|
|
||||||
aws-sign2@~0.7.0:
|
aws-sign2@~0.7.0:
|
||||||
version "0.7.0"
|
version "0.7.0"
|
||||||
resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8"
|
resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8"
|
||||||
@ -4333,7 +4348,7 @@ buffer-xor@^1.0.3:
|
|||||||
resolved "https://registry.yarnpkg.com/buffer-xor/-/buffer-xor-1.0.3.tgz#26e61ed1422fb70dd42e6e36729ed51d855fe8d9"
|
resolved "https://registry.yarnpkg.com/buffer-xor/-/buffer-xor-1.0.3.tgz#26e61ed1422fb70dd42e6e36729ed51d855fe8d9"
|
||||||
integrity sha1-JuYe0UIvtw3ULm42cp7VHYVf6Nk=
|
integrity sha1-JuYe0UIvtw3ULm42cp7VHYVf6Nk=
|
||||||
|
|
||||||
buffer@^4.3.0:
|
buffer@4.9.2, buffer@^4.3.0:
|
||||||
version "4.9.2"
|
version "4.9.2"
|
||||||
resolved "https://registry.yarnpkg.com/buffer/-/buffer-4.9.2.tgz#230ead344002988644841ab0244af8c44bbe3ef8"
|
resolved "https://registry.yarnpkg.com/buffer/-/buffer-4.9.2.tgz#230ead344002988644841ab0244af8c44bbe3ef8"
|
||||||
integrity sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==
|
integrity sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==
|
||||||
@ -7274,7 +7289,7 @@ eventemitter3@^4.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.4.tgz#b5463ace635a083d018bdc7c917b4c5f10a85384"
|
resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.4.tgz#b5463ace635a083d018bdc7c917b4c5f10a85384"
|
||||||
integrity sha512-rlaVLnVxtxvoyLsQQFBx53YmXHDxRIzzTLbdfxqi4yocpSjAxXwkU0cScM5JgSKMqEhrZpnvQ2D9gjylR0AimQ==
|
integrity sha512-rlaVLnVxtxvoyLsQQFBx53YmXHDxRIzzTLbdfxqi4yocpSjAxXwkU0cScM5JgSKMqEhrZpnvQ2D9gjylR0AimQ==
|
||||||
|
|
||||||
events@^1.0.2, events@^1.1.0:
|
events@1.1.1, events@^1.0.2, events@^1.1.0:
|
||||||
version "1.1.1"
|
version "1.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/events/-/events-1.1.1.tgz#9ebdb7635ad099c70dcc4c2a1f5004288e8bd924"
|
resolved "https://registry.yarnpkg.com/events/-/events-1.1.1.tgz#9ebdb7635ad099c70dcc4c2a1f5004288e8bd924"
|
||||||
integrity sha1-nr23Y1rQmccNzEwqH1AEKI6L2SQ=
|
integrity sha1-nr23Y1rQmccNzEwqH1AEKI6L2SQ=
|
||||||
@ -9236,7 +9251,7 @@ icss-utils@^4.1.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
postcss "^7.0.14"
|
postcss "^7.0.14"
|
||||||
|
|
||||||
ieee754@^1.1.4:
|
ieee754@1.1.13, ieee754@^1.1.4:
|
||||||
version "1.1.13"
|
version "1.1.13"
|
||||||
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84"
|
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84"
|
||||||
integrity sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==
|
integrity sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==
|
||||||
@ -10837,6 +10852,10 @@ jest@^24.8.0:
|
|||||||
import-local "^2.0.0"
|
import-local "^2.0.0"
|
||||||
jest-cli "^24.9.0"
|
jest-cli "^24.9.0"
|
||||||
|
|
||||||
|
jmespath@0.15.0:
|
||||||
|
version "0.15.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/jmespath/-/jmespath-0.15.0.tgz#a3f222a9aae9f966f5d27c796510e28091764217"
|
||||||
|
integrity sha1-o/Iiqarp+Wb10nx5ZRDigJF2Qhc=
|
||||||
jest@^26.0.1:
|
jest@^26.0.1:
|
||||||
version "26.0.1"
|
version "26.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/jest/-/jest-26.0.1.tgz#5c51a2e58dff7525b65f169721767173bf832694"
|
resolved "https://registry.yarnpkg.com/jest/-/jest-26.0.1.tgz#5c51a2e58dff7525b65f169721767173bf832694"
|
||||||
@ -15894,6 +15913,11 @@ sass-graph@2.2.5:
|
|||||||
scss-tokenizer "^0.2.3"
|
scss-tokenizer "^0.2.3"
|
||||||
yargs "^13.3.2"
|
yargs "^13.3.2"
|
||||||
|
|
||||||
|
sax@1.2.1:
|
||||||
|
version "1.2.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.1.tgz#7b8e656190b228e81a66aea748480d828cd2d37a"
|
||||||
|
integrity sha1-e45lYZCyKOgaZq6nSEgNgozS03o=
|
||||||
|
|
||||||
sax@1.2.x, sax@>=0.6.0, sax@^1.2.4, sax@~1.2.4:
|
sax@1.2.x, sax@>=0.6.0, sax@^1.2.4, sax@~1.2.4:
|
||||||
version "1.2.4"
|
version "1.2.4"
|
||||||
resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
|
resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
|
||||||
@ -17951,7 +17975,7 @@ url-parse-lax@^3.0.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
prepend-http "^2.0.0"
|
prepend-http "^2.0.0"
|
||||||
|
|
||||||
url-parse@^1.2.0, url-parse@^1.4.3:
|
url-parse@^1.2.0, url-parse@^1.4.3, url-parse@^1.4.7:
|
||||||
version "1.4.7"
|
version "1.4.7"
|
||||||
resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.4.7.tgz#a8a83535e8c00a316e403a5db4ac1b9b853ae278"
|
resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.4.7.tgz#a8a83535e8c00a316e403a5db4ac1b9b853ae278"
|
||||||
integrity sha512-d3uaVyzDB9tQoSXFvuSUNFibTd9zxd2bkVrDRvF5TmvWWQwqE4lgYJ5m+x1DbecWkw+LK4RNl2CU1hHuOKPVlg==
|
integrity sha512-d3uaVyzDB9tQoSXFvuSUNFibTd9zxd2bkVrDRvF5TmvWWQwqE4lgYJ5m+x1DbecWkw+LK4RNl2CU1hHuOKPVlg==
|
||||||
@ -17959,6 +17983,14 @@ url-parse@^1.2.0, url-parse@^1.4.3:
|
|||||||
querystringify "^2.1.1"
|
querystringify "^2.1.1"
|
||||||
requires-port "^1.0.0"
|
requires-port "^1.0.0"
|
||||||
|
|
||||||
|
url@0.10.3:
|
||||||
|
version "0.10.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/url/-/url-0.10.3.tgz#021e4d9c7705f21bbf37d03ceb58767402774c64"
|
||||||
|
integrity sha1-Ah5NnHcF8hu/N9A861h2dAJ3TGQ=
|
||||||
|
dependencies:
|
||||||
|
punycode "1.3.2"
|
||||||
|
querystring "0.2.0"
|
||||||
|
|
||||||
url@^0.11.0, url@~0.11.0:
|
url@^0.11.0, url@~0.11.0:
|
||||||
version "0.11.0"
|
version "0.11.0"
|
||||||
resolved "https://registry.yarnpkg.com/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1"
|
resolved "https://registry.yarnpkg.com/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1"
|
||||||
@ -18026,6 +18058,11 @@ utils-merge@1.0.1, utils-merge@1.x.x:
|
|||||||
resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
|
resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
|
||||||
integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=
|
integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=
|
||||||
|
|
||||||
|
uuid@3.3.2:
|
||||||
|
version "3.3.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131"
|
||||||
|
integrity sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==
|
||||||
|
|
||||||
uuid@^3.0.1, uuid@^3.3.2, uuid@^3.4.0:
|
uuid@^3.0.1, uuid@^3.3.2, uuid@^3.4.0:
|
||||||
version "3.4.0"
|
version "3.4.0"
|
||||||
resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee"
|
resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee"
|
||||||
@ -18812,6 +18849,14 @@ xml-name-validator@^3.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a"
|
resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a"
|
||||||
integrity sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==
|
integrity sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==
|
||||||
|
|
||||||
|
xml2js@0.4.19:
|
||||||
|
version "0.4.19"
|
||||||
|
resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.4.19.tgz#686c20f213209e94abf0d1bcf1efaa291c7827a7"
|
||||||
|
integrity sha512-esZnJZJOiJR9wWKMyuvSE1y6Dq5LCuJanqhxslH2bxM6duahNZ+HMpCLhBQGZkbX6xRf8x1Y2eJlgt2q3qo49Q==
|
||||||
|
dependencies:
|
||||||
|
sax ">=0.6.0"
|
||||||
|
xmlbuilder "~9.0.1"
|
||||||
|
|
||||||
xml2js@0.4.x, xml2js@^0.4.19, xml2js@^0.4.23:
|
xml2js@0.4.x, xml2js@^0.4.19, xml2js@^0.4.23:
|
||||||
version "0.4.23"
|
version "0.4.23"
|
||||||
resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.4.23.tgz#a0c69516752421eb2ac758ee4d4ccf58843eac66"
|
resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.4.23.tgz#a0c69516752421eb2ac758ee4d4ccf58843eac66"
|
||||||
@ -18830,6 +18875,10 @@ xmlbuilder@^11.0.0, xmlbuilder@~11.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-11.0.1.tgz#be9bae1c8a046e76b31127726347d0ad7002beb3"
|
resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-11.0.1.tgz#be9bae1c8a046e76b31127726347d0ad7002beb3"
|
||||||
integrity sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==
|
integrity sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==
|
||||||
|
|
||||||
|
xmlbuilder@~9.0.1:
|
||||||
|
version "9.0.7"
|
||||||
|
resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-9.0.7.tgz#132ee63d2ec5565c557e20f4c22df9aca686b10d"
|
||||||
|
integrity sha1-Ey7mPS7FVlxVfiD0wi35rKaGsQ0=
|
||||||
xmlchars@^2.2.0:
|
xmlchars@^2.2.0:
|
||||||
version "2.2.0"
|
version "2.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb"
|
resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb"
|
||||||
|
Loading…
Reference in New Issue
Block a user