[worker] add exact-file rclone deletion #168
No files matched your search
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@luzzle/web.worker": patch
|
||||
---
|
||||
|
||||
Add an exact-file remote deletion helper using the existing rclone runner,
|
||||
logging and configured flags.
|
||||
@@ -120,6 +120,100 @@ describe('RcloneClient flags passthrough', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('RcloneClient deleteFile', () => {
|
||||
const options = {
|
||||
remote: 'cdn',
|
||||
remotePath: 'published/books/key/cover #1.png',
|
||||
configPath: '/config/rclone.conf',
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it.each([{ flags: undefined }, { flags: [] }])(
|
||||
'deletes one exact remote file with flags=$flags',
|
||||
async ({ flags }) => {
|
||||
const spawnMock = await mockSpawnSuccess()
|
||||
const client = new RcloneClient(makeLogger())
|
||||
|
||||
await client.deleteFile({ ...options, flags })
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledExactlyOnceWith(
|
||||
'rclone',
|
||||
[
|
||||
'deletefile',
|
||||
'cdn:published/books/key/cover #1.png',
|
||||
'--config',
|
||||
'/config/rclone.conf',
|
||||
'--verbose',
|
||||
],
|
||||
{ stdio: ['ignore', 'pipe', 'pipe'] }
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('passes configured flags through unchanged', async () => {
|
||||
const spawnMock = await mockSpawnSuccess()
|
||||
const logger = makeLogger()
|
||||
const client = new RcloneClient(logger)
|
||||
const flags = ['--s3-endpoint', 'https://objects.example.test', '--dry-run']
|
||||
|
||||
await client.deleteFile({ ...options, flags })
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledExactlyOnceWith(
|
||||
'rclone',
|
||||
[
|
||||
'deletefile',
|
||||
'cdn:published/books/key/cover #1.png',
|
||||
'--config',
|
||||
'/config/rclone.conf',
|
||||
'--verbose',
|
||||
...flags,
|
||||
],
|
||||
{ stdio: ['ignore', 'pipe', 'pipe'] }
|
||||
)
|
||||
expect(logger.info).toHaveBeenCalledWith('rclone deletefile starting', {
|
||||
remote: 'cdn:published/books/key/cover #1.png',
|
||||
flags,
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a failed deletion without retrying', async () => {
|
||||
const { spawn } = await import('child_process')
|
||||
const child = new EventEmitter() as any
|
||||
child.stdout = new EventEmitter()
|
||||
child.stderr = new EventEmitter()
|
||||
vi.mocked(spawn).mockReturnValue(child)
|
||||
const logger = makeLogger()
|
||||
const client = new RcloneClient(logger)
|
||||
|
||||
const deletion = client.deleteFile(options)
|
||||
const rejection = expect(deletion).rejects.toThrow('rclone exited with code 1')
|
||||
child.stderr.emit('data', Buffer.from('remote rejected deletion\n'))
|
||||
child.emit('close', 1)
|
||||
|
||||
await rejection
|
||||
expect(logger.stderr).toHaveBeenCalledWith('remote rejected deletion')
|
||||
expect(spawn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('propagates a process error without retrying', async () => {
|
||||
const { spawn } = await import('child_process')
|
||||
const child = new EventEmitter() as any
|
||||
child.stdout = new EventEmitter()
|
||||
child.stderr = new EventEmitter()
|
||||
vi.mocked(spawn).mockReturnValue(child)
|
||||
const client = new RcloneClient(makeLogger())
|
||||
const error = new Error('rclone is unavailable')
|
||||
|
||||
const deletion = client.deleteFile(options)
|
||||
const rejection = expect(deletion).rejects.toBe(error)
|
||||
child.emit('error', error)
|
||||
|
||||
await rejection
|
||||
expect(spawn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('RcloneClient checkConnectivity', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
|
||||
@@ -27,6 +27,13 @@ export interface RcloneCopyOptions {
|
||||
flags?: string[]
|
||||
}
|
||||
|
||||
export interface RcloneDeleteFileOptions {
|
||||
remote: string
|
||||
remotePath: string
|
||||
configPath: string
|
||||
flags?: string[]
|
||||
}
|
||||
|
||||
export interface RcloneCheckOptions {
|
||||
remote: string
|
||||
remotePath: string
|
||||
@@ -124,6 +131,22 @@ export class RcloneClient {
|
||||
await this.run('rclone', args)
|
||||
}
|
||||
|
||||
async deleteFile(options: RcloneDeleteFileOptions): Promise<void> {
|
||||
const target = `${options.remote}:${options.remotePath}`
|
||||
const args = ['deletefile', target, '--config', options.configPath, '--verbose']
|
||||
|
||||
if (options.flags?.length) {
|
||||
args.push(...options.flags)
|
||||
}
|
||||
|
||||
this.logger.info('rclone deletefile starting', {
|
||||
remote: target,
|
||||
flags: options.flags,
|
||||
})
|
||||
|
||||
await this.run('rclone', args)
|
||||
}
|
||||
|
||||
checkConnectivity(options: RcloneCheckOptions): Promise<RcloneCheckResult> {
|
||||
const { remote, remotePath, configPath, timeoutMs = 10000 } = options
|
||||
const args = ['lsd', `${remote}:${remotePath}`, '--config', configPath, '--max-depth', '1']
|
||||
|
||||
Reference in new issue
Block a user