diff --git a/CHANGES b/CHANGES index 138094366..da361f1d7 100644 --- a/CHANGES +++ b/CHANGES @@ -3181,4 +3181,5 @@ * oidc: implement Device Authorization Grant * operator: fix viewing of backup progress and logs * notification: automatic app update failure notification +* backup sites: identify conflicting site locations diff --git a/dashboard/src/components/BackupSiteConfigDialog.vue b/dashboard/src/components/BackupSiteConfigDialog.vue index 9ee9fbcb2..aa84e8774 100644 --- a/dashboard/src/components/BackupSiteConfigDialog.vue +++ b/dashboard/src/components/BackupSiteConfigDialog.vue @@ -3,7 +3,7 @@ import { ref, useTemplateRef, watch } from 'vue'; import { MaskedInput, Dialog, FormGroup, TextInput, Checkbox } from '@cloudron/pankow'; import { prettyBinarySize } from '@cloudron/pankow/utils'; -import { s3like, mountlike, prettySiteLocation } from '../utils.js'; +import { s3like, mountlike } from '../utils.js'; import BackupSitesModel from '../models/BackupSitesModel.js'; import SystemModel from '../models/SystemModel.js'; @@ -205,7 +205,7 @@ defineExpose({ -
{{ prettySiteLocation(site) }}
+
{{ site.locationLabel }}
diff --git a/dashboard/src/utils.js b/dashboard/src/utils.js index 2658e22be..605a1e504 100644 --- a/dashboard/src/utils.js +++ b/dashboard/src/utils.js @@ -1,6 +1,6 @@ import { prettyBinarySize } from '@cloudron/pankow/utils'; -import { RELAY_PROVIDERS, ISTATES, STORAGE_PROVIDERS, EVENTS } from './constants.js'; +import { RELAY_PROVIDERS, ISTATES, EVENTS } from './constants.js'; import { Marked } from 'marked'; function safeMarked() { @@ -55,39 +55,6 @@ function s3like(provider) { || provider === 'contabo-objectstorage' || provider === 'synology-c2-objectstorage'; } -function regionName(provider, endpoint) { - const storageProvider = STORAGE_PROVIDERS.find(sp => sp.value === provider); - const regions = storageProvider.regions; - if (!regions) return endpoint; - const region = regions.find(r => r.value === endpoint); - if (!region) return endpoint; - return region.name; -} - -function prettySiteLocation(site) { - switch (site.provider) { - case 'filesystem': - return site.config.backupDir + (site.config.prefix ? `/${site.config.prefix}` : ''); - case 'disk': - case 'ext4': - case 'xfs': - case 'mountpoint': - return (site.config.mountOptions.diskPath || site.config.mountPoint) + (site.config.prefix ? ` / ${site.config.prefix}` : ''); - case 'cifs': - case 'nfs': - case 'sshfs': - return site.config.mountOptions.host + ':' + site.config.mountOptions.remoteDir + (site.config.prefix ? ` / ${site.config.prefix}` : ''); - case 's3': - return site.config.region + ' / ' + site.config.bucket + (site.config.prefix ? ` / ${site.config.prefix}` : ''); - case 'minio': - return site.config.endpoint + ' / ' + site.config.bucket + (site.config.prefix ? ` / ${site.config.prefix}` : ''); - case 'gcs': - return site.config.bucket + (site.config.prefix ? ` / ${site.config.prefix}` : ''); - default: - return regionName(site.provider, site.config.endpoint) + ' / ' + site.config.bucket + (site.config.prefix ? ` / ${site.config.prefix}` : ''); - } -} - function eventlogDetails(eventLog, app = null, appIdContext = '') { const data = eventLog.data; const errorMessage = data.errorMessage; @@ -712,7 +679,6 @@ export { getColor, prettySchedule, parseSchedule, - prettySiteLocation, parseFullBackupPath }; @@ -735,6 +701,5 @@ export default { getColor, prettySchedule, parseSchedule, - prettySiteLocation, parseFullBackupPath }; diff --git a/dashboard/src/views/BackupSitesView.vue b/dashboard/src/views/BackupSitesView.vue index 00d3f20f4..ff5771acf 100644 --- a/dashboard/src/views/BackupSitesView.vue +++ b/dashboard/src/views/BackupSitesView.vue @@ -20,7 +20,7 @@ import BackupSitesModel from '../models/BackupSitesModel.js'; import TasksModel from '../models/TasksModel.js'; import AppsModel from '../models/AppsModel.js'; -import { prettySchedule, prettySiteLocation } from '../utils.js'; +import { prettySchedule } from '../utils.js'; const profile = inject('profile'); @@ -318,7 +318,7 @@ onMounted(async () => {
Storage: {{ site.provider }} ({{ site.format }}) - at {{ prettySiteLocation(site) }} + at {{ site.locationLabel }}
diff --git a/src/backupsites.js b/src/backupsites.js index 8e67eb944..3ac75197e 100644 --- a/src/backupsites.js +++ b/src/backupsites.js @@ -79,6 +79,8 @@ function postProcess(result) { result.contents = safe.JSON.parse(result.contentsJson) || null; delete result.contentsJson; + result.locationLabel = storageApi(result).getLocationLabel(result.config); + return result; } @@ -440,6 +442,12 @@ async function setConfig(backupSite, newConfig, auditSource) { log('setConfig: validating new storage configuration'); const sanitizedConfig = await storageApi(backupSite).verifyConfig({ id: backupSite.id, provider: backupSite.provider, config: newConfig }); + const newLocationLabel = storageApi(backupSite).getLocationLabel(sanitizedConfig); + const existingSites = await list(); + if (existingSites.some(s => s.id !== backupSite.id && s.locationLabel === newLocationLabel)) { + throw new BoxError(BoxError.ALREADY_EXISTS, 'A backup site with the same storage destination already exists'); + } + await update(backupSite, { config: sanitizedConfig }); log('setConfig: setting up new storage configuration'); @@ -490,6 +498,12 @@ async function add(data, auditSource) { log('add: validating new storage configuration'); const sanitizedConfig = await storageApi({ provider }).verifyConfig({id, provider, config }); + const newLocationLabel = storageApi({ provider }).getLocationLabel(sanitizedConfig); + const existingSites = await list(); + if (existingSites.some(s => s.locationLabel === newLocationLabel)) { + throw new BoxError(BoxError.ALREADY_EXISTS, 'A backup site with the same storage destination already exists'); + } + await database.query('INSERT INTO backupSites (id, name, provider, configJson, contentsJson, limitsJson, integrityKeyPairJson, retentionJson, schedule, encryptionJson, format, enableForUpdates) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', [ id, name, provider, JSON.stringify(sanitizedConfig), JSON.stringify(contents), JSON.stringify(limits), JSON.stringify(integrityKeyPair), JSON.stringify(retention), schedule, JSON.stringify(encryption), format, enableForUpdates ]); diff --git a/src/storage/filesystem.js b/src/storage/filesystem.js index 8f215e085..2e46966c3 100644 --- a/src/storage/filesystem.js +++ b/src/storage/filesystem.js @@ -406,6 +406,28 @@ async function verifyConfig({ id, provider, config }) { return newConfig; } +function getLocationLabel(config) { + assert.strictEqual(typeof config, 'object'); + + const suffix = config.prefix ? ` / ${config.prefix}` : ''; + + switch (config._provider) { + case mounts.MOUNT_TYPE_FILESYSTEM: + return config.backupDir + suffix; + case mounts.MOUNT_TYPE_DISK: + case mounts.MOUNT_TYPE_EXT4: + case mounts.MOUNT_TYPE_XFS: + case mounts.MOUNT_TYPE_MOUNTPOINT: + return (config.mountOptions?.diskPath || config.mountPoint) + suffix; + case mounts.MOUNT_TYPE_CIFS: + case mounts.MOUNT_TYPE_NFS: + case mounts.MOUNT_TYPE_SSHFS: + return config.mountOptions.host + ':' + config.mountOptions.remoteDir + suffix; + default: + return config._provider + suffix; + } +} + function removePrivateFields(config) { delete config.mountOptions?.password; delete config.mountOptions?.privateKey; @@ -432,6 +454,7 @@ export default { cleanup, verifyConfig, + getLocationLabel, removePrivateFields, injectPrivateFields, diff --git a/src/storage/gcs.js b/src/storage/gcs.js index b11f4e11f..f2787dff7 100644 --- a/src/storage/gcs.js +++ b/src/storage/gcs.js @@ -251,6 +251,12 @@ async function teardown(config) { assert.strictEqual(typeof config, 'object'); } +function getLocationLabel(config) { + assert.strictEqual(typeof config, 'object'); + + return config.bucket + (config.prefix ? ` / ${config.prefix}` : ''); +} + function removePrivateFields(config) { delete config.credentials.private_key; return config; @@ -281,6 +287,7 @@ export default { cleanup, verifyConfig, + getLocationLabel, removePrivateFields, injectPrivateFields, }; diff --git a/src/storage/interface.js b/src/storage/interface.js index bb466f782..3fdde0f9a 100644 --- a/src/storage/interface.js +++ b/src/storage/interface.js @@ -12,6 +12,12 @@ import BoxError from '../boxerror.js'; // for the other API calls we leave it to the backend to retry. this allows // them to tune the concurrency based on failures/rate limits accordingly +function getLocationLabel(config) { + assert.strictEqual(typeof config, 'object'); + + return ''; +} + function removePrivateFields(config) { // in-place removal of tokens and api keys return config; @@ -164,6 +170,7 @@ export default { cleanup, verifyConfig, + getLocationLabel, removePrivateFields, injectPrivateFields }; diff --git a/src/storage/s3.js b/src/storage/s3.js index a0648443c..5b1fa8d54 100644 --- a/src/storage/s3.js +++ b/src/storage/s3.js @@ -514,6 +514,13 @@ async function teardown(config) { assert.strictEqual(typeof config, 'object'); } +function getLocationLabel(config) { + assert.strictEqual(typeof config, 'object'); + + const location = config._provider === 's3' ? (config.region || 'us-east-1') : config.endpoint; + return location + ' / ' + config.bucket + (config.prefix ? ` / ${config.prefix}` : ''); +} + function removePrivateFields(config) { delete config.secretAccessKey; delete config._provider; @@ -676,6 +683,7 @@ export default { teardown, cleanup, verifyConfig, + getLocationLabel, removePrivateFields, injectPrivateFields, getAvailableSize,