diff --git a/package.json b/package.json index 847ac5ba1..b8ebb31a1 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "@aws-sdk/client-s3": "^3.993.0", "@aws-sdk/lib-storage": "^3.993.0", "@cloudron/connect-lastmile": "^3.0.0", - "@cloudron/manifest-format": "^6.0.2", + "@cloudron/manifest-format": "^6.1.0", "@cloudron/pipework": "^2.1.2", "@cloudron/superagent": "^2.1.1", "@google-cloud/dns": "^5.3.1", diff --git a/setup/start.sh b/setup/start.sh index 8d97733de..b1675cef0 100755 --- a/setup/start.sh +++ b/setup/start.sh @@ -62,7 +62,7 @@ mkdir -p "${BOX_DATA_DIR}" "${APPS_DATA_DIR}" "${MAIL_DATA_DIR}" # keep these in sync with paths.js log "Ensuring directories" -mkdir -p "${PLATFORM_DATA_DIR}/"{graphite,mysql,postgresql,mongodb,redis,tls,logrotate.d,acme,backup,update,firewall,sshfs,cifs,oidc,diskusage,source-archives} +mkdir -p "${PLATFORM_DATA_DIR}/"{graphite,mysql,postgresql,mongodb,redis,tls,logrotate.d,acme,backup,update,firewall,sshfs,cifs,oidc,diskusage,source-archives,persistent} mkdir -p "${PLATFORM_DATA_DIR}/addons/mail/"{banner,dkim} mkdir -p "${PLATFORM_DATA_DIR}/logs/"{backup,updater,tasks} mkdir -p "${PLATFORM_DATA_DIR}/sftp/ssh" # sftp keys @@ -223,7 +223,7 @@ log "Changing ownership" # note, change ownership after db migrate. this allow db migrate to move files around as root and then we can fix it up here # be careful of what is chown'ed here. subdirs like mysql,redis etc are owned by the containers and will stop working if perms change chown -R "${USER}" /etc/cloudron -chown "${USER}:${USER}" -R "${PLATFORM_DATA_DIR}/"{nginx,addons,acme,backup,logs,update,sftp,firewall,sshfs,cifs,tls,oidc,diskusage,source-archives} +chown "${USER}:${USER}" -R "${PLATFORM_DATA_DIR}/"{nginx,addons,acme,backup,logs,update,sftp,firewall,sshfs,cifs,tls,oidc,diskusage,source-archives,persistent} chown "${USER}:${USER}" "${PLATFORM_DATA_DIR}/INFRA_VERSION" 2>/dev/null || true chown "${USER}:${USER}" "${PLATFORM_DATA_DIR}" chown "${USER}:${USER}" "${APPS_DATA_DIR}" diff --git a/src/apptask.js b/src/apptask.js index 727d89461..8564efadc 100644 --- a/src/apptask.js +++ b/src/apptask.js @@ -332,6 +332,7 @@ async function uninstallCommand(app, args, progressCallback) { await progressCallback({ percent: 30, message: 'Teardown addons' }); await services.teardownAddons(app, app.manifest.addons); + await services.teardownPersistentDirs(app); await progressCallback({ percent: 40, message: 'Cleanup file manager' }); @@ -445,15 +446,19 @@ async function installCommand(app, args, progressCallback) { if (!restoreConfig) { // install await progressCallback({ percent: 60, message: 'Setting up addons' }); await services.setupAddons(app, app.manifest.addons); + await services.setupPersistentDirs(app); } else if (app.installationState === apps.ISTATE_PENDING_IMPORT && restoreConfig.inPlace) { // in-place import await progressCallback({ percent: 60, message: 'Importing addons in-place' }); await services.setupAddons(app, app.manifest.addons); + await services.setupPersistentDirs(app); await services.clearAddons(app, _.omit(app.manifest.addons, ['localstorage'])); await apps.loadConfig(app); await services.restoreAddons(app, app.manifest.addons); + await services.runRestoreCommand(app); } else if ((app.installationState === apps.ISTATE_PENDING_IMPORT || app.installationState === apps.ISTATE_PENDING_RESTORE) && restoreConfig.remotePath) { // app import or app restore during full box restore await progressCallback({ percent: 65, message: 'Downloading backup and restoring addons' }); await services.setupAddons(app, app.manifest.addons); + await services.setupPersistentDirs(app); await services.clearAddons(app, app.manifest.addons); const backupSite = restoreConfig.backupSite; await backupSites.storageApi(backupSite).setup(backupSite.config); @@ -462,9 +467,11 @@ async function installCommand(app, args, progressCallback) { await backupSites.storageApi(backupSite).teardown(backupSite.config); await progressCallback({ percent: 75, message: 'Restoring addons' }); await services.restoreAddons(app, app.manifest.addons); + await services.runRestoreCommand(app); } else { // clone and restore await progressCallback({ percent: 65, message: 'Downloading backup and restoring addons' }); await services.setupAddons(app, app.manifest.addons); + await services.setupPersistentDirs(app); await services.clearAddons(app, app.manifest.addons); await backuptask.downloadApp(app, restoreConfig, (progress) => { progressCallback({ percent: 65, message: progress.message }); }); if (app.installationState === apps.ISTATE_PENDING_CLONE) { @@ -473,6 +480,7 @@ async function installCommand(app, args, progressCallback) { } await progressCallback({ percent: 70, message: 'Restoring addons' }); await services.restoreAddons(app, app.manifest.addons); + await services.runRestoreCommand(app); } // now we have the local package tarball, so lets build @@ -725,6 +733,13 @@ async function updateCommand(app, args, progressCallback) { await services.teardownAddons(app, unusedAddons); if (Object.keys(unusedAddons).includes('localstorage')) await updateApp(app, { storageVolumeId: null, storageVolumePrefix: null }); // lose reference + // teardown persistent dirs removed in the new manifest + const newPersistentDirs = updateConfig.manifest.persistentDirs || []; + const removedPersistentDirs = (app.manifest.persistentDirs || []).filter(d => !newPersistentDirs.includes(d)); + if (removedPersistentDirs.length) { + await services.teardownPersistentDirs({ ...app, manifest: { persistentDirs: removedPersistentDirs } }); + } + // free unused ports. this is done after backup, so the app object is not in some inconsistent state should backup fail const newTcpPorts = updateConfig.manifest.tcpPorts || {}; const newUdpPorts = updateConfig.manifest.udpPorts || {}; @@ -767,6 +782,7 @@ async function updateCommand(app, args, progressCallback) { await progressCallback({ percent: 60, message: 'Updating addons' }); await services.setupAddons(app, updateConfig.manifest.addons); + await services.setupPersistentDirs(app); // now we have the local package tarball, so lets build if (app.manifest.dockerImage.indexOf('local/') === 0) { diff --git a/src/backuptask.js b/src/backuptask.js index 343171049..b8a3fa6e1 100644 --- a/src/backuptask.js +++ b/src/backuptask.js @@ -326,6 +326,7 @@ async function snapshotApp(app, progressCallback) { progressCallback({ message: `Snapshotting app ${app.fqdn}` }); await apps.writeConfig(app); + await services.runBackupCommand(app); await services.backupAddons(app, app.manifest.addons); debug(`snapshotApp: ${app.fqdn} took ${(new Date() - startTime)/1000} seconds`); diff --git a/src/docker.js b/src/docker.js index 9a4739014..a5798a145 100644 --- a/src/docker.js +++ b/src/docker.js @@ -238,12 +238,27 @@ async function getAddonMounts(app) { return mounts; } +function getPersistentDirMounts(app) { + assert.strictEqual(typeof app, 'object'); + + const mounts = []; + if (!app.manifest.persistentDirs) return mounts; + + for (const dir of app.manifest.persistentDirs) { + const sanitized = dir.replace(/\//g, '_').replace(/^_/, ''); + const hostDir = `${paths.PERSISTENT_DATA_DIR}/${app.id}/${sanitized}`; + mounts.push({ Target: dir, Source: hostDir, Type: 'bind', ReadOnly: false }); + } + return mounts; +} + async function getMounts(app) { assert.strictEqual(typeof app, 'object'); const volumeMounts = await getVolumeMounts(app); const addonMounts = await getAddonMounts(app); - return volumeMounts.concat(addonMounts); + const persistentDirMounts = getPersistentDirMounts(app); + return volumeMounts.concat(addonMounts).concat(persistentDirMounts); } async function getAddressesForPort53() { diff --git a/src/paths.js b/src/paths.js index c131cf0db..a979c28a7 100644 --- a/src/paths.js +++ b/src/paths.js @@ -30,6 +30,7 @@ export default { DOCKER_SOCKET_PATH: '/var/run/docker.sock', PLATFORM_DATA_DIR: path.join(baseDir(), 'platformdata'), + PERSISTENT_DATA_DIR: path.join(baseDir(), 'platformdata/persistent'), APPS_DATA_DIR: path.join(baseDir(), 'appsdata'), SOURCE_ARCHIVES_DIR: path.join(baseDir(), 'platformdata/source-archives'), diff --git a/src/services.js b/src/services.js index 54c07975d..50658b3a4 100644 --- a/src/services.js +++ b/src/services.js @@ -356,6 +356,88 @@ async function restoreLocalStorage(app, options) { if (options.sqlite) await restoreSqlite(app, options.sqlite); } +function persistentDirHostPath(appId, containerDir) { + const sanitized = containerDir.replace(/\//g, '_').replace(/^_/, ''); + return path.join(paths.PERSISTENT_DATA_DIR, appId, sanitized); +} + +async function setupPersistentDirs(app) { + assert.strictEqual(typeof app, 'object'); + + if (!app.manifest.persistentDirs) return; + + debug('setupPersistentDirs'); + + for (const dir of app.manifest.persistentDirs) { + const hostDir = persistentDirHostPath(app.id, dir); + const [error] = await safe(shell.sudo([ SETUPVOLUME_CMD, hostDir ], {})); + if (error) throw new BoxError(BoxError.FS_ERROR, `Error creating persistent dir ${dir}: ${error.message}`); + } +} + +async function teardownPersistentDirs(app) { + assert.strictEqual(typeof app, 'object'); + + if (!app.manifest.persistentDirs) return; + + debug('teardownPersistentDirs'); + + for (const dir of app.manifest.persistentDirs) { + const hostDir = persistentDirHostPath(app.id, dir); + const [error] = await safe(shell.sudo([ RMVOLUME_CMD, hostDir ], {})); + if (error) throw new BoxError(BoxError.FS_ERROR, error); + } + + safe.fs.rmdirSync(path.join(paths.PERSISTENT_DATA_DIR, app.id)); +} + +async function runBackupCommand(app) { + assert.strictEqual(typeof app, 'object'); + + if (!app.manifest.backupCommand) return; + + debug('runBackupCommand'); + + const volumeDataDir = await apps.getStorageDir(app); + const dataDirMount = volumeDataDir ? `-v ${volumeDataDir}:/app/data` : ''; + const persistentDirMounts = app.manifest.persistentDirs ? app.manifest.persistentDirs.map(dir => `-v ${persistentDirHostPath(app.id, dir)}:${dir}`).join(' ') : ''; + + const runCmd = `docker run --rm --name=backup-${app.id} \ + --net cloudron \ + --log-driver=none \ + ${dataDirMount} \ + ${persistentDirMounts} \ + --label isCloudronManaged=true \ + --read-only -v /tmp -v /run ${app.manifest.dockerImage} \ + /bin/sh -c '${app.manifest.backupCommand.replace(/'/g, "'\\''")}'`; + + const [error] = await safe(shell.bash(runCmd, { encoding: 'utf8' })); + if (error) throw new BoxError(BoxError.ADDONS_ERROR, `Backup command failed: ${error.message}`); +} + +async function runRestoreCommand(app) { + assert.strictEqual(typeof app, 'object'); + + if (!app.manifest.restoreCommand) return; + + debug('runRestoreCommand'); + + const volumeDataDir = await apps.getStorageDir(app); + const dataDirMount = volumeDataDir ? `-v ${volumeDataDir}:/app/data` : ''; + const persistentDirMounts = app.manifest.persistentDirs ? app.manifest.persistentDirs.map(dir => `-v ${persistentDirHostPath(app.id, dir)}:${dir}`).join(' ') : ''; + + const runCmd = `docker run --rm --name=restore-${app.id} \ + --net cloudron \ + ${dataDirMount} \ + ${persistentDirMounts} \ + --label isCloudronManaged=true \ + --read-only -v /tmp -v /run ${app.manifest.dockerImage} \ + /bin/sh -c '${app.manifest.restoreCommand.replace(/'/g, "'\\''")}'`; + + const [error] = await safe(shell.bash(runCmd, { encoding: 'utf8' })); + if (error) throw new BoxError(BoxError.ADDONS_ERROR, `Restore command failed: ${error.message}`); +} + async function setupTurn(app, options) { assert.strictEqual(typeof app, 'object'); assert.strictEqual(typeof options, 'object'); @@ -2166,6 +2248,11 @@ export default { clearAddons, checkAddonsSupport, + setupPersistentDirs, + teardownPersistentDirs, + runBackupCommand, + runRestoreCommand, + getEnvironment, getContainerNamesSync,