mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
feat: yucca integration
This commit is contained in:
@@ -44,6 +44,7 @@
|
||||
"exiftool-vendored": "^35.0.0",
|
||||
"globals": "^17.0.0",
|
||||
"luxon": "^3.4.4",
|
||||
"orchestration-ui": "0.1.54",
|
||||
"pg": "^8.11.3",
|
||||
"pngjs": "^7.0.0",
|
||||
"prettier": "^3.7.4",
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
import { LoginResponseDto, StorageFolder } from '@immich/sdk';
|
||||
import * as sdk from 'orchestration-ui/sdk';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
import { createUserDto } from 'src/fixtures';
|
||||
import { errorDto } from 'src/responses';
|
||||
import { app, asBearerAuth, baseUrl, utils } from 'src/utils';
|
||||
import request from 'supertest';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
describe('/yucca', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let nonAdmin: LoginResponseDto;
|
||||
let requestOpts: any;
|
||||
let filename: string;
|
||||
|
||||
let socket: Socket;
|
||||
let libraryId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
sdk.defaults.baseUrl = baseUrl;
|
||||
|
||||
await utils.resetDatabase();
|
||||
admin = await utils.adminSetup();
|
||||
nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1);
|
||||
|
||||
requestOpts = { headers: asBearerAuth(admin.accessToken) };
|
||||
|
||||
await utils.resetBackups(admin.accessToken);
|
||||
await sdk.resetOrchestrator(requestOpts);
|
||||
|
||||
socket = io(baseUrl, {
|
||||
path: '/api/yucca/socket.io',
|
||||
transports: ['websocket'],
|
||||
extraHeaders: asBearerAuth(admin.accessToken),
|
||||
});
|
||||
|
||||
socket.onAny(console.info);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
socket.close();
|
||||
|
||||
// "resetDatabase" does not reinit the module config, trigger an update / clean up
|
||||
if (libraryId) {
|
||||
await utils.deleteLibrary(admin.accessToken, libraryId);
|
||||
}
|
||||
});
|
||||
|
||||
const waitForMessage = (type: string) => {
|
||||
return new Promise((resolve) => {
|
||||
const listener = (msg: string) => {
|
||||
const payload = JSON.parse(msg);
|
||||
if (payload.type !== type) {
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(payload);
|
||||
socket.offAny(listener);
|
||||
};
|
||||
|
||||
socket.onAny(listener);
|
||||
});
|
||||
};
|
||||
|
||||
describe('Orchestration Module', async () => {
|
||||
it('works', async () => {
|
||||
await expect(sdk.onboardingStatus(requestOpts)).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
hasOnboardedKey: false,
|
||||
hasBackend: false,
|
||||
hasBackup: false,
|
||||
hasSchedule: false,
|
||||
hasSkippedExtraConfig: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('is inaccessible without admin', async () => {
|
||||
await expect(sdk.onboardingStatus({ headers: asBearerAuth(nonAdmin.accessToken) })).rejects.toEqual(
|
||||
expect.objectContaining({ data: errorDto.forbidden }),
|
||||
);
|
||||
});
|
||||
|
||||
it('is inaccessible without logging in', async () => {
|
||||
await expect(sdk.onboardingStatus()).rejects.toEqual(expect.objectContaining({ data: errorDto.unauthorized }));
|
||||
});
|
||||
});
|
||||
|
||||
describe.sequential('Local Backup', async () => {
|
||||
beforeAll(async () => {
|
||||
await sdk.importRecoveryKey(
|
||||
{
|
||||
recoveryKey: '0'.repeat(32),
|
||||
},
|
||||
requestOpts,
|
||||
);
|
||||
});
|
||||
|
||||
it.sequential('configures a local backend', async () => {
|
||||
await utils.mkdir('/local-backend');
|
||||
|
||||
await sdk.createLocalBackend(
|
||||
{
|
||||
path: '/local-backend',
|
||||
},
|
||||
requestOpts,
|
||||
);
|
||||
});
|
||||
|
||||
it.sequential('configures Immich backup', async () => {
|
||||
const event = waitForMessage('IntegrationUpdate');
|
||||
|
||||
await sdk.configureImmichIntegration(
|
||||
{
|
||||
name: 'Immich',
|
||||
worm: false,
|
||||
cron: '0 3 * * *',
|
||||
backupConfiguration: true,
|
||||
dataFolders: [StorageFolder.Backups, StorageFolder.Upload],
|
||||
libraries: 'all',
|
||||
},
|
||||
requestOpts,
|
||||
);
|
||||
|
||||
await event;
|
||||
|
||||
await expect(sdk.getIntegrations(requestOpts)).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
immichIntegration: expect.objectContaining({
|
||||
configuration: {
|
||||
backupConfiguration: true,
|
||||
dataFolders: ['backups', 'upload'],
|
||||
libraries: 'all',
|
||||
},
|
||||
}),
|
||||
immichState: {
|
||||
dataFolders: expect.arrayContaining(Object.values(StorageFolder)),
|
||||
dataPath: '/data',
|
||||
libraries: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.sequential('updates configuration', async () => {
|
||||
await utils.mkdir('/test');
|
||||
|
||||
({ id: libraryId } = await utils.createLibrary(admin.accessToken, {
|
||||
ownerId: admin.userId,
|
||||
name: 'My Library',
|
||||
importPaths: ['/test'],
|
||||
}));
|
||||
|
||||
await expect(sdk.getIntegrations(requestOpts)).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
immichIntegration: expect.any(Object),
|
||||
immichState: expect.objectContaining({
|
||||
libraries: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: 'My Library',
|
||||
importPaths: ['/test'],
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.sequential('creates a snapshot', async () => {
|
||||
const event = waitForMessage('TaskEnd');
|
||||
|
||||
const {
|
||||
repositories: [{ id }],
|
||||
} = await sdk.getRepositories(requestOpts);
|
||||
|
||||
filename = await utils.createBackup(admin.accessToken);
|
||||
|
||||
await sdk.createBackup(id, requestOpts);
|
||||
await event;
|
||||
|
||||
const {
|
||||
snapshots: [{ id: snapshotId }],
|
||||
} = await sdk.getSnapshots(id, requestOpts);
|
||||
|
||||
await expect(sdk.getSnapshotListing(id, snapshotId, {}, requestOpts)).resolves.toMatchInlineSnapshot(`
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"isDirectory": true,
|
||||
"path": "/data",
|
||||
},
|
||||
{
|
||||
"isDirectory": true,
|
||||
"path": "/test",
|
||||
},
|
||||
{
|
||||
"isDirectory": true,
|
||||
"path": "/yucca",
|
||||
},
|
||||
],
|
||||
"parent": "/",
|
||||
"path": "/",
|
||||
}
|
||||
`);
|
||||
|
||||
await expect(sdk.getSnapshotListing(id, snapshotId, { path: '/data' }, requestOpts)).resolves
|
||||
.toMatchInlineSnapshot(`
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"isDirectory": true,
|
||||
"path": "/data/backups",
|
||||
},
|
||||
{
|
||||
"isDirectory": true,
|
||||
"path": "/data/upload",
|
||||
},
|
||||
],
|
||||
"parent": "/",
|
||||
"path": "/data",
|
||||
}
|
||||
`);
|
||||
|
||||
await expect(sdk.getSnapshotListing(id, snapshotId, { path: '/data/backups' }, requestOpts)).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
items: [
|
||||
{
|
||||
isDirectory: false,
|
||||
path: '/data/backups/.immich',
|
||||
},
|
||||
{
|
||||
isDirectory: false,
|
||||
path: expect.stringContaining('/data/backups/immich-db-backup-'),
|
||||
},
|
||||
],
|
||||
parent: '/data',
|
||||
path: '/data/backups',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe.sequential('Restore Local Backup', async () => {
|
||||
let cookie: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await sdk.resetOrchestrator(requestOpts);
|
||||
await utils.resetDatabase();
|
||||
socket.disconnect();
|
||||
await utils.disconnectDatabase();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await utils.connectDatabase();
|
||||
});
|
||||
|
||||
it.sequential(
|
||||
'restores backup',
|
||||
async () => {
|
||||
const { status, headers } = await request(app).post('/admin/database-backups/start-restore').send();
|
||||
expect(status).toBe(201);
|
||||
cookie = headers['set-cookie'][0].split(';')[0];
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { status, body } = await request(app).get('/server/config');
|
||||
expect(status).toBe(200);
|
||||
return body.maintenanceMode;
|
||||
},
|
||||
{
|
||||
interval: 500,
|
||||
timeout: 10_000,
|
||||
},
|
||||
)
|
||||
.toBeTruthy();
|
||||
|
||||
const maintenanceRequestOpts = {
|
||||
headers: {
|
||||
cookie,
|
||||
},
|
||||
};
|
||||
|
||||
await expect(sdk.getSchedules(maintenanceRequestOpts)).resolves.toEqual({ schedules: [] });
|
||||
|
||||
await sdk.importRecoveryKey(
|
||||
{
|
||||
recoveryKey: '0'.repeat(32),
|
||||
},
|
||||
maintenanceRequestOpts,
|
||||
);
|
||||
|
||||
const {
|
||||
backend: { id: backendId },
|
||||
} = await sdk.createLocalBackend(
|
||||
{
|
||||
path: '/local-backend',
|
||||
},
|
||||
maintenanceRequestOpts,
|
||||
);
|
||||
|
||||
const {
|
||||
repositories: [
|
||||
{
|
||||
id: repositoryId,
|
||||
snapshots: [{ id: snapshotId }],
|
||||
},
|
||||
],
|
||||
} = await sdk.inspectRepositories(maintenanceRequestOpts);
|
||||
|
||||
socket = io(baseUrl, {
|
||||
path: '/api/yucca/socket.io',
|
||||
transports: ['websocket'],
|
||||
extraHeaders: {
|
||||
cookie,
|
||||
},
|
||||
});
|
||||
|
||||
const event = waitForMessage('TaskEnd');
|
||||
await sdk.restoreFromPoint(
|
||||
repositoryId,
|
||||
snapshotId,
|
||||
backendId,
|
||||
{
|
||||
yuccaConfig: '/yucca',
|
||||
include: ['/data'],
|
||||
},
|
||||
maintenanceRequestOpts,
|
||||
);
|
||||
|
||||
await event;
|
||||
socket.disconnect();
|
||||
|
||||
const { status: restoreStatus } = await request(app).post('/admin/maintenance').set('Cookie', cookie).send({
|
||||
action: 'restore_database',
|
||||
restoreBackupFilename: filename,
|
||||
});
|
||||
|
||||
expect(restoreStatus).toBe(201);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { status, body } = await request(app).get('/server/config');
|
||||
expect(status).toBe(200);
|
||||
return body.maintenanceMode;
|
||||
},
|
||||
{
|
||||
interval: 500,
|
||||
timeout: 10_000,
|
||||
},
|
||||
)
|
||||
.toBeTruthy();
|
||||
|
||||
const { status: status2, body } = await request(app).get('/admin/maintenance/status');
|
||||
expect(status2).toBe(200);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
active: true,
|
||||
action: 'restore_database',
|
||||
}),
|
||||
);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { status, body } = await request(app).get('/server/config');
|
||||
expect(status).toBe(200);
|
||||
return body.maintenanceMode;
|
||||
},
|
||||
{
|
||||
interval: 500,
|
||||
timeout: 60_000,
|
||||
},
|
||||
)
|
||||
.toBeFalsy();
|
||||
|
||||
await expect(sdk.getSchedules(requestOpts)).resolves.toEqual({
|
||||
schedules: expect.arrayContaining([expect.objectContaining({ id: expect.any(String) })]),
|
||||
});
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -95,6 +95,7 @@ test.describe('Database Backups', () => {
|
||||
await page.waitForURL('/maintenance**');
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Database Backup' }).click();
|
||||
await page.getByRole('button', { name: 'Next' }).click();
|
||||
await page.getByRole('button', { name: 'Restore', exact: true }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Restore' }).click();
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
createUserAdmin,
|
||||
deleteAssets,
|
||||
deleteDatabaseBackup,
|
||||
deleteLibrary,
|
||||
getAssetInfo,
|
||||
getConfig,
|
||||
getConfigDefaults,
|
||||
@@ -467,6 +468,8 @@ export const utils = {
|
||||
updateLibrary: (accessToken: string, id: string, dto: UpdateLibraryDto) =>
|
||||
updateLibrary({ id, updateLibraryDto: dto }, { headers: asBearerAuth(accessToken) }),
|
||||
|
||||
deleteLibrary: (accessToken: string, id: string) => deleteLibrary({ id }, { headers: asBearerAuth(accessToken) }),
|
||||
|
||||
createPartner: (accessToken: string, id: string) =>
|
||||
createPartner({ partnerCreateDto: { sharedWithId: id } }, { headers: asBearerAuth(accessToken) }),
|
||||
|
||||
@@ -570,6 +573,10 @@ export const utils = {
|
||||
return executeCommand('docker', ['exec', 'immich-e2e-server', 'mv', source, dest]).promise;
|
||||
},
|
||||
|
||||
async mkdir(path: string) {
|
||||
return executeCommand('docker', ['exec', 'immich-e2e-server', 'mkdir', '-p', path]).promise;
|
||||
},
|
||||
|
||||
createBackup: async (accessToken: string) => {
|
||||
await utils.createJob(accessToken, {
|
||||
name: ManualJobName.BackupDatabase,
|
||||
|
||||
Reference in New Issue
Block a user