diff --git a/app/sep/apps/mysql_backups/binlog_payload b/app/sep/apps/mysql_backups/binlog_payload index aa9df021d..bf109881b 100644 --- a/app/sep/apps/mysql_backups/binlog_payload +++ b/app/sep/apps/mysql_backups/binlog_payload @@ -1605,6 +1605,11 @@ class Upload(Backup): log_path = Path(logging_dir) / "upload-binlog.log" self.log_stream = log_path.open("a") self.backup_type = BACKUP_TYPES[self.backup_code] + # Fail-safe default: an absent ENCRYPT is treated as "encrypt" so a + # standalone run against hand-authored config never silently produces an + # unencrypted backup on a customer host. This deliberately diverges from + # the form/config model default (False, an unchecked box); do not align + # the two. self.encrypt = settings.get("ENCRYPT", True) self.encrypt_using_tmpdir = settings.get("ENCRYPT_USING_TMPDIR", False) self.post_run_encrypt = settings.get("POST_RUN_ENCRYPT", False) diff --git a/app/sep/apps/mysql_backups/mydumper_payload b/app/sep/apps/mysql_backups/mydumper_payload index 065faf64b..1c7d95db6 100644 --- a/app/sep/apps/mysql_backups/mydumper_payload +++ b/app/sep/apps/mysql_backups/mydumper_payload @@ -1383,6 +1383,11 @@ class Upload(Backup): log_path = self.logging_dir / "upload-mydumper.log" self.log_stream = log_path.open("a") self.backup_type = BACKUP_TYPE + # Fail-safe default: an absent ENCRYPT is treated as "encrypt" so a + # standalone run against hand-authored config never silently produces an + # unencrypted backup on a customer host. This deliberately diverges from + # the form/config model default (False, an unchecked box); do not align + # the two. self.encrypt = settings.get("ENCRYPT", True) self.encrypt_using_tmpdir = settings.get("ENCRYPT_USING_TMPDIR", False) self.post_run_encrypt = settings.get("POST_RUN_ENCRYPT", False) diff --git a/app/sep/apps/mysql_backups/xtrabackup_payload b/app/sep/apps/mysql_backups/xtrabackup_payload index 18c304b19..6286f4fd3 100644 --- a/app/sep/apps/mysql_backups/xtrabackup_payload +++ b/app/sep/apps/mysql_backups/xtrabackup_payload @@ -1468,6 +1468,11 @@ class Upload(Backup): log_path = Path(logging_dir) / "upload-xtrabackup.log" self.log_stream = log_path.open("a") self.backup_type = BACKUP_TYPES[self.backup_code] + # Fail-safe default: an absent ENCRYPT is treated as "encrypt" so a + # standalone run against hand-authored config never silently produces an + # unencrypted backup on a customer host. This deliberately diverges from + # the form/config model default (False, an unchecked box); do not align + # the two. self.encrypt = settings.get("ENCRYPT", True) self.encrypt_using_tmpdir = settings.get("ENCRYPT_USING_TMPDIR", False) self.post_run_encrypt = settings.get("POST_RUN_ENCRYPT", False) diff --git a/tests/app/sep/apps/mysql_backups/test_payload_snapshot.py b/tests/app/sep/apps/mysql_backups/test_payload_snapshot.py index bf0a0b201..98167b9b9 100644 --- a/tests/app/sep/apps/mysql_backups/test_payload_snapshot.py +++ b/tests/app/sep/apps/mysql_backups/test_payload_snapshot.py @@ -28,6 +28,9 @@ compute the same ``Path(__file__).parent`` within the package). """ +import pytest +import yaml + from app.inventory.models import ServiceTypeEnum from app.sep.apps.framework.spec import assemble_envelope, ResolvedEntities from app.sep.apps.mysql_backups.forms import BackupCreate @@ -182,3 +185,54 @@ def test_spec_path_payload_matrix_matches_golden(): assert_or_update( PAYLOAD_DIR / "mysql_backups__spec_path.json", canonical_json(payloads) ) + + +def _all_servers_config(backup_type: str, encryption: dict) -> dict: + """Return the ``ALL_SERVERS`` block of the YAML config ``build_backup_spec`` emits. + + :param backup_type: The ``BackupType`` code (``M``/``X``/``B``). + :param encryption: The encryption fields to pass to the form, if any. + """ + service = _service() + resolved = ResolvedEntities( + service=service, + entities={"service_id": service}, + executor_host=_HOSTNAME, + ) + form = BackupCreate( + task_name=_TASK_NAME, + hostname=_HOSTNAME, + service_id=service.id, + backup_type=backup_type, + **encryption, + ) + return yaml.safe_load(build_backup_spec(form, resolved).config)["ALL_SERVERS"] + + +@pytest.mark.parametrize("backup_type", ["M", "X", "B"]) +@pytest.mark.parametrize( + "encryption", + [ + pytest.param( + {"encrypt": True, "encryption_recipient": "ops@example.com"}, + id="encrypt_true", + ), + pytest.param({"encrypt": False}, id="encrypt_false"), + pytest.param({}, id="encrypt_omitted"), + ], +) +def test_build_backup_spec_always_emits_encrypt_key(backup_type: str, encryption: dict): + """Pin that every generated config carries an explicit ``ENCRYPT`` key. + + Drives ``build_backup_spec`` directly for each backup type and for an + ``encrypt=True`` form, an ``encrypt=False`` form, and a form built without any + encryption fields, then asserts the serialised YAML always names ``ENCRYPT`` + with the form's value. This is the producer-side invariant the payload scripts + rely on: they read ``settings.get("ENCRYPT", True)``, so a config that ever + omitted the key would flip a stored backup from unencrypted to encrypted. The + assertion fails if ``exclude_unset``/``exclude_defaults`` (which would drop the + ``False`` default) is ever introduced into the builder. + """ + all_servers = _all_servers_config(backup_type, encryption) + assert "ENCRYPT" in all_servers + assert all_servers["ENCRYPT"] is encryption.get("encrypt", False)