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 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
@@ -0,0 +1,7 @@
1
+ """Enable `python -m backup` to run the CLI/menu."""
2
+ import sys
3
+
4
+ from .cli import main
5
+
6
+ if __name__ == "__main__":
7
+ sys.exit(main())
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