@toa.io/extensions.storages 0.20.0-alpha.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/package.json +38 -0
- package/readme.md +195 -0
- package/source/.test/albert.jpg +0 -0
- package/source/.test/empty.txt +0 -0
- package/source/.test/lenna.ascii +64 -0
- package/source/.test/lenna.png +0 -0
- package/source/.test/sample.avif +0 -0
- package/source/.test/sample.gif +0 -0
- package/source/.test/sample.heic +0 -0
- package/source/.test/sample.jpeg +0 -0
- package/source/.test/sample.jxl +0 -0
- package/source/.test/sample.webp +0 -0
- package/source/.test/util.ts +50 -0
- package/source/Aspect.ts +23 -0
- package/source/Entry.ts +14 -0
- package/source/Factory.ts +65 -0
- package/source/Provider.ts +8 -0
- package/source/Scanner.ts +109 -0
- package/source/Storage.test.ts +445 -0
- package/source/Storage.ts +236 -0
- package/source/deployment.ts +61 -0
- package/source/index.ts +3 -0
- package/source/manifest.ts +8 -0
- package/source/providers/FileSystem.test.ts +13 -0
- package/source/providers/FileSystem.ts +46 -0
- package/source/providers/Temporary.ts +14 -0
- package/source/providers/Test.ts +13 -0
- package/source/providers/index.test.ts +116 -0
- package/source/providers/index.ts +13 -0
- package/source/providers/readme.md +20 -0
- package/tsconfig.json +12 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { encode } from 'msgpackr'
|
|
2
|
+
import { providers } from './providers'
|
|
3
|
+
import type { Dependency, Variable } from '@toa.io/operations'
|
|
4
|
+
import type { context } from '@toa.io/norm'
|
|
5
|
+
|
|
6
|
+
export function deployment (instances: Instance[], annotation: Annotation): Dependency {
|
|
7
|
+
validate(instances, annotation)
|
|
8
|
+
|
|
9
|
+
const value = encode(annotation).toString('base64')
|
|
10
|
+
const pointer: Variable = { name: 'TOA_STORAGES', value }
|
|
11
|
+
const secrets = getSecrets(annotation)
|
|
12
|
+
|
|
13
|
+
return {
|
|
14
|
+
variables: { global: [pointer, ...secrets] }
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function validate (instances: Instance[], annotation: Annotation): void {
|
|
19
|
+
for (const instance of instances)
|
|
20
|
+
contains(instance, annotation)
|
|
21
|
+
|
|
22
|
+
for (const ref of Object.values(annotation)) {
|
|
23
|
+
const url = new URL(ref)
|
|
24
|
+
|
|
25
|
+
if (!(url.protocol in providers))
|
|
26
|
+
throw new Error(`Unknown storage provider '${url.protocol}'`)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function contains (instance: Instance, annotation: Annotation): void {
|
|
31
|
+
for (const name of instance.manifest)
|
|
32
|
+
if (!(name in annotation))
|
|
33
|
+
throw new Error(`Missing '${name}' storage annotation ` +
|
|
34
|
+
`declared in '${instance.component.locator.id}'`)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function getSecrets (annotation: Annotation): Variable[] {
|
|
38
|
+
const secrets: Variable[] = []
|
|
39
|
+
|
|
40
|
+
for (const [storage, ref] of Object.entries(annotation)) {
|
|
41
|
+
const url = new URL(ref)
|
|
42
|
+
const Provider = providers[url.protocol]
|
|
43
|
+
|
|
44
|
+
if (Provider.SECRETS === undefined)
|
|
45
|
+
continue
|
|
46
|
+
|
|
47
|
+
for (const secret of Provider.SECRETS)
|
|
48
|
+
secrets.push({
|
|
49
|
+
name: `TOA_STORAGES_${storage.toUpperCase()}_${secret.toUpperCase()}`,
|
|
50
|
+
secret: {
|
|
51
|
+
name: `toa-storages-${storage}`,
|
|
52
|
+
key: secret
|
|
53
|
+
}
|
|
54
|
+
} satisfies Variable)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return secrets
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
type Annotation = Record<string, string>
|
|
61
|
+
export type Instance = context.Dependency<string[]>
|
package/source/index.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { FileSystem } from './FileSystem'
|
|
2
|
+
|
|
3
|
+
it('should be ok', async () => {
|
|
4
|
+
const url = new URL('file:///path')
|
|
5
|
+
|
|
6
|
+
expect(() => new FileSystem(url)).not.toThrow()
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it('should throw if url contains host', async () => {
|
|
10
|
+
const url = new URL('file://host/path')
|
|
11
|
+
|
|
12
|
+
expect(() => new FileSystem(url)).toThrow()
|
|
13
|
+
})
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type Readable } from 'node:stream'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
import { createReadStream } from 'node:fs'
|
|
4
|
+
import fs from 'node:fs/promises'
|
|
5
|
+
import fse from 'fs-extra'
|
|
6
|
+
import { type Provider } from '../Provider'
|
|
7
|
+
|
|
8
|
+
export class FileSystem implements Provider {
|
|
9
|
+
protected readonly path: string
|
|
10
|
+
|
|
11
|
+
public constructor (url: URL) {
|
|
12
|
+
if (url.host !== '')
|
|
13
|
+
throw new Error('File system URL must not contain host')
|
|
14
|
+
|
|
15
|
+
this.path = url.pathname
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
public async get (path: string): Promise<Readable | null> {
|
|
19
|
+
path = join(this.path, path)
|
|
20
|
+
|
|
21
|
+
if (!await fse.exists(path))
|
|
22
|
+
return null
|
|
23
|
+
|
|
24
|
+
return createReadStream(path)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
public async put (rel: string, filename: string, stream: Readable): Promise<void> {
|
|
28
|
+
const dir = join(this.path, rel)
|
|
29
|
+
const path = join(dir, filename)
|
|
30
|
+
|
|
31
|
+
await fse.ensureDir(dir)
|
|
32
|
+
await fs.writeFile(path, stream)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
public async delete (path: string): Promise<void> {
|
|
36
|
+
await fse.remove(join(this.path, path))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
public async move (from: string, to: string): Promise<void> {
|
|
40
|
+
from = join(this.path, from)
|
|
41
|
+
to = join(this.path, to)
|
|
42
|
+
|
|
43
|
+
await fse.ensureDir(dirname(to))
|
|
44
|
+
await fs.rename(from, to)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { tmpdir } from 'node:os'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { FileSystem } from './FileSystem'
|
|
5
|
+
|
|
6
|
+
export class Temporary extends FileSystem {
|
|
7
|
+
protected override readonly path: string
|
|
8
|
+
|
|
9
|
+
public constructor (url: URL) {
|
|
10
|
+
super(url)
|
|
11
|
+
|
|
12
|
+
this.path = join(tmpdir(), url.pathname)
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import assert from 'node:assert'
|
|
2
|
+
import { Temporary } from './Temporary'
|
|
3
|
+
|
|
4
|
+
export class Test extends Temporary {
|
|
5
|
+
public static readonly SECRETS = ['USERNAME', 'PASSWORD']
|
|
6
|
+
|
|
7
|
+
public constructor (url: URL, secrets: Record<string, string>) {
|
|
8
|
+
super(url)
|
|
9
|
+
|
|
10
|
+
assert(secrets.USERNAME !== undefined, 'Missing USERNAME')
|
|
11
|
+
assert(secrets.PASSWORD !== undefined, 'Missing PASSWORD')
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { type Readable } from 'node:stream'
|
|
2
|
+
import { buffer } from '@toa.io/streams'
|
|
3
|
+
import { cases, open, rnd, read } from '../.test/util'
|
|
4
|
+
import { providers } from './index'
|
|
5
|
+
|
|
6
|
+
describe.each(cases)('%s', (protocol, url, secrets) => {
|
|
7
|
+
const Provider = providers[protocol]
|
|
8
|
+
const provider = new Provider(url, secrets)
|
|
9
|
+
|
|
10
|
+
let dir: string
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
dir = '/' + rnd()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('should be', async () => {
|
|
17
|
+
expect(provider).toBeInstanceOf(Provider)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('should return null if file not found', async () => {
|
|
21
|
+
const result = await provider.get(rnd())
|
|
22
|
+
|
|
23
|
+
expect(result).toBeNull()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('should create entry', async () => {
|
|
27
|
+
const stream = await open('lenna.png')
|
|
28
|
+
|
|
29
|
+
await provider.put(dir, 'lenna.png', stream)
|
|
30
|
+
|
|
31
|
+
const readable = await provider.get(dir + '/lenna.png') as Readable
|
|
32
|
+
const output = await buffer(readable)
|
|
33
|
+
const lenna = await read('lenna.png')
|
|
34
|
+
|
|
35
|
+
expect(output.compare(lenna)).toBe(0)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('should overwrite existing entry', async () => {
|
|
39
|
+
const stream0 = await open('lenna.png')
|
|
40
|
+
const stream1 = await open('albert.jpg')
|
|
41
|
+
|
|
42
|
+
await provider.put(dir, 'lenna.png', stream0)
|
|
43
|
+
await provider.put(dir, 'lenna.png', stream1)
|
|
44
|
+
|
|
45
|
+
const readable = await provider.get(dir + '/lenna.png') as Readable
|
|
46
|
+
const output = await buffer(readable)
|
|
47
|
+
const albert = await read('albert.jpg')
|
|
48
|
+
|
|
49
|
+
expect(output.compare(albert)).toBe(0)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('should get by path', async () => {
|
|
53
|
+
const stream = await open('lenna.png')
|
|
54
|
+
|
|
55
|
+
await provider.put(dir, 'lenna.png', stream)
|
|
56
|
+
|
|
57
|
+
const result = await provider.get('/bar/lenna.png')
|
|
58
|
+
|
|
59
|
+
expect(result).toBeNull()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
describe('danger', () => {
|
|
63
|
+
/*
|
|
64
|
+
|
|
65
|
+
WHEN MAKING CHANGES TO DELETION,
|
|
66
|
+
ALWAYS RUN TESTS IN STEP-BY-STEP DEBUGGING MODE
|
|
67
|
+
|
|
68
|
+
YOU MAY EVENTUALLY DELETE YOUR ENTIRE FILE SYSTEM
|
|
69
|
+
|
|
70
|
+
Sincerely yours, Murphy
|
|
71
|
+
|
|
72
|
+
*/
|
|
73
|
+
|
|
74
|
+
it('should delete entry', async () => {
|
|
75
|
+
const stream = await open('lenna.png')
|
|
76
|
+
|
|
77
|
+
await provider.put(dir, 'lenna.png', stream)
|
|
78
|
+
await provider.delete(dir + '/lenna.png')
|
|
79
|
+
|
|
80
|
+
const result = await provider.get(dir + '/lenna.png')
|
|
81
|
+
|
|
82
|
+
expect(result).toBeNull()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('should not throw if path does not exists', async () => {
|
|
86
|
+
await expect(provider.delete(dir + '/whatever')).resolves.not.toThrow()
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('should delete directory', async () => {
|
|
90
|
+
const stream = await open('lenna.png')
|
|
91
|
+
|
|
92
|
+
await provider.put(dir, 'lenna.png', stream)
|
|
93
|
+
await provider.delete(dir)
|
|
94
|
+
|
|
95
|
+
const result = await provider.get(dir + '/lenna.png')
|
|
96
|
+
|
|
97
|
+
expect(result).toBeNull()
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('should move an entry', async () => {
|
|
101
|
+
const stream = await open('lenna.png')
|
|
102
|
+
const dir2 = '/' + rnd()
|
|
103
|
+
|
|
104
|
+
await provider.put(dir, 'lenna.png', stream)
|
|
105
|
+
await provider.move(dir + '/lenna.png', dir2 + '/lenna2.png')
|
|
106
|
+
|
|
107
|
+
const result = await provider.get(dir2 + '/lenna2.png') as Readable
|
|
108
|
+
|
|
109
|
+
expect(result).not.toBeNull()
|
|
110
|
+
|
|
111
|
+
const nope = await provider.get(dir + '/lenna.png')
|
|
112
|
+
|
|
113
|
+
expect(nope).toBeNull()
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
})
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type Provider } from '../Provider'
|
|
2
|
+
import { FileSystem } from './FileSystem'
|
|
3
|
+
import { Temporary } from './Temporary'
|
|
4
|
+
import { Test } from './Test'
|
|
5
|
+
|
|
6
|
+
export const providers: Record<string, ProviderClass> = {
|
|
7
|
+
'file:': FileSystem,
|
|
8
|
+
'tmp:': Temporary,
|
|
9
|
+
'test:': Test
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type ProviderClass =
|
|
13
|
+
(new (url: URL, secrets: Record<string, string>) => Provider) & { SECRETS?: string[] }
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Developing a provider
|
|
2
|
+
|
|
3
|
+
1. Add an implementaion of the [Provider](../Provider.ts) interface under this directory.
|
|
4
|
+
2. Add an entry to the provider map in [`index.ts`](./index.ts).
|
|
5
|
+
3. Add a suite to the `suites` object in [`util.ts`](../.test/util.ts).
|
|
6
|
+
4. Run `$ npm test` in the [`storages` directory](../..).
|
|
7
|
+
|
|
8
|
+
Provider's constructor must have the following signature:
|
|
9
|
+
|
|
10
|
+
`constructor(url: URL)`
|
|
11
|
+
|
|
12
|
+
## Secrets
|
|
13
|
+
|
|
14
|
+
Provider class may have static `SECRETS` property of type `string[]` that lists the names of the secrets that it
|
|
15
|
+
requires.
|
|
16
|
+
The secrets are passed to the constructor as the second argument.
|
|
17
|
+
|
|
18
|
+
`constructor(url: URL, secrets: Record<string, string>)`
|
|
19
|
+
|
|
20
|
+
See [`Test` provider](./Test.ts) for an example.
|