jira-confluence-full-instance-backup 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- backup/__init__.py +8 -0
- backup/__main__.py +7 -0
- backup/archive.py +112 -0
- backup/cli.py +414 -0
- backup/config.py +136 -0
- backup/confluence.py +230 -0
- backup/jira.py +314 -0
- backup/manifest.py +130 -0
- backup/naming.py +63 -0
- backup/notify.py +232 -0
- backup/ui.py +140 -0
- backup/upload.py +167 -0
- jira_confluence_full_instance_backup-0.1.0.dist-info/METADATA +343 -0
- jira_confluence_full_instance_backup-0.1.0.dist-info/RECORD +18 -0
- jira_confluence_full_instance_backup-0.1.0.dist-info/WHEEL +5 -0
- jira_confluence_full_instance_backup-0.1.0.dist-info/entry_points.txt +2 -0
- jira_confluence_full_instance_backup-0.1.0.dist-info/licenses/LICENSE +21 -0
- jira_confluence_full_instance_backup-0.1.0.dist-info/top_level.txt +1 -0
backup/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Full-instance backup of Atlassian Cloud (Jira + Confluence).
|
|
2
|
+
|
|
3
|
+
Backs up to pluggable cloud storage (GCS / S3 / Azure / local). Modules run as
|
|
4
|
+
``python -m backup.<name>`` from the Jenkins pipeline (jira, confluence,
|
|
5
|
+
archive, upload, notify); the dual-mode CLI/menu is ``backup.cli`` (also the
|
|
6
|
+
``jira-confluence-backup`` console script and ``python -m backup``).
|
|
7
|
+
"""
|
|
8
|
+
__version__ = "0.1.0"
|
backup/__main__.py
ADDED
backup/archive.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Archive backup files with 7-Zip.
|
|
3
|
+
|
|
4
|
+
By default uses AES-256 with encrypted headers (-mhe=on), which hides filenames
|
|
5
|
+
inside the archive — the right posture for cloud-stored backups. Encryption can
|
|
6
|
+
be turned off (no password) and the compression level is configurable.
|
|
7
|
+
|
|
8
|
+
Uses subprocess to call `7z` — pre-installed on the Jenkins build agent.
|
|
9
|
+
|
|
10
|
+
Cooldown marker (jira_cooldown.txt) is preserved into the archive so downstream
|
|
11
|
+
stages and the notify step can see the Jira stage was skipped rather than failed.
|
|
12
|
+
|
|
13
|
+
Set SEVEN_ZIP_PATH if `7z` is not on PATH (e.g. local Windows testing:
|
|
14
|
+
"C:\\Program Files\\7-Zip\\7z.exe").
|
|
15
|
+
"""
|
|
16
|
+
import argparse
|
|
17
|
+
import os
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from . import manifest, naming, ui
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
SEVEN_ZIP = os.environ.get("SEVEN_ZIP_PATH", "7z")
|
|
26
|
+
DEFAULT_COMPRESSION = 5 # 0=store … 9=ultra
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def archive_directory(src_dir: Path, archive_path: Path, password: str = "",
|
|
30
|
+
level: int = DEFAULT_COMPRESSION) -> int:
|
|
31
|
+
"""
|
|
32
|
+
Run 7z to archive everything in src_dir into archive_path.
|
|
33
|
+
Encrypts with AES-256 + header encryption when `password` is non-empty;
|
|
34
|
+
otherwise produces an unencrypted archive. Returns archive size in bytes.
|
|
35
|
+
"""
|
|
36
|
+
if not src_dir.exists() or not any(src_dir.iterdir()):
|
|
37
|
+
raise RuntimeError(f"Source directory {src_dir} is empty — nothing to archive")
|
|
38
|
+
|
|
39
|
+
cmd = [SEVEN_ZIP, "a", "-t7z", f"-mx={level}"]
|
|
40
|
+
if password:
|
|
41
|
+
cmd += ["-mhe=on", f"-p{password}"] # encrypt headers + data
|
|
42
|
+
cmd += [str(archive_path), str(src_dir) + "/*"]
|
|
43
|
+
|
|
44
|
+
enc = "AES-256" if password else "no encryption"
|
|
45
|
+
# Don't print the command — password is in argv
|
|
46
|
+
ui.info(f"Creating {archive_path.name} from {src_dir} (7z, mx={level}, {enc})")
|
|
47
|
+
if not password:
|
|
48
|
+
ui.warn("Archive is NOT encrypted — avoid for cloud storage of sensitive data")
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
52
|
+
except FileNotFoundError:
|
|
53
|
+
raise RuntimeError(
|
|
54
|
+
f"7-Zip not found ('{SEVEN_ZIP}'). Install p7zip-full, or set "
|
|
55
|
+
f"SEVEN_ZIP_PATH to the 7z executable."
|
|
56
|
+
)
|
|
57
|
+
if result.returncode != 0:
|
|
58
|
+
raise RuntimeError(f"7z failed (exit {result.returncode}): {result.stderr.strip()}")
|
|
59
|
+
|
|
60
|
+
return archive_path.stat().st_size
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def run_archive(in_dir: Path, out_dir: Path, password: str = "",
|
|
64
|
+
name_template: str = naming.DEFAULT_ARCHIVE_TEMPLATE,
|
|
65
|
+
site: str | None = None,
|
|
66
|
+
level: int = DEFAULT_COMPRESSION) -> Path:
|
|
67
|
+
"""Archive in_dir → .7z in out_dir (encrypted iff password). Returns the path."""
|
|
68
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
archive_path = out_dir / naming.render_name(name_template, "atlassian",
|
|
70
|
+
ext=".7z", site=site)
|
|
71
|
+
size = archive_directory(in_dir, archive_path, password, level)
|
|
72
|
+
ui.ok(f"Archive: {archive_path} ({size / (1024 * 1024):.1f} MB)")
|
|
73
|
+
|
|
74
|
+
man = manifest.build(in_dir, archive_path, site=site or "")
|
|
75
|
+
man["encrypted"] = bool(password)
|
|
76
|
+
manifest.write(man, out_dir)
|
|
77
|
+
ui.info(f"Manifest: {manifest.MANIFEST_NAME} "
|
|
78
|
+
f"({len(man['sources'])} source file(s), complete)")
|
|
79
|
+
return archive_path
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def main():
|
|
83
|
+
parser = argparse.ArgumentParser()
|
|
84
|
+
parser.add_argument("--in", dest="in_dir", required=True, type=Path)
|
|
85
|
+
parser.add_argument("--out", dest="out_dir", required=True, type=Path)
|
|
86
|
+
parser.add_argument("--name-template",
|
|
87
|
+
default=os.environ.get("ARCHIVE_NAME_TEMPLATE",
|
|
88
|
+
naming.DEFAULT_ARCHIVE_TEMPLATE),
|
|
89
|
+
help="Archive filename template (tokens: {product}{site}"
|
|
90
|
+
"{date}{time}{datetime}{timestamp})")
|
|
91
|
+
parser.add_argument("--compression", type=int, choices=range(0, 10),
|
|
92
|
+
metavar="0-9",
|
|
93
|
+
default=int(os.environ.get("ARCHIVE_COMPRESSION",
|
|
94
|
+
DEFAULT_COMPRESSION)),
|
|
95
|
+
help="7z compression level: 0=store … 9=ultra (default 5)")
|
|
96
|
+
parser.add_argument("--no-encrypt", action="store_true",
|
|
97
|
+
help="Create an unencrypted archive (ignore ARCHIVE_PASSWORD)")
|
|
98
|
+
args = parser.parse_args()
|
|
99
|
+
|
|
100
|
+
password = "" if args.no_encrypt else os.environ.get("ARCHIVE_PASSWORD", "")
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
run_archive(args.in_dir, args.out_dir, password,
|
|
104
|
+
name_template=args.name_template,
|
|
105
|
+
site=os.environ.get("SITE_JIRA"),
|
|
106
|
+
level=args.compression)
|
|
107
|
+
except (RuntimeError, ValueError) as exc:
|
|
108
|
+
sys.exit(str(exc))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
if __name__ == "__main__":
|
|
112
|
+
main()
|
backup/cli.py
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Atlassian full-instance backup — dual-mode entrypoint.
|
|
4
|
+
|
|
5
|
+
Interactive (run on a VM):
|
|
6
|
+
python main.py
|
|
7
|
+
|
|
8
|
+
Automation (Jenkins / cron):
|
|
9
|
+
python main.py --all
|
|
10
|
+
python main.py --backup jira,confluence --archive --upload --notify
|
|
11
|
+
python main.py --all --dry-run # preview, no API calls / no cooldown burn
|
|
12
|
+
python main.py --backup jira --skip-existing
|
|
13
|
+
python main.py --validate # check the archive against its manifest
|
|
14
|
+
python main.py --cleanup --keep-days 28 # remove incomplete + old local backups
|
|
15
|
+
python main.py --test-connection
|
|
16
|
+
python main.py --show-config
|
|
17
|
+
python main.py --configure
|
|
18
|
+
|
|
19
|
+
Config comes from environment variables, optionally hydrated from a .env file
|
|
20
|
+
(see .env.example). Exit codes: 0 success, 1 generic failure, 2 human action
|
|
21
|
+
needed (e.g. refresh Jira cookies).
|
|
22
|
+
"""
|
|
23
|
+
import argparse
|
|
24
|
+
import sys
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from . import (archive, config, confluence, jira, manifest, naming,
|
|
28
|
+
notify, ui, upload)
|
|
29
|
+
|
|
30
|
+
DEFAULT_OUT = Path("out")
|
|
31
|
+
DEFAULT_ARCHIVE = Path("archive")
|
|
32
|
+
WEBHOOK_CHANNEL_HINTS = ("chat", "slack", "discord", "teams", "webhook")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ─────────────────────────── orchestration steps ───────────────────────────
|
|
36
|
+
|
|
37
|
+
def do_backup(cfg: config.Config, products: list[str], out_dir: Path,
|
|
38
|
+
archive_dir: Path, *, skip_existing: bool = False,
|
|
39
|
+
dry_run: bool = False) -> list[str]:
|
|
40
|
+
"""Run the requested product backups. Returns list of products that failed."""
|
|
41
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
errors = []
|
|
43
|
+
|
|
44
|
+
if "jira" in products:
|
|
45
|
+
ui.section("Jira backup")
|
|
46
|
+
if skip_existing and manifest.has_complete_today(archive_dir, "jira"):
|
|
47
|
+
ui.info("Skipping Jira — complete backup already exists today")
|
|
48
|
+
elif dry_run:
|
|
49
|
+
ui.info(f"[DRY] would trigger Jira backup on {cfg.site_jira}")
|
|
50
|
+
else:
|
|
51
|
+
cookies = jira.cookies_from_blob(cfg.jira_cookies)
|
|
52
|
+
missing = jira.missing_cookies(cookies)
|
|
53
|
+
if not cfg.jira_cookies or missing:
|
|
54
|
+
ui.error(f"Jira cookies missing/invalid: {missing or 'JIRA_COOKIES not set'}")
|
|
55
|
+
errors.append("jira")
|
|
56
|
+
else:
|
|
57
|
+
jira.run_backup(cfg.site_jira, cookies, out_dir,
|
|
58
|
+
name_template=cfg.product_name_template)
|
|
59
|
+
|
|
60
|
+
if "confluence" in products:
|
|
61
|
+
ui.section("Confluence backup")
|
|
62
|
+
if skip_existing and manifest.has_complete_today(archive_dir, "confluence"):
|
|
63
|
+
ui.info("Skipping Confluence — complete backup already exists today")
|
|
64
|
+
elif dry_run:
|
|
65
|
+
ui.info(f"[DRY] would trigger Confluence backup on {cfg.site_confluence}")
|
|
66
|
+
elif not (cfg.atl_email and cfg.atl_token):
|
|
67
|
+
ui.error("ATL_EMAIL / ATL_TOKEN not set")
|
|
68
|
+
errors.append("confluence")
|
|
69
|
+
else:
|
|
70
|
+
confluence.run_backup(cfg.site_confluence, cfg.atl_email, cfg.atl_token,
|
|
71
|
+
out_dir, name_template=cfg.product_name_template)
|
|
72
|
+
return errors
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def do_archive(cfg: config.Config, out_dir: Path, archive_dir: Path, *,
|
|
76
|
+
dry_run: bool = False, no_encrypt: bool = False) -> Path | None:
|
|
77
|
+
ui.section("Archive (7z)")
|
|
78
|
+
try:
|
|
79
|
+
level = int(cfg.archive_compression)
|
|
80
|
+
if not 0 <= level <= 9:
|
|
81
|
+
level = archive.DEFAULT_COMPRESSION
|
|
82
|
+
except (TypeError, ValueError):
|
|
83
|
+
level = archive.DEFAULT_COMPRESSION
|
|
84
|
+
password = "" if no_encrypt else cfg.archive_password
|
|
85
|
+
|
|
86
|
+
if dry_run:
|
|
87
|
+
name = naming.render_name(cfg.archive_name_template, "atlassian",
|
|
88
|
+
ext=".7z", site=cfg.site_jira)
|
|
89
|
+
enc = "AES-256" if password else "unencrypted"
|
|
90
|
+
ui.info(f"[DRY] would archive {out_dir} -> {archive_dir / name} "
|
|
91
|
+
f"(mx={level}, {enc}, + manifest)")
|
|
92
|
+
return None
|
|
93
|
+
return archive.run_archive(out_dir, archive_dir, password,
|
|
94
|
+
name_template=cfg.archive_name_template,
|
|
95
|
+
site=cfg.site_jira, level=level)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def do_upload(cfg: config.Config, archive_dir: Path, *, dry_run: bool = False) -> None:
|
|
99
|
+
ui.section(f"Upload ({cfg.storage_provider})")
|
|
100
|
+
if dry_run:
|
|
101
|
+
n = len(list(archive_dir.glob("*.7z"))) if archive_dir.exists() else 0
|
|
102
|
+
ui.info(f"[DRY] would upload {n} archive(s) + manifest to "
|
|
103
|
+
f"{cfg.storage_provider}:{cfg.storage_dest}")
|
|
104
|
+
return
|
|
105
|
+
if not cfg.storage_dest:
|
|
106
|
+
raise RuntimeError("STORAGE_DEST not set")
|
|
107
|
+
upload.run_upload(cfg.storage_provider, cfg.storage_dest, archive_dir,
|
|
108
|
+
endpoint_url=cfg.s3_endpoint_url or None,
|
|
109
|
+
region=cfg.aws_default_region or None)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def do_notify(cfg: config.Config, status: str, archive_dir: Path,
|
|
113
|
+
build_url: str = "", *, dry_run: bool = False) -> None:
|
|
114
|
+
ui.section("Notify")
|
|
115
|
+
if dry_run:
|
|
116
|
+
ui.info(f"[DRY] would notify channels={cfg.notify_channels} status={status}")
|
|
117
|
+
return
|
|
118
|
+
channels = [c.strip() for c in cfg.notify_channels.split(",") if c.strip()]
|
|
119
|
+
report = notify.build_report(status, archive_dir, build_url)
|
|
120
|
+
failures = notify.dispatch(channels, report, cfg.notify_webhook_url)
|
|
121
|
+
if failures:
|
|
122
|
+
ui.warn(f"{failures} channel(s) failed")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def do_validate(archive_dir: Path) -> bool:
|
|
126
|
+
ui.section("Validate backup")
|
|
127
|
+
ok, issues = manifest.validate(archive_dir)
|
|
128
|
+
if ok:
|
|
129
|
+
ui.ok("Backup valid: manifest complete and archive checksum matches")
|
|
130
|
+
else:
|
|
131
|
+
for issue in issues:
|
|
132
|
+
ui.error(issue)
|
|
133
|
+
return ok
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def do_cleanup(out_dir: Path, archive_dir: Path, keep_days: int | None) -> None:
|
|
137
|
+
ui.section("Cleanup backups")
|
|
138
|
+
removed = manifest.cleanup(out_dir, archive_dir, keep_days)
|
|
139
|
+
for path in removed:
|
|
140
|
+
ui.info(f"removed {path}")
|
|
141
|
+
ui.ok(f"Cleaned {len(removed)} item(s)") if removed else ui.info("Nothing to clean")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def do_test(cfg: config.Config) -> bool:
|
|
145
|
+
ui.section("Connection test")
|
|
146
|
+
ok_j, msg_j = (jira.test_connection(cfg.site_jira, cfg.jira_cookies)
|
|
147
|
+
if cfg.jira_cookies else (False, "JIRA_COOKIES not set"))
|
|
148
|
+
(ui.ok if ok_j else ui.error)(f"Jira: {msg_j}")
|
|
149
|
+
ok_c, msg_c = confluence.test_connection(cfg.site_confluence, cfg.atl_email, cfg.atl_token)
|
|
150
|
+
(ui.ok if ok_c else ui.error)(f"Confluence: {msg_c}")
|
|
151
|
+
return ok_j and ok_c
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def do_show(cfg: config.Config) -> None:
|
|
155
|
+
ui.section("Configuration")
|
|
156
|
+
ui.table("Current config (secrets masked)", config.display_rows(cfg))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def do_list(out_dir: Path, archive_dir: Path) -> None:
|
|
160
|
+
ui.section("Local backups")
|
|
161
|
+
man = manifest.read(archive_dir)
|
|
162
|
+
if man:
|
|
163
|
+
arch = man.get("archive", {})
|
|
164
|
+
ui.table("Latest manifest", [
|
|
165
|
+
("status", "complete" if man.get("complete") else "INCOMPLETE"),
|
|
166
|
+
("created", man.get("created_utc", "?")),
|
|
167
|
+
("products", ", ".join(man.get("products", [])) or "?"),
|
|
168
|
+
("archive", f"{arch.get('name', '?')} "
|
|
169
|
+
f"({arch.get('size', 0) / (1024 * 1024):.1f} MB)"),
|
|
170
|
+
])
|
|
171
|
+
else:
|
|
172
|
+
ui.info("No manifest in archive dir (no complete backup yet)")
|
|
173
|
+
|
|
174
|
+
files = []
|
|
175
|
+
for d in (out_dir, archive_dir):
|
|
176
|
+
if d.exists():
|
|
177
|
+
for f in sorted(d.glob("*")):
|
|
178
|
+
if f.is_file():
|
|
179
|
+
files.append((str(f), f"{f.stat().st_size / (1024 * 1024):.1f} MB"))
|
|
180
|
+
ui.table("Files", files) if files else ui.info("No local backup files found")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def do_configure(cfg: config.Config) -> None:
|
|
184
|
+
ui.section("Configure credentials → .env")
|
|
185
|
+
ui.info("Press Enter to keep the current value. Saved to .env (gitignored).")
|
|
186
|
+
|
|
187
|
+
cfg.site_jira = ui.prompt("Jira site URL", cfg.site_jira)
|
|
188
|
+
default_conf = cfg.site_confluence or (f"{cfg.site_jira}/wiki" if cfg.site_jira else "")
|
|
189
|
+
cfg.site_confluence = ui.prompt("Confluence site URL (.../wiki)", default_conf)
|
|
190
|
+
cfg.atl_email = ui.prompt("Atlassian account email", cfg.atl_email)
|
|
191
|
+
cfg.atl_token = ui.prompt("Atlassian API token (Confluence)", cfg.atl_token, secret=True)
|
|
192
|
+
cfg.jira_cookies = ui.prompt("Jira cookie blob (paste)", cfg.jira_cookies, secret=True)
|
|
193
|
+
cfg.archive_password = ui.prompt("Archive password (7z; blank = no encryption)",
|
|
194
|
+
cfg.archive_password, secret=True)
|
|
195
|
+
cfg.archive_compression = ui.prompt("Compression level 0-9 (0=store, 9=ultra)",
|
|
196
|
+
cfg.archive_compression)
|
|
197
|
+
cfg.product_name_template = ui.prompt("Product filename template", cfg.product_name_template)
|
|
198
|
+
cfg.archive_name_template = ui.prompt("Archive (.7z) filename template",
|
|
199
|
+
cfg.archive_name_template)
|
|
200
|
+
|
|
201
|
+
cfg.storage_provider = ui.prompt("Storage provider (gcs/s3/azure/local)",
|
|
202
|
+
cfg.storage_provider)
|
|
203
|
+
cfg.storage_dest = ui.prompt("Storage dest (bucket/container/dir)", cfg.storage_dest)
|
|
204
|
+
if cfg.storage_provider == "gcs":
|
|
205
|
+
cfg.gcp_credentials = ui.prompt("Path to GCP SA key JSON", cfg.gcp_credentials)
|
|
206
|
+
elif cfg.storage_provider == "s3":
|
|
207
|
+
cfg.aws_access_key_id = ui.prompt("AWS access key id", cfg.aws_access_key_id, secret=True)
|
|
208
|
+
cfg.aws_secret_access_key = ui.prompt("AWS secret access key",
|
|
209
|
+
cfg.aws_secret_access_key, secret=True)
|
|
210
|
+
cfg.aws_default_region = ui.prompt("AWS region", cfg.aws_default_region)
|
|
211
|
+
cfg.s3_endpoint_url = ui.prompt("S3 endpoint URL (blank for AWS)", cfg.s3_endpoint_url)
|
|
212
|
+
elif cfg.storage_provider == "azure":
|
|
213
|
+
cfg.azure_conn = ui.prompt("Azure connection string", cfg.azure_conn, secret=True)
|
|
214
|
+
|
|
215
|
+
cfg.notify_channels = ui.prompt("Notify channels (comma)", cfg.notify_channels)
|
|
216
|
+
if any(h in cfg.notify_channels for h in WEBHOOK_CHANNEL_HINTS):
|
|
217
|
+
cfg.notify_webhook_url = ui.prompt("Notify webhook URL", cfg.notify_webhook_url, secret=True)
|
|
218
|
+
if "email" in cfg.notify_channels:
|
|
219
|
+
cfg.smtp_host = ui.prompt("SMTP host", cfg.smtp_host)
|
|
220
|
+
cfg.smtp_port = ui.prompt("SMTP port", cfg.smtp_port)
|
|
221
|
+
cfg.smtp_user = ui.prompt("SMTP user", cfg.smtp_user)
|
|
222
|
+
cfg.smtp_password = ui.prompt("SMTP password", cfg.smtp_password, secret=True)
|
|
223
|
+
cfg.smtp_from = ui.prompt("From address", cfg.smtp_from)
|
|
224
|
+
cfg.smtp_to = ui.prompt("To address(es)", cfg.smtp_to)
|
|
225
|
+
|
|
226
|
+
if ui.confirm("Save to .env?", default=True):
|
|
227
|
+
path = config.save_env(cfg)
|
|
228
|
+
ui.ok(f"Saved {path} (chmod 600 where supported)")
|
|
229
|
+
else:
|
|
230
|
+
ui.warn("Not saved.")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def full_run(cfg: config.Config, out_dir: Path, archive_dir: Path,
|
|
234
|
+
do_notify_step: bool, build_url: str = "", *,
|
|
235
|
+
dry_run: bool = False, skip_existing: bool = False,
|
|
236
|
+
no_encrypt: bool = False) -> int:
|
|
237
|
+
"""Backup both → archive → upload → (notify). Returns process exit code."""
|
|
238
|
+
status = "success"
|
|
239
|
+
try:
|
|
240
|
+
errors = do_backup(cfg, ["jira", "confluence"], out_dir, archive_dir,
|
|
241
|
+
skip_existing=skip_existing, dry_run=dry_run)
|
|
242
|
+
if errors:
|
|
243
|
+
raise RuntimeError(f"backup failed: {', '.join(errors)}")
|
|
244
|
+
do_archive(cfg, out_dir, archive_dir, dry_run=dry_run, no_encrypt=no_encrypt)
|
|
245
|
+
do_upload(cfg, archive_dir, dry_run=dry_run)
|
|
246
|
+
except Exception as exc:
|
|
247
|
+
status = "failure"
|
|
248
|
+
ui.error(str(exc))
|
|
249
|
+
if do_notify_step:
|
|
250
|
+
try:
|
|
251
|
+
do_notify(cfg, status, archive_dir, build_url, dry_run=dry_run)
|
|
252
|
+
except Exception as exc:
|
|
253
|
+
ui.warn(f"notify failed: {exc}")
|
|
254
|
+
return 0 if status == "success" else 1
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# ─────────────────────────── interactive menu ───────────────────────────
|
|
258
|
+
|
|
259
|
+
def _safe(fn, *args, **kwargs) -> None:
|
|
260
|
+
"""Run a menu action, keeping the menu alive on any error."""
|
|
261
|
+
try:
|
|
262
|
+
fn(*args, **kwargs)
|
|
263
|
+
except SystemExit as exc:
|
|
264
|
+
ui.error(f"Aborted (exit {exc.code}).")
|
|
265
|
+
except KeyboardInterrupt:
|
|
266
|
+
ui.warn("Cancelled.")
|
|
267
|
+
except Exception as exc: # menu must survive
|
|
268
|
+
ui.error(str(exc))
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _menu_cleanup(out_dir: Path, archive_dir: Path) -> None:
|
|
272
|
+
raw = ui.prompt("Delete backups older than N days? (blank = only incomplete)", "")
|
|
273
|
+
keep_days = int(raw) if raw.strip().isdigit() else None
|
|
274
|
+
do_cleanup(out_dir, archive_dir, keep_days)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def menu(cfg: config.Config, out_dir: Path, archive_dir: Path) -> None:
|
|
278
|
+
while True:
|
|
279
|
+
ui.section("Atlassian Full-Instance Backup")
|
|
280
|
+
ui.table("", [
|
|
281
|
+
("Jira", cfg.site_jira or "(not set)"),
|
|
282
|
+
("Storage", f"{cfg.storage_provider}:{cfg.storage_dest or '(not set)'}"),
|
|
283
|
+
("Notify", cfg.notify_channels or "(none)"),
|
|
284
|
+
])
|
|
285
|
+
print(" 1) Backup Jira 7) Validate backup")
|
|
286
|
+
print(" 2) Backup Confluence 8) Cleanup backups")
|
|
287
|
+
print(" 3) Backup both 9) Test connections")
|
|
288
|
+
print(" 4) Full run 10) Configure credentials")
|
|
289
|
+
print(" 5) Archive ./out 11) Show configuration")
|
|
290
|
+
print(" 6) Upload ./archive 12) List local backups")
|
|
291
|
+
print(" 0) Exit")
|
|
292
|
+
choice = ui.prompt("Select").strip()
|
|
293
|
+
|
|
294
|
+
if choice == "1":
|
|
295
|
+
_safe(do_backup, cfg, ["jira"], out_dir, archive_dir)
|
|
296
|
+
elif choice == "2":
|
|
297
|
+
_safe(do_backup, cfg, ["confluence"], out_dir, archive_dir)
|
|
298
|
+
elif choice == "3":
|
|
299
|
+
_safe(do_backup, cfg, ["jira", "confluence"], out_dir, archive_dir)
|
|
300
|
+
elif choice == "4":
|
|
301
|
+
_safe(full_run, cfg, out_dir, archive_dir, True)
|
|
302
|
+
elif choice == "5":
|
|
303
|
+
_safe(do_archive, cfg, out_dir, archive_dir)
|
|
304
|
+
elif choice == "6":
|
|
305
|
+
_safe(do_upload, cfg, archive_dir)
|
|
306
|
+
elif choice == "7":
|
|
307
|
+
_safe(do_validate, archive_dir)
|
|
308
|
+
elif choice == "8":
|
|
309
|
+
_safe(_menu_cleanup, out_dir, archive_dir)
|
|
310
|
+
elif choice == "9":
|
|
311
|
+
_safe(do_test, cfg)
|
|
312
|
+
elif choice == "10":
|
|
313
|
+
_safe(do_configure, cfg)
|
|
314
|
+
elif choice == "11":
|
|
315
|
+
_safe(do_show, cfg)
|
|
316
|
+
elif choice == "12":
|
|
317
|
+
_safe(do_list, out_dir, archive_dir)
|
|
318
|
+
elif choice in ("0", "q", "exit", "quit"):
|
|
319
|
+
return
|
|
320
|
+
else:
|
|
321
|
+
ui.warn("Unknown choice.")
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
# ─────────────────────────── CLI ───────────────────────────
|
|
325
|
+
|
|
326
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
327
|
+
p = argparse.ArgumentParser(description="Atlassian full-instance backup (menu + CLI)")
|
|
328
|
+
p.add_argument("--backup", metavar="LIST",
|
|
329
|
+
help="Comma list: jira,confluence (or 'all')")
|
|
330
|
+
p.add_argument("--archive", action="store_true", help="Archive ./out into .7z")
|
|
331
|
+
p.add_argument("--upload", action="store_true", help="Upload ./archive to storage")
|
|
332
|
+
p.add_argument("--notify", action="store_true", help="Send notifications")
|
|
333
|
+
p.add_argument("--all", action="store_true",
|
|
334
|
+
help="Full run: backup both -> archive -> upload -> notify")
|
|
335
|
+
p.add_argument("--validate", action="store_true",
|
|
336
|
+
help="Verify the archive against its manifest (sha256)")
|
|
337
|
+
p.add_argument("--cleanup", action="store_true",
|
|
338
|
+
help="Remove incomplete (and, with --keep-days, old) local backups")
|
|
339
|
+
p.add_argument("--keep-days", type=int, default=None,
|
|
340
|
+
help="With --cleanup: also delete backups older than N days")
|
|
341
|
+
p.add_argument("--dry-run", action="store_true",
|
|
342
|
+
help="Preview steps without API calls / archiving / uploading")
|
|
343
|
+
p.add_argument("--skip-existing", action="store_true",
|
|
344
|
+
help="Skip a product that already has a complete backup today")
|
|
345
|
+
p.add_argument("--compression", type=int, choices=range(0, 10), metavar="0-9",
|
|
346
|
+
default=None, help="7z compression level 0-9 (overrides config)")
|
|
347
|
+
p.add_argument("--no-encrypt", action="store_true",
|
|
348
|
+
help="Create an unencrypted archive (ignore the archive password)")
|
|
349
|
+
p.add_argument("--test-connection", action="store_true", help="Test Jira+Confluence auth")
|
|
350
|
+
p.add_argument("--show-config", action="store_true", help="Print config (secrets masked)")
|
|
351
|
+
p.add_argument("--configure", action="store_true", help="Guided .env setup")
|
|
352
|
+
p.add_argument("--out", type=Path, default=DEFAULT_OUT, help="Backup download dir")
|
|
353
|
+
p.add_argument("--archive-dir", type=Path, default=DEFAULT_ARCHIVE, help="Archive dir")
|
|
354
|
+
p.add_argument("--build-url", default="", help="Build URL for notifications")
|
|
355
|
+
p.add_argument("--env-file", type=Path, default=None, help="Path to .env (default ./.env)")
|
|
356
|
+
return p
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def main(argv: list[str] | None = None) -> int:
|
|
360
|
+
args = build_parser().parse_args(argv)
|
|
361
|
+
cfg = config.load(args.env_file)
|
|
362
|
+
if args.compression is not None:
|
|
363
|
+
cfg.archive_compression = str(args.compression)
|
|
364
|
+
|
|
365
|
+
actionable = (args.backup or args.archive or args.upload or args.notify or
|
|
366
|
+
args.all or args.validate or args.cleanup or args.test_connection or
|
|
367
|
+
args.show_config or args.configure)
|
|
368
|
+
if not actionable:
|
|
369
|
+
menu(cfg, args.out, args.archive_dir) # interactive
|
|
370
|
+
return 0
|
|
371
|
+
|
|
372
|
+
if args.configure:
|
|
373
|
+
do_configure(cfg)
|
|
374
|
+
return 0
|
|
375
|
+
if args.show_config:
|
|
376
|
+
do_show(cfg)
|
|
377
|
+
return 0
|
|
378
|
+
if args.test_connection:
|
|
379
|
+
return 0 if do_test(cfg) else 1
|
|
380
|
+
if args.validate:
|
|
381
|
+
return 0 if do_validate(args.archive_dir) else 1
|
|
382
|
+
if args.cleanup:
|
|
383
|
+
do_cleanup(args.out, args.archive_dir, args.keep_days)
|
|
384
|
+
return 0
|
|
385
|
+
if args.all:
|
|
386
|
+
return full_run(cfg, args.out, args.archive_dir, do_notify_step=True,
|
|
387
|
+
build_url=args.build_url, dry_run=args.dry_run,
|
|
388
|
+
skip_existing=args.skip_existing, no_encrypt=args.no_encrypt)
|
|
389
|
+
|
|
390
|
+
# Granular step composition for pipelines.
|
|
391
|
+
status = "success"
|
|
392
|
+
try:
|
|
393
|
+
if args.backup:
|
|
394
|
+
products = (["jira", "confluence"] if args.backup.strip() == "all"
|
|
395
|
+
else [x.strip() for x in args.backup.split(",") if x.strip()])
|
|
396
|
+
errors = do_backup(cfg, products, args.out, args.archive_dir,
|
|
397
|
+
skip_existing=args.skip_existing, dry_run=args.dry_run)
|
|
398
|
+
if errors:
|
|
399
|
+
raise RuntimeError(f"backup failed: {', '.join(errors)}")
|
|
400
|
+
if args.archive:
|
|
401
|
+
do_archive(cfg, args.out, args.archive_dir, dry_run=args.dry_run,
|
|
402
|
+
no_encrypt=args.no_encrypt)
|
|
403
|
+
if args.upload:
|
|
404
|
+
do_upload(cfg, args.archive_dir, dry_run=args.dry_run)
|
|
405
|
+
except Exception as exc:
|
|
406
|
+
status = "failure"
|
|
407
|
+
ui.error(str(exc))
|
|
408
|
+
if args.notify:
|
|
409
|
+
do_notify(cfg, status, args.archive_dir, args.build_url, dry_run=args.dry_run)
|
|
410
|
+
return 0 if status == "success" else 1
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
if __name__ == "__main__":
|
|
414
|
+
sys.exit(main())
|
backup/config.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration: environment variables are the source of truth, optionally
|
|
3
|
+
hydrated from a .env file (real env always wins over .env).
|
|
4
|
+
|
|
5
|
+
The interactive menu can persist values back to .env (chmod 600). .env is
|
|
6
|
+
gitignored and must never be committed. No third-party deps — a small parser
|
|
7
|
+
handles .env so we don't pull in python-dotenv.
|
|
8
|
+
"""
|
|
9
|
+
import os
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from . import naming
|
|
14
|
+
|
|
15
|
+
# field name, env var, is_secret, default
|
|
16
|
+
SPEC = [
|
|
17
|
+
("site_jira", "SITE_JIRA", False, ""),
|
|
18
|
+
("site_confluence", "SITE_CONFLUENCE", False, ""),
|
|
19
|
+
("atl_email", "ATL_EMAIL", False, ""),
|
|
20
|
+
("atl_token", "ATL_TOKEN", True, ""),
|
|
21
|
+
("jira_cookies", "JIRA_COOKIES", True, ""),
|
|
22
|
+
("archive_password", "ARCHIVE_PASSWORD", True, ""),
|
|
23
|
+
("product_name_template","PRODUCT_NAME_TEMPLATE", False, naming.DEFAULT_PRODUCT_TEMPLATE),
|
|
24
|
+
("archive_name_template","ARCHIVE_NAME_TEMPLATE", False, naming.DEFAULT_ARCHIVE_TEMPLATE),
|
|
25
|
+
("archive_compression", "ARCHIVE_COMPRESSION", False, "5"),
|
|
26
|
+
("storage_provider", "STORAGE_PROVIDER", False, "gcs"),
|
|
27
|
+
("storage_dest", "STORAGE_DEST", False, ""),
|
|
28
|
+
("gcp_credentials", "GOOGLE_APPLICATION_CREDENTIALS", False, ""),
|
|
29
|
+
("s3_endpoint_url", "S3_ENDPOINT_URL", False, ""),
|
|
30
|
+
("aws_access_key_id", "AWS_ACCESS_KEY_ID", True, ""),
|
|
31
|
+
("aws_secret_access_key","AWS_SECRET_ACCESS_KEY", True, ""),
|
|
32
|
+
("aws_default_region", "AWS_DEFAULT_REGION", False, ""),
|
|
33
|
+
("azure_conn", "AZURE_STORAGE_CONNECTION_STRING", True, ""),
|
|
34
|
+
("notify_channels", "NOTIFY_CHANNELS", False, "google-chat"),
|
|
35
|
+
("notify_webhook_url", "NOTIFY_WEBHOOK_URL", True, ""),
|
|
36
|
+
("smtp_host", "SMTP_HOST", False, ""),
|
|
37
|
+
("smtp_port", "SMTP_PORT", False, "587"),
|
|
38
|
+
("smtp_starttls", "SMTP_STARTTLS", False, "true"),
|
|
39
|
+
("smtp_user", "SMTP_USER", False, ""),
|
|
40
|
+
("smtp_password", "SMTP_PASSWORD", True, ""),
|
|
41
|
+
("smtp_from", "SMTP_FROM", False, ""),
|
|
42
|
+
("smtp_to", "SMTP_TO", False, ""),
|
|
43
|
+
]
|
|
44
|
+
_ENV = {name: env for name, env, _, _ in SPEC}
|
|
45
|
+
_SECRET = {name for name, _, secret, _ in SPEC if secret}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class Config:
|
|
50
|
+
site_jira: str = ""
|
|
51
|
+
site_confluence: str = ""
|
|
52
|
+
atl_email: str = ""
|
|
53
|
+
atl_token: str = ""
|
|
54
|
+
jira_cookies: str = ""
|
|
55
|
+
archive_password: str = ""
|
|
56
|
+
product_name_template: str = naming.DEFAULT_PRODUCT_TEMPLATE
|
|
57
|
+
archive_name_template: str = naming.DEFAULT_ARCHIVE_TEMPLATE
|
|
58
|
+
archive_compression: str = "5"
|
|
59
|
+
storage_provider: str = "gcs"
|
|
60
|
+
storage_dest: str = ""
|
|
61
|
+
gcp_credentials: str = ""
|
|
62
|
+
s3_endpoint_url: str = ""
|
|
63
|
+
aws_access_key_id: str = ""
|
|
64
|
+
aws_secret_access_key: str = ""
|
|
65
|
+
aws_default_region: str = ""
|
|
66
|
+
azure_conn: str = ""
|
|
67
|
+
notify_channels: str = "google-chat"
|
|
68
|
+
notify_webhook_url: str = ""
|
|
69
|
+
smtp_host: str = ""
|
|
70
|
+
smtp_port: str = "587"
|
|
71
|
+
smtp_starttls: str = "true"
|
|
72
|
+
smtp_user: str = ""
|
|
73
|
+
smtp_password: str = ""
|
|
74
|
+
smtp_from: str = ""
|
|
75
|
+
smtp_to: str = ""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def parse_env_file(path: Path) -> dict[str, str]:
|
|
79
|
+
"""Minimal KEY=VALUE .env parser. Ignores blanks/comments, strips quotes."""
|
|
80
|
+
out: dict[str, str] = {}
|
|
81
|
+
if not path.exists():
|
|
82
|
+
return out
|
|
83
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
84
|
+
line = line.strip()
|
|
85
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
86
|
+
continue
|
|
87
|
+
key, _, val = line.partition("=")
|
|
88
|
+
key, val = key.strip(), val.strip()
|
|
89
|
+
if len(val) >= 2 and val[0] == val[-1] and val[0] in "\"'":
|
|
90
|
+
val = val[1:-1]
|
|
91
|
+
out[key] = val
|
|
92
|
+
return out
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def load(env_file: Path | None = None) -> Config:
|
|
96
|
+
"""Load .env (if present) into the process env without clobbering real env,
|
|
97
|
+
then build a Config from the environment."""
|
|
98
|
+
path = env_file or Path(".env")
|
|
99
|
+
for key, val in parse_env_file(path).items():
|
|
100
|
+
os.environ.setdefault(key, val) # real env wins over .env
|
|
101
|
+
values = {name: os.environ.get(env, default) for name, env, _, default in SPEC}
|
|
102
|
+
return Config(**values)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def save_env(cfg: Config, path: Path | None = None) -> Path:
|
|
106
|
+
"""Write the config to a .env file (chmod 600). Skips empty values."""
|
|
107
|
+
path = path or Path(".env")
|
|
108
|
+
lines = ["# Generated by main.py Configure. Gitignored — never commit.\n"]
|
|
109
|
+
for name, env, _, _ in SPEC:
|
|
110
|
+
val = getattr(cfg, name)
|
|
111
|
+
if val:
|
|
112
|
+
lines.append(f"{env}={val}\n")
|
|
113
|
+
path.write_text("".join(lines), encoding="utf-8")
|
|
114
|
+
try:
|
|
115
|
+
path.chmod(0o600) # best-effort; no-op semantics on Windows
|
|
116
|
+
except OSError:
|
|
117
|
+
pass
|
|
118
|
+
return path
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def mask(value: str) -> str:
|
|
122
|
+
if not value:
|
|
123
|
+
return "(not set)"
|
|
124
|
+
if len(value) <= 6:
|
|
125
|
+
return "***"
|
|
126
|
+
return f"{value[:3]}...{value[-2:]} ({len(value)} chars)"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def display_rows(cfg: Config) -> list[tuple[str, str]]:
|
|
130
|
+
"""Key/value rows for showing config — secrets masked."""
|
|
131
|
+
rows = []
|
|
132
|
+
for name, env, secret, _ in SPEC:
|
|
133
|
+
val = getattr(cfg, name)
|
|
134
|
+
shown = mask(val) if secret else (val or "(not set)")
|
|
135
|
+
rows.append((env, shown))
|
|
136
|
+
return rows
|