feat(appstore): add service volumes and published ports

This commit is contained in:
2026-05-24 07:28:18 +00:00
parent e6ebac76b4
commit 5228eeaa23
26 changed files with 1790 additions and 348 deletions
+86 -4
View File
@@ -185,7 +185,12 @@ export class BackupManager {
await this.exportDockerImage(service.image, `${tempDir}/data/image/image.tar`);
}
// 4. Build ingest items from temp directory files
// 4. Export declared service volume data when the volume opts into backup.
if (service.volumes?.some((volumeArg) => volumeArg.backup !== false)) {
await this.exportServiceVolumes(service, tempDir);
}
// 5. Build ingest items from temp directory files
const items: Array<{ stream: NodeJS.ReadableStream; name: string; type?: string }> = [];
// Service config
@@ -218,6 +223,19 @@ export class BackupManager {
}
}
const volumeDataDir = `${tempDir}/data/volumes`;
try {
for await (const filePath of this.walkFiles(volumeDataDir)) {
items.push({
stream: plugins.nodeFs.createReadStream(filePath),
name: plugins.path.relative(tempDir, filePath).replaceAll('\\', '/'),
type: 'volume',
});
}
} catch {
// No service volume data was exported.
}
// Docker image
if (includeImage && service.image) {
const imagePath = `${tempDir}/data/image/image.tar`;
@@ -233,7 +251,7 @@ export class BackupManager {
}
}
// 5. Build snapshot tags
// 6. Build snapshot tags
const tags: Record<string, string> = {
serviceName: service.name,
serviceId: String(service.id),
@@ -245,10 +263,10 @@ export class BackupManager {
tags.scheduleId = String(options.scheduleId);
}
// 6. Ingest multi-item snapshot into containerarchive
// 7. Ingest multi-item snapshot into containerarchive
const snapshot = await this.archive.ingestMulti(items, { tags });
// 7. Store backup record in database
// 8. Store backup record in database
const backup: IBackup = {
serviceId: service.id!,
serviceName: service.name,
@@ -675,6 +693,8 @@ export class BackupManager {
registry: serviceConfig.registry,
port: serviceConfig.port,
domain: serviceConfig.domain,
volumes: serviceConfig.volumes,
publishedPorts: serviceConfig.publishedPorts,
useOneboxRegistry: serviceConfig.useOneboxRegistry,
registryRepository: serviceConfig.registryRepository,
registryImageTag: serviceConfig.registryImageTag,
@@ -705,6 +725,8 @@ export class BackupManager {
port: serviceConfig.port,
domain: options.mode === 'clone' ? undefined : serviceConfig.domain,
envVars: serviceConfig.envVars,
volumes: serviceConfig.volumes,
publishedPorts: serviceConfig.publishedPorts,
useOneboxRegistry: serviceConfig.useOneboxRegistry,
registryImageTag: serviceConfig.registryImageTag,
autoUpdateOnPush: serviceConfig.autoUpdateOnPush,
@@ -729,6 +751,8 @@ export class BackupManager {
}
}
await this.restoreServiceVolumes(service, serviceConfig.volumes || [], tempDir, warnings);
// Cleanup
await Deno.remove(tempDir, { recursive: true });
@@ -791,6 +815,8 @@ export class BackupManager {
image: service.image,
registry: service.registry,
envVars: service.envVars,
volumes: service.volumes,
publishedPorts: service.publishedPorts,
port: service.port,
domain: service.domain,
useOneboxRegistry: service.useOneboxRegistry,
@@ -802,6 +828,62 @@ export class BackupManager {
};
}
private getVolumeBackupName(volumeArg: { mountPath: string }, indexArg: number): string {
const safeMountPath = volumeArg.mountPath
.replace(/^\/+/, '')
.replace(/\/+$/g, '')
.replace(/[^a-zA-Z0-9_.-]+/g, '-') || 'root';
return `${String(indexArg).padStart(3, '0')}-${safeMountPath}`;
}
private async exportServiceVolumes(serviceArg: IService, tempDirArg: string): Promise<void> {
if (!serviceArg.containerID) {
throw new Error(`Cannot export service volumes for ${serviceArg.name}: service has no container ID`);
}
const volumes = (serviceArg.volumes || []).filter((volumeArg) => volumeArg.backup !== false);
for (let i = 0; i < volumes.length; i++) {
const volume = volumes[i];
const backupName = this.getVolumeBackupName(volume, i);
const outputPath = `${tempDirArg}/data/volumes/${backupName}`;
await Deno.mkdir(outputPath, { recursive: true });
await this.copyFromContainer(serviceArg.containerID, `${volume.mountPath}/.`, outputPath);
logger.info(`Exported volume ${volume.mountPath} for service ${serviceArg.name}`);
}
}
private async restoreServiceVolumes(
serviceArg: IService,
volumesArg: NonNullable<IBackupServiceConfig['volumes']>,
tempDirArg: string,
warningsArg: string[],
): Promise<void> {
if (!serviceArg.containerID) {
if (volumesArg.some((volumeArg) => volumeArg.backup !== false)) {
warningsArg.push(`Could not restore service volumes for ${serviceArg.name}: service has no container ID`);
}
return;
}
const volumes = volumesArg.filter((volumeArg) => volumeArg.backup !== false);
for (let i = 0; i < volumes.length; i++) {
const volume = volumes[i];
const backupName = this.getVolumeBackupName(volume, i);
const inputPath = `${tempDirArg}/data/volumes/${backupName}`;
try {
await Deno.stat(inputPath);
} catch {
continue;
}
try {
await this.copyToContainer(`${inputPath}/.`, serviceArg.containerID, volume.mountPath);
logger.info(`Restored volume ${volume.mountPath} for service ${serviceArg.name}`);
} catch (error) {
warningsArg.push(`Volume restore failed for ${volume.mountPath}: ${getErrorMessage(error)}`);
}
}
}
/**
* Export MongoDB database
*/