From 63ed799b7173fac5588ed7900aaf78915018d3ba Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Fri, 31 Jul 2026 09:42:18 -0400 Subject: [PATCH 1/6] Add backup cleanup retention policy Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- application/single_app/config.py | 2 +- .../single_app/functions_data_management.py | 601 +++++++++++++++++- .../route_backend_data_management.py | 80 +++ .../static/js/admin/admin_data_management.js | 145 ++++- .../single_app/templates/admin_settings.html | 47 +- .../test_data_management_backup_durability.py | 208 +++++- .../test_admin_data_management_settings_ui.py | 26 +- 7 files changed, 1094 insertions(+), 15 deletions(-) diff --git a/application/single_app/config.py b/application/single_app/config.py index 9f8af2cc3..e7b780d6e 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -95,7 +95,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.250.105" +VERSION = "0.250.106" IS_DEVELOPMENT = is_development_env_enabled() SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax') diff --git a/application/single_app/functions_data_management.py b/application/single_app/functions_data_management.py index e46f16504..734c56608 100644 --- a/application/single_app/functions_data_management.py +++ b/application/single_app/functions_data_management.py @@ -208,6 +208,17 @@ DATA_MANAGEMENT_BACKUP_CAPACITY_FAILURE_POLICY_FAIL, DATA_MANAGEMENT_BACKUP_CAPACITY_FAILURE_POLICY_CONTINUE, } +DATA_MANAGEMENT_RETENTION_DEFAULT_VALUE = 30 +DATA_MANAGEMENT_RETENTION_DEFAULT_UNIT = "days" +DATA_MANAGEMENT_RETENTION_MAX_DAYS = 3650 +DATA_MANAGEMENT_RETENTION_UNIT_DAYS = { + "days": 1, + "weeks": 7, + "months": 30, + "years": 365, +} +DATA_MANAGEMENT_BACKUP_RETENTION_CLEANUP_LIMIT = 25 +DATA_MANAGEMENT_BACKUP_RETENTION_CLEANUP_INTERVAL_SECONDS = 23 * 3600 DATA_MANAGEMENT_SEARCH_KEYSET_PAGE_SIZE = 1000 DATA_MANAGEMENT_SEARCH_SCOPE_FILTER_BATCH_SIZE = 100 DATA_MANAGEMENT_SEARCH_BACKUP_PAGE_SIZE = 1000 @@ -281,7 +292,11 @@ "full_backup_frequency": "weekly", "scheduled_time_utc": DATA_MANAGEMENT_DEFAULT_TIME_UTC, "partial_backups_enabled": True, - "retention_days": 30, + "retention_value": DATA_MANAGEMENT_RETENTION_DEFAULT_VALUE, + "retention_unit": DATA_MANAGEMENT_RETENTION_DEFAULT_UNIT, + "retention_days": DATA_MANAGEMENT_RETENTION_DEFAULT_VALUE, + "retention_keep_latest_full": True, + "backup_retention_cleanup_last_run_at": None, "include_cosmos": True, "include_ai_search": True, "include_source_blobs": True, @@ -670,6 +685,65 @@ def calculate_next_data_management_run(settings, backup_type=DATA_MANAGEMENT_BAC return scheduled_earliest +def _max_retention_value_for_unit(retention_unit): + unit_days = DATA_MANAGEMENT_RETENTION_UNIT_DAYS.get( + retention_unit, + DATA_MANAGEMENT_RETENTION_UNIT_DAYS[DATA_MANAGEMENT_RETENTION_DEFAULT_UNIT], + ) + return max(1, DATA_MANAGEMENT_RETENTION_MAX_DAYS // unit_days) + + +def _normalize_data_management_retention_settings(source, payload=None, existing_settings=None): + """Normalize retention value/unit while preserving legacy retention_days callers.""" + payload_has_period = isinstance(payload, dict) and ( + "retention_value" in payload or "retention_unit" in payload + ) + payload_has_legacy_days = isinstance(payload, dict) and ( + "retention_days" in payload and not payload_has_period + ) + existing_has_period = isinstance(existing_settings, dict) and ( + "retention_value" in existing_settings or "retention_unit" in existing_settings + ) + use_period_fields = payload_has_period or (existing_has_period and not payload_has_legacy_days) + + if use_period_fields: + retention_unit = _safe_text( + source.get("retention_unit"), + DATA_MANAGEMENT_RETENTION_DEFAULT_UNIT, + ).lower() + if retention_unit not in DATA_MANAGEMENT_RETENTION_UNIT_DAYS: + retention_unit = DATA_MANAGEMENT_RETENTION_DEFAULT_UNIT + retention_value = _safe_int( + source.get("retention_value"), + default=DATA_MANAGEMENT_RETENTION_DEFAULT_VALUE, + minimum=1, + maximum=_max_retention_value_for_unit(retention_unit), + ) + source["retention_unit"] = retention_unit + source["retention_value"] = retention_value + source["retention_days"] = retention_value * DATA_MANAGEMENT_RETENTION_UNIT_DAYS[retention_unit] + else: + retention_days = _safe_int( + source.get("retention_days"), + default=DATA_MANAGEMENT_RETENTION_DEFAULT_VALUE, + minimum=1, + maximum=DATA_MANAGEMENT_RETENTION_MAX_DAYS, + ) + source["retention_unit"] = DATA_MANAGEMENT_RETENTION_DEFAULT_UNIT + source["retention_value"] = retention_days + source["retention_days"] = retention_days + + source["retention_keep_latest_full"] = _safe_bool( + source.get("retention_keep_latest_full"), + True, + ) + parsed_cleanup_run = _parse_iso_datetime(source.get("backup_retention_cleanup_last_run_at")) + source["backup_retention_cleanup_last_run_at"] = ( + parsed_cleanup_run.isoformat() if parsed_cleanup_run else None + ) + return source + + def normalize_data_management_settings(payload=None, existing_settings=None, current_time=None, application_settings=None): feature_context = _get_data_management_feature_context(application_settings) source = copy.deepcopy(DATA_MANAGEMENT_DEFAULT_SETTINGS) @@ -729,7 +803,11 @@ def normalize_data_management_settings(payload=None, existing_settings=None, cur source["full_backup_frequency"] = DATA_MANAGEMENT_DEFAULT_SETTINGS["full_backup_frequency"] source["scheduled_time_utc"] = normalize_data_management_time(source.get("scheduled_time_utc")) source["partial_backups_enabled"] = _safe_bool(source.get("partial_backups_enabled"), True) - source["retention_days"] = _safe_int(source.get("retention_days"), default=30, minimum=1, maximum=3650) + _normalize_data_management_retention_settings( + source, + payload=payload, + existing_settings=existing_settings, + ) source["include_cosmos"] = _safe_bool(source.get("include_cosmos"), True) source["include_ai_search"] = _safe_bool(source.get("include_ai_search"), True) source["include_source_blobs"] = _safe_bool(source.get("include_source_blobs"), feature_context["enhanced_citations_enabled"]) @@ -9189,6 +9267,7 @@ def sanitize_data_management_backup_for_admin(job): "warning_count": len(public_job.get("warnings") or []) + totals.get("warning_count", 0), "encrypted": any(bool(artifact.get("encrypted")) for artifact in artifacts if isinstance(artifact, dict)), "last_message": public_job.get("last_message"), + "can_delete": public_job.get("status") in DATA_MANAGEMENT_TERMINAL_STATUSES, } @@ -9309,6 +9388,506 @@ def get_data_management_backup_summary( } +def calculate_data_management_backup_retention_cutoff(settings, current_time=None): + """Return the UTC cutoff before which terminal backups are retention candidates.""" + normalized_settings = normalize_data_management_settings(existing_settings=settings or {}) + now = current_time if isinstance(current_time, datetime) else _now_utc() + if now.tzinfo is None: + now = now.replace(tzinfo=timezone.utc) + return now.astimezone(timezone.utc) - timedelta(days=normalized_settings["retention_days"]) + + +def _should_run_data_management_backup_retention_cleanup(settings, current_time=None): + if not _safe_bool((settings or {}).get("enabled"), False): + return False + now = current_time if isinstance(current_time, datetime) else _now_utc() + if now.tzinfo is None: + now = now.replace(tzinfo=timezone.utc) + last_run = _parse_iso_datetime((settings or {}).get("backup_retention_cleanup_last_run_at")) + if not last_run: + return True + return ( + now.astimezone(timezone.utc) - last_run + ).total_seconds() >= DATA_MANAGEMENT_BACKUP_RETENTION_CLEANUP_INTERVAL_SECONDS + + +def _backup_cleanup_not_found(error): + status_code = getattr(error, "status_code", None) + if status_code is None: + status_code = getattr(getattr(error, "response", None), "status_code", None) + return ( + status_code == 404 or + isinstance(error, (CosmosResourceNotFoundError, ResourceNotFoundError)) + ) + + +def _get_existing_backup_container_client(settings): + validate_data_management_storage_is_dedicated(settings) + blob_service_client = build_backup_storage_client(settings) + return blob_service_client.get_container_client(settings.get("backup_storage_container_name")) + + +def _backup_cleanup_allowed_prefix(job, settings): + backup_plan = job.get("backup_plan") if isinstance(job, dict) else {} + allowed_prefix = _safe_text( + (backup_plan or {}).get("backup_storage_path_prefix") or + (settings or {}).get("backup_storage_path_prefix"), + "simplechat-backups", + ).strip("/") + if not allowed_prefix: + raise DataManagementSettingsValidationError("Backup storage path prefix is required before cleanup.") + return allowed_prefix + + +def _normalize_backup_cleanup_blob_target(value, allowed_prefix, target_kind): + normalized = _safe_text(value).strip("/") + if not normalized: + return "" + if normalized == allowed_prefix and target_kind == "prefix": + raise DataManagementSettingsValidationError( + "Backup cleanup refused to delete the entire configured backup prefix." + ) + if normalized == allowed_prefix or normalized.startswith(f"{allowed_prefix}/"): + return normalized + raise DataManagementSettingsValidationError( + "Backup cleanup refused an artifact path outside the configured backup prefix." + ) + + +def _collect_backup_artifact_delete_targets(job, settings): + result = job.get("result") if isinstance((job or {}).get("result"), dict) else {} + allowed_prefix = _backup_cleanup_allowed_prefix(job, settings) + prefixes = set() + blob_names = set() + + base_prefix = _normalize_backup_cleanup_blob_target( + result.get("base_prefix"), + allowed_prefix, + "prefix", + ) + if base_prefix: + prefixes.add(f"{base_prefix.rstrip('/')}/") + + def collect(value): + if isinstance(value, dict): + for field_name in ("manifest_path", "artifact_path", "path"): + blob_name = _normalize_backup_cleanup_blob_target( + value.get(field_name), + allowed_prefix, + "blob", + ) + if blob_name: + blob_names.add(blob_name) + prefix = _normalize_backup_cleanup_blob_target( + value.get("prefix"), + allowed_prefix, + "prefix", + ) + if prefix: + prefixes.add(f"{prefix.rstrip('/')}/") + for nested_value in value.values(): + collect(nested_value) + elif isinstance(value, list): + for nested_value in value: + collect(nested_value) + + collect(result) + return prefixes, blob_names + + +def _get_backup_blob_name(blob_item): + if isinstance(blob_item, dict): + return _safe_text(blob_item.get("name")) + return _safe_text(getattr(blob_item, "name", blob_item)) + + +def _delete_backup_blob_artifacts(job, settings): + prefixes, blob_names = _collect_backup_artifact_delete_targets(job, settings) + summary = { + "prefix_count": len(prefixes), + "blob_name_count": len(blob_names), + "deleted_blob_count": 0, + "missing_blob_count": 0, + } + if not prefixes and not blob_names: + return summary + + container_client = _get_existing_backup_container_client(settings) + deleted_blob_names = set() + + for prefix in sorted(prefixes): + for blob_item in container_client.list_blobs(name_starts_with=prefix): + blob_name = _get_backup_blob_name(blob_item) + if not blob_name or blob_name in deleted_blob_names: + continue + try: + container_client.delete_blob(blob_name) + deleted_blob_names.add(blob_name) + summary["deleted_blob_count"] += 1 + except Exception as exc: + if _backup_cleanup_not_found(exc): + summary["missing_blob_count"] += 1 + continue + raise + + for blob_name in sorted(blob_names): + if blob_name in deleted_blob_names: + continue + try: + container_client.delete_blob(blob_name) + deleted_blob_names.add(blob_name) + summary["deleted_blob_count"] += 1 + except Exception as exc: + if _backup_cleanup_not_found(exc): + summary["missing_blob_count"] += 1 + continue + raise + + return summary + + +def _delete_backup_latest_item_states(job): + safe_job_id = _safe_text((job or {}).get("id")) + if not safe_job_id: + return 0 + container = _get_data_management_backup_item_states_container() + query = "SELECT * FROM c WHERE c.type = @type AND c.job_id = @job_id" + parameters = [ + {"name": "@type", "value": DATA_MANAGEMENT_BACKUP_LATEST_ITEM_STATE_TYPE}, + {"name": "@job_id", "value": safe_job_id}, + ] + states = list(container.query_items( + query=query, + parameters=parameters, + enable_cross_partition_query=True, + )) + deleted_count = 0 + for state in states: + if _safe_text((state or {}).get("job_id")) != safe_job_id: + continue + state_id = _safe_text((state or {}).get("id")) + source_scope = _safe_text((state or {}).get("source_scope")) or _get_backup_source_scope(job) + if not state_id or not source_scope: + continue + try: + container.delete_item(item=state_id, partition_key=source_scope) + deleted_count += 1 + except Exception as exc: + if _backup_cleanup_not_found(exc): + continue + raise + return deleted_count + + +def _delete_data_management_job_items(job_id): + safe_job_id = _safe_text(job_id) + if not safe_job_id: + return 0 + query = "SELECT * FROM c WHERE c.job_id = @job_id" + parameters = [{"name": "@job_id", "value": safe_job_id}] + items = list(cosmos_data_management_job_items_container.query_items( + query=query, + parameters=parameters, + partition_key=safe_job_id, + max_item_count=500, + )) + deleted_count = 0 + for item in items: + if _safe_text((item or {}).get("job_id")) != safe_job_id: + continue + item_id = _safe_text((item or {}).get("id")) + if not item_id: + continue + try: + cosmos_data_management_job_items_container.delete_item( + item=item_id, + partition_key=safe_job_id, + ) + deleted_count += 1 + except Exception as exc: + if _backup_cleanup_not_found(exc): + continue + raise + return deleted_count + + +def _delete_data_management_job_record(job): + safe_job_id = _safe_text((job or {}).get("id")) + if not safe_job_id: + return False + delete_kwargs = {} + if (job or {}).get("_etag"): + delete_kwargs.update({ + "etag": job.get("_etag"), + "match_condition": MatchConditions.IfNotModified, + }) + try: + cosmos_data_management_jobs_container.delete_item( + item=safe_job_id, + partition_key=safe_job_id, + **delete_kwargs, + ) + return True + except Exception as exc: + if _backup_cleanup_not_found(exc): + return False + raise + + +def _assert_backup_cleanup_allowed(job): + if not isinstance(job, dict) or job.get("operation") != DATA_MANAGEMENT_OPERATION_BACKUP: + raise DataManagementSettingsValidationError("Data Management backup was not found.") + if job.get("status") not in DATA_MANAGEMENT_TERMINAL_STATUSES: + raise DataManagementSettingsValidationError( + "Only completed, failed, or canceled backup jobs can be deleted. Cancel active backups first." + ) + + +def _delete_data_management_backup_job( + job, + settings, + requested_by=None, + requested_by_email=None, + reason="manual", +): + _assert_backup_cleanup_allowed(job) + safe_job_id = _safe_text(job.get("id")) + cleanup_job = copy.deepcopy(job) + cleanup_job["requested_by"] = _safe_text(requested_by) or _safe_text(job.get("requested_by")) or "system" + cleanup_job["requested_by_email"] = ( + _safe_text(requested_by_email) or + _safe_text(job.get("requested_by_email")) or + "system" + ) + deleted_at = _now_iso() + blob_summary = _delete_backup_blob_artifacts(job, settings) + latest_state_deleted_count = _delete_backup_latest_item_states(job) + job_item_deleted_count = _delete_data_management_job_items(safe_job_id) + job_deleted = _delete_data_management_job_record(job) + cleanup_summary = { + "job_id": safe_job_id, + "backup_type": job.get("backup_type"), + "status": job.get("status"), + "deleted_at": deleted_at, + "reason": _safe_text(reason) or "manual", + "job_deleted": job_deleted, + "job_item_deleted_count": job_item_deleted_count, + "latest_item_state_deleted_count": latest_state_deleted_count, + **blob_summary, + } + _log_data_management_activity( + cleanup_job, + "data_management_backup_deleted", + "success", + "Deleted Data Management backup artifacts and metadata.", + details=cleanup_summary, + ) + log_event( + "[DataManagement] Backup cleanup completed.", + cleanup_summary, + level=logging.INFO, + ) + return cleanup_summary + + +def delete_data_management_backup( + backup_id, + requested_by=None, + requested_by_email=None, + reason="manual", + settings=None, +): + safe_backup_id = _safe_text(backup_id) + if not safe_backup_id: + raise DataManagementSettingsValidationError("Backup id is required.") + job = get_data_management_job(safe_backup_id) + if not job: + raise DataManagementSettingsValidationError("Data Management backup was not found.") + cleanup_settings = normalize_data_management_settings( + existing_settings=settings or get_data_management_settings() + ) + return _delete_data_management_backup_job( + job, + cleanup_settings, + requested_by=requested_by, + requested_by_email=requested_by_email, + reason=reason, + ) + + +def _get_latest_successful_full_backup_id(): + query = ( + "SELECT TOP 1 * FROM c WHERE c.type = @type AND c.operation = @operation " + "AND c.backup_type = @backup_type " + "AND (c.status = @completed OR c.status = @completed_with_warnings) " + "ORDER BY c.created_at DESC, c.id DESC" + ) + parameters = [ + {"name": "@type", "value": DATA_MANAGEMENT_JOB_TYPE}, + {"name": "@operation", "value": DATA_MANAGEMENT_OPERATION_BACKUP}, + {"name": "@backup_type", "value": DATA_MANAGEMENT_BACKUP_FULL}, + {"name": "@completed", "value": DATA_MANAGEMENT_STATUS_COMPLETED}, + { + "name": "@completed_with_warnings", + "value": DATA_MANAGEMENT_STATUS_COMPLETED_WITH_WARNINGS, + }, + ] + jobs = list(cosmos_data_management_jobs_container.query_items( + query=query, + parameters=parameters, + enable_cross_partition_query=True, + max_item_count=1, + )) + for job in jobs: + if ( + isinstance(job, dict) and + job.get("operation") == DATA_MANAGEMENT_OPERATION_BACKUP and + job.get("backup_type") == DATA_MANAGEMENT_BACKUP_FULL and + job.get("status") in { + DATA_MANAGEMENT_STATUS_COMPLETED, + DATA_MANAGEMENT_STATUS_COMPLETED_WITH_WARNINGS, + } + ): + return _safe_text(job.get("id")) + return "" + + +def _get_backup_retention_candidates(cutoff_at, protected_backup_id="", limit=DATA_MANAGEMENT_BACKUP_RETENTION_CLEANUP_LIMIT): + safe_limit = _safe_int( + limit, + default=DATA_MANAGEMENT_BACKUP_RETENTION_CLEANUP_LIMIT, + minimum=1, + maximum=100, + ) + cutoff_iso = cutoff_at.isoformat() + query = ( + "SELECT TOP @limit * FROM c WHERE c.type = @type AND c.operation = @operation " + "AND c.created_at < @cutoff_at ORDER BY c.created_at ASC, c.id ASC" + ) + parameters = [ + {"name": "@limit", "value": safe_limit * 2}, + {"name": "@type", "value": DATA_MANAGEMENT_JOB_TYPE}, + {"name": "@operation", "value": DATA_MANAGEMENT_OPERATION_BACKUP}, + {"name": "@cutoff_at", "value": cutoff_iso}, + ] + jobs = list(cosmos_data_management_jobs_container.query_items( + query=query, + parameters=parameters, + enable_cross_partition_query=True, + max_item_count=safe_limit * 2, + )) + candidates = [] + for job in jobs: + if not isinstance(job, dict): + continue + if job.get("operation") != DATA_MANAGEMENT_OPERATION_BACKUP: + continue + if _safe_text(job.get("id")) == _safe_text(protected_backup_id): + continue + if job.get("status") not in DATA_MANAGEMENT_TERMINAL_STATUSES: + continue + completed_or_created_at = _parse_iso_datetime( + job.get("completed_at") or job.get("created_at") + ) + if not completed_or_created_at or completed_or_created_at >= cutoff_at: + continue + candidates.append(job) + if len(candidates) >= safe_limit: + break + return candidates + + +def _record_backup_retention_cleanup_run(settings, current_time): + updated_settings = copy.deepcopy(settings or get_data_management_settings()) + updated_settings["backup_retention_cleanup_last_run_at"] = current_time.isoformat() + updated_settings["last_settings_update_at"] = _now_iso() + return cosmos_settings_container.upsert_item( + normalize_data_management_settings(existing_settings=updated_settings) + ) + + +def cleanup_expired_data_management_backups( + settings=None, + current_time=None, + limit=DATA_MANAGEMENT_BACKUP_RETENTION_CLEANUP_LIMIT, + requested_by="system", + requested_by_email="system", + manual_execution=False, +): + cleanup_settings = normalize_data_management_settings( + existing_settings=settings or get_data_management_settings() + ) + now = current_time if isinstance(current_time, datetime) else _now_utc() + if now.tzinfo is None: + now = now.replace(tzinfo=timezone.utc) + now = now.astimezone(timezone.utc) + cutoff_at = calculate_data_management_backup_retention_cutoff( + cleanup_settings, + current_time=now, + ) + protected_backup_id = ( + _get_latest_successful_full_backup_id() + if cleanup_settings.get("retention_keep_latest_full") else + "" + ) + candidates = _get_backup_retention_candidates( + cutoff_at, + protected_backup_id=protected_backup_id, + limit=limit, + ) + result = { + "success": True, + "manual_execution": bool(manual_execution), + "cutoff_at": cutoff_at.isoformat(), + "retention_days": cleanup_settings.get("retention_days"), + "retention_value": cleanup_settings.get("retention_value"), + "retention_unit": cleanup_settings.get("retention_unit"), + "protected_latest_full_backup_id": protected_backup_id, + "candidate_count": len(candidates), + "deleted_count": 0, + "deleted_backups": [], + "errors": [], + } + for job in candidates: + try: + deletion = _delete_data_management_backup_job( + job, + cleanup_settings, + requested_by=requested_by, + requested_by_email=requested_by_email, + reason="retention", + ) + result["deleted_backups"].append(deletion) + result["deleted_count"] += 1 + except Exception as exc: + error = { + "job_id": _safe_text(job.get("id")), + "error": _sanitize_data_management_backup_text(str(exc)), + } + result["errors"].append(error) + log_event( + "[DataManagement] Backup retention cleanup failed for one backup.", + error, + level=logging.WARNING, + ) + + result["success"] = not result["errors"] + _record_backup_retention_cleanup_run(cleanup_settings, now) + log_event( + "[DataManagement] Backup retention cleanup completed.", + { + "success": result["success"], + "manual_execution": result["manual_execution"], + "cutoff_at": result["cutoff_at"], + "candidate_count": result["candidate_count"], + "deleted_count": result["deleted_count"], + "error_count": len(result["errors"]), + }, + level=logging.INFO if result["success"] else logging.WARNING, + ) + return result + + def _get_migration_catalog_definition(target_type): normalized_target_type = _safe_text(target_type) definitions = { @@ -16389,6 +16968,24 @@ def check_due_data_management_jobs_once(app=None): if not settings.get("enabled"): return recovery_results + if _should_run_data_management_backup_retention_cleanup(settings, current_time): + try: + cleanup_expired_data_management_backups( + settings=settings, + current_time=current_time, + requested_by="system", + requested_by_email="system", + manual_execution=False, + ) + settings = get_data_management_settings() + except Exception as exc: + log_event( + "[DataManagement] Scheduled backup retention cleanup failed.", + {"error": str(exc)}, + level=logging.ERROR, + exceptionTraceback=True, + ) + queued_jobs = [] for backup_type, next_key in ( (DATA_MANAGEMENT_BACKUP_FULL, "next_full_backup_run_at"), diff --git a/application/single_app/route_backend_data_management.py b/application/single_app/route_backend_data_management.py index 6e8aacb86..e2a4ffb1b 100644 --- a/application/single_app/route_backend_data_management.py +++ b/application/single_app/route_backend_data_management.py @@ -17,7 +17,9 @@ DataManagementCosmosEditorError, DataManagementHistoryPaginationError, DataManagementSettingsValidationError, + cleanup_expired_data_management_backups, create_data_management_migration_review_authorization, + delete_data_management_backup, export_data_management_migration_manifest, generate_data_management_encryption_key, get_data_management_cosmos_editor_containers, @@ -596,6 +598,84 @@ def list_admin_data_management_backups(): }), 400 return jsonify({"success": True, **backup_summary}), 200 + @bp.route("/api/admin/data-management/backups/retention/cleanup", methods=["POST"]) + @swagger_route(security=get_auth_security()) + @login_required + @admin_required + def cleanup_admin_data_management_backups(): + admin_user_id, admin_email = _get_admin_context() + try: + cleanup_result = cleanup_expired_data_management_backups( + requested_by=admin_user_id, + requested_by_email=admin_email, + manual_execution=True, + ) + except DataManagementSettingsValidationError as exc: + return jsonify({"success": False, "error": str(exc)}), 400 + except Exception as exc: + log_event( + "[DataManagement] Manual backup retention cleanup failed.", + {"error": str(exc)}, + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({"success": False, "error": "Backup retention cleanup could not be completed."}), 400 + + _log_data_management_admin_action( + "data_management_backup_retention_cleanup", + "Ran Data Management backup retention cleanup.", + { + "deleted_count": cleanup_result.get("deleted_count", 0), + "candidate_count": cleanup_result.get("candidate_count", 0), + "error_count": len(cleanup_result.get("errors") or []), + }, + ) + if cleanup_result.get("success") is False: + return jsonify({ + "success": False, + "error": "Backup retention cleanup completed with errors.", + "cleanup": cleanup_result, + }), 400 + return jsonify({"success": True, "cleanup": cleanup_result}), 200 + + @bp.route("/api/admin/data-management/backups/", methods=["DELETE"]) + @swagger_route(security=get_auth_security()) + @login_required + @admin_required + def delete_admin_data_management_backup(backup_id): + payload = request.get_json(silent=True) or {} + admin_user_id, admin_email = _get_admin_context() + try: + cleanup_result = delete_data_management_backup( + backup_id, + requested_by=admin_user_id, + requested_by_email=admin_email, + reason=payload.get("reason") if isinstance(payload, dict) else "manual", + ) + except DataManagementSettingsValidationError as exc: + return jsonify({"success": False, "error": str(exc)}), 400 + except Exception as exc: + log_event( + "[DataManagement] Backup deletion failed.", + {"backup_id": backup_id, "error": str(exc)}, + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({"success": False, "error": "Backup could not be deleted."}), 400 + + _log_data_management_admin_action( + "data_management_backup_deleted", + "Deleted a Data Management backup.", + { + "job_id": cleanup_result.get("job_id"), + "backup_type": cleanup_result.get("backup_type"), + "deleted_blob_count": cleanup_result.get("deleted_blob_count", 0), + "job_item_deleted_count": cleanup_result.get("job_item_deleted_count", 0), + "latest_item_state_deleted_count": cleanup_result.get("latest_item_state_deleted_count", 0), + }, + ) + return jsonify({"success": True, "cleanup": cleanup_result}), 200 + @bp.route("/api/admin/data-management/migration/catalog/", methods=["GET"]) @swagger_route(security=get_auth_security()) @login_required diff --git a/application/single_app/static/js/admin/admin_data_management.js b/application/single_app/static/js/admin/admin_data_management.js index f28b18178..115791f14 100644 --- a/application/single_app/static/js/admin/admin_data_management.js +++ b/application/single_app/static/js/admin/admin_data_management.js @@ -23,6 +23,7 @@ let cosmosEditorResultCount = 0; let cosmosEditorSelectedDocument = null; let cosmosEditorPendingDocument = null; let pendingDataManagementCancellationJob = null; +let pendingDataManagementBackupDelete = null; let migrationWorkflowRefreshTimer = null; let migrationWorkflowRefreshInFlight = false; @@ -105,6 +106,8 @@ function bindElements() { "data_management_enabled", "data_management_full_frequency", "data_management_scheduled_time_utc", + "data_management_retention_value", + "data_management_retention_unit", "data_management_retention_days", "data_management_partial_enabled", "data_management_low_impact_mode", @@ -204,6 +207,7 @@ function bindElements() { "data-management-test-storage-btn", "data-management-run-full-backup-btn", "data-management-run-partial-backup-btn", + "data-management-run-retention-cleanup-btn", "data-management-refresh-backups-btn", "data-management-view-full-backups-btn", "data-management-view-partial-backups-btn", @@ -245,6 +249,9 @@ function bindElements() { "data-management-migration-cancel-modal", "data-management-migration-cancel-message", "data-management-confirm-migration-cancel-btn", + "data-management-backup-delete-modal", + "data-management-backup-delete-message", + "data-management-confirm-backup-delete-btn", "data-management-cosmos-editor-section", "data-management-cosmos-editor-open-danger-btn", "data-management-cosmos-editor-locked-message", @@ -311,6 +318,7 @@ function bindEvents() { elements.dataManagementTestTargetEcStorageBtn?.addEventListener("click", testTargetEnhancedCitationStorage); elements.dataManagementRunFullBackupBtn?.addEventListener("click", () => queueBackup("full")); elements.dataManagementRunPartialBackupBtn?.addEventListener("click", () => queueBackup("partial")); + elements.dataManagementRunRetentionCleanupBtn?.addEventListener("click", runBackupRetentionCleanup); elements.dataManagementMigrationPreviewBtn?.addEventListener("click", () => runMigrationReview(elements.dataManagementMigrationPreviewBtn)); elements.dataManagementExecuteMigrationBtn?.addEventListener("click", () => queueMigration(false)); elements.dataManagementRefreshMigrationSummaryBtn?.addEventListener("click", () => runMigrationReview(elements.dataManagementRefreshMigrationSummaryBtn)); @@ -345,6 +353,10 @@ function bindEvents() { pendingDataManagementCancellationJob = null; }); elements.dataManagementConfirmMigrationCancelBtn?.addEventListener("click", requestDataManagementCancellation); + elements.dataManagementBackupDeleteModal?.addEventListener("hidden.bs.modal", () => { + pendingDataManagementBackupDelete = null; + }); + elements.dataManagementConfirmBackupDeleteBtn?.addEventListener("click", deleteDataManagementBackup); elements.dataManagementKeyVaultLink?.addEventListener("click", openKeyVaultSettings); elements.dataManagementCosmosEditorOpenDangerBtn?.addEventListener("click", showCosmosEditorDangerModal); elements.datamanagementcosmoseditordangeraccept?.addEventListener("change", updateCosmosEditorDangerAcceptState); @@ -359,6 +371,8 @@ function bindEvents() { elements.dataManagementCosmosEditorConfirmSaveBtn?.addEventListener("click", saveCosmosEditorDocument); elements.datamanagementmigrationtemporarydestinationruenabled?.addEventListener("change", updateMigrationCapacityVisibility); elements.datamanagementbackuptemporarysourceruenabled?.addEventListener("change", updateBackupCapacityVisibility); + elements.datamanagementretentionvalue?.addEventListener("input", updateRetentionControls); + elements.datamanagementretentionunit?.addEventListener("change", updateRetentionControls); [ elements.datamanagementmigrationmodenewonly, elements.datamanagementmigrationmodedeltaupsert, @@ -378,6 +392,7 @@ function bindEvents() { setMigrationTargetVisibility(); updateMigrationCapacityVisibility(); updateBackupCapacityVisibility(); + updateRetentionControls(); updateMigrationModeVisibility(); updateMigrationSearchWriteFreezeVisibility(); updateConnectionStringStatus(); @@ -649,6 +664,8 @@ function populateSettings(settings) { setChecked(elements.datamanagementenabled, settings.enabled); setValue(elements.datamanagementfullfrequency, settings.full_backup_frequency || "weekly"); setValue(elements.datamanagementscheduledtimeutc, settings.scheduled_time_utc || settings.default_scheduled_time_utc || "03:00"); + setValue(elements.datamanagementretentionvalue, settings.retention_value ?? settings.retention_days ?? 30); + setValue(elements.datamanagementretentionunit, settings.retention_unit || "days"); setValue(elements.datamanagementretentiondays, settings.retention_days ?? 30); setChecked(elements.datamanagementpartialenabled, settings.partial_backups_enabled !== false); setChecked(elements.datamanagementlowimpactmode, settings.low_impact_mode !== false); @@ -696,6 +713,7 @@ function populateSettings(settings) { } updateSourceBlobBackupAvailability(settings); updateKeyStorageExperience(settings); + updateRetentionControls(); setStorageAuthVisibility(); setMigrationTargetVisibility(); updateMigrationCapacityVisibility(); @@ -809,6 +827,34 @@ function updateBackupCapacityVisibility() { } } +function getRetentionUnitDays(unit) { + const unitDays = { + days: 1, + weeks: 7, + months: 30, + years: 365, + }; + return unitDays[unit] || unitDays.days; +} + +function getRetentionDaysValue() { + const unit = getValue(elements.datamanagementretentionunit) || "days"; + const value = getNumberValue(elements.datamanagementretentionvalue, 30); + return Math.max(1, Math.min(3650, value * getRetentionUnitDays(unit))); +} + +function updateRetentionControls() { + const unit = getValue(elements.datamanagementretentionunit) || "days"; + const maxValue = Math.max(1, Math.floor(3650 / getRetentionUnitDays(unit))); + if (elements.datamanagementretentionvalue) { + elements.datamanagementretentionvalue.max = String(maxValue); + if (getNumberValue(elements.datamanagementretentionvalue, 1) > maxValue) { + elements.datamanagementretentionvalue.value = String(maxValue); + } + } + setValue(elements.datamanagementretentiondays, getRetentionDaysValue()); +} + function setKeyStorageAlert(variant, iconClass, title, message, linkText) { const alertElement = elements.dataManagementKeyStorageAlert; const iconElement = elements.dataManagementKeyStorageAlertIcon; @@ -845,7 +891,9 @@ function collectSettings() { enabled: Boolean(elements.datamanagementenabled?.checked), full_backup_frequency: getValue(elements.datamanagementfullfrequency) || "weekly", scheduled_time_utc: getValue(elements.datamanagementscheduledtimeutc) || "03:00", - retention_days: getNumberValue(elements.datamanagementretentiondays, 30), + retention_value: getNumberValue(elements.datamanagementretentionvalue, 30), + retention_unit: getValue(elements.datamanagementretentionunit) || "days", + retention_days: getRetentionDaysValue(), partial_backups_enabled: Boolean(elements.datamanagementpartialenabled?.checked), low_impact_mode: Boolean(elements.datamanagementlowimpactmode?.checked), include_cosmos: Boolean(elements.datamanagementincludecosmos?.checked), @@ -2916,7 +2964,7 @@ function createBackupRow(backup) { row.appendChild(createBackupStorageCell(backup)); row.appendChild(createBackupProtectionCell(backup)); row.appendChild(createBackupWarningCell(backup)); - row.appendChild(createJobActionCell(backup.id)); + row.appendChild(createBackupActionCell(backup)); return row; } @@ -2984,6 +3032,28 @@ function createBackupWarningCell(backup) { return cell; } +function createBackupActionCell(backup) { + const cell = document.createElement("td"); + const viewButton = document.createElement("button"); + viewButton.type = "button"; + viewButton.className = "btn btn-outline-primary btn-sm"; + viewButton.disabled = !backup?.id; + viewButton.append(createIcon("bi bi-list-check me-1"), document.createTextNode("View Log")); + viewButton.addEventListener("click", () => loadDataManagementJobDetail(backup.id)); + cell.appendChild(viewButton); + + if (backup?.can_delete === true) { + const deleteButton = document.createElement("button"); + deleteButton.type = "button"; + deleteButton.className = "btn btn-outline-danger btn-sm ms-1"; + deleteButton.disabled = !backup.id; + deleteButton.append(createIcon("bi bi-trash3 me-1"), document.createTextNode("Delete")); + deleteButton.addEventListener("click", () => openDataManagementBackupDeleteModal(backup)); + cell.appendChild(deleteButton); + } + return cell; +} + function createLabeledValue(label, value, allowBreak = false) { const wrapper = document.createElement("div"); const labelElement = document.createElement("span"); @@ -3133,6 +3203,77 @@ async function requestDataManagementCancellation() { } } +async function runBackupRetentionCleanup() { + setBusy(elements.dataManagementRunRetentionCleanupBtn, true, "Cleaning..."); + try { + await saveDataManagementSettings(true); + const data = await requestJson("/api/admin/data-management/backups/retention/cleanup", { + method: "POST", + body: JSON.stringify({}), + }); + const cleanup = data.cleanup || {}; + const deletedCount = Number(cleanup.deleted_count || 0); + const message = deletedCount + ? `Backup retention cleanup deleted ${formatNumber(deletedCount)} expired backup${deletedCount === 1 ? "" : "s"}.` + : "Backup retention cleanup found no expired backups to delete."; + setStatus(message, "success"); + showToast(message, "success"); + loadDataManagementBackups(); + loadDataManagementJobs(); + } catch (error) { + setStatus(error.message || "Backup retention cleanup could not be completed.", "danger"); + showToast(error.message || "Backup retention cleanup could not be completed.", "danger"); + } finally { + setBusy(elements.dataManagementRunRetentionCleanupBtn, false); + } +} + +function openDataManagementBackupDeleteModal(backup) { + if (!backup?.id || !elements.dataManagementBackupDeleteModal || !window.bootstrap?.Modal) { + return; + } + pendingDataManagementBackupDelete = { + id: backup.id, + backupType: backup.backup_type || "backup", + }; + setText( + elements.dataManagementBackupDeleteMessage, + `Delete ${formatBackupType(backup.backup_type || "backup")} backup ${backup.id}?` + ); + window.bootstrap.Modal.getOrCreateInstance(elements.dataManagementBackupDeleteModal).show(); +} + +async function deleteDataManagementBackup() { + const pendingBackup = pendingDataManagementBackupDelete; + const backupId = pendingBackup?.id; + if (!backupId) { + return; + } + setBusy(elements.dataManagementConfirmBackupDeleteBtn, true, "Deleting..."); + try { + const data = await requestJson(`/api/admin/data-management/backups/${encodeURIComponent(backupId)}`, { + method: "DELETE", + body: JSON.stringify({ reason: "manual" }), + }); + const cleanup = data.cleanup || {}; + const deletedBlobCount = Number(cleanup.deleted_blob_count || 0); + const message = `Deleted ${formatBackupType(pendingBackup.backupType)} backup and ${formatNumber(deletedBlobCount)} stored artifact${deletedBlobCount === 1 ? "" : "s"}.`; + setStatus(message, "success"); + showToast("Backup deleted.", "success"); + window.bootstrap.Modal.getOrCreateInstance(elements.dataManagementBackupDeleteModal).hide(); + loadDataManagementBackups(); + loadDataManagementJobs(); + if (currentJobDetailId === backupId) { + stopJobDetailAutoRefresh({ clearJob: true }); + } + } catch (error) { + setStatus(error.message || "Backup could not be deleted.", "danger"); + showToast(error.message || "Backup could not be deleted.", "danger"); + } finally { + setBusy(elements.dataManagementConfirmBackupDeleteBtn, false); + } +} + async function retryDataManagementJob(job, button) { const jobId = job?.id; if (!jobId) { diff --git a/application/single_app/templates/admin_settings.html b/application/single_app/templates/admin_settings.html index 9bd8fb62a..4914ffc93 100644 --- a/application/single_app/templates/admin_settings.html +++ b/application/single_app/templates/admin_settings.html @@ -5590,8 +5590,19 @@
Schedule
Default is 03:00 UTC.
- - + +
+ + + +
+ +
Automatic cleanup keeps the newest successful full backup as a safety baseline.
@@ -6442,9 +6453,14 @@

Cosmos DB JSON E

Backup Inventory

Track completed full and partial backups created by Data Management jobs.

- +
+ + +
@@ -6849,6 +6865,27 @@
+ + diff --git a/functional_tests/test_data_management_backup_durability.py b/functional_tests/test_data_management_backup_durability.py index 2a94f1e30..69806fb4c 100644 --- a/functional_tests/test_data_management_backup_durability.py +++ b/functional_tests/test_data_management_backup_durability.py @@ -2,14 +2,16 @@ # test_data_management_backup_durability.py """ Functional test for durable Data Management backup jobs. -Version: 0.250.076 +Version: 0.250.106 Implemented in: 0.250.073 Updated in: 0.250.076 +Updated in: 0.250.106 This test ensures full and partial backups persist immutable plans and cutoffs, enforce source fencing, honor cancellation at durable boundaries, recover stale work, keep latest item state outside source records, and expose -only bounded sanitized progress. +only bounded sanitized progress. Version 0.250.106 adds backup cleanup and +retention policy coverage. """ import copy @@ -77,7 +79,7 @@ def upsert_item(self, body): return copy.deepcopy(saved) def delete_item(self, item, partition_key, etag=None, match_condition=None): - assert item == partition_key + assert item or partition_key self.documents.pop(item, None) def query_items(self, **_kwargs): @@ -101,6 +103,38 @@ def upsert_item(self, body): self.documents[(saved["source_scope"], saved["id"])] = saved return copy.deepcopy(saved) + def query_items(self, **_kwargs): + return iter(copy.deepcopy(list(self.documents.values()))) + + def delete_item(self, item, partition_key): + self.documents.pop((partition_key, item), None) + + +class FakeBlobProperties: + """Represent the blob-name subset used by cleanup code.""" + + def __init__(self, name): + self.name = name + + +class FakeBackupContainerClient: + """Store backup artifact names and delete by prefix or exact blob name.""" + + def __init__(self, blob_names): + self.blob_names = set(blob_names) + + def list_blobs(self, name_starts_with=""): + return [ + FakeBlobProperties(name) + for name in sorted(self.blob_names) + if name.startswith(name_starts_with or "") + ] + + def delete_blob(self, blob_name): + if blob_name not in self.blob_names: + raise FakeCosmosError(404) + self.blob_names.remove(blob_name) + class FakeExecutor: """Record recovery submissions without executing workers.""" @@ -123,7 +157,7 @@ def load_data_management_module(monkeypatch, job_container, item_state_container """Load production backup helpers with in-memory Cosmos dependencies.""" config_module = types.ModuleType("config") config_module.CLIENTS = {} - config_module.VERSION = "0.250.076" + config_module.VERSION = "0.250.106" config_module.cosmos_data_management_jobs_container = job_container config_module.cosmos_data_management_job_items_container = job_container config_module.cosmos_settings_container = job_container @@ -215,6 +249,172 @@ def backup_job(job_id, status="queued", lease_holder_id=None): } +def completed_backup_job(job_id, backup_type="partial", completed_at="2026-07-01T12:00:00+00:00"): + """Build a completed backup with cleanup-addressable artifacts.""" + job = backup_job(job_id, "completed") + job["backup_type"] = backup_type + job["created_at"] = completed_at + job["updated_at"] = completed_at + job["completed_at"] = completed_at + job["started_at"] = completed_at + job["backup_plan"]["backup_type"] = backup_type + base_prefix = f"simplechat-backups/{backup_type}/2026/07/01/120000-{job_id}" + job["result"] = { + "manifest_path": f"{base_prefix}/manifest.json", + "base_prefix": base_prefix, + "artifact_count": 2, + "artifacts": [ + { + "name": "settings", + "type": "cosmos_container", + "path": f"{base_prefix}/cosmos/settings/batches/000001.jsonl", + "bytes": 100, + }, + { + "name": "manifest", + "type": "manifest", + "path": f"{base_prefix}/manifest.json", + "bytes": 50, + }, + ], + } + return job + + +def backup_cleanup_settings(): + """Return settings sufficient for cleanup path validation.""" + return { + "enabled": True, + "backup_storage_authentication_type": "managed_identity", + "backup_storage_blob_endpoint": "https://backup.blob.core.windows.net", + "backup_storage_container_name": "simplechat-backups", + "backup_storage_path_prefix": "simplechat-backups", + "retention_value": 1, + "retention_unit": "weeks", + "retention_days": 7, + } + + +def test_backup_retention_value_units_preserve_legacy_retention_days(monkeypatch): + """Verify day/week/month/year settings normalize to bounded retention days.""" + module = load_data_management_module(monkeypatch, FakeJobContainer()) + + weekly = module.normalize_data_management_settings( + payload={"retention_value": 2, "retention_unit": "weeks"} + ) + legacy = module.normalize_data_management_settings( + existing_settings={"retention_days": 45} + ) + bounded_years = module.normalize_data_management_settings( + payload={"retention_value": 11, "retention_unit": "years"} + ) + + assert weekly["retention_days"] == 14 + assert weekly["retention_value"] == 2 + assert weekly["retention_unit"] == "weeks" + assert legacy["retention_days"] == 45 + assert legacy["retention_value"] == 45 + assert legacy["retention_unit"] == "days" + assert bounded_years["retention_value"] == 10 + assert bounded_years["retention_days"] == 3650 + + +def test_manual_backup_delete_removes_artifacts_job_items_and_latest_state(monkeypatch): + """Verify deleting a backup clears artifacts, metadata, and differential sidecar state.""" + job_id = "12121212-1212-1212-1212-121212121212" + job = completed_backup_job(job_id, "full") + job_container = FakeJobContainer([job]) + state_container = FakeItemStateContainer() + module = load_data_management_module(monkeypatch, job_container, state_container) + monkeypatch.setattr(module, "_record_data_management_job_event", lambda *_args, **_kwargs: None) + artifact_blobs = { + f"{job['result']['base_prefix']}/manifest.json", + f"{job['result']['base_prefix']}/cosmos/settings/batches/000001.jsonl", + } + backup_container = FakeBackupContainerClient(artifact_blobs) + monkeypatch.setattr( + module, + "_get_existing_backup_container_client", + lambda _settings: backup_container, + ) + job_container.upsert_item({ + "id": "job-item-1", + "job_id": job_id, + "type": module.DATA_MANAGEMENT_JOB_ITEM_TYPE, + "created_at": "2026-07-01T12:01:00+00:00", + }) + module._record_backup_latest_item_state( + job, + "cosmos", + "cosmos:settings", + "source-identity", + "source-version", + "succeeded", + checkpoint_id="checkpoint-1", + artifact_path=f"{job['result']['base_prefix']}/cosmos/settings/batches/000001.jsonl", + ) + + result = module.delete_data_management_backup( + job_id, + requested_by="admin-user", + requested_by_email="admin@example.com", + settings=backup_cleanup_settings(), + ) + + assert result["job_deleted"] is True + assert result["deleted_blob_count"] == 2 + assert result["job_item_deleted_count"] == 1 + assert result["latest_item_state_deleted_count"] == 1 + assert backup_container.blob_names == set() + assert job_id not in job_container.documents + assert all(document.get("job_id") != job_id for document in job_container.documents.values()) + assert state_container.documents == {} + + +def test_retention_cleanup_keeps_newest_successful_full_backup(monkeypatch): + """Verify automatic retention keeps one full backup baseline while deleting older backups.""" + protected_full = completed_backup_job( + "23232323-2323-2323-2323-232323232323", + "full", + "2026-07-01T12:00:00+00:00", + ) + expired_partial = completed_backup_job( + "34343434-3434-3434-3434-343434343434", + "partial", + "2026-07-02T12:00:00+00:00", + ) + recent_partial = completed_backup_job( + "45454545-4545-4545-4545-454545454545", + "partial", + "2026-07-30T12:00:00+00:00", + ) + job_container = FakeJobContainer([protected_full, expired_partial, recent_partial]) + state_container = FakeItemStateContainer() + module = load_data_management_module(monkeypatch, job_container, state_container) + monkeypatch.setattr(module, "_record_data_management_job_event", lambda *_args, **_kwargs: None) + backup_container = FakeBackupContainerClient({ + f"{expired_partial['result']['base_prefix']}/manifest.json", + }) + monkeypatch.setattr( + module, + "_get_existing_backup_container_client", + lambda _settings: backup_container, + ) + + result = module.cleanup_expired_data_management_backups( + settings=backup_cleanup_settings(), + current_time=datetime(2026, 7, 31, 12, 0, tzinfo=timezone.utc), + manual_execution=True, + ) + + assert result["success"] is True + assert result["protected_latest_full_backup_id"] == protected_full["id"] + assert result["deleted_count"] == 1 + assert protected_full["id"] in job_container.documents + assert expired_partial["id"] not in job_container.documents + assert recent_partial["id"] in job_container.documents + + def test_backup_plan_is_immutable_and_has_explicit_non_destructive_cutoff(monkeypatch): """Verify source cutoff and differential deletion policy cannot drift after queueing.""" module = load_data_management_module(monkeypatch, FakeJobContainer()) diff --git a/ui_tests/test_admin_data_management_settings_ui.py b/ui_tests/test_admin_data_management_settings_ui.py index a6fa14313..fc67c17c3 100644 --- a/ui_tests/test_admin_data_management_settings_ui.py +++ b/ui_tests/test_admin_data_management_settings_ui.py @@ -1,13 +1,14 @@ # test_admin_data_management_settings_ui.py """ UI test for Admin Settings Data Management controls. -Version: 0.250.105 +Version: 0.250.106 Implemented in: 0.241.211 Updated in: 0.241.221 Updated in: 0.250.102 Updated in: 0.250.103 Updated in: 0.250.104 Updated in: 0.250.105 +Updated in: 0.250.106 This test ensures admins can discover the Data Management tab, see the operational-business-hours warning, and access the backup, encryption, @@ -21,6 +22,7 @@ Version 0.250.102 adds independently bounded source-blob transfer controls. Version 0.250.103 adds the staged migration workflow, scalable catalogs, server-owned review, confirmation gating, and inline durable job progress. +Version 0.250.106 adds backup cleanup and unit-based retention controls. """ import json @@ -62,6 +64,9 @@ def test_admin_data_management_controls_render_from_template(): "data_management_enabled", "data_management_full_frequency", "data_management_scheduled_time_utc", + "data_management_retention_value", + "data_management_retention_unit", + "data_management_retention_days", "data_management_partial_enabled", "data_management_low_impact_mode", "data-management-advanced-scope-drawer", @@ -188,6 +193,7 @@ def test_admin_data_management_controls_render_from_template(): "data-management-backup-operations-section", "data-management-run-full-backup-btn", "data-management-run-partial-backup-btn", + "data-management-run-retention-cleanup-btn", "data-management-backup-inventory-section", "data-management-full-backup-count", "data-management-partial-backup-count", @@ -222,6 +228,9 @@ def test_admin_data_management_controls_render_from_template(): "data-management-migration-cancel-modal", "data-management-migration-cancel-message", "data-management-confirm-migration-cancel-btn", + "data-management-backup-delete-modal", + "data-management-backup-delete-message", + "data-management-confirm-backup-delete-btn", ] for element_id in required_ids: @@ -231,6 +240,8 @@ def test_admin_data_management_controls_render_from_template(): assert 'id="data-management" role="tabpanel" aria-labelledby="data-management-tab" data-testid="data-management-tab-pane" data-ignore-settings-change="true"' in template assert 'id="data-management-save-settings-btn" disabled aria-disabled="true"' in template assert '

Backup

' in template + assert "Delete backups after" in template + assert "Automatic cleanup keeps the newest successful full backup as a safety baseline." in template assert 'id="data-management-migration-title"' in template assert "Cosmos DB JSON Editor" in template assert "Query results and the JSON editor open in a modal" in template @@ -245,6 +256,9 @@ def test_admin_data_management_controls_render_from_template(): assert "I understand this editor can damage overall system health." in template assert "Required phrase: I understand this can damage system data" in template assert '

Backup Inventory

' in template + assert "Run Retention Cleanup" in template + assert "Delete Data Management Backup" in template + assert "Future partial backups will re-export affected unchanged items." in template assert 'aria-label="Backup inventory filters"' in template assert 'Available backups' in template assert 'Backup' in template @@ -303,6 +317,13 @@ def test_admin_data_management_controls_render_from_template(): assert 'backup_blob_chunk_size_mib' in js_source assert 'backup_blob_retry_count' in js_source assert 'backup_temporary_source_ru_enabled' in js_source + assert 'retention_value: getNumberValue(elements.datamanagementretentionvalue, 30)' in js_source + assert 'retention_unit: getValue(elements.datamanagementretentionunit) || "days"' in js_source + assert 'getRetentionDaysValue' in js_source + assert 'runBackupRetentionCleanup' in js_source + assert 'openDataManagementBackupDeleteModal' in js_source + assert 'deleteDataManagementBackup' in js_source + assert '/api/admin/data-management/backups/retention/cleanup' in js_source assert 'buildMigrationPlan' in js_source assert 'queueMigration(false)' in js_source assert 'loadMigrationCatalog(targetType, "reset")' in js_source @@ -568,6 +589,8 @@ def handle_data_management_api(route): expect(page.get_by_label("Enable scheduled backups")).to_be_visible() expect(page.get_by_label("Full backup frequency")).to_be_visible() expect(page.locator("#data_management_scheduled_time_utc")).to_have_value("03:00") + expect(page.get_by_label("Delete backups after")).to_be_visible() + expect(page.get_by_label("Retention unit")).to_be_visible() expect(page.get_by_label("Run partial backups daily between full backups")).to_be_visible() expect(page.get_by_role("button", name="Advanced backup scope")).to_be_visible() expect(page.locator("#data_management_target_cosmos_database")).to_have_value("SimpleChat") @@ -660,6 +683,7 @@ def handle_data_management_api(route): expect(page.locator("#data-management-jobs-tbody")).to_be_visible() expect(page.locator("#data-management-job-detail-modal")).to_be_attached() expect(page.locator("#data-management-migration-cancel-modal")).to_be_attached() + expect(page.locator("#data-management-backup-delete-modal")).to_be_attached() finally: context.close() browser.close() From d32a1021983416f76954b515e0304a4a6fb2d2f8 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Fri, 31 Jul 2026 09:48:58 -0400 Subject: [PATCH 2/6] Update release notes for backup cleanup Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/explanation/release_notes.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index b902b880e..e06cb7250 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -2,6 +2,16 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Version](/explanation/features/) and [Fixes by Version](/explanation/fixes/). +### **(v0.250.106)** + +#### New Features + +* **Backup Cleanup and Retention Policy Controls** + * Added Data Management backup cleanup controls so administrators can manually delete backup artifacts and metadata from Backup Inventory. + * Added unit-based backup retention settings for days, weeks, months, and years, with automatic cleanup that preserves the newest successful full backup as a restore safety baseline. + * Cleanup removes stored backup blobs, job timeline records, and differential sidecar state so future partial backups re-export affected unchanged items instead of pointing to deleted artifacts. + * (Ref: Closes #1130, `functions_data_management.py`, `route_backend_data_management.py`, `admin_settings.html`, `admin_data_management.js`) + ### **(v0.250.105)** #### User Interface Enhancements From d7069f3f4f14399f25249d461fc7fe930893d574 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Fri, 31 Jul 2026 10:01:18 -0400 Subject: [PATCH 3/6] Potential fix for pull request finding 'CodeQL / Information exposure through an exception' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- application/single_app/route_backend_data_management.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/application/single_app/route_backend_data_management.py b/application/single_app/route_backend_data_management.py index 1afec23eb..210bea1c9 100644 --- a/application/single_app/route_backend_data_management.py +++ b/application/single_app/route_backend_data_management.py @@ -619,7 +619,12 @@ def cleanup_admin_data_management_backups(): manual_execution=True, ) except DataManagementSettingsValidationError as exc: - return jsonify({"success": False, "error": str(exc)}), 400 + log_event( + "[DataManagement] Manual backup retention cleanup validation failed.", + {"error": str(exc)}, + level=logging.WARNING, + ) + return jsonify({"success": False, "error": "Backup retention cleanup request is invalid."}), 400 except Exception as exc: log_event( "[DataManagement] Manual backup retention cleanup failed.", From 3ea4f2331dc0a4c249925c96b11421a23ff308a9 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Fri, 31 Jul 2026 10:01:29 -0400 Subject: [PATCH 4/6] Potential fix for pull request finding 'CodeQL / Information exposure through an exception' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- application/single_app/functions_data_management.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/single_app/functions_data_management.py b/application/single_app/functions_data_management.py index 3c928fafe..afa328ce3 100644 --- a/application/single_app/functions_data_management.py +++ b/application/single_app/functions_data_management.py @@ -10931,7 +10931,7 @@ def cleanup_expired_data_management_backups( except Exception as exc: error = { "job_id": _safe_text(job.get("id")), - "error": _sanitize_data_management_backup_text(str(exc)), + "error": "Backup deletion failed for this item.", } result["errors"].append(error) log_event( From d459869069c35eba7049f9b700683119234daac7 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Fri, 31 Jul 2026 10:01:37 -0400 Subject: [PATCH 5/6] Potential fix for pull request finding 'CodeQL / Information exposure through an exception' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- application/single_app/route_backend_data_management.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/application/single_app/route_backend_data_management.py b/application/single_app/route_backend_data_management.py index 210bea1c9..debf5e3c3 100644 --- a/application/single_app/route_backend_data_management.py +++ b/application/single_app/route_backend_data_management.py @@ -666,7 +666,12 @@ def delete_admin_data_management_backup(backup_id): reason=payload.get("reason") if isinstance(payload, dict) else "manual", ) except DataManagementSettingsValidationError as exc: - return jsonify({"success": False, "error": str(exc)}), 400 + log_event( + "[DataManagement] Backup deletion validation failed.", + {"backup_id": backup_id, "error": str(exc)}, + level=logging.WARNING, + ) + return jsonify({"success": False, "error": "Backup deletion request is invalid."}), 400 except Exception as exc: log_event( "[DataManagement] Backup deletion failed.", From ca3d3d8938c513cc70061b4d447771ae3ff28ffc Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Fri, 31 Jul 2026 10:01:52 -0400 Subject: [PATCH 6/6] Potential fix for pull request finding 'CodeQL / Information exposure through an exception' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .../route_backend_data_management.py | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/application/single_app/route_backend_data_management.py b/application/single_app/route_backend_data_management.py index debf5e3c3..9959e047d 100644 --- a/application/single_app/route_backend_data_management.py +++ b/application/single_app/route_backend_data_management.py @@ -131,6 +131,28 @@ def _log_cosmos_editor_failure(action, message, details=None): ) +def _sanitize_backup_cleanup_result_for_response(cleanup_result): + if not isinstance(cleanup_result, dict): + return {} + + sanitized = dict(cleanup_result) + errors = sanitized.get("errors") + if isinstance(errors, list): + sanitized_errors = [] + for error_item in errors: + if isinstance(error_item, dict): + sanitized_errors.append({ + "job_id": error_item.get("job_id"), + "error": "Backup cleanup failed for this item.", + }) + else: + sanitized_errors.append({ + "error": "Backup cleanup failed for this item.", + }) + sanitized["errors"] = sanitized_errors + return sanitized + + def register_route_backend_data_management(bp): @bp.route("/api/admin/data-management/settings", methods=["GET"]) @swagger_route(security=get_auth_security()) @@ -643,13 +665,14 @@ def cleanup_admin_data_management_backups(): "error_count": len(cleanup_result.get("errors") or []), }, ) + public_cleanup_result = _sanitize_backup_cleanup_result_for_response(cleanup_result) if cleanup_result.get("success") is False: return jsonify({ "success": False, "error": "Backup retention cleanup completed with errors.", - "cleanup": cleanup_result, + "cleanup": public_cleanup_result, }), 400 - return jsonify({"success": True, "cleanup": cleanup_result}), 200 + return jsonify({"success": True, "cleanup": public_cleanup_result}), 200 @bp.route("/api/admin/data-management/backups/", methods=["DELETE"]) @swagger_route(security=get_auth_security())