diff --git a/app/constants.js b/app/constants.js index 547c7d518..cb0e8f17f 100644 --- a/app/constants.js +++ b/app/constants.js @@ -22,6 +22,7 @@ angular .constant('API_ENDPOINT_TEAM_MEMBERSHIPS', 'api/team_memberships') .constant('API_ENDPOINT_TEMPLATES', 'api/templates') .constant('API_ENDPOINT_WEBHOOKS', 'api/webhooks') + .constant('API_ENDPOINT_BACKUP', 'api/backup') .constant('DEFAULT_TEMPLATES_URL', 'https://raw.githubusercontent.com/portainer/templates/master/templates.json') .constant('PAGINATION_MAX_ITEMS', 10) .constant('APPLICATION_CACHE_VALIDITY', 3600) diff --git a/app/portainer/rest/backup.js b/app/portainer/rest/backup.js new file mode 100644 index 000000000..50526c5b0 --- /dev/null +++ b/app/portainer/rest/backup.js @@ -0,0 +1,27 @@ +angular.module('portainer.app').factory('Backup', [ + '$resource', + 'API_ENDPOINT_BACKUP', + function BackupFactory($resource, API_ENDPOINT_BACKUP) { + 'use strict'; + return $resource( + API_ENDPOINT_BACKUP + '/:subResource/:action', + {}, + { + download: { + method: 'POST', + responseType: 'blob', + ignoreLoadingBar: true, + transformResponse: (data, headersGetter) => ({ + file: data, + name: headersGetter('Content-Disposition').replace('attachment; filename=', ''), + }), + }, + getS3Settings: { method: 'GET', params: { subResource: 's3', action: 'settings' } }, + saveS3Settings: { method: 'POST', params: { subResource: 's3', action: 'settings' } }, + exportS3Backup: { method: 'POST', params: { subResource: 's3', action: 'execute' } }, + restoreS3Backup: { method: 'POST', params: { subResource: 's3', action: 'restore' } }, + getBackupStatus: { method: 'GET', params: { subResource: 's3', action: 'status' } }, + } + ); + }, +]); diff --git a/app/portainer/services/api/backupService.js b/app/portainer/services/api/backupService.js new file mode 100644 index 000000000..1ff04cda0 --- /dev/null +++ b/app/portainer/services/api/backupService.js @@ -0,0 +1,90 @@ +angular.module('portainer.app').factory('BackupService', [ + '$q', + '$async', + 'Backup', + 'FileUploadService', + function BackupServiceFactory($q, $async, Backup, FileUploadService) { + 'use strict'; + const service = {}; + + service.downloadBackup = function (payload) { + return Backup.download({}, payload).$promise; + }; + + service.uploadBackup = function (file, password) { + return FileUploadService.uploadBackup(file, password); + }; + + service.getS3Settings = function () { + var deferred = $q.defer(); + + Backup.getS3Settings() + .$promise.then(function success(data) { + deferred.resolve(data); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve backup S3 settings', err: err }); + }); + + return deferred.promise; + }; + + service.saveS3Settings = function (payload) { + var deferred = $q.defer(); + + Backup.saveS3Settings({}, payload) + .$promise.then(function success(data) { + deferred.resolve(data); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to save backup S3 settings', err: err }); + }); + + return deferred.promise; + }; + + service.exportBackup = function (payload) { + var deferred = $q.defer(); + + Backup.exportS3Backup({}, payload) + .$promise.then(function success(data) { + deferred.resolve(data); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to export backup', err: err }); + }); + + return deferred.promise; + }; + + service.restoreFromS3 = function (payload) { + var deferred = $q.defer(); + + Backup.restoreS3Backup({}, payload) + .$promise.then(function success(data) { + deferred.resolve(data); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to restore backup from S3', err: err }); + }); + + return deferred.promise; + }; + + service.getBackupStatus = function () { + var deferred = $q.defer(); + + Backup.getBackupStatus() + .$promise.then(function success(data) { + deferred.resolve(data); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve backup status', err: err }); + }); + + return deferred.promise; + }; + + return service; + }, +]); diff --git a/app/portainer/services/fileUpload.js b/app/portainer/services/fileUpload.js index a9df4606a..81340d623 100644 --- a/app/portainer/services/fileUpload.js +++ b/app/portainer/services/fileUpload.js @@ -61,6 +61,16 @@ angular.module('portainer.app').factory('FileUploadService', [ }); }; + service.uploadBackup = function (file, password) { + return Upload.upload({ + url: 'api/restore', + data: { + file, + password, + }, + }); + }; + service.createSwarmStack = function (stackName, swarmId, file, env, endpointId) { return Upload.upload({ url: 'api/stacks?method=file&type=1&endpointId=' + endpointId, diff --git a/app/portainer/views/init/admin/initAdmin.html b/app/portainer/views/init/admin/initAdmin.html index b3643ea58..72921d19e 100644 --- a/app/portainer/views/init/admin/initAdmin.html +++ b/app/portainer/views/init/admin/initAdmin.html @@ -11,8 +11,17 @@
+ +
+ + New Portainer installation + +
+ + -
+
@@ -98,6 +107,99 @@
+ + +
+
+ + + + + + + +
+
+ + This will restore the Portainer metadata which contains information about the endpoints, stacks and applications, as well as the configured users. + +
+
+ + +
+
+ + You can upload a backup file from your computer. + +
+
+ + +
+
+ + + {{ formValues.BackupFile.name }} + + +
+
+ + +
+ +
+ +
+
+ + +
+
+ + You are about to restore Portainer from this backup. + +
+
+ + After restoring has completed, please log in as a user that was known by the Portainer that was restored. + +
+
+ + +
+
+ +
+
+ + + +
+
+
diff --git a/app/portainer/views/init/admin/initAdminController.js b/app/portainer/views/init/admin/initAdminController.js index 63bd27d02..c8aac975b 100644 --- a/app/portainer/views/init/admin/initAdminController.js +++ b/app/portainer/views/init/admin/initAdminController.js @@ -8,7 +8,9 @@ angular.module('portainer.app').controller('InitAdminController', [ 'SettingsService', 'UserService', 'EndpointService', - function ($async, $scope, $state, Notifications, Authentication, StateManager, SettingsService, UserService, EndpointService) { + 'BackupService', + 'StatusService', + function ($async, $scope, $state, Notifications, Authentication, StateManager, SettingsService, UserService, EndpointService, BackupService, StatusService) { $scope.logo = StateManager.getState().application.logo; $scope.formValues = { @@ -20,6 +22,13 @@ angular.module('portainer.app').controller('InitAdminController', [ $scope.state = { actionInProgress: false, + showInitPassword: true, + showRestorePortainer: false, + }; + + $scope.togglePanel = function () { + $scope.state.showInitPassword = !$scope.state.showInitPassword; + $scope.state.showRestorePortainer = !$scope.state.showRestorePortainer; }; $scope.createAdminUser = function () { @@ -55,17 +64,35 @@ angular.module('portainer.app').controller('InitAdminController', [ }); }; - function createAdministratorFlow() { - UserService.administratorExists() - .then(function success(exists) { - if (exists) { - $state.go('portainer.home'); + async function waitPortainerRestart() { + for (let i = 0; i < 10; i++) { + await new Promise((resolve) => setTimeout(resolve, 5 * 1000)); + try { + const status = await StatusService.status(); + if (status && status.Version) { + return; } - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to verify administrator account existence'); - }); + } catch (e) {} + } + throw 'Timeout to wait for Portainer restarting'; } - createAdministratorFlow(); + + $scope.uploadBackup = async function () { + $scope.state.backupInProgress = true; + + const file = $scope.formValues.BackupFile; + const password = $scope.formValues.Password; + + try { + await BackupService.uploadBackup(file, password); + await waitPortainerRestart(); + Notifications.success('The backup has successfully been restored'); + $state.go('portainer.auth'); + } catch (err) { + Notifications.error('Failure', err, 'Unable to restore the backup'); + } finally { + $scope.state.backupInProgress = false; + } + }; }, ]); diff --git a/app/portainer/views/settings/settings.html b/app/portainer/views/settings/settings.html index bad170142..0b6c023fc 100644 --- a/app/portainer/views/settings/settings.html +++ b/app/portainer/views/settings/settings.html @@ -191,3 +191,56 @@ + +
+
+ + + +
+ +
+ +
+ +
+
+ + + +
+ +
+ +
+
+
+
+
+

This field is required.

+
+
+
+ + + +
+
+ +
+
+ +
+
+
+
+
diff --git a/app/portainer/views/settings/settingsController.js b/app/portainer/views/settings/settingsController.js index 86b352ced..a5438ce10 100644 --- a/app/portainer/views/settings/settingsController.js +++ b/app/portainer/views/settings/settingsController.js @@ -4,7 +4,10 @@ angular.module('portainer.app').controller('SettingsController', [ 'Notifications', 'SettingsService', 'StateManager', - function ($scope, $state, Notifications, SettingsService, StateManager) { + 'BackupService', + 'FileSaver', + 'Blob', + function ($scope, $state, Notifications, SettingsService, StateManager, BackupService, FileSaver) { $scope.state = { actionInProgress: false, availableEdgeAgentCheckinOptions: [ @@ -21,6 +24,8 @@ angular.module('portainer.app').controller('SettingsController', [ value: 30, }, ], + + backupInProgress: false, }; $scope.formValues = { @@ -29,6 +34,8 @@ angular.module('portainer.app').controller('SettingsController', [ labelValue: '', enableEdgeComputeFeatures: false, enableTelemetry: false, + passwordProtect: false, + password: '', }; $scope.removeFilteredContainerLabel = function (index) { @@ -49,6 +56,28 @@ angular.module('portainer.app').controller('SettingsController', [ updateSettings(settings); }; + $scope.downloadBackup = function () { + const payload = {}; + if ($scope.formValues.passwordProtect) { + payload.password = $scope.formValues.password; + } + + $scope.state.backupInProgress = true; + + BackupService.downloadBackup(payload) + .then(function success(data) { + const downloadData = new Blob([data.file], { type: 'application/gzip' }); + FileSaver.saveAs(downloadData, data.name); + Notifications.success('Backup successfully downloaded'); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to download backup'); + }) + .finally(function final() { + $scope.state.backupInProgress = false; + }); + }; + $scope.saveApplicationSettings = function () { var settings = $scope.settings;