├── .gitattributes
├── assets
├── backupdate.png
└── backupdate.webp
├── cliff.toml
├── CHANGELOG.md
├── README.md
├── LICENSE
└── backupdate.sh
/.gitattributes:
--------------------------------------------------------------------------------
1 | # Auto detect text files and perform LF normalization
2 | * text=auto
3 |
--------------------------------------------------------------------------------
/assets/backupdate.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hazzuk/compose-backupdate/HEAD/assets/backupdate.png
--------------------------------------------------------------------------------
/assets/backupdate.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hazzuk/compose-backupdate/HEAD/assets/backupdate.webp
--------------------------------------------------------------------------------
/cliff.toml:
--------------------------------------------------------------------------------
1 | # git-cliff ~ configuration file
2 | # https://git-cliff.org/docs/configuration
3 |
4 | # template set as unconventional
5 | #
6 | # recognise pull requests:
7 | # commit_parsers = [
8 | # { message = "^Merge", group = "Pull Requests" },
9 |
10 |
11 | # github release steps
12 |
13 | # 1. add version number to code
14 | # 2. `git cliff --tag 1.2.3 -o CHANGELOG.md`
15 | # 3. `chore(release): prepare for v1.2.3`
16 |
17 | # githubrelease header
18 |
19 | # REMOVE
20 | # All notable changes to this project will be documented in this file.
21 | #
22 | # ADD
23 | # Note on changes
24 | #
25 | # AND
26 | # #### Install command
27 | # ```bash
28 | # bash -c 'curl -fsSL -o /bin/backupdate https://raw.githubusercontent.com/hazzuk/compose-backupdate/refs/heads/release/backupdate.sh && chmod +x /bin/backupdate'
29 | # ```
30 |
31 |
32 | # [remote.github]
33 | # owner = "hazzuk"
34 | # repo = "compose-backupdate"
35 |
36 | [changelog]
37 | # template for the changelog header
38 | header = """
39 | # Changelog\n
40 | All notable changes to this project will be documented in this file.\n
41 | """
42 | # template for the changelog body
43 | # https://keats.github.io/tera/docs/#introduction
44 | body = """
45 | {% if version %}\
46 | ## {{ version | trim_start_matches(pat="v") }} - {{ timestamp | date(format="%Y-%m-%d") }}
47 | {% else %}\
48 | ## Unreleased
49 | {% endif %}\
50 | {% for group, commits in commits | group_by(attribute="group") %}
51 | ### {{ group | upper_first }}
52 | {% for commit in commits %}
53 | - {{ commit.message | split(pat="\n") | first | trim_end }}\
54 | {% endfor %}
55 | {% endfor %}\n
56 | """
57 | # template for the changelog footer
58 | footer = """
59 |
60 | """
61 | # remove the leading and trailing whitespace from the templates
62 | trim = true
63 |
64 |
65 | [git]
66 | # parse the commits based on https://www.conventionalcommits.org
67 | conventional_commits = false
68 | # filter out the commits that are not conventional
69 | filter_unconventional = false
70 | # process each line of a commit as an individual commit
71 | split_commits = false
72 | # regex for parsing and grouping commits
73 | commit_parsers = [
74 | { message = "^Merge", group = "Pull Requests" },
75 | { message = "^feat", group = "Features" },
76 | { message = "^fix", group = "Bug Fixes" },
77 | { message = "^doc", group = "Documentation" },
78 | { message = "^perf", group = "Performance" },
79 | { message = "^refactor", group = "Refactor" },
80 | { message = "^style", group = "Styling" },
81 | { message = "^test", group = "Testing" },
82 | { message = "^chore\\(release\\): prepare for", skip = true },
83 | { message = "^chore", group = "Miscellaneous Tasks" },
84 | { body = ".*security", group = "Security" },
85 | { body = ".*", group = "Other (unconventional)" },
86 | ]
87 | # filter out the commits that are not matched by commit parsers
88 | filter_commits = false
89 | # sort the tags topologically
90 | topo_order = false
91 | # sort the commits inside sections by oldest/newest order
92 | sort_commits = "oldest"
93 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | All notable changes to this project will be documented in this file.
4 |
5 | ## 1.2.0 - 2024-12-04
6 |
7 | ### Bug Fixes
8 |
9 | - fix(prune): only prune running stacks
10 | - fix: docker stop command wrong
11 |
12 | ### Documentation
13 |
14 | - docs: create faq section
15 | - docs: update faq restore answer
16 | - docs: update arguments phrasing
17 | - docs(logo): add new logo
18 | - docs: update project name
19 | - docs: updates to the readme
20 |
21 | ### Features
22 |
23 | - feat(restart): only restart running containers
24 | - feat(docker stop): improve error handling
25 | - feat(updates): remove/recreate all containers
26 |
27 | ### Miscellaneous Tasks
28 |
29 | - chore: update copyright notice
30 | - chore(logo): remove previous version
31 |
32 | ### Pull Requests
33 |
34 | - Merge pull request #17 from hazzuk/update-prune
35 | - Merge pull request #18 from hazzuk/fix-docker-stop
36 | - Merge pull request #19 from hazzuk/track-container-states
37 |
38 | ### Refactor
39 |
40 | - refactor: script update function name
41 |
42 | ### Styling
43 |
44 | - style: script console outputs
45 |
46 | ## 1.1.0 - 2024-10-07
47 |
48 | ### Bug Fixes
49 |
50 | - fix: wrong project about line
51 |
52 | ### Documentation
53 |
54 | - docs: reorganise content placement
55 | - docs: simplify functionality guide
56 | - docs: adjust updates wording
57 | - docs: add backup_blocklist guide
58 | - docs: update blocklist guide wording
59 |
60 | ### Features
61 |
62 | - feat: create volume blocklist option
63 | - feat: skip blocklisted volumes at backup
64 | - feat: create path blocklist
65 | - feat: skip blocklisted paths at backup
66 |
67 | ### Miscellaneous Tasks
68 |
69 | - chore: switch to release branch
70 | - chore: create project icon
71 | - chore: move project header image
72 | - chore: create full size project icon
73 | - chore: add comments for each section
74 |
75 | ### Pull Requests
76 |
77 | - Merge pull request #9 from hazzuk/backup-blocklist
78 |
79 | ### Styling
80 |
81 | - style: remove excess whitespace
82 |
83 | ## 1.0.0 - 2024-10-03
84 |
85 | ### Bug Fixes
86 |
87 | - fix: script variables not set
88 | - fix: working_dir value overwritten
89 | - fix: update stack_name when compose found
90 | - fix: docker_dir validation method
91 | - fix: update option requiring an argument
92 | - fix: more blank line outputs
93 | - fix: no such directory error
94 | - fix: backup volume wording
95 | - fix: version checker formatting
96 | - fix: unintended busybox prune
97 | - fix: avoid using subshell
98 | - fix(prune): premature script exit
99 | - fix(start): wording and echo placement
100 | - fix(prune): echo not formatted
101 | - fix: remove incomplete changelog feature
102 |
103 | ### Documentation
104 |
105 | - docs: script setup and usage
106 | - docs: add project header and args usage
107 | - docs: explained working_dir
108 | - docs: add update option and current dir
109 | - docs: add expanded options
110 | - docs: update repo description
111 | - docs: expand on script examples
112 | - docs: add reasons behind scripts origin
113 | - docs: minor word/style changes
114 | - docs: explain core functionality
115 | - docs: minor visual changes
116 |
117 | ### Features
118 |
119 | - feat: automatic volume backups
120 | - feat: add script usage, argument options
121 | - feat: add command to update compose stack
122 | - feat: add config validation
123 | - feat: refine console output
124 | - feat: more console output refinements
125 | - feat: normalise console outputs
126 | - feat: echo blank line breaks
127 | - feat: backup into stack_name directories
128 | - feat: add input validation before compose search
129 | - feat: add version update checker
130 | - feat: make version check optional
131 | - feat: add long options
132 | - feat: prune unused images after update
133 |
134 | ### Miscellaneous Tasks
135 |
136 | - chore: initial commit
137 | - chore: create git-cliff config
138 |
139 | ### Other (unconventional)
140 |
141 | - merge: #1 from hazzuk/long-options
142 |
143 | ### Pull Requests
144 |
145 | - Merge pull request #2 from hazzuk/image-prune
146 |
147 | ### Refactor
148 |
149 | - refactor: confirmation prompts
150 |
151 | ### Styling
152 |
153 | - style(prune): add hyphen for subcommand echo
154 | - style(changelog): remove leading echo
155 |
156 | ### Testing
157 |
158 | - test: bump version number
159 |
160 |
161 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | # backupdate
6 |
7 | Bash script for creating scheduled backups, and performing (backed-up) guided updates on Docker compose stacks.
8 |
9 |
10 |
11 | ## Why?
12 | Because I needed a tool that was...
13 |
14 | - Simple by design
15 | - Doesn't require changes inside my `compose.yaml` files
16 | - Works with both **bind mounts** and **named volumes**
17 | - Can be used to create 🕑scheduled backups
18 | - Can also create ad-hoc backups alongside guided container ⬆️updates
19 | - Not trying to replace existing cloud backup tools (like rclone)
20 |
21 |
22 |
23 | ## Core Functionality
24 |
25 | The core focus of *backupdate* is in creating archived backups of your Docker compose stacks.
26 |
27 | ### How it works
28 |
29 | 1. 🛑Stop any running containers in the Docker compose stack
30 | 1. 📁Create a **.tar.gz** backup of the stacks working directory
31 | 1. 📁Create **.tar.gz** backups of any associated named volumes
32 | 1. ⬇️Ask to pull any new container images (`-u`)
33 | 1. 🔁Restart previously running Docker compose containers
34 | 1. 🗑️Ask to prune any unused container images (`-u`)
35 |
36 | Read the official Docker documentation for more details on ["Back up, restore, or migrate data volumes"](https://docs.docker.com/engine/storage/volumes/#back-up-restore-or-migrate-data-volumes).
37 |
38 |
39 |
40 | ## Setup
41 |
42 | ### Install
43 | > [!WARNING]
44 | > This script is provided as-is, without any warranty. Use it at your own risk.
45 |
46 | > [!IMPORTANT]
47 | > The install command and the script must be run with elevated permissions.
48 |
49 | ```bash
50 | bash -c 'curl -fsSL -o /bin/backupdate https://raw.githubusercontent.com/hazzuk/compose-backupdate/refs/heads/release/backupdate.sh && chmod +x /bin/backupdate'
51 | ```
52 |
53 | ### Expected compose directory structure
54 | The script expects your docker compose working directory to be located at `$docker_dir/$stack_name`:
55 | ```
56 | $docker_dir = "/path/to/your/docker"
57 | $stack_name = "nextcloud"
58 |
59 | docker/
60 | ├─ nginx/
61 | │ └─ compose.yaml
62 | ├─ wordpress/
63 | │ └─ compose.yaml
64 | └─ nextcloud/
65 | └─ compose.yaml
66 | ```
67 |
68 |
69 |
70 | ## Options
71 |
72 | ### Command line
73 |
74 | #### Required
75 | - `-b ""`, `--backup-dir ""`: Backup directory
76 | - `-d ""`, `--docker-dir ""`: Docker compose directory parent
77 | - `-s ""`, `--stack-name ""`: Docker compose stack name
78 |
79 | #### Optional
80 | - `-l ""`, `--backup-blocklist ""`: Volumes/paths to ignore
81 | - `-u`, `--update`: Update the stack containers
82 | - `-v`, `--version`: Check the script version for updates
83 |
84 | ### Environment variables
85 | ```bash
86 | # backup directory
87 | export BACKUP_DIR="/path/to/your/backup"
88 | # docker compose directory parent
89 | export DOCKER_DIR="/path/to/your/docker"
90 | # docker compose stack name
91 | export STACK_NAME="nginx"
92 | # volumes/paths to ignore
93 | export BACKUP_BLOCKLIST="plex_media,/plex-cache"
94 | ```
95 |
96 |
97 |
98 | ## Example Usage
99 |
100 | ### 📀Backups
101 | ```bash
102 | backupdate -s "nginx" -d "/path/to/your/docker" -b "/path/to/your/backup"
103 | ```
104 | ```bash
105 | backupdate --stack-name "nginx" \
106 | --docker-dir "/very/long/path/to/docker" \
107 | --backup-dir "/very/long/path/to/the/backup"
108 | ```
109 |
110 | > [!TIP]
111 | > *backupdate* automatically searches for a `compose.yaml` / `docker-compose.yaml` file inside your current directory.
112 | > Running *backupdate* inside your Docker compose working directory won't require `--docker-dir` or `--stack-name`:
113 |
114 | ```bash
115 | cd /path/to/your/docker/nginx
116 |
117 | backupdate -u -b "/path/to/your/backup"
118 | ```
119 |
120 |
121 |
122 | ### ⬆️Updates *(manual only)*
123 | > [!NOTE]
124 | > Stack updates (unlike backups) can only be performed manually. This is by design.
125 |
126 | ```bash
127 | backupdate -u -s "nginx" -d "/path/to/your/docker" -b "/path/to/your/backup"
128 | ```
129 |
130 |
131 |
132 | ### 🕑Scheduled backups
133 | You can create a cron job or use another tool like [Cronicle](https://github.com/jhuckaby/Cronicle) to run something similar to this example script. Which will periodically backup your Docker compose stacks automatically:
134 |
135 | ```bash
136 | #!/bin/bash
137 |
138 | # set environment variables
139 | export DOCKER_DIR="/path/to/your/docker"
140 | export BACKUP_DIR="/path/to/your/backup"
141 |
142 | # set stack names
143 | stack_names=(
144 | "nginx"
145 | "portainer"
146 | "ghost"
147 | "home-assistant"
148 | )
149 |
150 | # create backups
151 | for stack in "${stack_names[@]}"; do
152 | backupdate -s "$stack"
153 | done
154 |
155 | # upload backups to cloud storage
156 | rclone sync $BACKUP_DIR dropbox:backup
157 | ```
158 |
159 |
160 |
161 | ### 🚫Backup blocklist
162 |
163 | By default, *backupdate* will backup all related named volumes and the stacks full working directory. You can use `-l` or `--backup-blocklist` if you want to explicitly exclude certain volumes or paths from the backup.
164 |
165 | ```bash
166 | # ignore the plex_media volume and the /plex-cache directory
167 |
168 | backupdate -s "plex" \
169 | -d "/path/to/your/docker" \
170 | -b "/path/to/your/backup" \
171 | -l "plex_media,/plex-cache"
172 | ```
173 |
174 | ```bash
175 | # you'll likely want to set the backup blocklist as an environment variable
176 | # when you need to ignore volumes/paths for multiple stacks
177 |
178 | export BACKUP_BLOCKLIST="\
179 | plex_media,\
180 | /plex-cache,\
181 | /nginx.conf,\
182 | nginx_logs,\
183 | /data/ghost.yml"
184 | ```
185 |
186 | > [!TIP]
187 | > To avoid being recognised as a volume, paths must start with a forward slash `/`.
188 | > Note that paths are interpreted as glob(3)-style wildcard patterns.
189 |
190 |
191 |
192 | ## FAQ
193 |
194 | ### How do I restore the backup?
195 |
196 | Restoring backups isn't currently a process automated by the script. But as *backupdate* is just executing [standard Docker commands](https://github.com/hazzuk/compose-backupdate/blob/7b3d2edb05374e707af79c00d303e9988065e7f8/backupdate.sh#L347-L351), just follow the official Docker guide on ["Restoring volumes from backups"](https://docs.docker.com/engine/storage/volumes/#restore-volume-from-a-backup).
197 |
198 | ### Do I need to stop containers before running backupdate?
199 |
200 | No, *backupdate* does that for you. All containers are stopped automatically before a backup to ensure the data saved is reliable.
201 |
202 | ### Are there any differences between backups and updates?
203 |
204 | Yes, *backupdate* is focused on backups. However, it can be used to simultaneously backup and update containers in a Docker compose stack. Running update will remove (`docker compose down`) all containers, perform a standard backup, then request the containers be recreated (`docker compose up -d`). This is different to backups, where the containers are only stopped, backed up, and then restarted. As updating a Docker container requires the container to be recreated so as to use the newly pulled Docker image.
205 |
206 | ### How does it backup DB volumes?
207 |
208 | The script treats all containers and their volumes the same. Shut them down, back them up, then restart them.
209 |
210 | There are alternative tools that do specifically handle database backups with Docker. This would probably be most useful if you don't want any downtime. Or want to create a lot more backups each day, then use another tool with deduplication abilities to save on storage space.
211 |
212 | ### Alternative tools
213 |
214 | - [offen/docker-volume-backup](https://github.com/offen/docker-volume-backup)
215 | - [loomchild/volume-backup](https://github.com/loomchild/volume-backup)
216 | - [tiredofit/docker-db-backup](https://github.com/tiredofit/docker-db-backup)
217 | - [prodrigestivill/docker-postgres-backup-local](https://github.com/prodrigestivill/docker-postgres-backup-local)
218 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Mozilla Public License Version 2.0
2 | ==================================
3 |
4 | 1. Definitions
5 | --------------
6 |
7 | 1.1. "Contributor"
8 | means each individual or legal entity that creates, contributes to
9 | the creation of, or owns Covered Software.
10 |
11 | 1.2. "Contributor Version"
12 | means the combination of the Contributions of others (if any) used
13 | by a Contributor and that particular Contributor's Contribution.
14 |
15 | 1.3. "Contribution"
16 | means Covered Software of a particular Contributor.
17 |
18 | 1.4. "Covered Software"
19 | means Source Code Form to which the initial Contributor has attached
20 | the notice in Exhibit A, the Executable Form of such Source Code
21 | Form, and Modifications of such Source Code Form, in each case
22 | including portions thereof.
23 |
24 | 1.5. "Incompatible With Secondary Licenses"
25 | means
26 |
27 | (a) that the initial Contributor has attached the notice described
28 | in Exhibit B to the Covered Software; or
29 |
30 | (b) that the Covered Software was made available under the terms of
31 | version 1.1 or earlier of the License, but not also under the
32 | terms of a Secondary License.
33 |
34 | 1.6. "Executable Form"
35 | means any form of the work other than Source Code Form.
36 |
37 | 1.7. "Larger Work"
38 | means a work that combines Covered Software with other material, in
39 | a separate file or files, that is not Covered Software.
40 |
41 | 1.8. "License"
42 | means this document.
43 |
44 | 1.9. "Licensable"
45 | means having the right to grant, to the maximum extent possible,
46 | whether at the time of the initial grant or subsequently, any and
47 | all of the rights conveyed by this License.
48 |
49 | 1.10. "Modifications"
50 | means any of the following:
51 |
52 | (a) any file in Source Code Form that results from an addition to,
53 | deletion from, or modification of the contents of Covered
54 | Software; or
55 |
56 | (b) any new file in Source Code Form that contains any Covered
57 | Software.
58 |
59 | 1.11. "Patent Claims" of a Contributor
60 | means any patent claim(s), including without limitation, method,
61 | process, and apparatus claims, in any patent Licensable by such
62 | Contributor that would be infringed, but for the grant of the
63 | License, by the making, using, selling, offering for sale, having
64 | made, import, or transfer of either its Contributions or its
65 | Contributor Version.
66 |
67 | 1.12. "Secondary License"
68 | means either the GNU General Public License, Version 2.0, the GNU
69 | Lesser General Public License, Version 2.1, the GNU Affero General
70 | Public License, Version 3.0, or any later versions of those
71 | licenses.
72 |
73 | 1.13. "Source Code Form"
74 | means the form of the work preferred for making modifications.
75 |
76 | 1.14. "You" (or "Your")
77 | means an individual or a legal entity exercising rights under this
78 | License. For legal entities, "You" includes any entity that
79 | controls, is controlled by, or is under common control with You. For
80 | purposes of this definition, "control" means (a) the power, direct
81 | or indirect, to cause the direction or management of such entity,
82 | whether by contract or otherwise, or (b) ownership of more than
83 | fifty percent (50%) of the outstanding shares or beneficial
84 | ownership of such entity.
85 |
86 | 2. License Grants and Conditions
87 | --------------------------------
88 |
89 | 2.1. Grants
90 |
91 | Each Contributor hereby grants You a world-wide, royalty-free,
92 | non-exclusive license:
93 |
94 | (a) under intellectual property rights (other than patent or trademark)
95 | Licensable by such Contributor to use, reproduce, make available,
96 | modify, display, perform, distribute, and otherwise exploit its
97 | Contributions, either on an unmodified basis, with Modifications, or
98 | as part of a Larger Work; and
99 |
100 | (b) under Patent Claims of such Contributor to make, use, sell, offer
101 | for sale, have made, import, and otherwise transfer either its
102 | Contributions or its Contributor Version.
103 |
104 | 2.2. Effective Date
105 |
106 | The licenses granted in Section 2.1 with respect to any Contribution
107 | become effective for each Contribution on the date the Contributor first
108 | distributes such Contribution.
109 |
110 | 2.3. Limitations on Grant Scope
111 |
112 | The licenses granted in this Section 2 are the only rights granted under
113 | this License. No additional rights or licenses will be implied from the
114 | distribution or licensing of Covered Software under this License.
115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a
116 | Contributor:
117 |
118 | (a) for any code that a Contributor has removed from Covered Software;
119 | or
120 |
121 | (b) for infringements caused by: (i) Your and any other third party's
122 | modifications of Covered Software, or (ii) the combination of its
123 | Contributions with other software (except as part of its Contributor
124 | Version); or
125 |
126 | (c) under Patent Claims infringed by Covered Software in the absence of
127 | its Contributions.
128 |
129 | This License does not grant any rights in the trademarks, service marks,
130 | or logos of any Contributor (except as may be necessary to comply with
131 | the notice requirements in Section 3.4).
132 |
133 | 2.4. Subsequent Licenses
134 |
135 | No Contributor makes additional grants as a result of Your choice to
136 | distribute the Covered Software under a subsequent version of this
137 | License (see Section 10.2) or under the terms of a Secondary License (if
138 | permitted under the terms of Section 3.3).
139 |
140 | 2.5. Representation
141 |
142 | Each Contributor represents that the Contributor believes its
143 | Contributions are its original creation(s) or it has sufficient rights
144 | to grant the rights to its Contributions conveyed by this License.
145 |
146 | 2.6. Fair Use
147 |
148 | This License is not intended to limit any rights You have under
149 | applicable copyright doctrines of fair use, fair dealing, or other
150 | equivalents.
151 |
152 | 2.7. Conditions
153 |
154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
155 | in Section 2.1.
156 |
157 | 3. Responsibilities
158 | -------------------
159 |
160 | 3.1. Distribution of Source Form
161 |
162 | All distribution of Covered Software in Source Code Form, including any
163 | Modifications that You create or to which You contribute, must be under
164 | the terms of this License. You must inform recipients that the Source
165 | Code Form of the Covered Software is governed by the terms of this
166 | License, and how they can obtain a copy of this License. You may not
167 | attempt to alter or restrict the recipients' rights in the Source Code
168 | Form.
169 |
170 | 3.2. Distribution of Executable Form
171 |
172 | If You distribute Covered Software in Executable Form then:
173 |
174 | (a) such Covered Software must also be made available in Source Code
175 | Form, as described in Section 3.1, and You must inform recipients of
176 | the Executable Form how they can obtain a copy of such Source Code
177 | Form by reasonable means in a timely manner, at a charge no more
178 | than the cost of distribution to the recipient; and
179 |
180 | (b) You may distribute such Executable Form under the terms of this
181 | License, or sublicense it under different terms, provided that the
182 | license for the Executable Form does not attempt to limit or alter
183 | the recipients' rights in the Source Code Form under this License.
184 |
185 | 3.3. Distribution of a Larger Work
186 |
187 | You may create and distribute a Larger Work under terms of Your choice,
188 | provided that You also comply with the requirements of this License for
189 | the Covered Software. If the Larger Work is a combination of Covered
190 | Software with a work governed by one or more Secondary Licenses, and the
191 | Covered Software is not Incompatible With Secondary Licenses, this
192 | License permits You to additionally distribute such Covered Software
193 | under the terms of such Secondary License(s), so that the recipient of
194 | the Larger Work may, at their option, further distribute the Covered
195 | Software under the terms of either this License or such Secondary
196 | License(s).
197 |
198 | 3.4. Notices
199 |
200 | You may not remove or alter the substance of any license notices
201 | (including copyright notices, patent notices, disclaimers of warranty,
202 | or limitations of liability) contained within the Source Code Form of
203 | the Covered Software, except that You may alter any license notices to
204 | the extent required to remedy known factual inaccuracies.
205 |
206 | 3.5. Application of Additional Terms
207 |
208 | You may choose to offer, and to charge a fee for, warranty, support,
209 | indemnity or liability obligations to one or more recipients of Covered
210 | Software. However, You may do so only on Your own behalf, and not on
211 | behalf of any Contributor. You must make it absolutely clear that any
212 | such warranty, support, indemnity, or liability obligation is offered by
213 | You alone, and You hereby agree to indemnify every Contributor for any
214 | liability incurred by such Contributor as a result of warranty, support,
215 | indemnity or liability terms You offer. You may include additional
216 | disclaimers of warranty and limitations of liability specific to any
217 | jurisdiction.
218 |
219 | 4. Inability to Comply Due to Statute or Regulation
220 | ---------------------------------------------------
221 |
222 | If it is impossible for You to comply with any of the terms of this
223 | License with respect to some or all of the Covered Software due to
224 | statute, judicial order, or regulation then You must: (a) comply with
225 | the terms of this License to the maximum extent possible; and (b)
226 | describe the limitations and the code they affect. Such description must
227 | be placed in a text file included with all distributions of the Covered
228 | Software under this License. Except to the extent prohibited by statute
229 | or regulation, such description must be sufficiently detailed for a
230 | recipient of ordinary skill to be able to understand it.
231 |
232 | 5. Termination
233 | --------------
234 |
235 | 5.1. The rights granted under this License will terminate automatically
236 | if You fail to comply with any of its terms. However, if You become
237 | compliant, then the rights granted under this License from a particular
238 | Contributor are reinstated (a) provisionally, unless and until such
239 | Contributor explicitly and finally terminates Your grants, and (b) on an
240 | ongoing basis, if such Contributor fails to notify You of the
241 | non-compliance by some reasonable means prior to 60 days after You have
242 | come back into compliance. Moreover, Your grants from a particular
243 | Contributor are reinstated on an ongoing basis if such Contributor
244 | notifies You of the non-compliance by some reasonable means, this is the
245 | first time You have received notice of non-compliance with this License
246 | from such Contributor, and You become compliant prior to 30 days after
247 | Your receipt of the notice.
248 |
249 | 5.2. If You initiate litigation against any entity by asserting a patent
250 | infringement claim (excluding declaratory judgment actions,
251 | counter-claims, and cross-claims) alleging that a Contributor Version
252 | directly or indirectly infringes any patent, then the rights granted to
253 | You by any and all Contributors for the Covered Software under Section
254 | 2.1 of this License shall terminate.
255 |
256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all
257 | end user license agreements (excluding distributors and resellers) which
258 | have been validly granted by You or Your distributors under this License
259 | prior to termination shall survive termination.
260 |
261 | ************************************************************************
262 | * *
263 | * 6. Disclaimer of Warranty *
264 | * ------------------------- *
265 | * *
266 | * Covered Software is provided under this License on an "as is" *
267 | * basis, without warranty of any kind, either expressed, implied, or *
268 | * statutory, including, without limitation, warranties that the *
269 | * Covered Software is free of defects, merchantable, fit for a *
270 | * particular purpose or non-infringing. The entire risk as to the *
271 | * quality and performance of the Covered Software is with You. *
272 | * Should any Covered Software prove defective in any respect, You *
273 | * (not any Contributor) assume the cost of any necessary servicing, *
274 | * repair, or correction. This disclaimer of warranty constitutes an *
275 | * essential part of this License. No use of any Covered Software is *
276 | * authorized under this License except under this disclaimer. *
277 | * *
278 | ************************************************************************
279 |
280 | ************************************************************************
281 | * *
282 | * 7. Limitation of Liability *
283 | * -------------------------- *
284 | * *
285 | * Under no circumstances and under no legal theory, whether tort *
286 | * (including negligence), contract, or otherwise, shall any *
287 | * Contributor, or anyone who distributes Covered Software as *
288 | * permitted above, be liable to You for any direct, indirect, *
289 | * special, incidental, or consequential damages of any character *
290 | * including, without limitation, damages for lost profits, loss of *
291 | * goodwill, work stoppage, computer failure or malfunction, or any *
292 | * and all other commercial damages or losses, even if such party *
293 | * shall have been informed of the possibility of such damages. This *
294 | * limitation of liability shall not apply to liability for death or *
295 | * personal injury resulting from such party's negligence to the *
296 | * extent applicable law prohibits such limitation. Some *
297 | * jurisdictions do not allow the exclusion or limitation of *
298 | * incidental or consequential damages, so this exclusion and *
299 | * limitation may not apply to You. *
300 | * *
301 | ************************************************************************
302 |
303 | 8. Litigation
304 | -------------
305 |
306 | Any litigation relating to this License may be brought only in the
307 | courts of a jurisdiction where the defendant maintains its principal
308 | place of business and such litigation shall be governed by laws of that
309 | jurisdiction, without reference to its conflict-of-law provisions.
310 | Nothing in this Section shall prevent a party's ability to bring
311 | cross-claims or counter-claims.
312 |
313 | 9. Miscellaneous
314 | ----------------
315 |
316 | This License represents the complete agreement concerning the subject
317 | matter hereof. If any provision of this License is held to be
318 | unenforceable, such provision shall be reformed only to the extent
319 | necessary to make it enforceable. Any law or regulation which provides
320 | that the language of a contract shall be construed against the drafter
321 | shall not be used to construe this License against a Contributor.
322 |
323 | 10. Versions of the License
324 | ---------------------------
325 |
326 | 10.1. New Versions
327 |
328 | Mozilla Foundation is the license steward. Except as provided in Section
329 | 10.3, no one other than the license steward has the right to modify or
330 | publish new versions of this License. Each version will be given a
331 | distinguishing version number.
332 |
333 | 10.2. Effect of New Versions
334 |
335 | You may distribute the Covered Software under the terms of the version
336 | of the License under which You originally received the Covered Software,
337 | or under the terms of any subsequent version published by the license
338 | steward.
339 |
340 | 10.3. Modified Versions
341 |
342 | If you create software not governed by this License, and you want to
343 | create a new license for such software, you may create and use a
344 | modified version of this License if you rename the license and remove
345 | any references to the name of the license steward (except to note that
346 | such modified license differs from this License).
347 |
348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary
349 | Licenses
350 |
351 | If You choose to distribute Source Code Form that is Incompatible With
352 | Secondary Licenses under the terms of this version of the License, the
353 | notice described in Exhibit B of this License must be attached.
354 |
355 | Exhibit A - Source Code Form License Notice
356 | -------------------------------------------
357 |
358 | This Source Code Form is subject to the terms of the Mozilla Public
359 | License, v. 2.0. If a copy of the MPL was not distributed with this
360 | file, You can obtain one at http://mozilla.org/MPL/2.0/.
361 |
362 | If it is not possible or desirable to put the notice in a particular
363 | file, then You may include the notice in a location (such as a LICENSE
364 | file in a relevant directory) where a recipient would be likely to look
365 | for such a notice.
366 |
367 | You may add additional accurate notices of copyright ownership.
368 |
369 | Exhibit B - "Incompatible With Secondary Licenses" Notice
370 | ---------------------------------------------------------
371 |
372 | This Source Code Form is "Incompatible With Secondary Licenses", as
373 | defined by the Mozilla Public License, v. 2.0.
374 |
--------------------------------------------------------------------------------
/backupdate.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | version="1.2.0"
3 |
4 | # Copyright (c) 2024 hazzuk
5 | # This Source Code Form is subject to the terms of the Mozilla Public
6 | # License, v. 2.0. If a copy of the MPL was not distributed with this
7 | # file, You can obtain one at https://mozilla.org/MPL/2.0/.
8 |
9 |
10 | # _ _ _ _
11 | # ___ ___ _____ ___ ___ ___ ___ ___| |_ ___ ___| |_ _ _ ___ _| |___| |_ ___
12 | # | _| . | | . | . |_ -| -_|___| . | .'| _| '_| | | . | . | .'| _| -_|
13 | # |___|___|_|_|_| _|___|___|___| |___|__,|___|_,_|___| _|___|__,|_| |___|
14 | # |_| |_|
15 | #
16 | # Bash script for creating scheduled backups, and performing (backed-up) guided updates on Docker compose stacks
17 | # https://github.com/hazzuk/compose-backupdate
18 |
19 |
20 | # exit on any error
21 | set -e
22 |
23 | # check if running as root
24 | if [ "$EUID" -ne 0 ]; then
25 | echo "Error, please run as root!"
26 | exit 1
27 | fi
28 |
29 | # variables
30 | # ---
31 |
32 | # required
33 | backup_dir=${BACKUP_DIR:-"null"} # -b "/opt/backup"
34 | docker_dir=${DOCKER_DIR:-"null"} # -d "/opt/docker"
35 | stack_name=${STACK_NAME:-"null"} # -s "nginx"
36 |
37 | # optional
38 | backup_blocklist=${BACKUP_BLOCKLIST:-"null"} # -l "media_vol,/media-bind"
39 | update_requested=false # -u
40 | version_requested=false # -v
41 |
42 | # internal
43 | timestamp=$(date +"%Y%m%d-%H%M%S")
44 | stack_running=false
45 | working_dir="null"
46 | volume_blocklist=()
47 | path_blocklist=()
48 | running_container_ids=""
49 | running_container_names=""
50 |
51 | # script
52 | # ---
53 |
54 | main() {
55 | # script version check
56 | if [ "$version_requested" = true ]; then
57 | script_update_check
58 | exit 0
59 | fi
60 |
61 | # check current directory for compose file
62 | docker_stack_dir
63 |
64 | # check script variables before continuing
65 | verify_config
66 |
67 | # create backup directory
68 | mkdir -p "$backup_dir/$stack_name" || { echo "Error, failed to create backup directory $backup_dir!"; exit 1; }
69 |
70 | # stop stack before backup
71 | echo "(stop)"
72 | docker_stack_stop
73 |
74 | # backup compose stack working directory
75 | echo "(backups)"
76 | backup_working_dir
77 |
78 | # backup docker volumes
79 | backup_stack_volumes
80 |
81 | # update if requested
82 | if [ "$update_requested" = true ]; then
83 | echo "(updates)"
84 |
85 | # print stack changelog url
86 | # print_changelog_url
87 |
88 | # update compose stack
89 | docker_stack_update
90 | fi
91 |
92 | # restart stack again if previously running
93 | echo "(restart)"
94 | docker_stack_start
95 |
96 | # prune unused docker images
97 | if [ "$update_requested" = true ]; then
98 | echo "(prune)"
99 | # new images must be associated with a running stack
100 | if [ "$stack_running" = true ]; then
101 | docker_image_prune
102 | else
103 | echo "- Docker stack was not recreated, skipping image prune"
104 | echo
105 | fi
106 | fi
107 |
108 | echo -e "backupdate complete!\n\n"
109 | exit 0
110 | }
111 |
112 | # utilities
113 | # ---
114 |
115 | usage() {
116 | echo "Usage: $0 [-b backup_dir] [-d docker_dir] [-s stack_name] [-l backup_blocklist] [-u] [-v]"
117 | echo " --backup-dir --docker-dir --stack-name --backup-blocklist --update --version"
118 | exit 1
119 | }
120 |
121 | parse_args() {
122 | local OPTIONS=b:d:s:l:uv
123 | local LONGOPTS=backup-dir:,docker-dir:,stack-name:,backup-blocklist:,update,version
124 |
125 | # parse options
126 | if ! PARSED=$(getopt --options=$OPTIONS --longoptions=$LONGOPTS --name "$0" -- "$@"); then
127 | exit 2
128 | fi
129 |
130 | # evaluate parsed options
131 | eval set -- "$PARSED"
132 |
133 | # Now handle the options
134 | while true; do
135 | case "$1" in
136 | -b|--backup-dir)
137 | backup_dir="$2"
138 | shift 2
139 | ;;
140 | -d|--docker-dir)
141 | docker_dir="$2"
142 | shift 2
143 | ;;
144 | -s|--stack-name)
145 | stack_name="$2"
146 | shift 2
147 | ;;
148 | -l|--backup-blocklist)
149 | backup_blocklist="$2"
150 | shift 2
151 | ;;
152 | -u|--update)
153 | update_requested=true
154 | shift
155 | ;;
156 | -v|--version)
157 | version_requested=true
158 | shift
159 | ;;
160 | --)
161 | shift
162 | break
163 | ;;
164 | *)
165 | echo "Unknown option: $1"
166 | exit 3
167 | ;;
168 | esac
169 | done
170 | }
171 |
172 | verify_config() {
173 | # check required inputs
174 | if [ "$backup_dir" = "null" ]; then
175 | echo "Error, backup_dir not provided!"
176 | usage
177 | fi
178 | if [ "$working_dir" = "null/$stack_name" ]; then
179 | echo "Error, docker_dir not provided!"
180 | usage
181 | fi
182 | if [ "$stack_name" = "null" ]; then
183 | echo "Error, stack_name not provided!"
184 | usage
185 | fi
186 | if [ "$working_dir" = "null" ]; then
187 | echo "Error, working_dir not set!"
188 | exit 1
189 | fi
190 |
191 | # echo script config
192 | echo "backupdate <$stack_name> $timestamp"
193 | echo "- backup_dir: $backup_dir"
194 | echo "- working_dir: $working_dir"
195 |
196 | # check backup blocklist
197 | if [ "$backup_blocklist" != "null" ]; then
198 | # convert string to array
199 | IFS=',' read -r -a blockarray <<< "$backup_blocklist"
200 |
201 | # process items in array
202 | for item in "${blockarray[@]}"; do
203 | if [[ $item == /* ]]; then
204 | # item starts with slash
205 | item="${item#/}"
206 | path_blocklist+=("$item")
207 | else
208 | volume_blocklist+=("$item")
209 | fi
210 | done
211 |
212 | # echo volume blocklist
213 | if [ ${#volume_blocklist[@]} -gt 0 ]; then
214 | echo "- volume_blocklist:"
215 | for vol in "${volume_blocklist[@]}"; do
216 | echo -e "\t- $vol"
217 | done
218 | fi
219 |
220 | # echo path blocklist
221 | if [ ${#path_blocklist[@]} -gt 0 ]; then
222 | echo "- path_blocklist:"
223 | for path in "${path_blocklist[@]}"; do
224 | echo -e "\t- $path"
225 | done
226 | fi
227 | fi
228 |
229 | echo
230 | }
231 |
232 | confirm() {
233 | local prompt="$1"
234 | read -r -p "${prompt} (y/N): " confirm
235 | if [[ "$confirm" == "y" || "$confirm" == "Y" ]]; then
236 | return 0 # true
237 | else
238 | return 1 # false
239 | fi
240 | }
241 |
242 | script_update_check() {
243 | local repo="hazzuk/compose-backupdate"
244 | local raw_url="https://raw.githubusercontent.com/$repo/refs/heads/release/backupdate.sh"
245 | local latest_version_line
246 | local latest_version
247 |
248 | # fetch second line (version="X.Y.Z") from the script hosted on github
249 | latest_version_line=$(curl -s "$raw_url" | sed -n '2p')
250 | # extract version from the fetched line
251 | latest_version=$(echo "$latest_version_line" | grep -oP '(?<=version=")[^"]+')
252 |
253 | if [[ $latest_version == "" ]]; then
254 | echo "Warn, could not check for updates"
255 | return 0
256 | fi
257 |
258 | # compare local version with the latest version
259 | if [[ "$version" != "$latest_version" ]]; then
260 | echo "A new version (v$latest_version) is available! You are using backupdate-v$version"
261 | else
262 | echo "Running backupdate-v$version"
263 | fi
264 | }
265 |
266 | # docker
267 | # ---
268 |
269 | docker_stack_stop() {
270 | local container_ids
271 | local container_names
272 |
273 | echo "Stopping <$stack_name> containers"
274 | cd "$working_dir" || exit
275 |
276 | # for updates, require stack to be removed
277 | if [ "$update_requested" = true ]; then
278 | stack_running=false
279 |
280 | # stop stack
281 | echo "- Update requested, removing all stack containers"
282 | docker compose down
283 |
284 | else
285 | # check stack running, with at least one container running
286 | if docker compose ls --quiet --filter "name=$stack_name" | grep -q "$stack_name"; then
287 | stack_running=true
288 |
289 | # get running containers ids
290 | container_ids=$(docker compose ps --quiet --filter "status=running")
291 |
292 | # get running containers names
293 | if [ -n "$container_ids" ]; then
294 | # shellcheck disable=SC2086
295 | container_names=$(docker inspect --format '{{.Name}}' $container_ids | sed 's|^/||' | tr -d '\r')
296 |
297 | # print container names
298 | for name in $container_names; do
299 | echo "- $name"
300 | done
301 |
302 | # set global variables
303 | running_container_ids=$container_ids
304 | running_container_names=$container_names
305 |
306 | else
307 | echo "Error, stack running but no container IDs found!"
308 | exit 1
309 | fi
310 |
311 | # stop stack
312 | docker compose --progress "quiet" stop
313 |
314 | else
315 | stack_running=false
316 | echo "- Docker stack <$stack_name> not running, skipping docker stop"
317 | fi
318 | fi
319 | echo
320 | }
321 |
322 | docker_stack_start() {
323 | # check stack was previously running
324 | if [ "$stack_running" = true ]; then
325 | cd "$working_dir" || exit
326 |
327 | echo "Resuming <$stack_name> containers"
328 |
329 | # restart only previously running containers
330 | if [ -n "$running_container_ids" ]; then
331 |
332 | # print container names
333 | for name in $running_container_names; do
334 | echo "- $name"
335 | done
336 |
337 | # restart containers
338 | echo -e "\nInfo, restarted container IDs"
339 | # shellcheck disable=SC2086
340 | docker start $running_container_ids
341 | fi
342 |
343 | else
344 | # for updates, stack should be recreated with updated images
345 | if [ "$update_requested" = true ]; then
346 | if confirm "Do you want to recreate <$stack_name>'s containers now?"; then
347 | echo "- Recreating Docker stack..."
348 | docker compose up -d
349 |
350 | # new stack running, can prune unused images
351 | stack_running=true
352 | else
353 | echo "- Stack recreation canceled"
354 | fi
355 |
356 | # stack was not previously running
357 | else
358 | echo "- Docker stack <$stack_name> not previously running, skipping docker start"
359 | fi
360 | fi
361 | echo
362 | }
363 |
364 | docker_stack_dir() {
365 | # possible compose file names
366 | local compose_files=("compose.yaml" "compose.yml" "docker-compose.yaml" "docker-compose.yml")
367 | # current directory name
368 | local current_dir
369 | current_dir=$(basename "$PWD") # "nginx"?
370 |
371 | # check neither $docker_dir or $stack_name were provided
372 | if [[ "$docker_dir" == "null" && "$stack_name" == "null" ]]; then
373 | echo "Info, neither docker_dir or stack_name were provided, using current directory"
374 | # but if $docker_dir was provided alone (likely as an environment variable), and is correct
375 | elif [[ "$docker_dir/$current_dir" = "$(pwd)" && "$stack_name" == "null" ]]; then
376 | echo "Info, stack_name was not provided, using current directory"
377 | # otherwise something was provided, do not use current directory
378 | else
379 | # update working_dir with provided options
380 | working_dir="$docker_dir/$stack_name"
381 | return 0
382 | fi
383 |
384 | # search current directory for compose file
385 | for file in "${compose_files[@]}"; do
386 | if [[ -f "$file" ]]; then
387 | # update working_dir and stack_name to current directory
388 | working_dir="$(pwd)"
389 | stack_name=$current_dir
390 | echo -e "Found <$stack_name> $file in current directory\n "
391 | return 0
392 | fi
393 | done
394 | echo "Error, compose file not found in current directory!"
395 | usage
396 | }
397 |
398 | docker_stack_update() {
399 | if confirm "Are you sure you want to update <$stack_name>?"; then
400 | echo "- Updating Docker stack..."
401 | docker compose pull
402 | else
403 | echo "- Update canceled"
404 | fi
405 | echo
406 | }
407 |
408 | docker_image_prune() {
409 | local docker_images
410 | local docker_images_unused=()
411 |
412 | echo "Searching for unused docker images..."
413 | # collect docker images output
414 | docker_images=$(
415 | docker images --format "table {{.ID}}\t{{.Repository}}\t{{.Tag}}\t{{.Size}}" | \
416 | tail -n +2
417 | )
418 |
419 | # process images output
420 | while read -r image_id repository tag size; do
421 | # skip unused busybox image
422 | if [[ "$repository" == "busybox" ]]; then
423 | continue
424 | fi
425 | # check the image is not being used by a running/stopped container
426 | if [[ -z $(docker ps -a --filter "ancestor=$image_id" --format '{{.ID}}') ]]; then
427 | # append unused image_id to array
428 | docker_images_unused+=("$image_id")
429 | # print unused image details
430 | printf "%-16s %-45s %-10s\n" "- $image_id" "$repository:$tag" "$size"
431 | fi
432 | done <<< "$docker_images" # avoid subshell
433 |
434 | # check no unused images found
435 | if [[ ${#docker_images_unused[@]} -eq 0 ]]; then
436 | echo -e "- No unused images found\n "
437 | return 0
438 | else
439 | # prompt user for confirmation before proceeding
440 | if confirm "Do you want to prune unused images?"; then
441 | # prune unused images
442 | for image_id in "${docker_images_unused[@]}"; do
443 | echo "- Removing $image_id"
444 | docker rmi "$image_id" -f
445 | done
446 | else
447 | echo "- Prune cancelled"
448 | fi
449 | fi
450 | echo
451 | }
452 |
453 | # backups
454 | # ---
455 |
456 | backup_working_dir() {
457 | local exclude_opts=""
458 | local exclude_info=""
459 |
460 | echo "Backup <$stack_name> directory: $working_dir"
461 |
462 | # set blocklist options
463 | if [ ${#path_blocklist[@]} -gt 0 ]; then
464 | for path in "${path_blocklist[@]}"; do
465 | exclude_opts+="--exclude=$path "
466 | exclude_info+="$path "
467 | done
468 | echo "- Skipping blocklisted paths: $exclude_info"
469 | fi
470 |
471 | # create archive with exclude options
472 | eval tar -czf "$backup_dir/$stack_name/d-$stack_name-$timestamp.tar.gz" "$exclude_opts" -C "$working_dir" .
473 | echo "- Directory backup complete"
474 | }
475 |
476 | backup_stack_volumes() {
477 | # get all stack volumes
478 | local stack_volumes
479 | stack_volumes=$(
480 | docker volume ls --filter "label=com.docker.compose.project=$stack_name" --format "{{.Name}}"
481 | )
482 |
483 | # check volumes found
484 | if [ -z "$stack_volumes" ]; then
485 | echo -e "Info, no related volumes found for <$stack_name>\n "
486 | return 0
487 | fi
488 |
489 | # backup each volume
490 | for volume_name in $stack_volumes; do
491 | echo "Backup volume: <$volume_name>"
492 |
493 | # skip blocklisted volumes
494 | if [[ " ${volume_blocklist[*]} " == *" $volume_name "* ]]; then
495 | echo "- Skipping blocklisted volume"
496 | continue
497 | fi
498 | # create backup
499 | backup_volume "$volume_name"
500 | done
501 | echo
502 | }
503 |
504 | backup_volume() {
505 | local volume_name=$1
506 |
507 | # backup volume data with temporary container
508 | docker run --rm \
509 | -v "$volume_name":/volume_data \
510 | -v "$backup_dir":/backup \
511 | busybox tar czf "/backup/$stack_name/v-$volume_name-$timestamp.tar.gz" -C /volume_data . || \
512 | { echo "Error, failed to create busybox backup container!"; exit 1; }
513 | echo "- Volume backup complete"
514 | }
515 |
516 | # print_changelog_url() {
517 | # local changelog_file="$working_dir/changelog.url"
518 |
519 | # # check changelog.url exists
520 | # if [[ -f "$changelog_file" ]]; then
521 | # echo "Link to read the <$stack_name> changelog: "
522 | # cat "$changelog_file"
523 | # else
524 | # # ask user to create changelog.url
525 | # echo "File $changelog_file does not exist"
526 | # read -r -p "Please provide a URL (or press Enter to continue without): " user_input
527 |
528 | # if [[ $user_input == http* ]]; then
529 | # # create changelog.url with user input
530 | # echo "$user_input" > "$changelog_file"
531 | # echo "- $changelog_file created"
532 | # else
533 | # echo "- No valid URL provided. Continuing without the <$stack_name> changelog"
534 | # fi
535 | # fi
536 | # }
537 |
538 | # run script
539 | # ---
540 |
541 | parse_args "$@"
542 | main
543 |
--------------------------------------------------------------------------------