├── .dockerignore ├── assets └── screenshot.jpg ├── .gitignore ├── publish.sh ├── run.sh ├── package.json ├── docker-compose.yml ├── Dockerfile ├── src ├── configuration.js ├── portainer.js ├── util.js ├── render.js └── index.js └── readme.md /.dockerignore: -------------------------------------------------------------------------------- 1 | backup 2 | Dockerfile 3 | .gitignore 4 | .git 5 | node_modules 6 | -------------------------------------------------------------------------------- /assets/screenshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SavageSoftware/portainer-backup/HEAD/assets/screenshot.jpg -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Backup directory 9 | backup 10 | 11 | # trash directory 12 | trash 13 | 14 | # Runtime data 15 | pids 16 | *.pid 17 | *.seed 18 | *.pid.lock 19 | 20 | # Directory for instrumented libs generated by jscoverage/JSCover 21 | lib-cov 22 | 23 | # Coverage directory used by tools like istanbul 24 | coverage 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # TypeScript v1 declaration files 46 | typings/ 47 | 48 | # Optional npm cache directory 49 | .npm 50 | 51 | # Optional eslint cache 52 | .eslintcache 53 | 54 | # Optional REPL history 55 | .node_repl_history 56 | 57 | # Output of 'npm pack' 58 | *.tgz 59 | 60 | # Yarn Integrity file 61 | .yarn-integrity 62 | 63 | # dotenv environment variables file 64 | .env 65 | 66 | # next.js build output 67 | .next 68 | 69 | .idea 70 | 71 | # Ignore backup files. 72 | *-backup.json 73 | backup/* 74 | tmp/* 75 | .DS_Store 76 | -------------------------------------------------------------------------------- /publish.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | # ********************************************************************* 3 | # ___ ___ ___ ___ ___ 4 | # / __| /_\ \ / /_\ / __| __| 5 | # \__ \/ _ \ V / _ \ (_ | _| 6 | # |___/_/_\_\_/_/_\_\___|___| ___ ___ ___ 7 | # / __|/ _ \| __|_ _\ \ / /_\ | _ \ __| 8 | # \__ \ (_) | _| | | \ \/\/ / _ \| / _| 9 | # |___/\___/|_| |_| \_/\_/_/ \_\_|_\___| 10 | # 11 | # ------------------------------------------------------------------- 12 | # PORTAINER-BACKUP 13 | # https://github.com/SavageSoftware/portainer-backup 14 | # ------------------------------------------------------------------- 15 | # 16 | # This script compiles and builds the current project sources into 17 | # a Docker container image for ARM64 and x86_64 platforms. Next, 18 | # the script publishes the Docker images, publishes the project 19 | # to the NPM registry. 20 | # 21 | # ********************************************************************* 22 | # COPYRIGHT SAVAGESOFTWARE,LLC, @ 2022, ALL RIGHTS RESERVED 23 | # ********************************************************************* 24 | 25 | # BUMP VERSION IN PACKAGE.JSON 26 | #npm version patch 27 | 28 | # BUILD AND PUSH DOCKER IMAGES TO: DockerHub 29 | # https://hub.docker.com/repository/docker/savagesoftware/portainer-backup 30 | ./build.sh --push 31 | 32 | # PUSH README.MD TO: DockerHub 33 | # https://hub.docker.com/repository/docker/savagesoftware/portainer-backup 34 | docker pushrm savagesoftware/portainer-backup 35 | 36 | # PUBLISH TO: NPM REGISTRY 37 | # https://www.npmjs.com/package/portainer-backup 38 | npm publish 39 | -------------------------------------------------------------------------------- /run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # ********************************************************************* 3 | # ___ ___ ___ ___ ___ 4 | # / __| /_\ \ / /_\ / __| __| 5 | # \__ \/ _ \ V / _ \ (_ | _| 6 | # |___/_/_\_\_/_/_\_\___|___| ___ ___ ___ 7 | # / __|/ _ \| __|_ _\ \ / /_\ | _ \ __| 8 | # \__ \ (_) | _| | | \ \/\/ / _ \| / _| 9 | # |___/\___/|_| |_| \_/\_/_/ \_\_|_\___| 10 | # 11 | # ------------------------------------------------------------------- 12 | # PORTAINER-BACKUP 13 | # https://github.com/SavageSoftware/portainer-backup 14 | # ------------------------------------------------------------------- 15 | # 16 | # This script executes a backup of portainer data using the 17 | # 'portainer-backup' Docker container image. 18 | # 19 | # ********************************************************************* 20 | # COPYRIGHT SAVAGESOFTWARE,LLC, @ 2022, ALL RIGHTS RESERVED 21 | # ********************************************************************* 22 | docker run -it --rm \ 23 | --name portainer-backup \ 24 | --volume $PWD/backup:/backup \ 25 | --env TZ="America/New_York" \ 26 | --env PORTAINER_BACKUP_URL="http://portainer:9000" \ 27 | --env PORTAINER_BACKUP_TOKEN="YOUR_PORTAINER_ACCESS_TOKEN_GOES_HERE" \ 28 | --env PORTAINER_BACKUP_PASSWORD="" \ 29 | --env PORTAINER_BACKUP_OVERWRITE=true \ 30 | --env PORTAINER_BACKUP_SCHEDULE="0 0 0 * * *" \ 31 | savagesoftware/portainer-backup:latest $@ 32 | 33 | # ------------------------ 34 | # OPTIONAL ENV VARIABLES 35 | # ------------------------ 36 | # --env PORTAINER_BACKUP_STACKS=true 37 | # --env PORTAINER_BACKUP_DRYRUN=true 38 | # --env PORTAINER_BACKUP_CONCISE=true 39 | # --env PORTAINER_BACKUP_DIRECTORY=/backup 40 | # --env PORTAINER_BACKUP_FILENAME=portainer-backup.tar.gz 41 | # --env FORCE_COLOR=0 42 | 43 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "portainer-backup", 3 | "version": "0.0.7", 4 | "description": "Utility for scripting or scheduling scheduled backups for Portainer", 5 | "readme": "README.md", 6 | "keywords": [ 7 | "portainer", 8 | "backup", 9 | "docker", 10 | "kubernetes", 11 | "container" 12 | ], 13 | "homepage": "https://github.com/SavageSoftware/portainer-backup", 14 | "bugs": "https://github.com/SavageSoftware/portainer-backup/issues", 15 | "author": { 16 | "name": "SavageSoftware, LLC.", 17 | "url": "https://savagesoftware.com" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "https://github.com/SavageSoftware/portainer-backup.git" 22 | }, 23 | "engines": { 24 | "node": ">=16" 25 | }, 26 | "license": "Apache", 27 | "main": "index.js", 28 | "type": "module", 29 | "bin": { 30 | "portainer-backup": "src/index.js" 31 | }, 32 | "scripts": { 33 | "start": "node src/index.js", 34 | "build": "npm install", 35 | "help": "node src/index.js help", 36 | "schedule": "node src/index.js schedule", 37 | "backup": "node src/index.js backup", 38 | "stacks": "node src/index.js stacks", 39 | "info": "node src/index.js info", 40 | "restore": "node src/index.js restore" 41 | }, 42 | "dependencies": { 43 | "axios": "^0.27.2", 44 | "cli-table3": "^0.6.2", 45 | "colors": "^1.4.0", 46 | "death": "^1.1.0", 47 | "dev-null": "^0.1.1", 48 | "figlet": "^1.5.2", 49 | "figures": "^5.0.0", 50 | "gradient-string": "^2.0.1", 51 | "log-symbols": "^5.1.0", 52 | "luxon": "^3.0.1", 53 | "node-cron": "^3.0.1", 54 | "pretty-bytes": "^6.0.0", 55 | "sanitize-filename": "^1.6.3", 56 | "semver-parser": "^4.0.1", 57 | "uuid": "^8.3.2", 58 | "yargs": "^17.5.1" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # ********************************************************************* 2 | # ___ ___ ___ ___ ___ 3 | # / __| /_\ \ / /_\ / __| __| 4 | # \__ \/ _ \ V / _ \ (_ | _| 5 | # |___/_/_\_\_/_/_\_\___|___| ___ ___ ___ 6 | # / __|/ _ \| __|_ _\ \ / /_\ | _ \ __| 7 | # \__ \ (_) | _| | | \ \/\/ / _ \| / _| 8 | # |___/\___/|_| |_| \_/\_/_/ \_\_|_\___| 9 | # 10 | # ------------------------------------------------------------------- 11 | # PORTAINER-BACKUP 12 | # https://github.com/SavageSoftware/portainer-backup 13 | # ------------------------------------------------------------------- 14 | # 15 | # This docker-compose script will create a new portainer-backup 16 | # docker container to perform automated backups on a defined schedule. 17 | # 18 | # Make sure to substitute your Portainer server URL and access token. 19 | # Also map the backup volume to a valid path where you want the 20 | # backup files to be saved. 21 | # 22 | # ********************************************************************* 23 | # COPYRIGHT SAVAGESOFTWARE,LLC, @ 2022, ALL RIGHTS RESERVED 24 | # ********************************************************************* 25 | version: '3.8' 26 | 27 | services: 28 | portainer-backup: 29 | container_name: portainer-backup 30 | image: savagesoftware/portainer-backup:latest 31 | hostname: portainer-backup 32 | restart: unless-stopped 33 | command: schedule 34 | environment: 35 | TZ: America/New_York 36 | PORTAINER_BACKUP_URL: "http://portainer:9000" 37 | PORTAINER_BACKUP_TOKEN: "PORTAINER_ACCESS_TOKEN" 38 | PORTAINER_BACKUP_PASSWORD: "" 39 | PORTAINER_BACKUP_OVERWRITE: 1 40 | PORTAINER_BACKUP_SCHEDULE: "0 0 0 * * *" 41 | PORTAINER_BACKUP_STACKS: 1 42 | PORTAINER_BACKUP_DRYRUN: 0 43 | PORTAINER_BACKUP_CONCISE: 1 44 | PORTAINER_BACKUP_DIRECTORY: "/backup" 45 | PORTAINER_BACKUP_FILENAME: "portainer-backup.tar.gz" 46 | volumes: 47 | - /var/backup:/backup 48 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # ********************************************************************* 2 | # ___ ___ ___ ___ ___ 3 | # / __| /_\ \ / /_\ / __| __| 4 | # \__ \/ _ \ V / _ \ (_ | _| 5 | # |___/_/_\_\_/_/_\_\___|___| ___ ___ ___ 6 | # / __|/ _ \| __|_ _\ \ / /_\ | _ \ __| 7 | # \__ \ (_) | _| | | \ \/\/ / _ \| / _| 8 | # |___/\___/|_| |_| \_/\_/_/ \_\_|_\___| 9 | # 10 | # ------------------------------------------------------------------- 11 | # PORTAINER-BACKUP 12 | # https://github.com/SavageSoftware/portainer-backup 13 | # ------------------------------------------------------------------- 14 | # 15 | # This Dockerfile creates an Alpine-based Linux docker image 16 | # with the PORTAINER-BACKUP utility installed. This is useful 17 | # for creating a Docker container to perform scheduled backups 18 | # of a portainer server. 19 | # 20 | # ********************************************************************* 21 | # COPYRIGHT SAVAGESOFTWARE,LLC, @ 2022, ALL RIGHTS RESERVED 22 | # ********************************************************************* 23 | FROM node:lts-alpine 24 | 25 | # IMAGE ARGUMENTS PASSED IN FROM BUILDER 26 | ARG TARGETARCH 27 | ARG BUILDDATE 28 | ARG BUILDVERSION 29 | 30 | # PROVIDE IMAGE LABLES 31 | LABEL "com.example.vendor"="ACME Incorporated" 32 | LABEL vendor="Savage Software, LLC" 33 | LABEL maintainer="Robert Savage" 34 | LABEL version="$VERSION" 35 | LABEL description="Utility for scripting or scheduling scheduled backups for Portainer" 36 | LABEL url="https://github.com/SavageSoftware/portainer-backup" 37 | LABEL org.label-schema.schema-version="$VERSION" 38 | LABEL org.label-schema.build-date="$BUILDDATE" 39 | LABEL org.label-schema.name="savagesoftware/portainer-backup" 40 | LABEL org.label-schema.description="Utility for scripting or scheduling scheduled backups for Portainer" 41 | LABEL org.label-schema.url="https://github.com/SavageSoftware/portainer-backup" 42 | LABEL org.label-schema.vcs-url="https://github.com/SavageSoftware/portainer-backup.git" 43 | LABEL org.label-schema.vendor="Savage Software, LLC" 44 | LABEL org.label-schema.version=$VERSION 45 | LABEL org.label-schema.docker.cmd="docker run -it --rm --name portainer-backup --volume $PWD/backup:/backup savagesoftware/portainer-backup:latest backup" 46 | 47 | # INSTALL ADDITIONAL IMAGE DEPENDENCIES AND COPY APPLICATION TO IMAGE 48 | RUN apk update && apk add --no-cache tzdata 49 | RUN mkdir -p /portainer-backup/src 50 | COPY package.json /portainer-backup 51 | COPY src/*.js /portainer-backup/src 52 | WORKDIR /portainer-backup 53 | VOLUME "/backup" 54 | RUN npm install --silent 55 | 56 | # DEFAULT ENV VARIABLE VALUES 57 | ENV TZ="America/New_York" 58 | ENV PORTAINER_BACKUP_URL="http://portainer:9000" 59 | ENV PORTAINER_BACKUP_TOKEN="" 60 | ENV PORTAINER_BACKUP_DIRECTORY="/backup" 61 | ENV PORTAINER_BACKUP_FILENAME="/portainer-backup.tar.gz" 62 | ENV PORTAINER_BACKUP_OVERWRITE=false 63 | ENV PORTAINER_BACKUP_CONCISE=false 64 | ENV PORTAINER_BACKUP_DEBUG=false 65 | ENV PORTAINER_BACKUP_DRYRUN=false 66 | ENV PORTAINER_BACKUP_STACKS=false 67 | 68 | # NODEJS RUNNING THIS APPLICATION IS THE ENTRYPOINT 69 | ENTRYPOINT [ "/usr/local/bin/node", "/portainer-backup/src/index.js" ] 70 | 71 | # DEFAULT COMMAND (if none provided) 72 | CMD ["schedule"] 73 | -------------------------------------------------------------------------------- /src/configuration.js: -------------------------------------------------------------------------------- 1 | /** 2 | * ------------------------------------------------------------------- 3 | * ___ ___ ___ ___ ___ 4 | * / __| /_\ \ / /_\ / __| __| 5 | * \__ \/ _ \ V / _ \ (_ | _| 6 | * |___/_/_\_\_/_/_\_\___|___| ___ ___ ___ 7 | * / __|/ _ \| __|_ _\ \ / /_\ | _ \ __| 8 | * \__ \ (_) | _| | | \ \/\/ / _ \| / _| 9 | * |___/\___/|_| |_| \_/\_/_/ \_\_|_\___| 10 | * 11 | * ------------------------------------------------------------------- 12 | * COPYRIGHT SAVAGESOFTWARE,LLC, @ 2022, ALL RIGHTS RESERVED 13 | * https://github.com/SavageSoftware/portainer-backup 14 | * ------------------------------------------------------------------- 15 | */ 16 | 17 | // import libraries 18 | import path from 'node:path'; 19 | import sanitize from 'sanitize-filename'; 20 | import Util from './util.js' 21 | 22 | export class Configuration { 23 | 24 | /** 25 | * Default Constructor 26 | * 27 | * Initialize configuration settings with default values 28 | * or values defined via environment variables. 29 | */ 30 | constructor() { 31 | // initialize configuration settings using envrironment variables 32 | this.portainer = { 33 | token: process.env.PORTAINER_BACKUP_TOKEN || "", 34 | baseUrl: process.env.PORTAINER_BACKUP_URL || "http://127.0.0.1:9000", 35 | ignoreVersion: process.env.PORTAINER_BACKUP_IGNORE_VERSION || false 36 | }; 37 | this.backup = { 38 | directory: process.env.PORTAINER_BACKUP_DIRECTORY || "backup", 39 | filename: sanitize(process.env.PORTAINER_BACKUP_FILENAME || "portainer-backup.tar.gz"), 40 | file: "", /* INITIALIZED BELOW */ 41 | password: process.env.PORTAINER_BACKUP_PASSWORD || "", 42 | stacks: Util.evalBool(process.env.PORTAINER_BACKUP_STACKS), 43 | overwrite: Util.evalBool(process.env.PORTAINER_BACKUP_OVERWRITE), 44 | schedule: process.env.PORTAINER_BACKUP_SCHEDULE || "0 0 0 * * * *" 45 | }; 46 | this.dryRun = Util.evalBool(process.env.PORTAINER_BACKUP_DRYRUN); 47 | this.debug = Util.evalBool(process.env.PORTAINER_BACKUP_DEBUG); 48 | this.quiet = Util.evalBool(process.env.PORTAINER_BACKUP_QUIET); 49 | this.json = Util.evalBool(process.env.PORTAINER_BACKUP_JSON); 50 | this.concise = Util.evalBool(process.env.PORTAINER_BACKUP_CONCISE); 51 | this.mkdir = Util.evalBool(process.env.PORTAINER_BACKUP_MKDIR); 52 | } 53 | 54 | /** 55 | * override configuration settings if any configurtation 56 | * arguments have been explicitly provided via the command 57 | * line arguments 58 | * 59 | * @param {*} args 60 | */ 61 | process(args){ 62 | 63 | // check arguments for valid settings; override config settings 64 | if(args.token) this.portainer.token = args.token; 65 | if(args.url) this.portainer.baseUrl = args.url; 66 | if(args.ignoreVersion) this.portainer.ignoreVersion = Util.evalBool(args.ignoreVersion); 67 | if(args.directory) this.backup.directory = args.directory; 68 | if(args.filename) this.backup.filename = args.filename; 69 | if(args.password) this.backup.password = args.password; 70 | if(args.overwrite) this.backup.overwrite = Util.evalBool(args.overwrite); 71 | if(args.stacks) this.backup.stacks = Util.evalBool(args.stacks); 72 | if(args.schedule) this.backup.schedule = args.schedule; 73 | if(args.dryrun) this.dryRun = Util.evalBool(args.dryrun); 74 | if(args.debug) this.debug = Util.evalBool(args.debug); 75 | if(args.quiet) this.quiet = Util.evalBool(args.quiet); 76 | if(args.json) this.json = Util.evalBool(args.json); 77 | if(args.concise) this.concise = Util.evalBool(args.concise); 78 | if(args.mkdir) this.mkdir = Util.evalBool(args.mkdir); 79 | 80 | // construct backup file path using backup directory and backup filename 81 | this.backup.file = path.resolve(this.backup.directory, this.backup.filename); 82 | this.backup.directory = path.resolve(this.backup.directory); 83 | } 84 | } 85 | 86 | -------------------------------------------------------------------------------- /src/portainer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * ------------------------------------------------------------------- 3 | * ___ ___ ___ ___ ___ 4 | * / __| /_\ \ / /_\ / __| __| 5 | * \__ \/ _ \ V / _ \ (_ | _| 6 | * |___/_/_\_\_/_/_\_\___|___| ___ ___ ___ 7 | * / __|/ _ \| __|_ _\ \ / /_\ | _ \ __| 8 | * \__ \ (_) | _| | | \ \/\/ / _ \| / _| 9 | * |___/\___/|_| |_| \_/\_/_/ \_\_|_\___| 10 | * 11 | * ------------------------------------------------------------------- 12 | * COPYRIGHT SAVAGESOFTWARE,LLC, @ 2022, ALL RIGHTS RESERVED 13 | * https://github.com/SavageSoftware/portainer-backup 14 | * ------------------------------------------------------------------- 15 | */ 16 | 17 | // import libraries 18 | import axios from 'axios'; 19 | import fs from 'node:fs'; 20 | 21 | /** 22 | * This class is responsible for managing access 23 | * and communication with a Portainer server. 24 | */ 25 | export class Portainer { 26 | 27 | // minimum supported portainer server version 28 | static MIN_VERSION = "2.11.0"; 29 | 30 | // relative API URL paths 31 | static URL = { 32 | STATUS: "/api/status", 33 | BACKUP: "/api/backup", 34 | STACKS: "/api/stacks" 35 | } 36 | 37 | /** 38 | * Default Constructor 39 | * 40 | * @param {*} context 41 | */ 42 | constructor(context) { 43 | this.context = context; 44 | this.config = context.config; 45 | } 46 | 47 | /** 48 | * Get portainer status (version and instance id) from portainer server. 49 | * 50 | * @returns Promise with JSON object that contains "Version" and "InstanceID" elements. 51 | * exmaple: { 52 | * Version: '2.11.1', 53 | * InstanceID: '8d98af6e-8908-4d5c-80f0-11b8e6272219' 54 | * } 55 | */ 56 | status() { 57 | return new Promise((resolve, reject) => { 58 | const url = new URL(Portainer.URL.STATUS, this.config.portainer.baseUrl).toString(); 59 | 60 | // next, get Portainer status via status API (no access token required for this call) 61 | axios.get(url) 62 | 63 | // handle success (200) on status request 64 | .then((response) => { 65 | resolve(response.data); 66 | }) 67 | // handle error (!200) on status request 68 | .catch((err) => { 69 | reject(err); 70 | }); 71 | }); 72 | } 73 | 74 | /** 75 | * Download portainer data backup archive file from portainer server. 76 | * 77 | * @returns Promise with HTTP response stream data from backup file request. 78 | */ 79 | backup() { 80 | return new Promise((resolve, reject) => { 81 | const url = new URL(Portainer.URL.BACKUP, this.config.portainer.baseUrl).toString(); 82 | 83 | // include API TOKEN in request header; identify expected stream response 84 | const options = { 85 | headers: { 'X-API-Key': `${this.config.portainer.token}`}, 86 | responseType: 'stream', 87 | }; 88 | 89 | // create request payload; include optional backup protection password 90 | let payload = { 91 | password: (this.config.backup.password) ? this.config.backup.password : "" 92 | }; 93 | 94 | // execute API call to perform backup of portainer data 95 | // (this will generate a data stream which includes a TAR.GZ archive file) 96 | axios.post(url, payload, options) 97 | // handle success (200) on status request 98 | .then((response) => { 99 | return resolve(response); 100 | }) 101 | // handle error (!200) on status request 102 | .catch((err) => { 103 | return reject(err); 104 | }); 105 | }); 106 | } 107 | 108 | /** 109 | * Save stream data to backup file (async). 110 | * 111 | * @param {*} stream input stream of data to save to backup file 112 | * @param {*} file target file to save backup archive to. 113 | * @returns Promise with BACKUP FILE that was written to filesystem. 114 | */ 115 | saveBackup(stream, file) { 116 | return new Promise((resolve, reject) => { 117 | 118 | // write downlaoded backup file to file system 119 | const writer = fs.createWriteStream(file) 120 | 121 | // pipe file data from response stream to file writer 122 | stream.pipe(writer); 123 | 124 | // return promise on file completion or error 125 | writer.on('finish', ()=>{ resolve(file); }); 126 | writer.on('error', reject); 127 | }); 128 | } 129 | 130 | /** 131 | * Get stacks metadata/catalog from portainer server. 132 | * @returns Promise with stacks metadata/catalog obtained from portainer server. 133 | */ 134 | stacksMetadata() { 135 | return new Promise((resolve, reject) => { 136 | const url = new URL(Portainer.URL.STACKS, this.config.portainer.baseUrl).toString(); 137 | axios.get(url, {headers: { 'X-API-Key': `${this.config.portainer.token}`}}) 138 | // handle success (200) on status request 139 | .then((response) => { 140 | return resolve(response.data); 141 | }) 142 | // handle error (!200) on status request 143 | .catch((err) => { 144 | return reject(err); 145 | }); 146 | }); 147 | } 148 | 149 | /** 150 | * Get stack docker-compose file for the given stack ID from the portainer server. 151 | * 152 | * @param {*} stackId target stack to acquire 153 | * @returns docker-compose data for the requested stack ID. 154 | */ 155 | stackFile(stackId) { 156 | return new Promise((resolve, reject) => { 157 | const url = new URL(Portainer.URL.STACKS, this.config.portainer.baseUrl).toString(); 158 | axios.get(url + `/${stackId}/file`, {headers: { 'X-API-Key': `${this.config.portainer.token}`}}) 159 | // handle success (200) on status request 160 | .then((response) => { 161 | return resolve(response.data); 162 | }) 163 | // handle error (!200) on status request 164 | .catch((err) => { 165 | return reject(err); 166 | }); 167 | }); 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | /** 2 | * ------------------------------------------------------------------- 3 | * ___ ___ ___ ___ ___ 4 | * / __| /_\ \ / /_\ / __| __| 5 | * \__ \/ _ \ V / _ \ (_ | _| 6 | * |___/_/_\_\_/_/_\_\___|___| ___ ___ ___ 7 | * / __|/ _ \| __|_ _\ \ / /_\ | _ \ __| 8 | * \__ \ (_) | _| | | \ \/\/ / _ \| / _| 9 | * |___/\___/|_| |_| \_/\_/_/ \_\_|_\___| 10 | * 11 | * ------------------------------------------------------------------- 12 | * COPYRIGHT SAVAGESOFTWARE,LLC, @ 2022, ALL RIGHTS RESERVED 13 | * https://github.com/SavageSoftware/portainer-backup 14 | * ------------------------------------------------------------------- 15 | */ 16 | 17 | import { DateTime } from "luxon"; 18 | import sanitize from 'sanitize-filename'; 19 | 20 | /** 21 | * This class is used to expose commonly used utility methods. 22 | */ 23 | export default class Util { 24 | constructor() { /* nothing */ } 25 | 26 | /** 27 | * Used to determine if an unknown object value results in a TRUE|FALSE. 28 | * @param {*} arg any boolean, number or string 29 | * @returns true|false 30 | */ 31 | static evalBool(arg) { 32 | 33 | // object 34 | if(!arg) return false; 35 | if(arg === undefined) return false; 36 | if(arg === null) return false; 37 | 38 | // bool 39 | if(arg === false) return false; 40 | if(arg === true) return true; 41 | 42 | // int 43 | if(arg === 0) return false; 44 | if(arg === 1) return true; 45 | 46 | // string 47 | let argLower = arg.toString().toLowerCase(); 48 | if(argLower == "") return false; 49 | if(argLower == "false") return false; 50 | if(argLower == "no") return false; 51 | if(argLower == "0") return false; 52 | return true; 53 | } 54 | 55 | /** 56 | * Wrap a filename string so that it can fix 57 | * inside a fixed width container. 58 | * 59 | * @param {*} input file name (not path) 60 | * @param {*} width maximum character width to wrap within 61 | * @returns 62 | */ 63 | static wrapFilePath(input, width) { 64 | let leadingSlash = input.startsWith('/'); 65 | width = parseInt(width) || 80; 66 | let res = [] 67 | , cLine = "" 68 | , words = input.split("/") 69 | ; 70 | 71 | for (let i = 0; i < words.length; ++i) { 72 | let cWord = words[i]; 73 | if ((cLine + cWord).length <= width) { 74 | cLine += (cLine ? "/" : "") + cWord; 75 | } else { 76 | res.push(cLine); 77 | cLine = "/" + cWord; 78 | } 79 | } 80 | 81 | if (cLine) { 82 | res.push(cLine); 83 | } 84 | 85 | return (leadingSlash ? "/" : "") + res.join("\n"); 86 | }; 87 | 88 | /** 89 | * Process file or directory string for named or tokenized substitutions 90 | * Sanitize the name for any invalid filesystem characters. 91 | * @param {*} name 92 | */ 93 | static hasSubstitutions(name){ 94 | // find any substitution replacements in file|directory name string 95 | const substitutions = name.match(/\{\{.*?\}\}/g); 96 | if(substitutions) return true; 97 | return false; 98 | } 99 | 100 | /** 101 | * Process file or directory string for named or tokenized substitutions 102 | * Sanitize the name for any invalid filesystem characters. 103 | * @param {*} name 104 | */ 105 | static processSubstitutions(name){ 106 | 107 | // copy name to working results variable 108 | let results = name; 109 | 110 | // find any substitution replacements in file|directory name string 111 | const substitutions = results.match(/\{\{.*?\}\}/g); 112 | if(substitutions){ 113 | 114 | // get current date & time 115 | let now = DateTime.now(); 116 | 117 | // iterate over substitutions 118 | for (let substitution of substitutions) { 119 | 120 | // remove "{{" and "}}" from substitution string 121 | let token = substitution.slice(2, -2); 122 | 123 | // handle "UTC_" prefix 124 | if(token.startsWith("UTC_")){ 125 | now = now.toUTC(); 126 | token = token.slice(4); 127 | } 128 | 129 | // process the token string and replace value with either a known named format or tokenized replacement 130 | let value = substitution; 131 | switch(token){ 132 | // simple, commonly used formats 133 | case "DATETIME": { value = now.toFormat("yyyy-MM-dd'T'HHmmss"); break; } 134 | case "TIMESTAMP": { value = now.toFormat("yyyyMMdd'T'HHmmss.SSSZZZ"); break; } 135 | case "DATE": { value = now.toFormat("yyyy-MM-dd"); break; } 136 | case "TIME": { value = now.toFormat("HHmmss"); break; } 137 | 138 | // ISO standard formats 139 | case "ISO8601": { value = now.toISO(); break; } 140 | case "ISO": { value = now.toISO(); break; } 141 | case "ISO_BASIC": { value = now.toISO({format: 'basic'}); break; } 142 | case "ISO_NO_OFFSET": { value = now.toISO({includeOffset: false}); break; } 143 | case "ISO_DATE": { value = now.toISODate(); break; } 144 | case "ISO_WEEKDATE": { value = now.toISOWeekDate(); break; } 145 | case "ISO_TIME": { value = now.toISOTime(); break; } 146 | 147 | // other standards-based formats 148 | case "RFC2822": { value = now.toRFC2822(); break; } 149 | case "HTTP": { value = now.toHTTP(); break; } 150 | case "MILLIS": { value = now.toMillis(); break; } 151 | case "SECONDS": { value = now.toSeconds(); break; } 152 | case "UNIX": { value = now.toSeconds(); break; } 153 | case "EPOCH": { value = now.toSeconds(); break; } 154 | 155 | // locale based formats 156 | case "LOCALE": { value = now.toLocaleString(); break; } 157 | case "LOCALE_DATE": { value = now.dt.toLocaleString(DateTime.DATE_SHORT); break; } 158 | case "LOCALE_TIME": { value = now.dt.toLocaleString(DateTime.TIME_24_SIMPLE); break; } 159 | 160 | // named date preset formats 161 | case "DATE_SHORT": { value = now.toLocaleString(DateTime.DATE_SHORT); break; } 162 | case "DATE_MED": { value = now.toLocaleString(DateTime.DATE_MED); break; } 163 | case "DATE_FULL": { value = now.toLocaleString(DateTime.DATE_FULL); break; } 164 | case "DATE_HUGE": { value = now.toLocaleString(DateTime.DATE_HUGE); break; } 165 | case "DATE_MED_WITH_WEEKDAY": { value = now.toLocaleString(DateTime.DATE_MED_WITH_WEEKDAY); break; } 166 | 167 | // named time preset formats 168 | case "TIME_SIMPLE": { value = now.toLocaleString(DateTime.TIME_SIMPLE); break; } 169 | case "TIME_WITH_SECONDS": { value = now.toLocaleString(DateTime.TIME_WITH_SECONDS); break; } 170 | case "TIME_WITH_SHORT_OFFSET": { value = now.toLocaleString(DateTime.TIME_WITH_SHORT_OFFSET); break; } 171 | case "TIME_WITH_LONG_OFFSET": { value = now.toLocaleString(DateTime.TIME_WITH_LONG_OFFSET); break; } 172 | case "TIME_24_SIMPLE": { value = now.toLocaleString(DateTime.TIME_24_SIMPLE); break; } 173 | case "TIME_24_WITH_SECONDS": { value = now.toLocaleString(DateTime.TIME_24_WITH_SECONDS); break; } 174 | case "TIME_24_WITH_SHORT_OFFSET": { value = now.toLocaleString(DateTime.TIME_24_WITH_SHORT_OFFSET); break; } 175 | case "TIME_24_WITH_LONG_OFFSET": { value = now.toLocaleString(DateTime.TIME_24_WITH_LONG_OFFSET); break; } 176 | 177 | // nam,ed date/time preset formats 178 | case "DATETIME_SHORT": { value = now.toLocaleString(DateTime.DATETIME_SHORT); break; } 179 | case "DATETIME_MED": { value = now.toLocaleString(DateTime.DATETIME_MED); break; } 180 | case "DATETIME_FULL": { value = now.toLocaleString(DateTime.DATETIME_FULL); break; } 181 | case "DATETIME_HUGE": { value = now.toLocaleString(DateTime.DATETIME_HUGE); break; } 182 | case "DATETIME_SHORT_WITH_SECONDS": { value = now.toLocaleString(DateTime.DATETIME_SHORT_WITH_SECONDS); break; } 183 | case "DATETIME_MED_WITH_SECONDS": { value = now.toLocaleString(DateTime.DATETIME_MED_WITH_SECONDS); break; } 184 | case "DATETIME_FULL_WITH_SECONDS": { value = now.toLocaleString(DateTime.DATETIME_FULL_WITH_SECONDS); break; } 185 | case "DATETIME_HUGE_WITH_SECONDS": { value = now.toLocaleString(DateTime.DATETIME_HUGE_WITH_SECONDS); break; } 186 | 187 | // tokenized string 188 | default: { value = now.toFormat(token); break; } 189 | } 190 | results = results.replace(substitution, value); 191 | } 192 | 193 | //console.log("UNSANITIZED :: ", results); 194 | results = sanitize(results, { replacement: (offender)=>{ 195 | if(offender === '/') return "-"; 196 | if(offender === ':') return "_"; // "꞉"; // U+FF1A 197 | return ""; 198 | }}); 199 | //console.log("SANITIZED :: ", results); 200 | } 201 | 202 | // return resulting substituted string 203 | return results; 204 | } 205 | } 206 | 207 | 208 | 209 | -------------------------------------------------------------------------------- /src/render.js: -------------------------------------------------------------------------------- 1 | /** 2 | * ------------------------------------------------------------------- 3 | * ___ ___ ___ ___ ___ 4 | * / __| /_\ \ / /_\ / __| __| 5 | * \__ \/ _ \ V / _ \ (_ | _| 6 | * |___/_/_\_\_/_/_\_\___|___| ___ ___ ___ 7 | * / __|/ _ \| __|_ _\ \ / /_\ | _ \ __| 8 | * \__ \ (_) | _| | | \ \/\/ / _ \| / _| 9 | * |___/\___/|_| |_| \_/\_/_/ \_\_|_\___| 10 | * 11 | * ------------------------------------------------------------------- 12 | * COPYRIGHT SAVAGESOFTWARE,LLC, @ 2022, ALL RIGHTS RESERVED 13 | * https://github.com/SavageSoftware/portainer-backup 14 | * ------------------------------------------------------------------- 15 | */ 16 | 17 | // import libraries 18 | import Table from 'cli-table3'; 19 | import figlet from 'figlet'; 20 | import colors from 'colors'; 21 | import prettyBytes from 'pretty-bytes'; 22 | import gradient from 'gradient-string'; 23 | import symbols from 'log-symbols'; 24 | import figures from 'figures'; 25 | import Util from './util.js' 26 | import fs from 'node:fs'; 27 | import {Portainer} from './portainer.js'; 28 | 29 | /** 30 | * This class is used to display/render much of the 31 | * command line output used in this application. 32 | */ 33 | export class Render { 34 | 35 | /** 36 | * Default Constructor 37 | * @param {*} context the application context 38 | */ 39 | constructor(context) { 40 | this.context = context; 41 | } 42 | 43 | /** 44 | * Display program title & header 45 | */ 46 | title() { 47 | // display program title (using ASCII art) 48 | this.writeln(); 49 | this.writeln(); 50 | this.writeln(gradient.cristal(figlet.textSync('Portainer Backup', { 51 | font: 'Small', 52 | horizontalLayout: 'default', 53 | verticalLayout: 'default', 54 | width: 80, 55 | whitespaceBreak: true 56 | }))); 57 | 58 | // display header message 59 | var table = new Table({ 60 | head: [{hAlign:'center', content: `Made with ${colors.bold.red(figures.heart)} by SavageSoftware, LLC © 2022 (Version ${this.context.version})`}], 61 | style:{head:['dim'],border:['dim']}, 62 | wordWrap:false, 63 | colWidths:[66] 64 | }); 65 | this.writeln(table.toString()); 66 | this.writeln(); 67 | } 68 | 69 | /** 70 | * Display a table with current runtime configuration settings. 71 | * Different settings/option may be displayed based on the current 72 | * operation (i.e. 'backup', 'restore' , 'schedule', etc.) 73 | */ 74 | configuration(){ 75 | 76 | // create and format a table to contaion configuration settings 77 | var table = new Table({ 78 | //head: [{hAlign:'center', content: 'Configuration Setting'}, {hAlign:'center', content: 'Value'}], 79 | style:{head:['white'],border:[]}, 80 | wordWrap:true, 81 | colWidths:[null,null] 82 | }); 83 | 84 | // include standard configuration elements 85 | table.push( 86 | [{colSpan:2,hAlign:'center',content: `${symbols.info} -- CONFIGURATION PROPERTIES -- ${symbols.info}`}], 87 | ['PORTAINER_BACKUP_URL', this.context.config.portainer.baseUrl] 88 | ); 89 | 90 | if(this.context.operation != "info"){ 91 | table.push(['PORTAINER_BACKUP_TOKEN', this.context.config.portainer.token ? "*********************" : colors.red("!! MISSING !!")],); 92 | table.push(['PORTAINER_BACKUP_DIRECTORY', Util.wrapFilePath(this.context.config.backup.directory, 45)]); 93 | 94 | if(this.context.operation == "backup") 95 | table.push(['PORTAINER_BACKUP_FILENAME', this.context.config.backup.filename]); 96 | 97 | table.push(['PORTAINER_BACKUP_PASSWORD', this.context.config.backup.password ? "********" : colors.gray("(NONE)")]); 98 | 99 | if(this.context.operation == "backup"){ 100 | if(this.context.config.backup.stacks) 101 | table.push(['PORTAINER_BACKUP_STACKS', colors.green("ENABLED; stack files will be saved")]); 102 | } 103 | 104 | if(this.context.config.backup.overwrite) 105 | table.push(['PORTAINER_BACKUP_OVERWRITE', colors.green("ENABLED; files will be overwritten as needed.")]); 106 | 107 | if(this.context.operation == "schedule") 108 | table.push(['PORTAINER_BACKUP_SCHEDULE', this.context.config.backup.schedule]); 109 | } 110 | 111 | // include optional configuration elements 112 | if(this.context.config.debug) table.push(['PORTAINER_BACKUP_DEBUG', colors.green("ENABLED")]); 113 | if(this.context.config.dryRun) table.push(['PORTAINER_BACKUP_DRYRUN', colors.yellow("ENABLED; no files will be written.")]); 114 | 115 | // print the configuration table 116 | this.writeln(`The table below ${figures.triangleDown} lists the current runtime configuration settings:`) 117 | this.writeln(); 118 | this.writeln(table.toString()); 119 | this.writeln(); 120 | } 121 | 122 | /** 123 | * Display a table with portainer server status information. 124 | */ 125 | status(data){ 126 | var table = new Table({ style:{head:[],border:[]} }); 127 | table.push([{colSpan:2,hAlign:'center',content:`${symbols.info} -- PORTAINER SERVER -- ${symbols.info}`}]); 128 | table.push(['Portainer Version', data.Version],['Instance ID', data.InstanceID] ); 129 | this.writeln(table.toString()); 130 | this.writeln(); 131 | } 132 | 133 | /** 134 | * Display a backup completed successfully message. 135 | */ 136 | success(){ 137 | var table = new Table({ 138 | head: [symbols.success + ' PORTAINER BACKUP COMPLETED SUCCESSFULLY!'], 139 | style:{head:['brightGreen'],border:['brightGreen'], paddingLeft: 2, paddingRight: 2} 140 | }); 141 | this.writeln(table.toString()); 142 | this.writeln(); 143 | } 144 | 145 | /** 146 | * Display a termination signal message. 147 | * @param {*} signal the signal detected causing the application to terminate. 148 | */ 149 | terminate(signal){ 150 | var table = new Table({ 151 | head: [`${symbols.warning} TERMINATE SIGNAL DETECTED: ${colors.bgBlue.white.bold(" - " + signal + " - ")}`], 152 | style:{head:['brightYellow'],border:['brightYellow'], paddingLeft: 2, paddingRight: 2} 153 | }); 154 | this.writeln(); 155 | this.writeln(table.toString()); 156 | this.writeln(); 157 | } 158 | 159 | /** 160 | * Display an unsupported version message. 161 | * @param {*} version current version of connected portainer server. 162 | */ 163 | unsupportedVersion(version){ 164 | var table = new Table({ 165 | head: [{colSpan: 2, content: `${symbols.warning} -- UNSUPPORTED PORTAINER VERSION -- ${symbols.warning}`}], 166 | style:{head:['brightYellow'],border:['brightYellow'], paddingLeft: 2, paddingRight: 2} 167 | }); 168 | table.push([colors.yellow("PORTAINER VERSION"), colors.yellow(version)]) 169 | table.push([colors.yellow("MINIMUM SUPPORTED VERSION"), colors.yellow(Portainer.MIN_VERSION)]) 170 | this.writeln(); 171 | this.writeln(table.toString()); 172 | this.writeln(); 173 | } 174 | 175 | /** 176 | * Display detials about the current backup file. 177 | * @param {*} file current backup file 178 | */ 179 | backupFile(file){ 180 | // display configuration settings 181 | var table = new Table({ 182 | style:{head:[],border:[]}, wordWrap:true 183 | }); 184 | 185 | // get file stats 186 | let stats = fs.statSync(file); 187 | 188 | // add table elements from response data and backup file 189 | table.push( 190 | [{colSpan:2,hAlign:'center',content:`${symbols.info} -- BACKUP FILE -- ${symbols.info}`}], 191 | ['PATH', Util.wrapFilePath(`${file}`, 55)], 192 | ['SIZE', prettyBytes(stats.size)], 193 | ['CREATED', stats.ctime.toString()], 194 | ['PROTECTED', this.context.config.backup.password ? "PROTECTED (with password)" : "NO PASSWORD"] 195 | ); 196 | 197 | // print table to output stream 198 | this.writeln(table.toString()); 199 | this.writeln(); 200 | } 201 | 202 | /** 203 | * Display the number of stacks found in the stacks metadata/catalog. 204 | * @param {*} stacks an array of stack objects from the stacks metadata/catalog 205 | */ 206 | stacksCount(stacks){ 207 | // create table for stack count 208 | var table = new Table({ 209 | head: [symbols.info + ` Acquired [${stacks.length}] stacks from portainer server!`], 210 | style:{head:[],border:['blue'], paddingLeft: 2, paddingRight: 2} 211 | }); 212 | 213 | // print stacks count table 214 | this.writeln(table.toString()); 215 | this.writeln(); 216 | } 217 | 218 | /** 219 | * Display a table listing all the stack files. 220 | * @param {*} stacks an array of stack objects (including 'stack.file' references) 221 | */ 222 | stackFiles(stacks){ 223 | // create table for stack files listing 224 | let table = new Table({ style:{head:[],border:[]}, wordWrap:true }); 225 | table.push([{colSpan:3,hAlign:'center',content:`${symbols.info} -- STACK FILES -- ${symbols.info}`}], 226 | [ {hAlign:'center', content: "ID"}, {hAlign:'left', content: 'Name'}, {hAlign:'left', content: 'File'}]); 227 | 228 | // iterate stacks; append this stack info to the stack results table 229 | for(let index in stacks) 230 | { 231 | let stack = stacks[index]; // reference stack instance 232 | table.push([stack.Id, stack.Name, Util.wrapFilePath(stack.file, 55)]); 233 | } 234 | 235 | // print stack files table 236 | this.writeln(table.toString()); 237 | this.writeln(); 238 | } 239 | 240 | /** 241 | * Display a backup summary 242 | * @param {*} context 243 | */ 244 | summary(context){ 245 | // create a summary table 246 | var table = new Table({ style:{head:[],border:[]}, wordWrap:true }); 247 | table.push( 248 | [{colSpan:2,hAlign:'center',content:`${symbols.info} -- BACKUP SUMMARY -- ${symbols.info}`}], 249 | ['RESULT', (context.results.success)? colors.bold.green(`${symbols.success} SUCCCESS`) : colors.error(`${symbols.error} FAILED`) ] 250 | ); 251 | 252 | // add backup filename to summary table (if backup file exists) 253 | if(context.operation === "backup"){ 254 | table.push(['BACKUP FILE', context.results.backup.filename]); 255 | } 256 | 257 | // add stacks count to summary table (if any exist) 258 | if(context.cache.stacks && context.cache.stacks.length > 0){ 259 | table.push(['STACKS BACKED UP', `${context.cache.stacks.length} STACK FILES`]); 260 | } 261 | 262 | // add elapsed time to summary table 263 | table.push(['ELAPSED TIME', `${context.results.elapsed/1000} seconds`]); 264 | 265 | // print summary table to output stream 266 | this.writeln(table.toString()); 267 | this.writeln(); 268 | } 269 | 270 | /** 271 | * Display a "dry-run" warning message. 272 | */ 273 | dryRun(){ 274 | // create and display dry-run warning message table 275 | var table = new Table({ 276 | head: [symbols.warning + ' This was a DRY-RUN; no backup files were saved.'], 277 | style:{head:['yellow'],border:['yellow'], paddingLeft: 2, paddingRight: 2} 278 | }); 279 | this.writeln(table.toString()); 280 | this.writeln(); 281 | } 282 | 283 | /** 284 | * Display a goodbye message used with application is terminating. 285 | */ 286 | goodbye(){ 287 | this.writeln("-------- GOODBYE --------"); 288 | this.writeln(); 289 | } 290 | 291 | /** 292 | * Write/print the data parameter to output stream. 293 | * 294 | * @param data optional string or object to print 295 | */ 296 | write(data){ 297 | if(data) this.context.output.stream.write(data.toString()); 298 | } 299 | 300 | /** 301 | * Write/print the data parameter followed by a NEWLINE to output stream. 302 | * 303 | * @param data optional string or object to print 304 | */ 305 | writeln(data){ 306 | if(data) this.context.output.stream.write(data.toString()); 307 | this.context.output.stream.write("\n"); 308 | } 309 | 310 | /** 311 | * Display error message and any additional error details. 312 | * 313 | * @param {*} err Error error object 314 | * @param {*} message String error message 315 | * @param {*} note String optional note 316 | */ 317 | error(err, message, note){ 318 | 319 | // display error message (if exists) 320 | if(message){ 321 | var failTable = new Table({ 322 | head: [symbols.error + ' ' + message], 323 | style:{head:[],border:['red'], paddingLeft: 2, paddingRight: 2}, 324 | }); 325 | this.writeln(); 326 | this.writeln(failTable.toString()); 327 | this.writeln(); 328 | } 329 | 330 | // create error results table; err error object instance mesage 331 | var table = new Table({ 332 | //head: [{hAlign:'center', content: "Results"}, {hAlign:'center', content: 'Value'}], 333 | style:{head:['white'],border:['red']}, wordWrap:true, colWidths:[15,50] 334 | }); 335 | table.push([{colSpan:2,hAlign:'center', content: colors.bgRed.white(`${symbols.error} -------------------------- ERROR -------------------------- ${symbols.error}`)}]), 336 | table.push(['ERROR MESSAGE', err.message]); 337 | 338 | // display additional error context details 339 | if(err.response){ 340 | // ALL 341 | table.push(['API RESPONSE STATUS', colors.red(err.response.status + ` (${err.response.statusText})`)]); 342 | 343 | // 401 344 | if(err.response.status == 401) 345 | table.push(['NOTES', "Check your 'PORTAINER_BACKUP_TOKEN' to make sure it is valid and is assigned to a user with 'ADMIN' privileges."]); 346 | } 347 | 348 | // ERROR=ECONNRESET 349 | if(err.errno == -54) 350 | table.push(['NOTES', "Check your 'PORTAINER_BACKUP_URL'; unable to access portainer server via this URL."]); 351 | 352 | // add any optional notes 353 | if(note) 354 | table.push(['NOTES', note]); 355 | 356 | 357 | // display error results 358 | this.writeln(table.toString()); 359 | this.writeln(); 360 | } 361 | } 362 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | [![Docker](https://img.shields.io/docker/v/savagesoftware/portainer-backup/latest?color=darkgreen&logo=docker&label=DockerHub%20Latest%20Image)](https://hub.docker.com/repository/docker/savagesoftware/portainer-backup/) 2 | [![NPM](https://img.shields.io/npm/v/portainer-backup?color=darkgreen&logo=npm&label=NPM%20Registry)](https://www.npmjs.com/package/portainer-backup) 3 | [![GitHub package.json version](https://img.shields.io/github/package-json/v/savagesoftware/portainer-backup?color=darkgreen&label=GitHub%20Source&logo=github)](https://github.com/SavageSoftware/portainer-backup) 4 | [![node-current](https://img.shields.io/node/v/portainer-backup)](https://www.npmjs.com/package/portainer-backup) 5 | [![portainer](https://img.shields.io/badge/Portainer->=v2.11.0-darkgreen)](https://www.portainer.io/) 6 | 7 | # Portainer Backup 8 | 9 | (Developed with ♥ by SavageSoftware, LLC.) 10 | 11 | --- 12 | 13 | ## Overview 14 | 15 | A utility for scripting or scheduling Portainer backups. This utility can backup the entire Portainer database, optionally protect the archive file with a password and can additionally backup the `docker-compose` files for stacks created in the Portainer web interface. 16 | 17 | ![SCREENSHOT](https://github.com/SavageSoftware/portainer-backup/raw/master/assets/screenshot.jpg) 18 | 19 | | Resources | URL | 20 | | --- | --- | 21 | | DockerHub Image | https://hub.docker.com/repository/docker/savagesoftware/portainer-backup/ | 22 | | NPM Package Registry | https://www.npmjs.com/package/portainer-backup | 23 | 24 | --- 25 | 26 | ## Table of Contents 27 | 28 | * [Overview](#overview) 29 | * [TL;DR](#tldr) 30 | * [Prerequisites](#prerequisites) 31 | * [Installation](#installation) 32 | * [Supported Commands & Operations](#supported-commands--operations) 33 | * [Backup](#backup) 34 | * [Test](#test) 35 | * [Schedule](#schedule) 36 | * [Info](#info) 37 | * [Stacks](#stacks) 38 | * [Restore](#restore) 39 | * [Return Value](#return-value) 40 | * [Command Line Options & Environment Variables](#command-line-options--environment-variables) 41 | * [Schedule Expression](#schedule-expression) 42 | * [Filename & Directory Date/Time Substituions](#filename--directory-datetime-substituions) 43 | * [Supported Presets](#supported-presets) 44 | * [Supported Tokens](#supported-tokens) 45 | * [Command Line Help](#command-line-help) 46 | * [Docker Compose](#docker-compose) 47 | 48 | --- 49 | 50 | ## TL;DR 51 | 52 | **NodeJS & NPM** 53 | 54 | Command to install **portainer-backup** using node's **NPM** command: 55 | 56 | ```shell 57 | npm install --global portainer-backup 58 | ``` 59 | 60 | Command to launch **portainer-backup** after installing with NPM to perform a **backup** of your portainer server: 61 | 62 | ```shell 63 | portainer-backup \ 64 | backup \ 65 | --url "http://portainer:9000" \ 66 | --token "PORTAINER_ACCESS_TOKEN" \ 67 | --directory $PWD/backup 68 | ``` 69 | 70 | **NPX** 71 | 72 | Command to install & launch **portainer-backup** using node's [NPX](https://nodejs.dev/learn/the-npx-nodejs-package-runner) command to perform a **backup** of your portainer server: 73 | 74 | ```shell 75 | npx portainer-backup \ 76 | backup \ 77 | --url "http://portainer:9000" \ 78 | --token "PORTAINER_ACCESS_TOKEN" \ 79 | --directory $PWD/backup 80 | ``` 81 | 82 | **DOCKER** 83 | 84 | Command to launch **portainer-backup** using a Docker container to perform a **backup** of your portainer server: 85 | 86 | ```shell 87 | docker run -it --rm \ 88 | --name portainer-backup \ 89 | --volume $PWD/backup:/backup \ 90 | --env PORTAINER_BACKUP_URL="http://portainer:9000" \ 91 | --env PORTAINER_BACKUP_TOKEN="YOUR_ACCESS_TOKEN" \ 92 | savagesoftware/portainer-backup:latest \ 93 | backup 94 | ``` 95 | 96 | Supported Docker platforms: 97 | * `linux/amd64` (Intel/AMD x64) 98 | * `linux/arm64` (ARMv8) 99 | * `linux/arm` (ARMv7) 100 | 101 | --- 102 | 103 | ## Prerequisites 104 | 105 | **Portainer-backup** requires the following prerequisites: 106 | 107 | | Prerequisite | Version | Link | 108 | | ------------ | ------- | -------- | 109 | | NodeJS | v16 (LTS) | https://nodejs.org | 110 | | Portainer | v2.11.0 (and newer) | https://www.portainer.io | 111 | | Portainer Access Token | N/A | https://docs.portainer.io/v/ce-2.11/api/access | 112 | 113 | [![node-current](https://img.shields.io/node/v/portainer-backup)](https://www.npmjs.com/package/portainer-backup) 114 | [![portainer](https://img.shields.io/badge/Portainer-v2.11.0-darkgreen)](https://www.portainer.io/) 115 | 116 | This utility has only been tested on Portainer **v2.11.0** and later. 117 | 118 | > **NOTE:** If attempting to use with an older version of Portainer this utility will exit with an error message. While it is untested, you can use the `--ignore-version` option to bypass the version validation/enforcement. 119 | 120 | You will need to obtain a [Portiner Access Token](https://docs.portainer.io/v/ce-2.11/api/access) from your Portainer server from an adminstrative user account. 121 | 122 | --- 123 | 124 | ## Installation 125 | 126 | Command to install **portainer-backup** using node's **NPM** command: 127 | 128 | ```shell 129 | npm install --global portainer-backup 130 | ``` 131 | 132 | [![NPM](https://nodei.co/npm/portainer-backup.png?downloads=true&downloadRank=false&stars=false)](https://www.npmjs.com/package/portainer-backup) 133 | 134 | --- 135 | 136 | ## Supported Commands & Operations 137 | 138 | This utility requires a single command to execute one of the built in operations. 139 | 140 | | Command | Description | 141 | | ---------- | ----------- | 142 | | [`backup`](#backup) | Backup portainer data archive | 143 | | [`schedule`](#schedule) | Run scheduled portainer backups | 144 | | [`stacks`](#stacks) | Backup portainer stacks | 145 | | [`test`](#test) | Test backup (no files are saved) | 146 | | [`info`](#info) | Get portainer server info | 147 | | [`restore`](#restore) | Restore portainer data | 148 | 149 | > **NOTE:** The `restore` command is not currently implemented due to issues with the Portainer API. 150 | 151 | ### Backup 152 | 153 | The **backup** operation will perform a single backup of the Portainer data from the specified server. This backup file will be TAR.GZ archive and can optionally be protected with a password (`--password`). The process will terminate immedately after the **backup** operation is complete. 154 | 155 | The following command will perform a **backup** of the Portainer data. 156 | ```shell 157 | portainer-backup \ 158 | backup \ 159 | --url "http://portainer:9000" \ 160 | --token "PORTAINER_ACCESS_TOKEN" \ 161 | --directory $PWD/backup \ 162 | --overwrite 163 | ``` 164 | 165 | The following docker command will perform a **backup** of the Portainer data. 166 | ```shell 167 | docker run -it --rm \ 168 | --name portainer-backup \ 169 | --volume $PWD/backup:/backup \ 170 | --env TZ="America/New_York" \ 171 | --env PORTAINER_BACKUP_URL="http://portainer:9000" \ 172 | --env PORTAINER_BACKUP_TOKEN="PORTAINER_ACCESS_TOKEN" \ 173 | --env PORTAINER_BACKUP_OVERWRITE=true \ 174 | --env PORTAINER_BACKUP_DIRECTORY=/backup \ 175 | savagesoftware/portainer-backup:latest \ 176 | backup 177 | ``` 178 | 179 | ### Test 180 | 181 | The **test** operation will perform a single backup of the Portainer data from the specified server. With the **test** operation, no data will be saved on the filesystem. The **test** operation is the same as using the `--dryrun` option. The process will terminate immedately after the **test** operation is complete. 182 | 183 | The following command will perform a **test** of the Portainer data. 184 | ```shell 185 | portainer-backup \ 186 | test \ 187 | --url "http://portainer:9000" \ 188 | --token "PORTAINER_ACCESS_TOKEN" \ 189 | --directory $PWD/backup 190 | ``` 191 | 192 | The following docker command will perform a **test** of the Portainer data. 193 | ```shell 194 | docker run -it --rm \ 195 | --name portainer-backup \ 196 | --volume $PWD/backup:/backup \ 197 | --env TZ="America/New_York" \ 198 | --env PORTAINER_BACKUP_URL="http://portainer:9000" \ 199 | --env PORTAINER_BACKUP_TOKEN="PORTAINER_ACCESS_TOKEN" \ 200 | --env PORTAINER_BACKUP_DIRECTORY=/backup \ 201 | savagesoftware/portainer-backup:latest \ 202 | test 203 | ``` 204 | 205 | 206 | ### Schedule 207 | 208 | The **schedule** operation will perform continious scheduled backups of the Portainer data from the specified server. The `--schedule` option or `PORTAINER_BACKUP_SCHEDULE` environment variable takes a cron-like string expression to define the backup schedule. The process will run continiously unless a validation step fails immediately after startup. 209 | 210 | The following command will perform a **test** of the Portainer data. 211 | ```shell 212 | portainer-backup \ 213 | schedule \ 214 | --url "http://portainer:9000" \ 215 | --token "PORTAINER_ACCESS_TOKEN" \ 216 | --directory $PWD/backup \ 217 | --overwrite \ 218 | --schedule "0 0 0 * * *" 219 | ``` 220 | 221 | The following docker command will perform a **schedule** of the Portainer data. 222 | ```shell 223 | docker run -it --rm \ 224 | --name portainer-backup \ 225 | --volume $PWD/backup:/backup \ 226 | --env TZ="America/New_York" \ 227 | --env PORTAINER_BACKUP_URL="http://portainer:9000" \ 228 | --env PORTAINER_BACKUP_TOKEN="PORTAINER_ACCESS_TOKEN" \ 229 | --env PORTAINER_BACKUP_OVERWRITE=true \ 230 | --env PORTAINER_BACKUP_DIRECTORY=/backup \ 231 | --env PORTAINER_BACKUP_SCHEDULE="0 0 0 * * *" \ 232 | savagesoftware/portainer-backup:latest \ 233 | schedule 234 | ``` 235 | 236 | ### Info 237 | 238 | The **info** operation will perform an information request to the specified Portainer server. The process will terminate immedately after the **info** operation is complete. 239 | 240 | The following command will perform a **info** from the Portainer server. 241 | ```shell 242 | portainer-backup info --url "http://portainer:9000" 243 | ``` 244 | 245 | The following docker command will perform a **info** request from the Portainer data. 246 | ```shell 247 | docker run -it --rm \ 248 | --name portainer-backup \ 249 | --env PORTAINER_BACKUP_URL="http://portainer:9000" \ 250 | savagesoftware/portainer-backup:latest \ 251 | info 252 | ``` 253 | 254 | ### Stacks 255 | 256 | The **stacks** operation will perform a single backup of the Portainer stacks `docker-compose` data from the specified server. This operation does not backup the Portainer database/data files, only the stacks. Alternatively you can include stacks backups in the **backup** operation using the `--stacks` option. The process will terminate immedately after the **stacks** operation is complete. 257 | 258 | The following command will perform a **stacks** of the Portainer data. 259 | ```shell 260 | portainer-backup \ 261 | backup \ 262 | --url "http://portainer:9000" \ 263 | --token "PORTAINER_ACCESS_TOKEN" \ 264 | --directory $PWD/backup \ 265 | --stacks 266 | ``` 267 | 268 | The following docker command will perform a **stacks** of the Portainer data. 269 | ```shell 270 | docker run -it --rm \ 271 | --name portainer-backup \ 272 | --volume $PWD/backup:/backup \ 273 | --env TZ="America/New_York" \ 274 | --env PORTAINER_BACKUP_URL="http://portainer:9000" \ 275 | --env PORTAINER_BACKUP_TOKEN="PORTAINER_ACCESS_TOKEN" \ 276 | --env PORTAINER_BACKUP_OVERWRITE=true \ 277 | --env PORTAINER_BACKUP_DIRECTORY=/backup \ 278 | savagesoftware/portainer-backup:latest \ 279 | stacks 280 | ``` 281 | 282 | ### Restore 283 | 284 | The **restore** operation is not implemented at this time. We encountered trouble getting the Portainer **restore** API (https://app.swaggerhub.com/apis/portainer/portainer-ce/2.11.1#/backup/Restore) to work properly and are investigating this issue further. 285 | 286 | --- 287 | 288 | ## Return Value 289 | 290 | **Portainer-backup** will return a numeric value after the process exits. 291 | 292 | | Value | Description | 293 | | ----- | ----------- | 294 | | 0 | Utility executed command successfully | 295 | | 1 | Utility encountered an error and failed | 296 | 297 | --- 298 | 299 | ## Command Line Options & Environment Variables 300 | 301 | **Portainer-backup** supports both command line arguments and environment variables for all configuration options. 302 | 303 | | Option | Environment Variable | Type | Description | 304 | | ----------- | -------------------- | ---- | ----------- | 305 | | `-t`, `--token` | `PORTAINER_BACKUP_TOKEN` | string | Portainer access token | 306 | | `-u`, `--url` | `PORTAINER_BACKUP_URL` | string | Portainer base url | 307 | | `-Z`, `--ignore-version` | `PORTAINER_BACKUP_IGNORE_VERSION` | true\|false | Bypass portainer version check/enforcement | 308 | | `-d`, `--directory`, `--dir` | `PORTAINER_BACKUP_DIRECTORY` | string | Backup directory/path | 309 | | `-f`, `--filename` | `PORTAINER_BACKUP_FILENAME` | string | Backup filename | 310 | | `-p`, `--password`, `--pw` | `PORTAINER_BACKUP_PASSWORD` | string | Backup archive password | 311 | | `-M`, `--mkdir`, `--make-directory` | `PORTAINER_BACKUP_MKDIR` | true\|false | Create backup directory path | 312 | | `-o`, `--overwrite` | `PORTAINER_BACKUP_OVERWRITE` | true\|false | Overwrite existing files | 313 | | `-s`, `--schedule`, `--sch` | `PORTAINER_BACKUP_SCHEDULE` | string | Cron expression for scheduled backups | 314 | | `-i`, `--include-stacks`, `--stacks`| `PORTAINER_BACKUP_STACKS` | true\|false | Include stack files in backup | 315 | | `-q`, `--quiet` | `PORTAINER_BACKUP_QUIET` | true\|false | Do not display any console output | 316 | | `-D`, `--dryrun` | `PORTAINER_BACKUP_DRYRUN` | true\|false | Execute command task without persisting any data | 317 | | `-X`, `--debug` | `PORTAINER_BACKUP_DEBUG` | true\|false | Print stack trace for any errors encountered| 318 | | `-J`, `--json` | `PORTAINER_BACKUP_JSON` | true\|false | Print formatted/strucutred JSON data | 319 | | `-c`, `--concise` | `PORTAINER_BACKUP_CONCISE` | true\|false | Print concise/limited output | 320 | | `-v`, `--version` | _(N/A)_ | | Show utility version number | 321 | | `-h`, `--help` | _(N/A)_ | | Show help | 322 | 323 | > **NOTE:** If both an environment variable and a command line option are configured for the same option, the command line option will take priority. 324 | 325 | --- 326 | 327 | ## Schedule Expression 328 | 329 | **Portainer-backup** accepts a cron-like expression via the `--schedule` option or `PORTAINER_BACKUP_SCHEDULE` environment variable 330 | 331 | > **NOTE:** Additional details on the supported cron syntax can be found here: https://github.com/node-cron/node-cron/blob/master/README.md#cron-syntax 332 | 333 | 334 | ``` 335 | Syntax Format: 336 | 337 | ┌──────────────────────── second (optional) 338 | │ ┌──────────────────── minute 339 | │ │ ┌──────────────── hour 340 | │ │ │ ┌──────────── day of month 341 | │ │ │ │ ┌──────── month 342 | │ │ │ │ │ ┌──── day of week 343 | │ │ │ │ │ │ 344 | │ │ │ │ │ │ 345 | * * * * * * 346 | 347 | Examples: 348 | 349 | 0 0 0 * * * Daily at 12:00am 350 | 0 0 5 1 * * 1st day of month @ 5:00am 351 | 0 */15 0 * * * Every 15 minutes 352 | ``` 353 | 354 | ### Allowed field values 355 | 356 | | field | value | 357 | |--------------|---------------------| 358 | | second | 0-59 | 359 | | minute | 0-59 | 360 | | hour | 0-23 | 361 | | day of month | 1-31 | 362 | | month | 1-12 (or names) | 363 | | day of week | 0-7 (or names, 0 or 7 are sunday) | 364 | 365 | #### Using multiples values 366 | 367 | | Expression | Description | 368 | | ---------- | ----------- | 369 | | `0 0 4,8,12 * * *` | Runs at 4p, 8p and 12p | 370 | 371 | #### Using ranges 372 | 373 | | Expression | Description | 374 | | ---------- | ----------- | 375 | | `0 0 1-5 * * *` | Runs hourly from 1 to 5 | 376 | 377 | #### Using step values 378 | 379 | Step values can be used in conjunction with ranges, following a range with '/' and a number. e.g: `1-10/2` that is the same as `2,4,6,8,10`. Steps are also permitted after an asterisk, so if you want to say “every two minutes”, just use `*/2`. 380 | 381 | | Expression | Description | 382 | | ---------- | ----------- | 383 | | `0 0 */2 * * *` | Runs every 2 hours | 384 | 385 | #### Using names 386 | 387 | For month and week day you also may use names or short names. e.g: 388 | 389 | | Expression | Description | 390 | | ---------- | ----------- | 391 | | `* * * * January,September Sunday` | Runs on Sundays of January and September | 392 | | `* * * * Jan,Sep Sun` | Runs on Sundays of January and September | 393 | 394 | --- 395 | 396 | ## Filename & Directory Date/Time Substituions 397 | 398 | **Portainer-backup** supports a substituion syntax for dynamically assigning date and time elements to the **directory** and **filename** options. 399 | 400 | | Command Line Option | Environment Variable | 401 | | ------------------- | -------------------- | 402 | | `-d`, `--directory`, `--dir` | `PORTAINER_BACKUP_DIRECTORY` | 403 | | `-f`, `--filename` | `PORTAINER_BACKUP_FILENAME` | 404 | 405 | 406 | All substitution presets and/or tokens are included in between double curly braces: `{{ PRESET|TOKEN }}` 407 | 408 | Example: 409 | ``` 410 | --filename "portainer-backup-{{DATE}}.tar.gz" 411 | ``` 412 | 413 | **Portainer-backup** uses the [Luxon](https://moment.github.io) library for parting date and time syntax. Please see https://moment.github.io/luxon/#/formatting for more information. 414 | 415 | All date and times are rendered in the local date/time of the system running the **portainer-backup** utility. Alternatively you can incude the `UTC_` prefix in front of any of the tokens below to use UTC time instead. 416 | 417 | Filenames are also processed through a `sanitize` funtion whick will strip characters that are not supported in filename. The `:` character is replaced with `_` and the `/` character is replaced with `-`. 418 | 419 | ### Supported Presets 420 | 421 | The folllowing substition **presets** are defined by and supported in **portainer-backup**: 422 | 423 | | Token | Format | Example (US) | 424 | | ----- | ------ | ------------ | 425 | | `DATETIME` | `yyyy-MM-dd'T'HHmmss` | 2022-03-05T231356 | 426 | | `TIMESTAMP` | `yyyyMMdd'T'HHmmss.SSSZZZ` | 20220305T184827.445-0500 | 427 | | `DATE` | `yyyy-MM-dd` | 2022-03-05 | 428 | | `TIME` | `HHmmss` | 231356 | 429 | | `ISO8601` | `yyyy-MM-dd'T'hh_mm_ss.SSSZZ` | 2017-04-20T11_32_00.000-04_00 | 430 | | `ISO` | `yyyy-MM-dd'T'hh_mm_ss.SSSZZ` | 2017-04-20T11_32_00.000-04_00 | 431 | | `ISO_BASIC` | `yyyyMMdd'T'hhmmss.SSSZZZ` | 20220305T191048.871-05_00 | 432 | | `ISO_NO_OFFSET` | `yyyy-MM-dd'T'hh_mm_ss.SSS` | 2022-03-05T19_12_43.296 | 433 | | `ISO_DATE` | `yyyy-MM-dd` | 2017-04-20 | 434 | | `ISO_WEEKDATE` | `yyyy-'W'kk-c` | 2017-W17-7 | 435 | | `ISO_TIME` | `hh_mm_ss.SSSZZZ` | 11_32_00.000-04_00 | 436 | | `RFC2822` | `ccc, dd LLL yyyy HH_mm_ss ZZZ` | Thu, 20 Apr 2017 11_32_00 -0400 | 437 | | `HTTP` | `ccc, dd LLL yyyy HH_mm_ss ZZZZ` | Thu, 20 Apr 2017 03_32_00 GMT | 438 | | `MILLIS` | `x` | 1492702320000 | 439 | | `SECONDS` | `X` | 1492702320.000 | 440 | | `UNIX` | `X` | 1492702320.000 | 441 | | `EPOCH` | `X` | 1492702320.000 | 442 | 443 | The folllowing substition **presets** are provided my the [Luxon](https://moment.github.io) library and are supported in **portainer-backup**: 444 | (See the following Luxon docs for more information: https://moment.github.io/luxon/#/formatting?id=presets) 445 | 446 | (The following presets are using the October 14, 1983 at `13:30:23` as an example.) 447 | 448 | | Name | Description | Example in en_US | Example in fr | 449 | | ---------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------ | ---------------------------------------------------------- | 450 | | `DATE_SHORT` | short date | `10/14/1983` | `14/10/1983` | 451 | | `DATE_MED` | abbreviated date | `Oct 14, 1983` | `14 oct. 1983` | 452 | | `DATE_MED_WITH_WEEKDAY` | abbreviated date with abbreviated weekday | `Fri, Oct 14, 1983` | `ven. 14 oct. 1983` | 453 | | `DATE_FULL` | full date | `October 14, 1983` | `14 octobre 1983` | 454 | | `DATE_HUGE` | full date with weekday | `Friday, October 14, 1983` | `vendredi 14 octobre 1983` | 455 | | `TIME_SIMPLE` | time | `1:30 PM` | `13:30` | 456 | | `TIME_WITH_SECONDS` | time with seconds | `1:30:23 PM` | `13:30:23` | 457 | | `TIME_WITH_SHORT_OFFSET` | time with seconds and abbreviated named offset | `1:30:23 PM EDT` | `13:30:23 UTC−4` | 458 | | `TIME_WITH_LONG_OFFSET` | time with seconds and full named offset | `1:30:23 PM Eastern Daylight Time` | `13:30:23 heure d’été de l’Est` | 459 | | `TIME_24_SIMPLE` | 24-hour time | `13:30` | `13:30` | 460 | | `TIME_24_WITH_SECONDS` | 24-hour time with seconds | `13:30:23` | `13:30:23` | 461 | | `TIME_24_WITH_SHORT_OFFSET` | 24-hour time with seconds and abbreviated named offset | `13:30:23 EDT` | `13:30:23 UTC−4` | 462 | | `TIME_24_WITH_LONG_OFFSET` | 24-hour time with seconds and full named offset | `13:30:23 Eastern Daylight Time` | `13:30:23 heure d’été de l’Est` | 463 | | `DATETIME_SHORT` | short date & time | `10/14/1983, 1:30 PM` | `14/10/1983 à 13:30` | 464 | | `DATETIME_MED` | abbreviated date & time | `Oct 14, 1983, 1:30 PM` | `14 oct. 1983 à 13:30` | 465 | | `DATETIME_FULL` | full date and time with abbreviated named offset | `October 14, 1983, 1:30 PM EDT` | `14 octobre 1983 à 13:30 UTC−4` | 466 | | `DATETIME_HUGE` | full date and time with weekday and full named offset | `Friday, October 14, 1983, 1:30 PM Eastern Daylight Time` | `vendredi 14 octobre 1983 à 13:30 heure d’été de l’Est` | 467 | | `DATETIME_SHORT_WITH_SECONDS`| short date & time with seconds | `10/14/1983, 1:30:23 PM` | `14/10/1983 à 13:30:23` | 468 | | `DATETIME_MED_WITH_SECONDS` | abbreviated date & time with seconds | `Oct 14, 1983, 1:30:23 PM` | `14 oct. 1983 à 13:30:23` | 469 | | `DATETIME_FULL_WITH_SECONDS` | full date and time with abbreviated named offset with seconds | `October 14, 1983, 1:30:23 PM EDT` | `14 octobre 1983 à 13:30:23 UTC−4` | 470 | | `DATETIME_HUGE_WITH_SECONDS` | full date and time with weekday and full named offset with seconds | `Friday, October 14, 1983, 1:30:23 PM Eastern Daylight Time` | `vendredi 14 octobre 1983 à 13:30:23 heure d’été de l’Est` | 471 | 472 | 473 | ### Supported Tokens 474 | 475 | If one of the substitution presets does not meet your needs, you can build your own date/time string using the supported **tokens** listed below. 476 | (See the following Luxon docs for more information: https://moment.github.io/luxon/#/formatting?id=table-of-tokens) 477 | 478 | Example: 479 | 480 | ``` 481 | --filename "portainer-backup-{{yyyy-MM-dd}}.tar.gz" 482 | ``` 483 | 484 | (Examples below given for `2014-08-06T13:07:04.054` considered as a local time in America/New_York.) 485 | 486 | | Standalone token | Format token | Description | Example | 487 | | ---------------- | ------------ | -------------------------------------------------------------- | ------------------------------------------------------------- | 488 | | S | | millisecond, no padding | `54` | 489 | | SSS | | millisecond, padded to 3 | `054` | 490 | | u | | fractional seconds, functionally identical to SSS | `054` | 491 | | uu | | fractional seconds, between 0 and 99, padded to 2 | `05` | 492 | | uuu | | fractional seconds, between 0 and 9 | `0` | 493 | | s | | second, no padding | `4` | 494 | | ss | | second, padded to 2 padding | `04` | 495 | | m | | minute, no padding | `7` | 496 | | mm | | minute, padded to 2 | `07` | 497 | | h | | hour in 12-hour time, no padding | `1` | 498 | | hh | | hour in 12-hour time, padded to 2 | `01` | 499 | | H | | hour in 24-hour time, no padding | `9` | 500 | | HH | | hour in 24-hour time, padded to 2 | `13` | 501 | | Z | | narrow offset | `+5` | 502 | | ZZ | | short offset | `+05:00` | 503 | | ZZZ | | techie offset | `+0500` | 504 | | ZZZZ | | abbreviated named offset | `EST` | 505 | | ZZZZZ | | unabbreviated named offset | `Eastern Standard Time` | 506 | | z | | IANA zone | `America/New_York` | 507 | | a | | meridiem | `AM` | 508 | | d | | day of the month, no padding | `6` | 509 | | dd | | day of the month, padded to 2 | `06` | 510 | | c | E | day of the week, as number from 1-7 (Monday is 1, Sunday is 7) | `3` | 511 | | ccc | EEE | day of the week, as an abbreviate localized string | `Wed` | 512 | | cccc | EEEE | day of the week, as an unabbreviated localized string | `Wednesday` | 513 | | ccccc | EEEEE | day of the week, as a single localized letter | `W` | 514 | | L | M | month as an unpadded number | `8` | 515 | | LL | MM | month as a padded number | `08` | 516 | | LLL | MMM | month as an abbreviated localized string | `Aug` | 517 | | LLLL | MMMM | month as an unabbreviated localized string | `August` | 518 | | LLLLL | MMMMM | month as a single localized letter | `A` | 519 | | y | | year, unpadded | `2014` | 520 | | yy | | two-digit year | `14` | 521 | | yyyy | | four- to six- digit year, pads to 4 | `2014` | 522 | | G | | abbreviated localized era | `AD` | 523 | | GG | | unabbreviated localized era | `Anno Domini` | 524 | | GGGGG | | one-letter localized era | `A` | 525 | | kk | | ISO week year, unpadded | `14` | 526 | | kkkk | | ISO week year, padded to 4 | `2014` | 527 | | W | | ISO week number, unpadded | `32` | 528 | | WW | | ISO week number, padded to 2 | `32` | 529 | | o | | ordinal (day of year), unpadded | `218` | 530 | | ooo | | ordinal (day of year), padded to 3 | `218` | 531 | | q | | quarter, no padding | `3` | 532 | | qq | | quarter, padded to 2 | `03` | 533 | | D | | localized numeric date | `9/4/2017` | 534 | | DD | | localized date with abbreviated month | `Aug 6, 2014` | 535 | | DDD | | localized date with full month | `August 6, 2014` | 536 | | DDDD | | localized date with full month and weekday | `Wednesday, August 6, 2014` | 537 | | t | | localized time | `9:07 AM` | 538 | | tt | | localized time with seconds | `1:07:04 PM` | 539 | | ttt | | localized time with seconds and abbreviated offset | `1:07:04 PM EDT` | 540 | | tttt | | localized time with seconds and full offset | `1:07:04 PM Eastern Daylight Time` | 541 | | T | | localized 24-hour time | `13:07` | 542 | | TT | | localized 24-hour time with seconds | `13:07:04` | 543 | | TTT | | localized 24-hour time with seconds and abbreviated offset | `13:07:04 EDT` | 544 | | TTTT | | localized 24-hour time with seconds and full offset | `13:07:04 Eastern Daylight Time` | 545 | | f | | short localized date and time | `8/6/2014, 1:07 PM` | 546 | | ff | | less short localized date and time | `Aug 6, 2014, 1:07 PM` | 547 | | fff | | verbose localized date and time | `August 6, 2014, 1:07 PM EDT` | 548 | | ffff | | extra verbose localized date and time | `Wednesday, August 6, 2014, 1:07 PM Eastern Daylight Time` | 549 | | F | | short localized date and time with seconds | `8/6/2014, 1:07:04 PM` | 550 | | FF | | less short localized date and time with seconds | `Aug 6, 2014, 1:07:04 PM` | 551 | | FFF | | verbose localized date and time with seconds | `August 6, 2014, 1:07:04 PM EDT` | 552 | | FFFF | | extra verbose localized date and time with seconds | `Wednesday, August 6, 2014, 1:07:04 PM Eastern Daylight Time` | 553 | | X | | unix timestamp in seconds | `1407287224` | 554 | | x | | unix timestamp in milliseconds | `1407287224054` | 555 | 556 | 557 | --- 558 | 559 | ## Command Line Help 560 | 561 | Use the `help` command or `--help` option to see a listing of command line options directly via the CLI. 562 | 563 | ``` 564 | ___ _ _ ___ _ 565 | | _ \___ _ _| |_ __ _(_)_ _ ___ _ _ | _ ) __ _ __| |___ _ _ __ 566 | | _/ _ \ '_| _/ _` | | ' \/ -_) '_| | _ \/ _` / _| / / || | '_ \ 567 | |_| \___/_| \__\__,_|_|_||_\___|_| |___/\__,_\__|_\_\\_,_| .__/ 568 | |_| 569 | ┌──────────────────────────────────────────────────────────────────┐ 570 | │ Made with ♥ by SavageSoftware, LLC © 2022 (Version 0.0.6) │ 571 | └──────────────────────────────────────────────────────────────────┘ 572 | 573 | Usage: [(options...)] 574 | 575 | Commands: 576 | portainer-backup backup Backup portainer data 577 | portainer-backup schedule Run scheduled portainer backups 578 | portainer-backup stacks Backup portainer stacks 579 | portainer-backup info Get portainer server info 580 | portainer-backup test Test backup data & stacks (backup --dryru 581 | n --stacks) 582 | portainer-backup restore Restore portainer data 583 | portainer-backup help Show help 584 | portainer-backup version Show version 585 | 586 | Portainer Options: 587 | -t, --token Portainer access token 588 | [string] [default: ""] 589 | -u, --url Portainer base url 590 | [string] [default: "http://portainer:9000"] 591 | -Z, --ignore-version Bypass portainer version check/enforcement 592 | [boolean] [default: false] 593 | 594 | Backup Options: (applies only to 'backup' command) 595 | -d, --directory, --dir Backup directory/path 596 | [string] [default: "/backup"] 597 | -f, --filename Backup filename 598 | [string] [default: "portainer-backup.tar.gz"] 599 | -p, --password, --pwd Backup archive password [string] [default: ""] 600 | -o, --overwrite Overwrite existing files 601 | [boolean] [default: false] 602 | -M, --mkdir, --make-directory Create backup directory path if needed 603 | [boolean] [default: false] 604 | -s, --schedule, --sch Cron expression for scheduled backups 605 | [string] [default: "0 0 0 * * * *"] 606 | -i, --include-stacks, --stacks Include stack files in backup 607 | [boolean] [default: false] 608 | 609 | Stacks Options: (applies only to 'stacks' command) 610 | -d, --directory, --dir Backup directory/path [string] [default: "/backup"] 611 | -o, --overwrite Overwrite existing files [boolean] [default: false] 612 | -M, --mkdir, --make-directory Create backup directory path if needed 613 | [boolean] [default: false] 614 | Restore Options: (applies only to 'restore' command) 615 | -p, --password, --pwd Backup archive password [string] [default: ""] 616 | 617 | Options: 618 | -h, --help Show help [boolean] 619 | -q, --quiet Do not display any console output [boolean] [default: false] 620 | -D, --dryrun Execute command task without persisting any data. 621 | [boolean] [default: false] 622 | -X, --debug Print details stack trace for any errors encountered 623 | [boolean] [default: false] 624 | -J, --json Print formatted/strucutred JSON data [boolean] [default: false] 625 | -c, --concise Print concise/limited output [boolean] [default: false] 626 | -v, --version Show version number [boolean] 627 | 628 | Command Examples: 629 | info --url http://192.168.1.100:9000 630 | 631 | backup --url http://192.168.1.100:9000 632 | --token XXXXXXXXXXXXXXXX 633 | --overwrite 634 | --stacks 635 | 636 | stacks --url http://192.168.1.100:9000 637 | --token XXXXXXXXXXXXXXXX 638 | --overwrite 639 | 640 | restore --url http://192.168.1.100:9000 641 | --token XXXXXXXXXXXXXXXX 642 | ./file-to-restore.tar.gz 643 | 644 | schedule --url http://192.168.1.100:9000 645 | --token XXXXXXXXXXXXXXXX 646 | --schedule "0 0 0 * * *" 647 | 648 | Schedule Expression Examples: (cron syntax) 649 | 650 | ┌──────────────────────── second (optional) 651 | │ ┌──────────────────── minute 652 | │ │ ┌──────────────── hour 653 | │ │ │ ┌──────────── day of month 654 | │ │ │ │ ┌──────── month 655 | │ │ │ │ │ ┌──── day of week 656 | │ │ │ │ │ │ 657 | │ │ │ │ │ │ 658 | * * * * * * 659 | 660 | 0 0 0 * * * Daily at 12:00am 661 | 0 0 5 1 * * 1st day of month @ 5:00am 662 | 0 */15 0 * * * Every 15 minutes 663 | 664 | Additional Examples @ https://github.com/node-cron/node-cron#cron-syntax 665 | ``` 666 | 667 | --- 668 | 669 | ## Docker Compose 670 | 671 | Alternatively you can use a `docker-compose.yml` file to launch your **portainer-backup** container. Below is a sample `docker-compose.yml` file you can use to get started: 672 | 673 | ```yaml 674 | version: '3.8' 675 | services: 676 | portainer-backup: 677 | container_name: portainer-backup 678 | image: savagesoftware/portainer-backup:latest 679 | hostname: portainer-backup 680 | restart: unless-stopped 681 | command: schedule 682 | environment: 683 | TZ: America/New_York 684 | PORTAINER_BACKUP_URL: "http://portainer:9000" 685 | PORTAINER_BACKUP_TOKEN: "PORTAINER_ACCESS_TOKEN" 686 | PORTAINER_BACKUP_PASSWORD: "" 687 | PORTAINER_BACKUP_OVERWRITE: 1 688 | PORTAINER_BACKUP_SCHEDULE: "0 0 0 * * *" 689 | PORTAINER_BACKUP_STACKS: 1 690 | PORTAINER_BACKUP_DRYRUN: 0 691 | PORTAINER_BACKUP_CONCISE: 1 692 | PORTAINER_BACKUP_DIRECTORY: "/backup" 693 | PORTAINER_BACKUP_FILENAME: "portainer-backup.tar.gz" 694 | volumes: 695 | - /var/backup:/backup 696 | ``` 697 | 698 | Just run the `docker-compose up -d` command in the same directory as your `docker-compose.yml` file to launch the container instance. 699 | 700 | Supported Docker platforms: 701 | * `linux/amd64` (Intel/AMD x64) 702 | * `linux/arm64` (ARMv8) 703 | * `linux/arm` (ARMv7) 704 | 705 | --- 706 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * ------------------------------------------------------------------- 5 | * ___ ___ ___ ___ ___ 6 | * / __| /_\ \ / /_\ / __| __| 7 | * \__ \/ _ \ V / _ \ (_ | _| 8 | * |___/_/_\_\_/_/_\_\___|___| ___ ___ ___ 9 | * / __|/ _ \| __|_ _\ \ / /_\ | _ \ __| 10 | * \__ \ (_) | _| | | \ \/\/ / _ \| / _| 11 | * |___/\___/|_| |_| \_/\_/_/ \_\_|_\___| 12 | * 13 | * ------------------------------------------------------------------- 14 | * COPYRIGHT SAVAGESOFTWARE,LLC, @ 2022, ALL RIGHTS RESERVED 15 | * https://github.com/SavageSoftware/portainer-backup 16 | * ------------------------------------------------------------------- 17 | */ 18 | 19 | // import libraries 20 | import fs from 'node:fs'; 21 | import path from 'node:path'; 22 | import symbols from 'log-symbols'; 23 | import sanitize from 'sanitize-filename'; 24 | import figures from 'figures'; 25 | import {Portainer} from './portainer.js'; 26 | import {Render} from './render.js'; 27 | import Util from './util.js'; 28 | import {Configuration} from './configuration.js'; 29 | import yargs from 'yargs'; 30 | import { hideBin } from 'yargs/helpers'; 31 | import DevNull from 'dev-null'; 32 | import cron from 'node-cron' 33 | import { compareSemVer, isValidSemVer, parseSemVer } from 'semver-parser'; 34 | import ON_DEATH from 'death'; 35 | import { createRequire } from 'module'; 36 | 37 | // use the require approach to obtain package.json 38 | const require = createRequire(import.meta.url); 39 | const pkg = require('../package.json'); 40 | 41 | // create deafult context 42 | let context = { 43 | version: pkg.version, 44 | output: { 45 | stream: process.stdout 46 | }, 47 | cache: { 48 | args: undefined, 49 | stacks: [] 50 | }, 51 | operation: "backup", 52 | config: new Configuration(), 53 | results: { 54 | success: false, 55 | started: undefined, 56 | finished: undefined, 57 | elapsed: undefined, 58 | backup: undefined, 59 | portainer: undefined, 60 | stacks: undefined, 61 | error: undefined 62 | } 63 | }; 64 | 65 | // create rendering utility and pass in STDOUT stream via constructor 66 | let render = new Render(context); 67 | 68 | // create portainer instance; pass in config via constructor 69 | let portainer = new Portainer(context); 70 | 71 | // listen for signals to terminate the process 72 | // and handle any uncaught exceptions in the process 73 | ON_DEATH({ 74 | debug: false, 75 | uncaughtException: true, 76 | SIGINT: true, 77 | SIGTERM: true, 78 | SIGQUIT: true, 79 | SIGABRT: true, 80 | SIGILL: true 81 | })(function(signal, err) { 82 | if(err && err.message){ 83 | render.writeln(); 84 | render.error(err); 85 | console.error(err); 86 | } 87 | render.terminate(signal); 88 | render.goodbye(); 89 | process.exit(0); 90 | }); 91 | 92 | // run a first pass on command line argument only looking for "--quiet" or "--json" 93 | // this is done to supress console output for these options 94 | let initArgs = yargs(hideBin(process.argv)) 95 | // optional argument : portainer server access token (PORTAINER_BACKUP_TOKEN) 96 | .boolean("q") 97 | .alias("q", "quiet") 98 | .help(false) 99 | .boolean("J") 100 | .alias("J", "json") 101 | .parse(); 102 | 103 | // handle '--quiet' and '--json' option; set output stream to null device 104 | if(initArgs.quiet || initArgs.json) context.output.stream = new DevNull(); 105 | 106 | // display program title (using ASCII art) 107 | render.title(); 108 | 109 | // parse full set of command line arguments 110 | yargs(hideBin(process.argv)) 111 | 112 | // intercept arguments for additional processing before executing any commands 113 | .middleware(function (argv) { 114 | context.cache.args = argv; // cache command line arguments 115 | context.operation = argv['_'][0]; // set context operation reference 116 | context.results.started = new Date(); // record start timestamp 117 | context.config.process(argv); // process the arguments for configuration 118 | }, false) 119 | 120 | // usage description (one command is required, options are optional) 121 | .usage('Usage: [(options...)]') 122 | 123 | // command example syntax 124 | .epilog("Command Examples:") 125 | .epilog(' info --url http://192.168.1.100:9000\n') 126 | .epilog(' backup --url http://192.168.1.100:9000\n --token XXXXXXXXXXXXXXXX\n --overwrite\n --stacks\n') 127 | .epilog(' stacks --url http://192.168.1.100:9000\n --token XXXXXXXXXXXXXXXX\n --overwrite\n') 128 | .epilog(' restore --url http://192.168.1.100:9000\n --token XXXXXXXXXXXXXXXX\n ./file-to-restore.tar.gz\n') 129 | .epilog(' schedule --url http://192.168.1.100:9000\n --token XXXXXXXXXXXXXXXX\n --schedule "0 0 0 * * *"\n') 130 | 131 | // schedule cron expression syntax 132 | .epilog("Schedule Expression Examples: (cron syntax)") 133 | .epilog(" ") 134 | .epilog(" ┌──────────────────────── second (optional)") 135 | .epilog(" │ ┌──────────────────── minute ") 136 | .epilog(" │ │ ┌──────────────── hour ") 137 | .epilog(" │ │ │ ┌──────────── day of month ") 138 | .epilog(" │ │ │ │ ┌──────── month ") 139 | .epilog(" │ │ │ │ │ ┌──── day of week ") 140 | .epilog(" │ │ │ │ │ │ ") 141 | .epilog(" │ │ │ │ │ │ ") 142 | .epilog(" * * * * * * ") 143 | .epilog(" ") 144 | .epilog(" 0 0 0 * * * Daily at 12:00am ") 145 | .epilog(" 0 0 5 1 * * 1st day of month @ 5:00am") 146 | .epilog(" 0 */15 0 * * * Every 15 minutes ") 147 | .epilog(" ") 148 | .epilog(" Additional Examples @ https://github.com/node-cron/node-cron#cron-syntax") 149 | .epilog(" ") 150 | .epilog('-------------------------------------------------------------------------------') 151 | .epilog(' MORE DETAILS @ https://github.com/SavageSoftware/portainer-backup ') 152 | .epilog('-------------------------------------------------------------------------------') 153 | 154 | // we will handle this extraction/interpretation of envrionment variables process 155 | // manually to be more flexible with accepting values as well as to include 156 | // the envrionment variable values as the default in the CLI help 157 | // .env("PORTAINER_BACKUP") 158 | 159 | // perform a backup of the portainer data and optionally stack files 160 | .command('backup', 'Backup portainer data', () => {}, (argv) => { 161 | backup(); 162 | }) 163 | 164 | // perform scheduled backups of the portainer data and optionally stack files 165 | .command('schedule', 'Run scheduled portainer backups', () => {}, (argv) => { 166 | schedule(); 167 | }) 168 | 169 | // perform a backup of the portainer stack files only 170 | .command('stacks', 'Backup portainer stacks', () => {}, (argv) => { 171 | stacks(); 172 | }) 173 | 174 | // display information about the connected portainer server 175 | .command('info', 'Get portainer server info', () => {}, (argv) => { 176 | info(); 177 | }) 178 | 179 | // perform a test backup of the portainer data and optionally stack files 180 | // no files will be written; this is the same as using the '--dryrun' option 181 | // with the backup task 182 | .command('test', "Test backup data & stacks (backup --dryrun --stacks)", () => {}, (argv) => { 183 | context.config.dryRun = true; // TEST==DRY-RUN 184 | backup(); 185 | }) 186 | 187 | // restore a previous backup file to a new portainer server 188 | .command('restore ', 'Restore portainer data', () => {}, (argv) => { 189 | restore(); 190 | }) 191 | 192 | // display command line help 193 | .command('help', 'Show help', () => {}, (argv) => { 194 | yargs.showHelp(); 195 | }) 196 | 197 | // display application versoin 198 | .command('version', 'Show version', () => {}, (argv) => { 199 | render.writeln(pkg.version); 200 | }) 201 | 202 | // help command alias 203 | .help("h") 204 | .alias('h', 'help') 205 | 206 | // application version alias 207 | .alias('v', 'version') 208 | 209 | // optional argument : supress command line output (PORTAINER_BACKUP_QUIET) 210 | .options({ 211 | 'q': { 212 | alias: ['quiet'], 213 | default: context.config.quiet, 214 | describe: 'Do not display any console output', 215 | type: 'boolean', 216 | } 217 | }) 218 | 219 | // optional argument : if enabled, the 'backup' or 'stacks' operation 220 | // will not write any data to the filesystem. (PORTAINER_BACKUP_DRYRUN) 221 | .options({ 222 | 'D': { 223 | alias: 'dryrun', 224 | default: context.config.dryRun, 225 | describe: 'Execute command task without persisting any data.', 226 | type: 'boolean' 227 | } 228 | }) 229 | 230 | // optional argument : if enabled, and an error is encountered, the complete 231 | // stack trace will be printed to the console (PORTAINER_BACKUP_DEBUG) 232 | .options({ 233 | 'X': { 234 | alias: 'debug', 235 | default: context.config.debug, 236 | describe: 'Print details stack trace for any errors encountered', 237 | type: 'boolean' 238 | } 239 | }) 240 | 241 | // optional argument : if enabled, output JSON formatted structured data 242 | // instead of human readable output. (PORTAINER_BACKUP_JSON) 243 | .options({ 244 | 'J': { 245 | alias: 'json', 246 | default: context.config.json, 247 | describe: 'Print formatted/strucutred JSON data', 248 | type: 'boolean' 249 | } 250 | }) 251 | 252 | // optional argument : if enabled, output human readable information 253 | // to the console but in a more compact/concise format. (PORTAINER_BACKUP_CONCISE) 254 | .options({ 255 | 'c': { 256 | alias: 'concise', 257 | default: context.config.concise, 258 | describe: 'Print concise/limited output', 259 | type: 'boolean' 260 | } 261 | }) 262 | 263 | // optional argument : allow bypassing portainer version enforcement (PORTAINER_BACKUP_IGNORE_VERSION) 264 | .options({ 265 | 'Z': { 266 | alias: 'ignore-version', 267 | default: context.config.portainer.ignoreVersion, 268 | describe: 'Bypass portainer version check/enforcement', 269 | type: 'boolean' 270 | } 271 | }) 272 | 273 | // optional argument : portainer server access token (PORTAINER_BACKUP_TOKEN) 274 | .options({ 275 | 't': { 276 | alias: ['token'], 277 | default: context.config.portainer.token, 278 | describe: 'Portainer access token', 279 | type: 'string', 280 | requiresArg: true, 281 | nargs: 1 282 | } 283 | }) 284 | 285 | // optional argument : portainer server base URL (PORTAINER_BACKUP_URL) 286 | .options({ 287 | 'u': { 288 | alias: ['url'], 289 | default: context.config.portainer.baseUrl, 290 | describe: 'Portainer base url', 291 | type: 'string', 292 | requiresArg: true, 293 | nargs: 1 294 | } 295 | }) 296 | 297 | // optional argument : directory to write backup files to (PORTAINER_BACKUP_DIRECTORY) 298 | .options({ 299 | 'd': { 300 | alias: ['directory', 'dir'], 301 | default: context.config.backup.directory, 302 | describe: 'Backup directory/path', 303 | type: 'string', 304 | requiresArg: true, 305 | nargs: 1, 306 | normalize: true 307 | } 308 | }) 309 | 310 | // optional argument : filename of data archive file to write 311 | // backup to inside backup directory (PORTAINER_BACKUP_FILENAME) 312 | .options({ 313 | 'f': { 314 | alias: 'filename', 315 | default: context.config.backup.filename, 316 | describe: 'Backup filename', 317 | type: 'string', 318 | requiresArg: true, 319 | nargs: 1, 320 | normalize: true 321 | } 322 | }) 323 | 324 | // optional argument : password to use to protect backup archive file (PORTAINER_BACKUP_PASSWORD) 325 | .options({ 326 | 'p': { 327 | alias: ['password', 'pwd'], 328 | default: context.config.backup.password, 329 | describe: 'Backup archive password', 330 | type: 'string', 331 | requiresArg: true, 332 | nargs: 1 333 | } 334 | }) 335 | 336 | // optional argument : allow overwritting of existing backup 337 | // data and stack file with same name (PORTAINER_BACKUP_OVERWRITE) 338 | .options({ 339 | 'o': { 340 | alias: 'overwrite', 341 | default: context.config.backup.overwrite, 342 | describe: 'Overwrite existing files', 343 | type: 'boolean' 344 | } 345 | }) 346 | 347 | // optional argument : include stack files in addition to backup 348 | // data archive file in a 'backup' opertation (PORTAINER_BACKUP_STACKS) 349 | .options({ 350 | 'i': { 351 | alias: ['include-stacks','stacks',], 352 | default: context.config.backup.stacks, 353 | describe: 'Include stack files in backup', 354 | type: 'boolean' 355 | } 356 | }) 357 | 358 | // optional argument : include stack files in addition to backup 359 | // data archive file in a 'backup' opertation (PORTAINER_BACKUP_STACKS) 360 | .options({ 361 | 'M': { 362 | alias: ['mkdir','make-directory',], 363 | default: context.config.backup.stacks, 364 | describe: 'Create backup directory path if needed', 365 | type: 'boolean' 366 | } 367 | }) 368 | 369 | // optional argument : the cron-like expression for scheduling 370 | // automated 'backup' opertations (PORTAINER_BACKUP_SCHEDULE) 371 | .options({ 372 | 's': { 373 | alias: ['schedule', 'sch'], 374 | default: context.config.backup.schedule, 375 | describe: 'Cron expression for scheduled backups', 376 | type: 'string', 377 | requiresArg: true, 378 | nargs: 1 379 | } 380 | }) 381 | 382 | // setup option groups 383 | .group(['t','u', 'Z'], 'Portainer Options:') 384 | .group(['d','f','p','o','M','s','i'], "Backup Options: (applies only to 'backup' command)") 385 | .group(['d','o','M'], "Stacks Options: (applies only to 'stacks' command)") 386 | .group(['p'], "Restore Options: (applies only to 'restore' command)") 387 | 388 | // we always require a single command operation 389 | .demandCommand(1, "ATTENTION: You must provide at least one command: [backup, stacks, restore, info, test, schedule]\n") 390 | .strictCommands() 391 | .strictOptions() 392 | .parse(); 393 | 394 | 395 | // ******************************************************************************************************** 396 | // ******************************************************************************************************** 397 | // TASKS/OPERATIONS 398 | // ******************************************************************************************************** 399 | // ******************************************************************************************************** 400 | 401 | /** 402 | * EXECUTE TASK: [SCHEDULE] 403 | */ 404 | function schedule(){ 405 | 406 | // display runtime configuration settings 407 | if(!context.config.concise) render.configuration(); 408 | 409 | // the following promise chain is responsible for 410 | // performing the complete backup workflow sequence. 411 | initialize(context) // initialize operation 412 | .then((ctx)=>{ 413 | return portainerStatusCheck(context) // check for access to portainer API/server 414 | }) 415 | .then((ctx)=>{ 416 | return validatePortainerVersion(context); // perform portainer version check 417 | }) 418 | .then((ctx)=>{ 419 | return validateAccessToken(context); // perform portainer access token check 420 | }) 421 | .then((ctx)=>{ 422 | return validateSchedule(context); 423 | }) 424 | .then((ctx)=>{ 425 | cron.schedule(context.config.backup.schedule, function() { 426 | console.log('----------------------------------------------------------------------'); 427 | console.log(`[${new Date().toISOString()}] ... Running Scheduled Backup`); 428 | console.log('----------------------------------------------------------------------'); 429 | console.log(); 430 | 431 | validateBackupDirectory(context) // validate backup directory/path 432 | .then(()=>{ 433 | return validateBackupFile(context) // validate backup file 434 | }) 435 | .then(()=>{ 436 | return portainerBackupData(context) // perform portainer data backup 437 | }) 438 | .then((ctx)=>{ 439 | if(context.config.backup.stacks) 440 | return portainerBackupStacks(context); // perform portainer stacks backup (if needed) 441 | }) 442 | .then((ctx)=>{ 443 | finish(); 444 | if(!context.config.concise) 445 | render.summary(context); // display backup summary 446 | }) 447 | .then((ctx)=>{ 448 | if(context.config.dryRun) render.dryRun(); // display DRY-RUN message (if needed) 449 | }) 450 | .then((ctx)=>{ 451 | if(!context.config.concise) 452 | render.success(); // display backup complete message 453 | }) 454 | .catch((err)=>{ 455 | if(context.config.debug) console.error(err); // debug ouput message (if needed) 456 | }) 457 | .finally(() => { // finished 458 | console.log('----------------------------------------------------------------------'); 459 | console.log(`[${new Date().toISOString()}] ... Waiting for next scheduled backup`); 460 | console.log('----------------------------------------------------------------------'); 461 | console.log(); 462 | }); 463 | }); 464 | }) 465 | .catch((err)=>{ 466 | finish(err); 467 | if(context.config.debug) console.error(err); // debug error message (if needed) 468 | render.goodbye(); // goodbye message 469 | process.exit(context.results.success ? 0 : 1); // exit the running process 470 | }) 471 | .finally(() => { 472 | console.log(); 473 | console.log('----------------------------------------------------------------------'); 474 | console.log(`[${new Date().toISOString()}] ... Waiting for next scheduled backup`); 475 | console.log('----------------------------------------------------------------------'); 476 | console.log(); 477 | }); 478 | } 479 | 480 | /** 481 | * EXECUTE TASK: [BACKUP] 482 | */ 483 | function backup(){ 484 | 485 | // display runtime configuration settings 486 | if(!context.config.concise) render.configuration(); 487 | 488 | // the following promise chain is responsible for 489 | // performing the complete backup workflow sequence. 490 | initialize(context) // initialize operation 491 | .then(()=>{ 492 | return validateBackupDirectory(context); // validate backup directory/path 493 | }) 494 | .then(()=>{ 495 | return validateBackupFile(context) // validate backup file 496 | }) 497 | .then((ctx)=>{ 498 | return portainerStatusCheck(context) // check for access to portainer API/server 499 | }) 500 | .then((ctx)=>{ 501 | return validatePortainerVersion(context); // perform portainer version check 502 | }) 503 | .then((ctx)=>{ 504 | return validateAccessToken(context); // perform portainer access token check 505 | }) 506 | .then((ctx)=>{ 507 | return portainerBackupData(context); // perform portainer data backup 508 | }) 509 | .then((ctx)=>{ 510 | if(context.config.backup.stacks) 511 | return portainerBackupStacks(context); // perform portainer stacks backup (if needed) 512 | }) 513 | .then((ctx)=>{ 514 | finish(); 515 | if(!context.config.concise) 516 | render.summary(context); // display backup summary 517 | }) 518 | .then((ctx)=>{ 519 | if(context.config.dryRun) render.dryRun(); // display DRY-RUN message (if needed) 520 | }) 521 | .then((ctx)=>{ 522 | if(!context.config.concise) 523 | render.success(); // display backup complete message 524 | }) 525 | .catch((err)=>{ 526 | finish(err); 527 | if(context.config.debug) console.error(err); // debug error message (if needed) 528 | }) 529 | .finally(() => { // finished 530 | render.goodbye(); // goodbye message 531 | process.exit(context.results.success ? 0 : 1); // exit the running process 532 | }); 533 | } 534 | 535 | 536 | /** 537 | * EXECUTE TASK: [STACKS] 538 | */ 539 | function stacks(){ 540 | 541 | // display runtime configuration settings 542 | if(!context.config.concise) render.configuration(); 543 | 544 | // the following promise chain is responsible for 545 | // performing the complete [STACKS] workflow sequence. 546 | initialize(context) // initialize operation 547 | .then(()=>{ 548 | return validateBackupDirectory(context); // validate backup directory/path 549 | }) 550 | .then((ctx)=>{ 551 | return portainerStatusCheck(context) // check for access to portainer API/server 552 | }) 553 | .then((ctx)=>{ 554 | return validatePortainerVersion(context); // perform portainer version check 555 | }) 556 | .then((ctx)=>{ 557 | return validateAccessToken(context); // perform portainer access token check 558 | }) 559 | .then((ctx)=>{ 560 | return portainerBackupStacks(context); // perform portainer stacks backup 561 | }) 562 | .then((ctx)=>{ 563 | finish(); 564 | if(!context.config.concise) 565 | render.summary(context); // display backup summary 566 | }) 567 | .then((ctx)=>{ 568 | if(context.config.dryRun) render.dryRun(); // display DRY-RUN message (if needed) 569 | }) 570 | .then((ctx)=>{ 571 | if(!context.config.concise) 572 | render.success(); // display backup complete message 573 | }) 574 | .catch((err)=>{ 575 | finish(err); 576 | if(context.config.debug) console.error(err); // debug error message (if needed) 577 | }) 578 | .finally(() => { // finished 579 | render.goodbye(); // goodbye message 580 | process.exit(context.results.success ? 0 : 1); // exit the running process 581 | }); 582 | } 583 | 584 | 585 | /** 586 | * EXECUTE TASK: [INFO] 587 | */ 588 | function info(){ 589 | 590 | // display runtime configuration settings 591 | if(!context.config.concise) render.configuration(); 592 | 593 | // the following promise chain is responsible for 594 | // performing the complete [INFO] workflow sequence. 595 | initialize(context) // initialize operation 596 | .then(()=>{ 597 | return portainerStatusCheck(context); // validate configuration and runtime environment 598 | }) 599 | .then((ctx)=>{ 600 | return validatePortainerVersion(context); // perform portainer version check 601 | }) 602 | .then(()=>{ 603 | finish(); 604 | }) 605 | .catch((err)=>{ 606 | finish(err); 607 | if(context.config.debug) console.error(err); // debug error message (if needed) 608 | }) 609 | .finally(() => { // finished 610 | render.goodbye(); // goodbye message 611 | process.exit(context.results.success ? 0 : 1); // exit the running process 612 | }); 613 | } 614 | 615 | 616 | /** 617 | * EXECUTE TASK: [RESTORE] <---- NOT YET SUPPORTED 618 | */ 619 | function restore(){ 620 | let err = new Error("The 'restore' method has not yet been implemented."); 621 | render.error(err, null, "The portainer API for restoring backups has a flaw preveting uploading restore files at this time."); 622 | finish(err); 623 | process.exit(1); // exit with error code 624 | } 625 | 626 | 627 | // ******************************************************************************************************** 628 | // ******************************************************************************************************** 629 | // HELPER METHODS 630 | // ******************************************************************************************************** 631 | // ******************************************************************************************************** 632 | 633 | /** 634 | * This method is called when either a [backup] or [stacks] task/operation has completed 635 | * and we need to finalize the results data and calaulate the operation elapsed time. 636 | * 637 | * This method is also responsible for printing JSON formatted ouptut when a task 638 | * is complete if the process has been configured to do so. 639 | * 640 | * @param {*} err optional error object instance | null 641 | */ 642 | function finish(err){ 643 | 644 | // record finished timestamp 645 | context.results.finished = new Date(); 646 | 647 | // update results object with elapsed duration, success and eny errors 648 | context.results.elapsed = context.results.finished - context.results.started; 649 | context.results.success = (err) ? false : true; 650 | if(err){ 651 | context.results.error = { 652 | message: err.message, 653 | number: err.errno 654 | } 655 | } 656 | 657 | // optionally print JSON data to output stream 658 | if(context.config.json){ 659 | process.stdout.write(JSON.stringify(context.results, null, 2)); 660 | process.stdout.write("\n"); 661 | } 662 | } 663 | 664 | 665 | /** 666 | * Validate configuration settings and environment conditions for backup 667 | * @param {*} context application context 668 | * @returns Promise 669 | */ 670 | function initialize(context){ 671 | return new Promise((resolve, reject) => { 672 | 673 | try{ 674 | // initialize operation 675 | render.write("Initializing operation : "); 676 | 677 | // copy backup info in results data 678 | context.results.backup = { 679 | directory: context.config.backup.directory, 680 | filename: context.config.backup.filename, 681 | protected: (context.config.backup.password) ? true : false, 682 | status: "pending" 683 | } 684 | 685 | // success 686 | render.writeln(`${symbols.success} ${context.operation.toUpperCase()}`); 687 | return resolve(context); 688 | } 689 | catch(err){ 690 | render.writeln(`${symbols.error} ${context.operation.toUpperCase()}`); 691 | render.error(err, "Failed to initialize operation!"); 692 | return reject(err); 693 | } 694 | }); 695 | } 696 | 697 | 698 | 699 | // ******************************************************************************************************** 700 | // ******************************************************************************************************** 701 | // VALIDATION METHODS 702 | // ******************************************************************************************************** 703 | // ******************************************************************************************************** 704 | 705 | /** 706 | * Validate the schedule (cron-like) expression for scheduled backups. 707 | * @param {*} context application context 708 | * @returns Promise 709 | */ 710 | function validateSchedule(context){ 711 | return new Promise((resolve, reject) => { 712 | 713 | // validate access token for API authorization 714 | render.write("Validating schedule expression : ") 715 | 716 | if(cron.validate(context.config.backup.schedule)){ 717 | render.writeln(symbols.success); 718 | return resolve(context); 719 | } 720 | 721 | // no access token found; error 722 | render.writeln(symbols.error); 723 | let err = new Error(`Invalid 'PORTAINER_BACKUP_SCHEDULE' cron expression: [${context.config.backup.schedule}]`) 724 | render.error(err, "Invalid schedule cron expression!", 725 | "The 'PORTAINER_BACKUP_SCHEDULE' environment variable or '--schedule' command "+ 726 | "line option does not have a valid cron expression; " + 727 | "Please see the documention for more details on the schedule cron expression."); 728 | return reject(err); 729 | }); 730 | } 731 | 732 | /** 733 | * Validate that a portainer access token has been provided. 734 | * (This does not validate the token's authorization, only that a token was provided.) 735 | * @param {*} context application context 736 | * @returns Promise 737 | */ 738 | function validateAccessToken(context){ 739 | return new Promise((resolve, reject) => { 740 | 741 | // validate access token for API authorization 742 | render.write("Validating portainer access token : ") 743 | if(context.config.portainer.token && 744 | context.config.portainer.token != undefined && 745 | context.config.portainer.token !== ""){ 746 | render.writeln(symbols.success); 747 | return resolve(context); 748 | } 749 | 750 | // no access token found; error 751 | render.writeln(symbols.error); 752 | let err = new Error("'PORTAINER_BACKUP_TOKEN' is missing!"); 753 | render.error(err, "An API access token must be configured!", 754 | "The 'PORTAINER_BACKUP_TOKEN' environment variable or '--token' command line option is missing ; " + 755 | "You can create an API token in portainer under your user acocunt / access tokens: " + 756 | "https://docs.portainer.io/v/ce-2.11/api/access#creating-an-access-token"); 757 | return reject(err); 758 | }); 759 | } 760 | 761 | /** 762 | * Validate that the provided backup path/directory does exists on the file system. 763 | * @param {*} context application context 764 | * @returns Promise 765 | */ 766 | function validateBackupDirectory(context){ 767 | return new Promise((resolve, reject) => { 768 | 769 | let createdDirectory = false; 770 | 771 | // copy configured backup directory path into results data structure 772 | // (this will re-set the config for new substitutions if needed) 773 | context.results.backup.directory = context.config.backup.directory; 774 | 775 | // replace substitution tokens in backup directory string if needed 776 | const pathParts = context.results.backup.directory.split(path.sep); 777 | for(let index in pathParts){ 778 | pathParts[index] = Util.processSubstitutions(pathParts[index]); 779 | } 780 | context.results.backup.directory = path.resolve(pathParts.join(path.sep)); 781 | 782 | // ensure directory exist; if not create it 783 | if(context.config.mkdir && !fs.existsSync(context.results.backup.directory)){ 784 | fs.mkdirSync(context.results.backup.directory, { recursive: true }); 785 | createdDirectory = true; 786 | } 787 | 788 | // ensure backup path/directory exists 789 | render.write("Validating target backup directory : ") 790 | 791 | if(fs.existsSync(context.results.backup.directory)){ 792 | render.writeln(`${symbols.success} ${createdDirectory?"CREATED":"EXISTS"} ${figures.arrowRight} ${context.results.backup.directory}`); 793 | return resolve(context); 794 | } 795 | 796 | // backup path/directory does not exist; error 797 | render.writeln(`${symbols.error} ${context.results.backup.directory}`); 798 | let err = new Error("'PORTAINER_BACKUP_DIRECTORY' is invalid!"); 799 | render.error(err, "The target backup directory does not exist. ", 800 | "The 'PORTAINER_BACKUP_DIRECTORY' environment variable or " + 801 | "'--directory' command line option is not pointing to a valid " + 802 | "directory on the filesystem. Please ensure the " + 803 | "backup directory or mount path exists. You can use the " + 804 | "'PORTAINER_BACKUP_MKDIR' environment variable or '--mkdir' command line " + 805 | "option to dynamically create directories if needed."); 806 | return reject(err); 807 | }); 808 | } 809 | 810 | 811 | /** 812 | * Validate the target backup file to ensure we don't overrite 813 | * an existing instance unless configured to override files. 814 | * @param {*} context application context 815 | * @returns Promise 816 | */ 817 | function validateBackupFile(context){ 818 | return new Promise((resolve, reject) => { 819 | 820 | // copy configured backup file name into results data structure 821 | // (this will re-set the config for new substitutions if needed) 822 | context.results.backup.filename = context.config.backup.filename; 823 | 824 | // build backup filename string with all tokenized substitutions replaced 825 | context.results.backup.filename = Util.processSubstitutions(context.results.backup.filename); 826 | 827 | // construct complete backup file path using backup directory and backup filename 828 | context.results.backup.file = path.resolve(context.results.backup.directory, context.results.backup.filename); 829 | 830 | // validate now 831 | render.write("Validating target backup file : ") 832 | 833 | // if file does not exists, then there is no overwrite conflict 834 | if(context.config.dryRun && !fs.existsSync(context.results.backup.file)){ 835 | render.writeln(`${symbols.success} DRYRUN ${figures.arrowRight} ${context.results.backup.filename}`); 836 | context.results.backup.status="dryrun"; 837 | return resolve(context); 838 | } 839 | 840 | // if file does not exists, then there is no overwrite conflict 841 | if(!fs.existsSync(context.results.backup.file)){ 842 | render.writeln(`${symbols.success} ${context.results.backup.filename}`); 843 | context.results.backup.status="ready"; 844 | return resolve(context); 845 | } 846 | 847 | // if file overwrites are allowed and this is a dry-run, then skip this validation check 848 | if(context.config.backup.overwrite && context.config.dryRun) { 849 | render.writeln(`${symbols.warning} DRYRUN ${figures.arrowRight} ${context.results.backup.filename}`); 850 | context.results.backup.status="dryrun"; 851 | return resolve(context); 852 | } 853 | 854 | // if dry-run is enabled, then skip this validation check 855 | if(context.config.dryRun) { 856 | render.writeln(`${symbols.error} DRYRUN ${figures.arrowRight} ${context.results.backup.filename}`); 857 | context.results.backup.status="dryrun"; 858 | return resolve(context); 859 | } 860 | 861 | // if file overwrites are allowed, then skip this validation check 862 | if(context.config.backup.overwrite) { 863 | render.writeln(`${symbols.warning} OVERWRITE ${figures.arrowRight} ${context.results.backup.filename}`); 864 | context.results.backup.status="overwrite"; 865 | context.results.backup.overwrite=true; 866 | return resolve(context); 867 | } 868 | 869 | // if the file does already exist, then there is a conflict; error 870 | render.writeln(`${symbols.error} ${context.results.backup.filename}`); 871 | context.results.backup.status="already-exists"; 872 | let err = new Error(`Backup file [${Util.wrapFilePath(context.results.backup.file, 30)}] already exists.`); 873 | render.error(err, "The target backup data file already exists!", 874 | "Set the 'PORTAINER_BACKUP_OVERWRITE' environment varaiable " + 875 | "or '--overwrite' command line option to enable file overwriting."); 876 | return reject(err); 877 | }); 878 | } 879 | 880 | /** 881 | * Validate portainer minimum supported version 882 | * @param {*} context application context 883 | * @returns Promise 884 | */ 885 | function validatePortainerVersion(context){ 886 | return new Promise((resolve, reject) => { 887 | 888 | // validate portainer minimum supported version 889 | render.write("Validating portainer version : ") 890 | if(compareSemVer(context.results.portainer.version, Portainer.MIN_VERSION) >= 0){ 891 | render.writeln(`${symbols.success} v${context.results.portainer.version}`); 892 | return resolve(context); 893 | } 894 | 895 | // the connected portainer server does not meet the minimum version requirements 896 | if(context.config.portainer.ignoreVersion){ 897 | render.writeln(`${symbols.warning} v${context.results.portainer.version} [UNSUPPORTED]`); 898 | if(!context.config.concise) render.unsupportedVersion(context.results.portainer.version); 899 | return resolve(context); 900 | } 901 | 902 | // the connected portainer server does not meet the minimum version requirements 903 | render.writeln(`${symbols.error} v${context.results.portainer.version}`); 904 | if(!context.config.concise) render.unsupportedVersion(context.results.portainer.version); 905 | let err = new Error("The portainer server is older than the minimum supported version."); 906 | render.error(err, "The portainer server is older than the minimum supported version.", 907 | `The portainer server is [${context.results.portainer.version}]; "+ 908 | "The minimum supported version is [${Portainer.MIN_VERSION}]. ` + 909 | "Please upgrade your portainer server or use the 'PORTAINER_BACKUP_IGNORE_VERSION' " + 910 | "environment variable or '--ignore-version' command line " + 911 | "option to override the version checking."); 912 | return reject(err); 913 | }); 914 | } 915 | 916 | /** 917 | * Validate stack files to ensure there are no conflicts with existing files on the filesystem. 918 | * @param {*} context application context 919 | * @returns Promise 920 | */ 921 | function validateStackFiles(context){ 922 | return new Promise((resolve, reject) => { 923 | let numberOfConflicts = 0; 924 | render.writeln("Validate stack file conflicts : ") 925 | render.writeln(); 926 | 927 | // create new map collection for stacks (indexed by stack ID) 928 | context.results.stacks = new Map(); 929 | 930 | // iterate stacks 931 | for(let index in context.cache.stacks) 932 | { 933 | // reference stack instance 934 | let stack = context.cache.stacks[index]; 935 | 936 | // check for existing docker-compose file for this stack data 937 | const filename = sanitize(`${stack.Name}.docker-compose.yaml`); 938 | const stackFile = path.resolve(context.results.backup.directory, filename) 939 | 940 | // assign stack file reference 941 | stack.file = stackFile; 942 | 943 | // copy stack info in results data 944 | context.results.stacks[stack.Id] = { 945 | id: stack.Id, 946 | name: stack.Name, 947 | file: stack.file, 948 | filename: filename, 949 | directory: context.config.backup.directory, 950 | status: "pending" 951 | }; 952 | 953 | render.write(`${figures.arrowRight} ${stackFile} ... `) 954 | 955 | // validate overwrite of existing file 956 | if(context.config.dryRun && !fs.existsSync(stackFile)){ 957 | render.writeln(`${symbols.success} (DRYRUN)`); 958 | context.results.stacks[stack.Id].status = "dryrun"; 959 | } 960 | else if(!fs.existsSync(stackFile)){ 961 | render.writeln(symbols.success); 962 | context.results.stacks[stack.Id].status = "pending"; 963 | } 964 | else if (context.config.backup.overwrite && context.config.dryRun){ 965 | render.writeln(`${symbols.warning} (OVERWRITE; DRYRUN)`); 966 | context.results.stacks[stack.Id].status = "dryrun"; 967 | } 968 | else if (context.config.dryRun){ 969 | render.writeln(`${symbols.error} (DRYRUN)`); 970 | context.results.stacks[stack.Id].status = "dryrun"; 971 | } 972 | else if (context.config.backup.overwrite){ 973 | render.writeln(`${symbols.warning} (OVERWRITE)`); 974 | context.results.stacks[stack.Id].status = "overwrite"; 975 | context.results.stacks[stack.Id].overwrite = true; 976 | } 977 | else{ 978 | numberOfConflicts++; 979 | render.writeln(symbols.error); 980 | context.results.stacks[stack.Id].status = "already-exists"; 981 | } 982 | } 983 | 984 | // check for conflicts 985 | if(numberOfConflicts > 0){ 986 | let err = new Error(`[${numberOfConflicts}] stack file(s) with the same name already exists in the target backup directory.`) 987 | render.error(err, "One or more target stack data files already exists!", 988 | "Set the 'PORTAINER_BACKUP_OVERWRITE' environment varaiable or '--overwrite' command line option to enable file overwriting."); 989 | return reject(err); 990 | } 991 | 992 | // no file conflicts 993 | render.writeln(); 994 | return resolve(context); 995 | }); 996 | } 997 | 998 | // ******************************************************************************************************** 999 | // ******************************************************************************************************** 1000 | // DATA RETRIEVAL METHODS 1001 | // ******************************************************************************************************** 1002 | // ******************************************************************************************************** 1003 | 1004 | 1005 | /** 1006 | * Retrieve portainer status from portainer server API. 1007 | * This status will include the portainer instance ID and server version. 1008 | * @param {*} context application context 1009 | * @returns Promise 1010 | */ 1011 | function portainerStatusCheck(context){ 1012 | render.write("Validating portainer server : ") 1013 | return portainer.status() 1014 | .then((data)=>{ // SUCCESS 1015 | render.writeln(`${symbols.success} ${context.config.portainer.baseUrl}`); 1016 | 1017 | // display portainer server status 1018 | if(!context.config.concise){ 1019 | render.writeln(); 1020 | render.status(data); 1021 | } 1022 | 1023 | // copy portainer info in results data 1024 | context.results.portainer = { 1025 | version: data.Version, 1026 | instance: data.InstanceID, 1027 | url: context.config.portainer.baseUrl 1028 | } 1029 | 1030 | Promise.resolve(context); 1031 | }) 1032 | .catch((err)=>{ // ERROR RETRIEVING PORTAINER STATUS 1033 | render.writeln(`${symbols.error} ${context.config.portainer.baseUrl}`); 1034 | render.error(err, "Connection to portainer server failed!"); 1035 | return Promise.reject(err); 1036 | }) 1037 | } 1038 | 1039 | /** 1040 | * Retrieve portainer data backup stream from portainer server. 1041 | * Once the response stream is available, delegate to handler for saving stream data. 1042 | * @param {*} context application context 1043 | * @returns Promise 1044 | */ 1045 | function portainerBackupData(context){ 1046 | render.write("Retrieving portainer data backup : ") 1047 | return portainer.backup() 1048 | .then((response)=>{ 1049 | render.writeln(symbols.success); 1050 | 1051 | if(context.config.dryRun) { 1052 | context.results.backup.status = "dryrun" 1053 | if(!context.config.backup.stacks) render.writeln(); 1054 | return Promise.resolve(); 1055 | } 1056 | context.results.backup.status="saving"; 1057 | return portainerSaveBackupData(context,response); 1058 | }) 1059 | .catch((err)=>{ // ERROR RETRIEVING PORTAINER BACKUP 1060 | render.writeln(symbols.error); 1061 | render.error(err, "Retrieving portainer data backup failed!"); 1062 | return Promise.reject(err); 1063 | }) 1064 | } 1065 | 1066 | /** 1067 | * Save portainer data backup archive stream to filesystem. 1068 | * @param {*} context application context 1069 | * @param {*} response portainer server response with data stream 1070 | * @returns Promise 1071 | */ 1072 | function portainerSaveBackupData(context, response){ 1073 | 1074 | 1075 | render.write("Saving portainer data backup : ") 1076 | return portainer.saveBackup(response.data, context.results.backup.file) 1077 | .then((file)=>{ 1078 | render.writeln(symbols.success); 1079 | 1080 | render.writeln(); 1081 | render.writeln(` ${figures.arrowRight} ${context.results.backup.file} ... ${symbols.success}`) 1082 | render.writeln(); 1083 | 1084 | // udpate backup rsults data 1085 | let stats = fs.statSync(context.results.backup.file); 1086 | context.results.backup.size = stats.size, 1087 | context.results.backup.created = stats.ctime.toISOString(), 1088 | context.results.backup.status = "saved"; 1089 | 1090 | // display backup file details 1091 | if(!context.config.concise){ 1092 | render.backupFile(file); 1093 | } 1094 | return Promise.resolve(context); 1095 | }) 1096 | .catch((err)=>{ // ERROR SAVING PORTAINER BACKUP 1097 | render.writeln(symbols.error); 1098 | render.error(err, "Saving portainer data backup failed!"); 1099 | context.results.backup.status = "failed"; 1100 | return Promise.reject(err); 1101 | }) 1102 | } 1103 | 1104 | 1105 | /** 1106 | * Retrieve portainer stacks metadata/catalog from portainer server. 1107 | * Once the metadata/catalog is available, validate the stacks aginst 1108 | * the filesystem to ensure no existinf file conflicts then delegate to 1109 | * handler for iterating stacks and retrieving individual stack 1110 | * docker-compose data. 1111 | * @param {*} context application context 1112 | * @returns Promise 1113 | */ 1114 | function portainerBackupStacks(context){ 1115 | return portainerAcquireStacks(context) 1116 | .then(()=>{ 1117 | return validateStackFiles(context); 1118 | }) 1119 | .then(()=>{ 1120 | if(!context.config.dryRun) 1121 | return portainerBackupStackFiles(context) 1122 | }) 1123 | .then(()=>{ 1124 | return Promise.resolve(context); 1125 | }) 1126 | .catch((err)=>{ 1127 | return Promise.reject(err); 1128 | }) 1129 | } 1130 | 1131 | /** 1132 | * Retrieve portainer stacks metadata/catalog from portainer server. 1133 | * @param {*} context application context 1134 | * @returns Promise 1135 | */ 1136 | function portainerAcquireStacks(context){ 1137 | // execute API call to get stacks from Portainer server 1138 | render.write("Acquiring portainer stacks catalog : ") 1139 | 1140 | return portainer.stacksMetadata() 1141 | .then((stacks)=>{ 1142 | render.writeln(`${symbols.success} ${stacks.length} STACKS`); 1143 | 1144 | // assign stacks array reference 1145 | context.cache.stacks = stacks; 1146 | 1147 | // display stacks metadata details 1148 | if(!context.config.concise){ 1149 | render.writeln(); 1150 | render.stacksCount(stacks); 1151 | } 1152 | 1153 | return Promise.resolve(context); 1154 | }) 1155 | .catch((err)=>{ // ERROR FETCHING STACKS METADATA 1156 | render.writeln(symbols.error); 1157 | render.writeln(); 1158 | render.error(err, "Portainer failed to acquire stacks metadata!"); 1159 | return Promise.reject(err); 1160 | }) 1161 | } 1162 | 1163 | /** 1164 | * Iterate over previously retrieved stacks metadata/catalog (located in 1165 | * context cache) and retrieve individual stack docker-compose data. Once 1166 | * each statck data is acquired, save the stack data to the filesystem. 1167 | * @param {*} context application context 1168 | * @returns Promise 1169 | */ 1170 | function portainerBackupStackFiles(context){ 1171 | 1172 | // iterate over stacks metadata and fetch each stack file 1173 | render.writeln("Downloading & save stack files : ") 1174 | render.writeln(); 1175 | 1176 | // iterate over the stacks asynchronously 1177 | return Promise.all(context.cache.stacks.map(async (stack) => { 1178 | return portainer.stackFile(stack.Id) 1179 | .then((data)=>{ 1180 | render.writeln(`${figures.arrowRight} saving (stack #${stack.Id}) [${stack.Name}.docker-compose.yml] ... ${symbols.success}`) 1181 | 1182 | // write docker-compose file for the stack data 1183 | fs.writeFileSync(stack.file, data.StackFileContent); 1184 | let stats = fs.statSync(stack.file); 1185 | context.results.stacks[stack.Id].size = stats.size, 1186 | context.results.stacks[stack.Id].created = stats.ctime.toISOString(), 1187 | context.results.stacks[stack.Id].status = "saved"; 1188 | }) 1189 | .catch(err=>{ 1190 | context.results.stacks[stack.Id].status = "failed"; 1191 | render.writeln(`${figures.arrowRight} saving (stack #${stack.Id}) [${stack.Name}.docker-compose.yml] ... ${symbols.error}`) 1192 | render.error(err, `Portainer failed to save stack file: (stack #${stack.Id}) [${stack.Name}.docker-compose.yml]`); 1193 | Promise.reject(err); 1194 | }); 1195 | 1196 | })).then(()=>{ 1197 | render.writeln(); 1198 | render.writeln(`Saving stack files complete : ${symbols.success} ${context.cache.stacks.length} STACK FILES`); 1199 | render.writeln(); 1200 | 1201 | // print listing table of stack files 1202 | if(!context.config.concise){ 1203 | render.stackFiles(context.cache.stacks); 1204 | } 1205 | Promise.resolve(context); 1206 | }); 1207 | } 1208 | --------------------------------------------------------------------------------