portainer-backup
Version:
Utility for scripting or scheduling scheduled backups for Portainer
1,208 lines (1,063 loc) • 48.2 kB
JavaScript
/**
* -------------------------------------------------------------------
* ___ ___ ___ ___ ___
* / __| /_\ \ / /_\ / __| __|
* \__ \/ _ \ V / _ \ (_ | _|
* |___/_/_\_\_/_/_\_\___|___| ___ ___ ___
* / __|/ _ \| __|_ _\ \ / /_\ | _ \ __|
* \__ \ (_) | _| | | \ \/\/ / _ \| / _|
* |___/\___/|_| |_| \_/\_/_/ \_\_|_\___|
*
* -------------------------------------------------------------------
* COPYRIGHT SAVAGESOFTWARE,LLC, @ 2022, ALL RIGHTS RESERVED
* https://github.com/SavageSoftware/portainer-backup
* -------------------------------------------------------------------
*/
// import libraries
import fs from 'node:fs';
import path from 'node:path';
import symbols from 'log-symbols';
import sanitize from 'sanitize-filename';
import figures from 'figures';
import {Portainer} from './portainer.js';
import {Render} from './render.js';
import Util from './util.js';
import {Configuration} from './configuration.js';
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
import DevNull from 'dev-null';
import cron from 'node-cron'
import { compareSemVer, isValidSemVer, parseSemVer } from 'semver-parser';
import ON_DEATH from 'death';
import { createRequire } from 'module';
// use the require approach to obtain package.json
const require = createRequire(import.meta.url);
const pkg = require('../package.json');
// create deafult context
let context = {
version: pkg.version,
output: {
stream: process.stdout
},
cache: {
args: undefined,
stacks: []
},
operation: "backup",
config: new Configuration(),
results: {
success: false,
started: undefined,
finished: undefined,
elapsed: undefined,
backup: undefined,
portainer: undefined,
stacks: undefined,
error: undefined
}
};
// create rendering utility and pass in STDOUT stream via constructor
let render = new Render(context);
// create portainer instance; pass in config via constructor
let portainer = new Portainer(context);
// listen for signals to terminate the process
// and handle any uncaught exceptions in the process
ON_DEATH({
debug: false,
uncaughtException: true,
SIGINT: true,
SIGTERM: true,
SIGQUIT: true,
SIGABRT: true,
SIGILL: true
})(function(signal, err) {
if(err && err.message){
render.writeln();
render.error(err);
console.error(err);
}
render.terminate(signal);
render.goodbye();
process.exit(0);
});
// run a first pass on command line argument only looking for "--quiet" or "--json"
// this is done to supress console output for these options
let initArgs = yargs(hideBin(process.argv))
// optional argument : portainer server access token (PORTAINER_BACKUP_TOKEN)
.boolean("q")
.alias("q", "quiet")
.help(false)
.boolean("J")
.alias("J", "json")
.parse();
// handle '--quiet' and '--json' option; set output stream to null device
if(initArgs.quiet || initArgs.json) context.output.stream = new DevNull();
// display program title (using ASCII art)
render.title();
// parse full set of command line arguments
yargs(hideBin(process.argv))
// intercept arguments for additional processing before executing any commands
.middleware(function (argv) {
context.cache.args = argv; // cache command line arguments
context.operation = argv['_'][0]; // set context operation reference
context.results.started = new Date(); // record start timestamp
context.config.process(argv); // process the arguments for configuration
}, false)
// usage description (one command is required, options are optional)
.usage('Usage: <command> [(options...)]')
// command example syntax
.epilog("Command Examples:")
.epilog(' info --url http://192.168.1.100:9000\n')
.epilog(' backup --url http://192.168.1.100:9000\n --token XXXXXXXXXXXXXXXX\n --overwrite\n --stacks\n')
.epilog(' stacks --url http://192.168.1.100:9000\n --token XXXXXXXXXXXXXXXX\n --overwrite\n')
.epilog(' restore --url http://192.168.1.100:9000\n --token XXXXXXXXXXXXXXXX\n ./file-to-restore.tar.gz\n')
.epilog(' schedule --url http://192.168.1.100:9000\n --token XXXXXXXXXXXXXXXX\n --schedule "0 0 0 * * *"\n')
// schedule cron expression syntax
.epilog("Schedule Expression Examples: (cron syntax)")
.epilog(" ")
.epilog(" ┌──────────────────────── second (optional)")
.epilog(" │ ┌──────────────────── minute ")
.epilog(" │ │ ┌──────────────── hour ")
.epilog(" │ │ │ ┌──────────── day of month ")
.epilog(" │ │ │ │ ┌──────── month ")
.epilog(" │ │ │ │ │ ┌──── day of week ")
.epilog(" │ │ │ │ │ │ ")
.epilog(" │ │ │ │ │ │ ")
.epilog(" * * * * * * ")
.epilog(" ")
.epilog(" 0 0 0 * * * Daily at 12:00am ")
.epilog(" 0 0 5 1 * * 1st day of month @ 5:00am")
.epilog(" 0 */15 0 * * * Every 15 minutes ")
.epilog(" ")
.epilog(" Additional Examples @ https://github.com/node-cron/node-cron#cron-syntax")
.epilog(" ")
.epilog('-------------------------------------------------------------------------------')
.epilog(' MORE DETAILS @ https://github.com/SavageSoftware/portainer-backup ')
.epilog('-------------------------------------------------------------------------------')
// we will handle this extraction/interpretation of envrionment variables process
// manually to be more flexible with accepting values as well as to include
// the envrionment variable values as the default in the CLI help
// .env("PORTAINER_BACKUP")
// perform a backup of the portainer data and optionally stack files
.command('backup', 'Backup portainer data', () => {}, (argv) => {
backup();
})
// perform scheduled backups of the portainer data and optionally stack files
.command('schedule', 'Run scheduled portainer backups', () => {}, (argv) => {
schedule();
})
// perform a backup of the portainer stack files only
.command('stacks', 'Backup portainer stacks', () => {}, (argv) => {
stacks();
})
// display information about the connected portainer server
.command('info', 'Get portainer server info', () => {}, (argv) => {
info();
})
// perform a test backup of the portainer data and optionally stack files
// no files will be written; this is the same as using the '--dryrun' option
// with the backup task
.command('test', "Test backup data & stacks (backup --dryrun --stacks)", () => {}, (argv) => {
context.config.dryRun = true; // TEST==DRY-RUN
backup();
})
// restore a previous backup file to a new portainer server
.command('restore <filename>', 'Restore portainer data', () => {}, (argv) => {
restore();
})
// display command line help
.command('help', 'Show help', () => {}, (argv) => {
yargs.showHelp();
})
// display application versoin
.command('version', 'Show version', () => {}, (argv) => {
render.writeln(pkg.version);
})
// help command alias
.help("h")
.alias('h', 'help')
// application version alias
.alias('v', 'version')
// optional argument : supress command line output (PORTAINER_BACKUP_QUIET)
.options({
'q': {
alias: ['quiet'],
default: context.config.quiet,
describe: 'Do not display any console output',
type: 'boolean',
}
})
// optional argument : if enabled, the 'backup' or 'stacks' operation
// will not write any data to the filesystem. (PORTAINER_BACKUP_DRYRUN)
.options({
'D': {
alias: 'dryrun',
default: context.config.dryRun,
describe: 'Execute command task without persisting any data.',
type: 'boolean'
}
})
// optional argument : if enabled, and an error is encountered, the complete
// stack trace will be printed to the console (PORTAINER_BACKUP_DEBUG)
.options({
'X': {
alias: 'debug',
default: context.config.debug,
describe: 'Print details stack trace for any errors encountered',
type: 'boolean'
}
})
// optional argument : if enabled, output JSON formatted structured data
// instead of human readable output. (PORTAINER_BACKUP_JSON)
.options({
'J': {
alias: 'json',
default: context.config.json,
describe: 'Print formatted/strucutred JSON data',
type: 'boolean'
}
})
// optional argument : if enabled, output human readable information
// to the console but in a more compact/concise format. (PORTAINER_BACKUP_CONCISE)
.options({
'c': {
alias: 'concise',
default: context.config.concise,
describe: 'Print concise/limited output',
type: 'boolean'
}
})
// optional argument : allow bypassing portainer version enforcement (PORTAINER_BACKUP_IGNORE_VERSION)
.options({
'Z': {
alias: 'ignore-version',
default: context.config.portainer.ignoreVersion,
describe: 'Bypass portainer version check/enforcement',
type: 'boolean'
}
})
// optional argument : portainer server access token (PORTAINER_BACKUP_TOKEN)
.options({
't': {
alias: ['token'],
default: context.config.portainer.token,
describe: 'Portainer access token',
type: 'string',
requiresArg: true,
nargs: 1
}
})
// optional argument : portainer server base URL (PORTAINER_BACKUP_URL)
.options({
'u': {
alias: ['url'],
default: context.config.portainer.baseUrl,
describe: 'Portainer base url',
type: 'string',
requiresArg: true,
nargs: 1
}
})
// optional argument : directory to write backup files to (PORTAINER_BACKUP_DIRECTORY)
.options({
'd': {
alias: ['directory', 'dir'],
default: context.config.backup.directory,
describe: 'Backup directory/path',
type: 'string',
requiresArg: true,
nargs: 1,
normalize: true
}
})
// optional argument : filename of data archive file to write
// backup to inside backup directory (PORTAINER_BACKUP_FILENAME)
.options({
'f': {
alias: 'filename',
default: context.config.backup.filename,
describe: 'Backup filename',
type: 'string',
requiresArg: true,
nargs: 1,
normalize: true
}
})
// optional argument : password to use to protect backup archive file (PORTAINER_BACKUP_PASSWORD)
.options({
'p': {
alias: ['password', 'pwd'],
default: context.config.backup.password,
describe: 'Backup archive password',
type: 'string',
requiresArg: true,
nargs: 1
}
})
// optional argument : allow overwritting of existing backup
// data and stack file with same name (PORTAINER_BACKUP_OVERWRITE)
.options({
'o': {
alias: 'overwrite',
default: context.config.backup.overwrite,
describe: 'Overwrite existing files',
type: 'boolean'
}
})
// optional argument : include stack files in addition to backup
// data archive file in a 'backup' opertation (PORTAINER_BACKUP_STACKS)
.options({
'i': {
alias: ['include-stacks','stacks',],
default: context.config.backup.stacks,
describe: 'Include stack files in backup',
type: 'boolean'
}
})
// optional argument : include stack files in addition to backup
// data archive file in a 'backup' opertation (PORTAINER_BACKUP_STACKS)
.options({
'M': {
alias: ['mkdir','make-directory',],
default: context.config.backup.stacks,
describe: 'Create backup directory path if needed',
type: 'boolean'
}
})
// optional argument : the cron-like expression for scheduling
// automated 'backup' opertations (PORTAINER_BACKUP_SCHEDULE)
.options({
's': {
alias: ['schedule', 'sch'],
default: context.config.backup.schedule,
describe: 'Cron expression for scheduled backups',
type: 'string',
requiresArg: true,
nargs: 1
}
})
// setup option groups
.group(['t','u', 'Z'], 'Portainer Options:')
.group(['d','f','p','o','M','s','i'], "Backup Options: (applies only to 'backup' command)")
.group(['d','o','M'], "Stacks Options: (applies only to 'stacks' command)")
.group(['p'], "Restore Options: (applies only to 'restore' command)")
// we always require a single command operation
.demandCommand(1, "ATTENTION: You must provide at least one command: [backup, stacks, restore, info, test, schedule]\n")
.strictCommands()
.strictOptions()
.parse();
// ********************************************************************************************************
// ********************************************************************************************************
// TASKS/OPERATIONS
// ********************************************************************************************************
// ********************************************************************************************************
/**
* EXECUTE TASK: [SCHEDULE]
*/
function schedule(){
// display runtime configuration settings
if(!context.config.concise) render.configuration();
// the following promise chain is responsible for
// performing the complete backup workflow sequence.
initialize(context) // initialize operation
.then((ctx)=>{
return portainerStatusCheck(context) // check for access to portainer API/server
})
.then((ctx)=>{
return validatePortainerVersion(context); // perform portainer version check
})
.then((ctx)=>{
return validateAccessToken(context); // perform portainer access token check
})
.then((ctx)=>{
return validateSchedule(context);
})
.then((ctx)=>{
cron.schedule(context.config.backup.schedule, function() {
console.log('----------------------------------------------------------------------');
console.log(`[${new Date().toISOString()}] ... Running Scheduled Backup`);
console.log('----------------------------------------------------------------------');
console.log();
validateBackupDirectory(context) // validate backup directory/path
.then(()=>{
return validateBackupFile(context) // validate backup file
})
.then(()=>{
return portainerBackupData(context) // perform portainer data backup
})
.then((ctx)=>{
if(context.config.backup.stacks)
return portainerBackupStacks(context); // perform portainer stacks backup (if needed)
})
.then((ctx)=>{
finish();
if(!context.config.concise)
render.summary(context); // display backup summary
})
.then((ctx)=>{
if(context.config.dryRun) render.dryRun(); // display DRY-RUN message (if needed)
})
.then((ctx)=>{
if(!context.config.concise)
render.success(); // display backup complete message
})
.catch((err)=>{
if(context.config.debug) console.error(err); // debug ouput message (if needed)
})
.finally(() => { // finished
console.log('----------------------------------------------------------------------');
console.log(`[${new Date().toISOString()}] ... Waiting for next scheduled backup`);
console.log('----------------------------------------------------------------------');
console.log();
});
});
})
.catch((err)=>{
finish(err);
if(context.config.debug) console.error(err); // debug error message (if needed)
render.goodbye(); // goodbye message
process.exit(context.results.success ? 0 : 1); // exit the running process
})
.finally(() => {
console.log();
console.log('----------------------------------------------------------------------');
console.log(`[${new Date().toISOString()}] ... Waiting for next scheduled backup`);
console.log('----------------------------------------------------------------------');
console.log();
});
}
/**
* EXECUTE TASK: [BACKUP]
*/
function backup(){
// display runtime configuration settings
if(!context.config.concise) render.configuration();
// the following promise chain is responsible for
// performing the complete backup workflow sequence.
initialize(context) // initialize operation
.then(()=>{
return validateBackupDirectory(context); // validate backup directory/path
})
.then(()=>{
return validateBackupFile(context) // validate backup file
})
.then((ctx)=>{
return portainerStatusCheck(context) // check for access to portainer API/server
})
.then((ctx)=>{
return validatePortainerVersion(context); // perform portainer version check
})
.then((ctx)=>{
return validateAccessToken(context); // perform portainer access token check
})
.then((ctx)=>{
return portainerBackupData(context); // perform portainer data backup
})
.then((ctx)=>{
if(context.config.backup.stacks)
return portainerBackupStacks(context); // perform portainer stacks backup (if needed)
})
.then((ctx)=>{
finish();
if(!context.config.concise)
render.summary(context); // display backup summary
})
.then((ctx)=>{
if(context.config.dryRun) render.dryRun(); // display DRY-RUN message (if needed)
})
.then((ctx)=>{
if(!context.config.concise)
render.success(); // display backup complete message
})
.catch((err)=>{
finish(err);
if(context.config.debug) console.error(err); // debug error message (if needed)
})
.finally(() => { // finished
render.goodbye(); // goodbye message
process.exit(context.results.success ? 0 : 1); // exit the running process
});
}
/**
* EXECUTE TASK: [STACKS]
*/
function stacks(){
// display runtime configuration settings
if(!context.config.concise) render.configuration();
// the following promise chain is responsible for
// performing the complete [STACKS] workflow sequence.
initialize(context) // initialize operation
.then(()=>{
return validateBackupDirectory(context); // validate backup directory/path
})
.then((ctx)=>{
return portainerStatusCheck(context) // check for access to portainer API/server
})
.then((ctx)=>{
return validatePortainerVersion(context); // perform portainer version check
})
.then((ctx)=>{
return validateAccessToken(context); // perform portainer access token check
})
.then((ctx)=>{
return portainerBackupStacks(context); // perform portainer stacks backup
})
.then((ctx)=>{
finish();
if(!context.config.concise)
render.summary(context); // display backup summary
})
.then((ctx)=>{
if(context.config.dryRun) render.dryRun(); // display DRY-RUN message (if needed)
})
.then((ctx)=>{
if(!context.config.concise)
render.success(); // display backup complete message
})
.catch((err)=>{
finish(err);
if(context.config.debug) console.error(err); // debug error message (if needed)
})
.finally(() => { // finished
render.goodbye(); // goodbye message
process.exit(context.results.success ? 0 : 1); // exit the running process
});
}
/**
* EXECUTE TASK: [INFO]
*/
function info(){
// display runtime configuration settings
if(!context.config.concise) render.configuration();
// the following promise chain is responsible for
// performing the complete [INFO] workflow sequence.
initialize(context) // initialize operation
.then(()=>{
return portainerStatusCheck(context); // validate configuration and runtime environment
})
.then((ctx)=>{
return validatePortainerVersion(context); // perform portainer version check
})
.then(()=>{
finish();
})
.catch((err)=>{
finish(err);
if(context.config.debug) console.error(err); // debug error message (if needed)
})
.finally(() => { // finished
render.goodbye(); // goodbye message
process.exit(context.results.success ? 0 : 1); // exit the running process
});
}
/**
* EXECUTE TASK: [RESTORE] <---- NOT YET SUPPORTED
*/
function restore(){
let err = new Error("The 'restore' method has not yet been implemented.");
render.error(err, null, "The portainer API for restoring backups has a flaw preveting uploading restore files at this time.");
finish(err);
process.exit(1); // exit with error code
}
// ********************************************************************************************************
// ********************************************************************************************************
// HELPER METHODS
// ********************************************************************************************************
// ********************************************************************************************************
/**
* This method is called when either a [backup] or [stacks] task/operation has completed
* and we need to finalize the results data and calaulate the operation elapsed time.
*
* This method is also responsible for printing JSON formatted ouptut when a task
* is complete if the process has been configured to do so.
*
* @param {*} err optional error object instance | null
*/
function finish(err){
// record finished timestamp
context.results.finished = new Date();
// update results object with elapsed duration, success and eny errors
context.results.elapsed = context.results.finished - context.results.started;
context.results.success = (err) ? false : true;
if(err){
context.results.error = {
message: err.message,
number: err.errno
}
}
// optionally print JSON data to output stream
if(context.config.json){
process.stdout.write(JSON.stringify(context.results, null, 2));
process.stdout.write("\n");
}
}
/**
* Validate configuration settings and environment conditions for backup
* @param {*} context application context
* @returns Promise
*/
function initialize(context){
return new Promise((resolve, reject) => {
try{
// initialize operation
render.write("Initializing operation : ");
// copy backup info in results data
context.results.backup = {
directory: context.config.backup.directory,
filename: context.config.backup.filename,
protected: (context.config.backup.password) ? true : false,
status: "pending"
}
// success
render.writeln(`${symbols.success} ${context.operation.toUpperCase()}`);
return resolve(context);
}
catch(err){
render.writeln(`${symbols.error} ${context.operation.toUpperCase()}`);
render.error(err, "Failed to initialize operation!");
return reject(err);
}
});
}
// ********************************************************************************************************
// ********************************************************************************************************
// VALIDATION METHODS
// ********************************************************************************************************
// ********************************************************************************************************
/**
* Validate the schedule (cron-like) expression for scheduled backups.
* @param {*} context application context
* @returns Promise
*/
function validateSchedule(context){
return new Promise((resolve, reject) => {
// validate access token for API authorization
render.write("Validating schedule expression : ")
if(cron.validate(context.config.backup.schedule)){
render.writeln(symbols.success);
return resolve(context);
}
// no access token found; error
render.writeln(symbols.error);
let err = new Error(`Invalid 'PORTAINER_BACKUP_SCHEDULE' cron expression: [${context.config.backup.schedule}]`)
render.error(err, "Invalid schedule cron expression!",
"The 'PORTAINER_BACKUP_SCHEDULE' environment variable or '--schedule' command "+
"line option does not have a valid cron expression; " +
"Please see the documention for more details on the schedule cron expression.");
return reject(err);
});
}
/**
* Validate that a portainer access token has been provided.
* (This does not validate the token's authorization, only that a token was provided.)
* @param {*} context application context
* @returns Promise
*/
function validateAccessToken(context){
return new Promise((resolve, reject) => {
// validate access token for API authorization
render.write("Validating portainer access token : ")
if(context.config.portainer.token &&
context.config.portainer.token != undefined &&
context.config.portainer.token !== ""){
render.writeln(symbols.success);
return resolve(context);
}
// no access token found; error
render.writeln(symbols.error);
let err = new Error("'PORTAINER_BACKUP_TOKEN' is missing!");
render.error(err, "An API access token must be configured!",
"The 'PORTAINER_BACKUP_TOKEN' environment variable or '--token' command line option is missing ; " +
"You can create an API token in portainer under your user acocunt / access tokens: " +
"https://docs.portainer.io/v/ce-2.11/api/access#creating-an-access-token");
return reject(err);
});
}
/**
* Validate that the provided backup path/directory does exists on the file system.
* @param {*} context application context
* @returns Promise
*/
function validateBackupDirectory(context){
return new Promise((resolve, reject) => {
let createdDirectory = false;
// copy configured backup directory path into results data structure
// (this will re-set the config for new substitutions if needed)
context.results.backup.directory = context.config.backup.directory;
// replace substitution tokens in backup directory string if needed
const pathParts = context.results.backup.directory.split(path.sep);
for(let index in pathParts){
pathParts[index] = Util.processSubstitutions(pathParts[index]);
}
context.results.backup.directory = path.resolve(pathParts.join(path.sep));
// ensure directory exist; if not create it
if(context.config.mkdir && !fs.existsSync(context.results.backup.directory)){
fs.mkdirSync(context.results.backup.directory, { recursive: true });
createdDirectory = true;
}
// ensure backup path/directory exists
render.write("Validating target backup directory : ")
if(fs.existsSync(context.results.backup.directory)){
render.writeln(`${symbols.success} ${createdDirectory?"CREATED":"EXISTS"} ${figures.arrowRight} ${context.results.backup.directory}`);
return resolve(context);
}
// backup path/directory does not exist; error
render.writeln(`${symbols.error} ${context.results.backup.directory}`);
let err = new Error("'PORTAINER_BACKUP_DIRECTORY' is invalid!");
render.error(err, "The target backup directory does not exist. ",
"The 'PORTAINER_BACKUP_DIRECTORY' environment variable or " +
"'--directory' command line option is not pointing to a valid " +
"directory on the filesystem. Please ensure the " +
"backup directory or mount path exists. You can use the " +
"'PORTAINER_BACKUP_MKDIR' environment variable or '--mkdir' command line " +
"option to dynamically create directories if needed.");
return reject(err);
});
}
/**
* Validate the target backup file to ensure we don't overrite
* an existing instance unless configured to override files.
* @param {*} context application context
* @returns Promise
*/
function validateBackupFile(context){
return new Promise((resolve, reject) => {
// copy configured backup file name into results data structure
// (this will re-set the config for new substitutions if needed)
context.results.backup.filename = context.config.backup.filename;
// build backup filename string with all tokenized substitutions replaced
context.results.backup.filename = Util.processSubstitutions(context.results.backup.filename);
// construct complete backup file path using backup directory and backup filename
context.results.backup.file = path.resolve(context.results.backup.directory, context.results.backup.filename);
// validate now
render.write("Validating target backup file : ")
// if file does not exists, then there is no overwrite conflict
if(context.config.dryRun && !fs.existsSync(context.results.backup.file)){
render.writeln(`${symbols.success} DRYRUN ${figures.arrowRight} ${context.results.backup.filename}`);
context.results.backup.status="dryrun";
return resolve(context);
}
// if file does not exists, then there is no overwrite conflict
if(!fs.existsSync(context.results.backup.file)){
render.writeln(`${symbols.success} ${context.results.backup.filename}`);
context.results.backup.status="ready";
return resolve(context);
}
// if file overwrites are allowed and this is a dry-run, then skip this validation check
if(context.config.backup.overwrite && context.config.dryRun) {
render.writeln(`${symbols.warning} DRYRUN ${figures.arrowRight} ${context.results.backup.filename}`);
context.results.backup.status="dryrun";
return resolve(context);
}
// if dry-run is enabled, then skip this validation check
if(context.config.dryRun) {
render.writeln(`${symbols.error} DRYRUN ${figures.arrowRight} ${context.results.backup.filename}`);
context.results.backup.status="dryrun";
return resolve(context);
}
// if file overwrites are allowed, then skip this validation check
if(context.config.backup.overwrite) {
render.writeln(`${symbols.warning} OVERWRITE ${figures.arrowRight} ${context.results.backup.filename}`);
context.results.backup.status="overwrite";
context.results.backup.overwrite=true;
return resolve(context);
}
// if the file does already exist, then there is a conflict; error
render.writeln(`${symbols.error} ${context.results.backup.filename}`);
context.results.backup.status="already-exists";
let err = new Error(`Backup file [${Util.wrapFilePath(context.results.backup.file, 30)}] already exists.`);
render.error(err, "The target backup data file already exists!",
"Set the 'PORTAINER_BACKUP_OVERWRITE' environment varaiable " +
"or '--overwrite' command line option to enable file overwriting.");
return reject(err);
});
}
/**
* Validate portainer minimum supported version
* @param {*} context application context
* @returns Promise
*/
function validatePortainerVersion(context){
return new Promise((resolve, reject) => {
// validate portainer minimum supported version
render.write("Validating portainer version : ")
if(compareSemVer(context.results.portainer.version, Portainer.MIN_VERSION) >= 0){
render.writeln(`${symbols.success} v${context.results.portainer.version}`);
return resolve(context);
}
// the connected portainer server does not meet the minimum version requirements
if(context.config.portainer.ignoreVersion){
render.writeln(`${symbols.warning} v${context.results.portainer.version} [UNSUPPORTED]`);
if(!context.config.concise) render.unsupportedVersion(context.results.portainer.version);
return resolve(context);
}
// the connected portainer server does not meet the minimum version requirements
render.writeln(`${symbols.error} v${context.results.portainer.version}`);
if(!context.config.concise) render.unsupportedVersion(context.results.portainer.version);
let err = new Error("The portainer server is older than the minimum supported version.");
render.error(err, "The portainer server is older than the minimum supported version.",
`The portainer server is [${context.results.portainer.version}]; "+
"The minimum supported version is [${Portainer.MIN_VERSION}]. ` +
"Please upgrade your portainer server or use the 'PORTAINER_BACKUP_IGNORE_VERSION' " +
"environment variable or '--ignore-version' command line " +
"option to override the version checking.");
return reject(err);
});
}
/**
* Validate stack files to ensure there are no conflicts with existing files on the filesystem.
* @param {*} context application context
* @returns Promise
*/
function validateStackFiles(context){
return new Promise((resolve, reject) => {
let numberOfConflicts = 0;
render.writeln("Validate stack file conflicts : ")
render.writeln();
// create new map collection for stacks (indexed by stack ID)
context.results.stacks = new Map();
// iterate stacks
for(let index in context.cache.stacks)
{
// reference stack instance
let stack = context.cache.stacks[index];
// check for existing docker-compose file for this stack data
const filename = sanitize(`${stack.Name}.docker-compose.yaml`);
const stackFile = path.resolve(context.results.backup.directory, filename)
// assign stack file reference
stack.file = stackFile;
// copy stack info in results data
context.results.stacks[stack.Id] = {
id: stack.Id,
name: stack.Name,
file: stack.file,
filename: filename,
directory: context.config.backup.directory,
status: "pending"
};
render.write(`${figures.arrowRight} ${stackFile} ... `)
// validate overwrite of existing file
if(context.config.dryRun && !fs.existsSync(stackFile)){
render.writeln(`${symbols.success} (DRYRUN)`);
context.results.stacks[stack.Id].status = "dryrun";
}
else if(!fs.existsSync(stackFile)){
render.writeln(symbols.success);
context.results.stacks[stack.Id].status = "pending";
}
else if (context.config.backup.overwrite && context.config.dryRun){
render.writeln(`${symbols.warning} (OVERWRITE; DRYRUN)`);
context.results.stacks[stack.Id].status = "dryrun";
}
else if (context.config.dryRun){
render.writeln(`${symbols.error} (DRYRUN)`);
context.results.stacks[stack.Id].status = "dryrun";
}
else if (context.config.backup.overwrite){
render.writeln(`${symbols.warning} (OVERWRITE)`);
context.results.stacks[stack.Id].status = "overwrite";
context.results.stacks[stack.Id].overwrite = true;
}
else{
numberOfConflicts++;
render.writeln(symbols.error);
context.results.stacks[stack.Id].status = "already-exists";
}
}
// check for conflicts
if(numberOfConflicts > 0){
let err = new Error(`[${numberOfConflicts}] stack file(s) with the same name already exists in the target backup directory.`)
render.error(err, "One or more target stack data files already exists!",
"Set the 'PORTAINER_BACKUP_OVERWRITE' environment varaiable or '--overwrite' command line option to enable file overwriting.");
return reject(err);
}
// no file conflicts
render.writeln();
return resolve(context);
});
}
// ********************************************************************************************************
// ********************************************************************************************************
// DATA RETRIEVAL METHODS
// ********************************************************************************************************
// ********************************************************************************************************
/**
* Retrieve portainer status from portainer server API.
* This status will include the portainer instance ID and server version.
* @param {*} context application context
* @returns Promise
*/
function portainerStatusCheck(context){
render.write("Validating portainer server : ")
return portainer.status()
.then((data)=>{ // SUCCESS
render.writeln(`${symbols.success} ${context.config.portainer.baseUrl}`);
// display portainer server status
if(!context.config.concise){
render.writeln();
render.status(data);
}
// copy portainer info in results data
context.results.portainer = {
version: data.Version,
instance: data.InstanceID,
url: context.config.portainer.baseUrl
}
Promise.resolve(context);
})
.catch((err)=>{ // ERROR RETRIEVING PORTAINER STATUS
render.writeln(`${symbols.error} ${context.config.portainer.baseUrl}`);
render.error(err, "Connection to portainer server failed!");
return Promise.reject(err);
})
}
/**
* Retrieve portainer data backup stream from portainer server.
* Once the response stream is available, delegate to handler for saving stream data.
* @param {*} context application context
* @returns Promise
*/
function portainerBackupData(context){
render.write("Retrieving portainer data backup : ")
return portainer.backup()
.then((response)=>{
render.writeln(symbols.success);
if(context.config.dryRun) {
context.results.backup.status = "dryrun"
if(!context.config.backup.stacks) render.writeln();
return Promise.resolve();
}
context.results.backup.status="saving";
return portainerSaveBackupData(context,response);
})
.catch((err)=>{ // ERROR RETRIEVING PORTAINER BACKUP
render.writeln(symbols.error);
render.error(err, "Retrieving portainer data backup failed!");
return Promise.reject(err);
})
}
/**
* Save portainer data backup archive stream to filesystem.
* @param {*} context application context
* @param {*} response portainer server response with data stream
* @returns Promise
*/
function portainerSaveBackupData(context, response){
render.write("Saving portainer data backup : ")
return portainer.saveBackup(response.data, context.results.backup.file)
.then((file)=>{
render.writeln(symbols.success);
render.writeln();
render.writeln(` ${figures.arrowRight} ${context.results.backup.file} ... ${symbols.success}`)
render.writeln();
// udpate backup rsults data
let stats = fs.statSync(context.results.backup.file);
context.results.backup.size = stats.size,
context.results.backup.created = stats.ctime.toISOString(),
context.results.backup.status = "saved";
// display backup file details
if(!context.config.concise){
render.backupFile(file);
}
return Promise.resolve(context);
})
.catch((err)=>{ // ERROR SAVING PORTAINER BACKUP
render.writeln(symbols.error);
render.error(err, "Saving portainer data backup failed!");
context.results.backup.status = "failed";
return Promise.reject(err);
})
}
/**
* Retrieve portainer stacks metadata/catalog from portainer server.
* Once the metadata/catalog is available, validate the stacks aginst
* the filesystem to ensure no existinf file conflicts then delegate to
* handler for iterating stacks and retrieving individual stack
* docker-compose data.
* @param {*} context application context
* @returns Promise
*/
function portainerBackupStacks(context){
return portainerAcquireStacks(context)
.then(()=>{
return validateStackFiles(context);
})
.then(()=>{
if(!context.config.dryRun)
return portainerBackupStackFiles(context)
})
.then(()=>{
return Promise.resolve(context);
})
.catch((err)=>{
return Promise.reject(err);
})
}
/**
* Retrieve portainer stacks metadata/catalog from portainer server.
* @param {*} context application context
* @returns Promise
*/
function portainerAcquireStacks(context){
// execute API call to get stacks from Portainer server
render.write("Acquiring portainer stacks catalog : ")
return portainer.stacksMetadata()
.then((stacks)=>{
render.writeln(`${symbols.success} ${stacks.length} STACKS`);
// assign stacks array reference
context.cache.stacks = stacks;
// display stacks metadata details
if(!context.config.concise){
render.writeln();
render.stacksCount(stacks);
}
return Promise.resolve(context);
})
.catch((err)=>{ // ERROR FETCHING STACKS METADATA
render.writeln(symbols.error);
render.writeln();
render.error(err, "Portainer failed to acquire stacks metadata!");
return Promise.reject(err);
})
}
/**
* Iterate over previously retrieved stacks metadata/catalog (located in
* context cache) and retrieve individual stack docker-compose data. Once
* each statck data is acquired, save the stack data to the filesystem.
* @param {*} context application context
* @returns Promise
*/
function portainerBackupStackFiles(context){
// iterate over stacks metadata and fetch each stack file
render.writeln("Downloading & save stack files : ")
render.writeln();
// iterate over the stacks asynchronously
return Promise.all(context.cache.stacks.map(async (stack) => {
return portainer.stackFile(stack.Id)
.then((data)=>{
render.writeln(`${figures.arrowRight} saving (stack #${stack.Id}) [${stack.Name}.docker-compose.yml] ... ${symbols.success}`)
// write docker-compose file for the stack data
fs.writeFileSync(stack.file, data.StackFileContent);
let stats = fs.statSync(stack.file);
context.results.stacks[stack.Id].size = stats.size,
context.results.stacks[stack.Id].created = stats.ctime.toISOString(),
context.results.stacks[stack.Id].status = "saved";
})
.catch(err=>{
context.results.stacks[stack.Id].status = "failed";
render.writeln(`${figures.arrowRight} saving (stack #${stack.Id}) [${stack.Name}.docker-compose.yml] ... ${symbols.error}`)
render.error(err, `Portainer failed to save stack file: (stack #${stack.Id}) [${stack.Name}.docker-compose.yml]`);
Promise.reject(err);
});
})).then(()=>{
render.writeln();
render.writeln(`Saving stack files complete : ${symbols.success} ${context.cache.stacks.length} STACK FILES`);
render.writeln();
// print listing table of stack files
if(!context.config.concise){
render.stackFiles(context.cache.stacks);
}
Promise.resolve(context);
});
}