This commit is contained in:
Stavros 2024-06-17 20:30:47 +03:00
parent 4a222c4fde
commit e85f8eb642
15 changed files with 229 additions and 69 deletions

View File

@ -34,6 +34,7 @@ const appEventSchema = z.object({
})
.extend({})
.catchall(z.unknown()),
archiveName: z.string().optional(),
});
export type AppEventFormInput = z.input<typeof appEventSchema>['form'];

View File

@ -472,12 +472,19 @@ export class AppExecutors {
}
};
public backupApp = async (appId: string, form: AppEventForm, skipEnvGeneration = false) => {
public backupApp = async (
appId: string,
archiveName: string,
form: AppEventForm,
skipEnvGeneration = false,
) => {
try {
await SocketManager.emit({ type: 'app', event: 'status_change', data: { appId } });
const { appDataDirPath, appDirPath } = this.getAppPaths(appId);
const backupDir = path.join(DATA_DIR, 'backups', appId);
const backupRootDir = path.join(DATA_DIR, 'backups', appId);
const tmpData = path.join(backupRootDir, archiveName);
const archive = `${archiveName}.tar.gz`;
this.logger.info('Backing up app...');
@ -497,29 +504,41 @@ export class AppExecutors {
this.logger.info('Copying files to backup location...');
// Remove old backup archive
await fs.promises.rm(`${backupDir}.tar.gz`, { force: true, recursive: true });
// Remove old backup archive if exists
if (await pathExists(path.join(backupRootDir, archive))) {
await fs.promises.rm(path.join(backupRootDir, archive), { force: true, recursive: true });
}
// Create app backup directory
await fs.promises.mkdir(backupDir, { recursive: true });
// Create app backups directory if it doesn't exist
if (!(await pathExists(backupRootDir))) {
await fs.promises.mkdir(backupRootDir, { recursive: true });
}
// Remove old temp backup directory if exists
if (await pathExists(tmpData)) {
await fs.promises.rm(tmpData, { force: true, recursive: true });
}
// Create app temp backup directory
await fs.promises.mkdir(tmpData, { recursive: true });
// Move app data and app directories
await fs.promises.cp(appDataDirPath, path.join(backupDir, 'data'), { recursive: true });
await fs.promises.cp(appDirPath, path.join(backupDir, 'app'), { recursive: true });
await fs.promises.cp(appDataDirPath, path.join(tmpData, 'data'), { recursive: true });
await fs.promises.cp(appDirPath, path.join(tmpData, 'app'), { recursive: true });
// Check if the user config folder exists and if it does copy it too
if (await pathExists(path.join(DATA_DIR, 'user-config', appId))) {
await fs.promises.cp(
path.join(DATA_DIR, 'user-config', appId),
path.join(backupDir, 'user-config'),
path.join(tmpData, 'user-config'),
);
}
// Create the archive
await execAsync(`tar -czpf ${backupDir}.tar.gz -C ${backupDir} .`);
await execAsync(`tar -czpf ${backupRootDir}/${archiveName}.tar.gz -C ${tmpData} .`);
// Remove the backup folder
await fs.promises.rm(backupDir, { force: true, recursive: true });
await fs.promises.rm(tmpData, { force: true, recursive: true });
this.logger.info('Backup completed!');
@ -538,20 +557,26 @@ export class AppExecutors {
}
};
public restoreApp = async (appId: string, form: AppEventForm, skipEnvGeneration = false) => {
public restoreApp = async (
appId: string,
archiveName: string,
form: AppEventForm,
skipEnvGeneration = false,
) => {
try {
await SocketManager.emit({ type: 'app', event: 'status_change', data: { appId } });
const { appDataDirPath, appDirPath } = this.getAppPaths(appId);
const backupDir = path.join(DATA_DIR, 'backups', appId);
const archive = path.join(DATA_DIR, 'backups', `${appId}.tar.gz`);
const backupRootDir = path.join(DATA_DIR, 'backups', appId);
const tmpData = path.join(backupRootDir, archiveName);
const archive = `${archiveName}.tar.gz`;
const client = await getDbClient();
this.logger.info('Restoring app from backup...');
// Verify the app has a backup
if (!(await pathExists(archive))) {
throw new Error('App does not have any backups!');
// Verify the archive exists
if (!(await pathExists(path.join(backupRootDir, archive)))) {
throw new Error('Archive does not exist!');
}
// Ensure app directory and generate env
@ -577,25 +602,30 @@ export class AppExecutors {
recursive: true,
});
// Delete old tmp data directory if exists
if (await pathExists(tmpData)) {
await fs.promises.rm(tmpData, { force: true, recursive: true });
}
// Unzip the archive
await fs.promises.mkdir(backupDir, { recursive: true });
await execAsync(`tar -xf ${archive} -C ${backupDir}`);
await fs.promises.mkdir(tmpData, { recursive: true });
await execAsync(`tar -xf ${path.join(backupRootDir, archive)} -C ${tmpData}`);
// Copy data from the backup folder
await fs.promises.cp(path.join(backupDir, 'app'), appDirPath, { recursive: true });
await fs.promises.cp(path.join(backupDir, 'data'), appDataDirPath, { recursive: true });
await fs.promises.cp(path.join(tmpData, 'app'), appDirPath, { recursive: true });
await fs.promises.cp(path.join(tmpData, 'data'), appDataDirPath, { recursive: true });
// Copy user config foler if it exists
if (await pathExists(path.join(backupDir, 'user-config'))) {
if (await pathExists(path.join(tmpData, 'user-config'))) {
await fs.promises.cp(
path.join(backupDir, 'user-config'),
path.join(tmpData, 'user-config'),
path.join(DATA_DIR, 'user-config', appId),
{ recursive: true },
);
}
// Delete backup folder
await fs.promises.rm(backupDir, { force: true, recursive: true });
await fs.promises.rm(tmpData, { force: true, recursive: true });
// Set the version in the database
const configFileRaw = await fs.promises.readFile(path.join(appDirPath, 'config.json'), {

View File

@ -64,11 +64,11 @@ const runCommand = async (jobData: unknown) => {
}
if (data.command === 'backup') {
({ success, message } = await backupApp(data.appid, data.form));
({ success, message } = await backupApp(data.appid, data.archiveName || '', data.form));
}
if (data.command === 'restore') {
({ success, message } = await restoreApp(data.appid, data.form));
({ success, message } = await restoreApp(data.appid, data.archiveName || '', data.form));
}
} else if (data.type === 'repo') {
if (data.command === 'clone') {

View File

@ -34,12 +34,13 @@ type OpenType = 'local' | 'domain' | 'local_domain';
type AppDetailsContainerProps = {
app: Awaited<ReturnType<GetAppCommand['execute']>>;
backups: string[];
localDomain?: string;
optimisticStatus: AppStatusEnum;
setOptimisticStatus: (status: AppStatusEnum) => void;
};
export const AppDetailsContainer: React.FC<AppDetailsContainerProps> = ({ app, localDomain, optimisticStatus, setOptimisticStatus }) => {
export const AppDetailsContainer: React.FC<AppDetailsContainerProps> = ({ app, backups, localDomain, optimisticStatus, setOptimisticStatus }) => {
const t = useTranslations();
const installDisclosure = useDisclosure();
@ -253,15 +254,16 @@ export const AppDetailsContainer: React.FC<AppDetailsContainerProps> = ({ app, l
status={optimisticStatus}
onBackup={openBackupModal}
onRestore={openRestoreModal}
backups={backups}
/>
<BackupModal
onConfirm={() => backupMutation.execute({ id: app.info.id })}
onSubmit={(values) => backupMutation.execute({ id: app.id, archiveName: values.archiveName })}
isOpen={backupDisclosure.isOpen}
onClose={backupDisclosure.close}
info={app.info}
/>
<RestoreModal
onConfirm={() => restoreMutation.execute({ id: app.info.id })}
onConfirm={() => restoreMutation.execute({ id: app.info.id, archiveName: 'hi' })}
isOpen={restoreDisclosure.isOpen}
onClose={restoreDisclosure.close}
info={app.info}

View File

@ -8,11 +8,12 @@ import { GetAppCommand } from '@/server/services/app-catalog/commands';
interface IProps {
app: Awaited<ReturnType<GetAppCommand['execute']>>;
backups: string[];
localDomain?: string;
}
export const AppDetailsWrapper = (props: IProps) => {
const { app, localDomain } = props;
const { app, localDomain, backups } = props;
const [optimisticStatus, setOptimisticStatus] = useOptimistic<AppStatus>(app.status);
const changeStatus = (status: AppStatus) => {
@ -90,5 +91,13 @@ export const AppDetailsWrapper = (props: IProps) => {
selector: { type: 'app', data: { property: 'appId', value: app.id } },
});
return <AppDetailsContainer localDomain={localDomain} app={app} optimisticStatus={optimisticStatus} setOptimisticStatus={setOptimisticStatus} />;
return (
<AppDetailsContainer
localDomain={localDomain}
app={app}
backups={backups}
optimisticStatus={optimisticStatus}
setOptimisticStatus={setOptimisticStatus}
/>
);
};

View File

@ -3,17 +3,25 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
import { useTranslations } from 'next-intl';
import { AppInfo } from '@runtipi/shared';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { useForm } from 'react-hook-form';
interface IProps {
info: AppInfo;
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
onSubmit: (values: { archiveName: string }) => void;
}
export const BackupModal: React.FC<IProps> = ({ info, isOpen, onClose, onConfirm }) => {
export const BackupModal: React.FC<IProps> = ({ info, isOpen, onClose, onSubmit }) => {
const t = useTranslations();
type FormValues = {
archiveName: string;
};
const { register, handleSubmit } = useForm<FormValues>();
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent size="sm">
@ -21,13 +29,18 @@ export const BackupModal: React.FC<IProps> = ({ info, isOpen, onClose, onConfirm
<DialogTitle>{t('APP_BACKUP_TITLE', { name: info.name })}</DialogTitle>
</DialogHeader>
<DialogDescription>
<span className="text-muted">{t('APP_BACKUP_SUBTITILE')}</span>
<form onSubmit={handleSubmit(onSubmit)}>
<p className="text-muted">{t('APP_BACKUP_SUBTITILE')}</p>
<div className="mt-1 mb-3">
<Input {...register('archiveName')} label="Backup" placeholder="mybackup"></Input>
</div>
<div className="d-flex justify-content-end">
<Button type="submit" className="btn-success">
{t('APP_BACKUP_SUBMIT')}
</Button>
</div>
</form>
</DialogDescription>
<DialogFooter>
<Button onClick={onConfirm} className="btn-success">
{t('APP_BACKUP_SUBMIT')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);

View File

@ -9,7 +9,6 @@ import { Tabs } from '@/components/ui/tabs';
import { SettingsTabTriggers } from '../SettingsTabTriggers';
import { TabsContent } from '@radix-ui/react-tabs';
import { Button } from '@/components/ui/Button';
import { IconStackPop, IconStackPush } from '@tabler/icons-react';
interface IProps {
info: AppInfo;
@ -20,10 +19,11 @@ interface IProps {
onReset: () => void;
status?: AppStatus;
onBackup: () => void;
onRestore: () => void;
onRestore: (backup: string) => void;
backups: string[];
}
export const UpdateSettingsModal: React.FC<IProps> = ({ info, config, isOpen, onClose, onSubmit, onReset, status, onBackup, onRestore }) => {
export const UpdateSettingsModal: React.FC<IProps> = ({ info, config, isOpen, onClose, onSubmit, onReset, status, onBackup, onRestore, backups }) => {
const t = useTranslations();
return (
@ -47,18 +47,22 @@ export const UpdateSettingsModal: React.FC<IProps> = ({ info, config, isOpen, on
/>
</TabsContent>
<TabsContent value="backups" className="p-3">
<h3 className="mb-1">{t('APP_BACKUP_SUBMIT')}</h3>
<p className="text-muted mb-2">{t('APP_BACKUP_SETTINGS_SUBTITLE')}</p>
<Button onClick={onBackup}>
{t('APP_BACKUP_SUBMIT')}
<IconStackPush className="ms-1" size={14} />
</Button>
<h3 className="mb-1 mt-3">{t('APP_RESTORE_SUBMIT')}</h3>
<p className="text-muted mb-2">{t('APP_RESTORE_SETTINGS_SUBTITILE')}</p>
<Button onClick={onRestore}>
{t('APP_RESTORE_SUBMIT')}
<IconStackPop className="ms-1" size={14} />
</Button>
<h3 className="mb-0">{t('APP_BACKUP_SUBMIT')}</h3>
<div className="d-flex justify-content-between mb-2 mt-0">
<p className="text-muted my-auto">Manage backups for your app.</p>
<Button onClick={onBackup}>{t('APP_BACKUP_SUBMIT')}</Button>
</div>
<pre>
{backups.length !== 0 ? (
<div className="card">
{backups.map((backup) => (
<RenderBackup backup={backup} />
))}
</div>
) : (
<p className="mx-auto my-3 text-muted">No backups found! Why don't you create one?</p>
)}
</pre>
</TabsContent>
</Tabs>
</DialogDescription>
@ -67,3 +71,21 @@ export const UpdateSettingsModal: React.FC<IProps> = ({ info, config, isOpen, on
</Dialog>
);
};
interface props {
backup: string;
// onRestore: () => void;
// onDelete: () => void;
}
const RenderBackup: React.FC<props> = ({ backup }) => {
return (
<div key={backup} className="card-body d-flex justify-content-between">
<p className="my-auto">{backup}</p>
<div>
<Button className="btn-danger">Delete</Button>
<Button className="ms-2">Restore</Button>
</div>
</div>
);
};

View File

@ -16,9 +16,10 @@ export async function generateMetadata({ params }: { params: { id: string } }):
export default async function AppDetailsPage({ params }: { params: { id: string } }) {
try {
const app = await appCatalog.getApp(params.id);
const appBackups = await appCatalog.getAppBackups(params.id);
const settings = TipiConfig.getSettings();
return <AppDetailsWrapper app={app} localDomain={settings.localDomain} />;
return <AppDetailsWrapper app={app} backups={appBackups} localDomain={settings.localDomain} />;
} catch (e) {
const translator = await getTranslatorFromCookie();

View File

@ -9,16 +9,17 @@ import { ensureUser } from '../utils/ensure-user';
const input = z.object({
id: z.string(),
archiveName: z.string(),
});
/**
* Given an app id, backs up the app.
*/
export const backupAppAction = action(input, async ({ id }) => {
export const backupAppAction = action(input, async ({ id, archiveName }) => {
try {
await ensureUser();
await appLifecycle.executeCommand('backupApp', { appId: id });
await appLifecycle.executeCommand('backupApp', { appId: id, archiveName });
revalidatePath('/apps');
revalidatePath(`/app/${id}`);

View File

@ -0,0 +1,34 @@
'use server';
import { z } from 'zod';
import { action } from '@/lib/safe-action';
import { revalidatePath } from 'next/cache';
import { handleActionError } from '../utils/handle-action-error';
import { ensureUser } from '../utils/ensure-user';
import fs from 'fs';
import { DATA_DIR } from '@/config/constants';
import path from 'path';
const input = z.object({
id: z.string(),
archiveName: z.string(),
});
/**
* Given an app id, backs up the app.
*/
export const backupAppAction = action(input, async ({ id, archiveName }) => {
try {
await ensureUser();
await fs.promises.rm(path.join(DATA_DIR, 'backups', id, archiveName), { force: true, recursive: true });
revalidatePath('/apps');
revalidatePath(`/app/${id}`);
revalidatePath(`/app-store/${id}`);
return { success: true };
} catch (e) {
return handleActionError(e);
}
});

View File

@ -9,16 +9,17 @@ import { ensureUser } from '../utils/ensure-user';
const input = z.object({
id: z.string(),
archiveName: z.string(),
});
/**
* Given an app id, backs up the app.
*/
export const restoreAppAction = action(input, async ({ id }) => {
export const restoreAppAction = action(input, async ({ id, archiveName }) => {
try {
await ensureUser();
await appLifecycle.executeCommand('restoreApp', { appId: id });
await appLifecycle.executeCommand('restoreApp', { appId: id, archiveName });
revalidatePath('/apps');
revalidatePath(`/app/${id}`);

View File

@ -2,6 +2,7 @@ import { AppCacheManager } from './app-cache-manager';
import { AppQueries } from '@/server/queries/apps/apps.queries';
import { GetInstalledAppsCommand, GetGuestDashboardApps, GetAppCommand } from './commands';
import { IAppLifecycleCommand } from '../app-lifecycle/commands/types';
import { GetAppBackups } from './commands/get-app-backups';
class CommandInvoker {
public async execute<T>(command: IAppLifecycleCommand<T>) {
@ -42,6 +43,11 @@ export class AppCatalogClass {
const command = new GetAppCommand({ queries: this.queries, appId: id });
return this.commandInvoker.execute(command);
}
public async getAppBackups(id: string) {
const command = new GetAppBackups({ appId: id });
return this.commandInvoker.execute(command);
}
}
export type AppCatalog = InstanceType<typeof AppCatalogClass>;

View File

@ -0,0 +1,34 @@
import { ICommand } from './types';
import fs from 'fs';
import { DATA_DIR } from '@/config/constants';
import path from 'path';
import { pathExists } from 'fs-extra';
type ReturnValue = Awaited<ReturnType<InstanceType<typeof GetAppBackups>['execute']>>;
export class GetAppBackups implements ICommand<ReturnValue> {
private appId: string;
constructor(params: { appId: string }) {
this.appId = params.appId;
}
async execute() {
const backupsRootDir = path.join(DATA_DIR, 'backups', this.appId);
if (!(await pathExists(backupsRootDir))) {
return [];
}
const files = await fs.promises.readdir(path.join(DATA_DIR, 'backups', this.appId));
const backups: string[] = [];
for (const file in files) {
if (files[file]!.includes('.tar.gz')) {
backups.push(files[file]!);
}
}
return backups;
}
}

View File

@ -15,8 +15,14 @@ export class BackupAppCommand implements IAppLifecycleCommand {
this.eventDispatcher = params.eventDispatcher;
}
private async sendEvent(appId: string, form: AppEventFormInput): Promise<void> {
const { success, stdout } = await this.eventDispatcher.dispatchEventAsync({ type: 'app', command: 'backup', appid: appId, form });
private async sendEvent(appId: string, archiveName: string, form: AppEventFormInput): Promise<void> {
const { success, stdout } = await this.eventDispatcher.dispatchEventAsync({
type: 'app',
command: 'backup',
appid: appId,
archiveName,
form,
});
if (success) {
await this.queries.updateApp(appId, { status: 'running' });
@ -26,8 +32,8 @@ export class BackupAppCommand implements IAppLifecycleCommand {
}
}
async execute(params: { appId: string }): Promise<void> {
const { appId } = params;
async execute(params: { appId: string; archiveName: string }): Promise<void> {
const { appId, archiveName } = params;
const app = await this.queries.getApp(appId);
if (!app) {
@ -37,6 +43,6 @@ export class BackupAppCommand implements IAppLifecycleCommand {
// Run script
await this.queries.updateApp(appId, { status: 'backing_up' });
void this.sendEvent(appId, castAppConfig(app.config));
void this.sendEvent(appId, archiveName, castAppConfig(app.config));
}
}

View File

@ -15,8 +15,8 @@ export class RestoreAppCommand implements IAppLifecycleCommand {
this.eventDispatcher = params.eventDispatcher;
}
private async sendEvent(appId: string, form: AppEventFormInput): Promise<void> {
const { success, stdout } = await this.eventDispatcher.dispatchEventAsync({ type: 'app', command: 'restore', appid: appId, form });
private async sendEvent(appId: string, archiveName: string, form: AppEventFormInput): Promise<void> {
const { success, stdout } = await this.eventDispatcher.dispatchEventAsync({ type: 'app', command: 'restore', appid: appId, archiveName, form });
if (success) {
await this.queries.updateApp(appId, { status: 'running' });
@ -26,8 +26,8 @@ export class RestoreAppCommand implements IAppLifecycleCommand {
}
}
async execute(params: { appId: string }): Promise<void> {
const { appId } = params;
async execute(params: { appId: string; archiveName: string }): Promise<void> {
const { appId, archiveName } = params;
const app = await this.queries.getApp(appId);
if (!app) {
@ -37,6 +37,6 @@ export class RestoreAppCommand implements IAppLifecycleCommand {
// Run script
await this.queries.updateApp(appId, { status: 'restoring' });
void this.sendEvent(appId, castAppConfig(app.config));
void this.sendEvent(appId, archiveName, castAppConfig(app.config));
}
}