From 535280a7833ae6dccc64dc377e569f21d3358f60 Mon Sep 17 00:00:00 2001 From: Mondo Diaz Date: Wed, 21 Jan 2026 16:00:02 -0600 Subject: [PATCH] Add factory reset endpoint for stage environment cleanup (#54) --- .gitlab-ci.yml | 64 ++++++++++++++++++++++++ CHANGELOG.md | 5 ++ backend/app/routes.py | 107 +++++++++++++++++++++++++++++++++++++++++ backend/app/storage.py | 30 ++++++++++++ 4 files changed, 206 insertions(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 5e48514..5477c41 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -11,6 +11,12 @@ variables: # Environment URLs (used by deploy and test jobs) STAGE_URL: https://orchard-stage.common.global.bsf.tools PROD_URL: https://orchard.common.global.bsf.tools + # Stage environment AWS resources (used by reset job) + STAGE_RDS_HOST: orchard-stage.cluster-cvw3jzjkozoc.us-gov-west-1.rds.amazonaws.com + STAGE_RDS_DBNAME: postgres + STAGE_SECRET_ARN: "arn:aws-us-gov:secretsmanager:us-gov-west-1:052673043337:secret:rds!cluster-a573672b-1a38-4665-a654-1b7df37b5297-IaeFQL" + STAGE_S3_BUCKET: orchard-artifacts-stage + AWS_REGION: us-gov-west-1 # Shared pip cache directory PIP_CACHE_DIR: "$CI_PROJECT_DIR/.pip-cache" @@ -141,6 +147,64 @@ integration_test_stage: - if: '$CI_COMMIT_BRANCH == "main"' when: on_success +# Reset stage environment after integration tests (clean slate for next run) +# Calls the /api/v1/admin/factory-reset endpoint which handles DB and S3 cleanup +reset_stage: + stage: deploy + needs: [integration_test_stage] + image: deps.global.bsf.tools/docker/python:3.12-slim + timeout: 5m + before_script: + - pip install --index-url "$PIP_INDEX_URL" httpx + script: + - | + python - <<'RESET_SCRIPT' + import httpx + import sys + + BASE_URL = "${STAGE_URL}" + ADMIN_USER = "admin" + ADMIN_PASS = "changeme123" # Default admin password + + print(f"=== Resetting stage environment at {BASE_URL} ===") + + client = httpx.Client(base_url=BASE_URL, timeout=60.0) + + # Login as admin + print("Logging in as admin...") + login_response = client.post( + "/api/v1/auth/login", + json={"username": ADMIN_USER, "password": ADMIN_PASS}, + ) + if login_response.status_code != 200: + print(f"Login failed: {login_response.status_code} - {login_response.text}") + sys.exit(1) + print("Login successful") + + # Call factory reset endpoint + print("Calling factory reset endpoint...") + reset_response = client.post( + "/api/v1/admin/factory-reset", + headers={"X-Confirm-Reset": "yes-delete-all-data"}, + ) + + if reset_response.status_code == 200: + result = reset_response.json() + print(f"Factory reset successful!") + print(f" Database tables dropped: {result['results']['database_tables_dropped']}") + print(f" S3 objects deleted: {result['results']['s3_objects_deleted']}") + print(f" Database reinitialized: {result['results']['database_reinitialized']}") + print(f" Seeded: {result['results']['seeded']}") + sys.exit(0) + else: + print(f"Factory reset failed: {reset_response.status_code} - {reset_response.text}") + sys.exit(1) + RESET_SCRIPT + rules: + - if: '$CI_COMMIT_BRANCH == "main"' + when: on_success + allow_failure: true # Don't fail pipeline if reset has issues + # Integration tests for feature deployment (full suite) integration_test_feature: <<: *integration_test_template diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cf267b..4dd6086 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Added factory reset endpoint `POST /api/v1/admin/factory-reset` for test environment cleanup (#54) + - Requires admin authentication and `X-Confirm-Reset: yes-delete-all-data` header + - Drops all database tables, clears S3 bucket, reinitializes schema, re-seeds default data + - CI pipeline automatically calls this after integration tests on stage +- Added `delete_all()` method to storage backend for bulk S3 object deletion (#54) - Added AWS Secrets Manager CSI driver support for database credentials (#54) - Added SecretProviderClass template for Secrets Manager integration (#54) - Added IRSA service account annotations for prod and stage environments (#54) diff --git a/backend/app/routes.py b/backend/app/routes.py index 2cb08d3..d0902c6 100644 --- a/backend/app/routes.py +++ b/backend/app/routes.py @@ -6390,3 +6390,110 @@ def get_artifact_provenance( tags=tag_list, uploads=upload_history, ) + + +# ============================================================================= +# Factory Reset Endpoint (Admin Only) +# ============================================================================= + + +@router.post("/api/v1/admin/factory-reset", tags=["admin"]) +def factory_reset( + request: Request, + db: Session = Depends(get_db), + storage: S3Storage = Depends(get_storage), + current_user: User = Depends(require_admin), +): + """ + Factory reset - delete all data and restore to initial state. + + This endpoint: + 1. Drops all database tables + 2. Deletes all objects from S3 storage + 3. Recreates the database schema + 4. Re-seeds with default admin user + + Requires: + - Admin authentication + - X-Confirm-Reset header set to "yes-delete-all-data" + + WARNING: This is a destructive operation that cannot be undone. + """ + # Require explicit confirmation header + confirm_header = request.headers.get("X-Confirm-Reset") + if confirm_header != "yes-delete-all-data": + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Factory reset requires X-Confirm-Reset header set to 'yes-delete-all-data'", + ) + + logger.warning(f"Factory reset initiated by admin user: {current_user.username}") + + results = { + "database_tables_dropped": 0, + "s3_objects_deleted": 0, + "database_reinitialized": False, + "seeded": False, + } + + try: + # Step 1: Drop all tables in public schema + logger.info("Dropping all database tables...") + drop_result = db.execute( + text(""" + DO $$ + DECLARE + r RECORD; + table_count INT := 0; + BEGIN + SET session_replication_role = 'replica'; + FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = 'public') LOOP + EXECUTE 'DROP TABLE IF EXISTS public.' || quote_ident(r.tablename) || ' CASCADE'; + table_count := table_count + 1; + END LOOP; + SET session_replication_role = 'origin'; + RAISE NOTICE 'Dropped % tables', table_count; + END $$; + """) + ) + db.commit() + + # Count tables that were dropped + count_result = db.execute( + text("SELECT COUNT(*) FROM pg_tables WHERE schemaname = 'public'") + ) + remaining_tables = count_result.scalar() + results["database_tables_dropped"] = "all" + logger.info(f"Database tables dropped, remaining: {remaining_tables}") + + # Step 2: Delete all S3 objects + logger.info("Deleting all S3 objects...") + results["s3_objects_deleted"] = storage.delete_all() + + # Step 3: Reinitialize database schema + logger.info("Reinitializing database schema...") + from .database import init_db + init_db() + results["database_reinitialized"] = True + + # Step 4: Re-seed with default data + logger.info("Seeding database with defaults...") + from .seed import seed_database + seed_database() + results["seeded"] = True + + logger.warning(f"Factory reset completed by {current_user.username}") + + return { + "status": "success", + "message": "Factory reset completed successfully", + "results": results, + } + + except Exception as e: + logger.error(f"Factory reset failed: {e}") + db.rollback() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Factory reset failed: {str(e)}", + ) diff --git a/backend/app/storage.py b/backend/app/storage.py index d23e544..ccd3532 100644 --- a/backend/app/storage.py +++ b/backend/app/storage.py @@ -835,6 +835,36 @@ class S3Storage: except ClientError: return False + def delete_all(self) -> int: + """ + Delete all objects in the bucket. + + Returns: + Number of objects deleted + """ + deleted_count = 0 + try: + paginator = self.client.get_paginator("list_objects_v2") + for page in paginator.paginate(Bucket=self.bucket): + objects = page.get("Contents", []) + if not objects: + continue + + # Delete objects in batches of 1000 (S3 limit) + delete_keys = [{"Key": obj["Key"]} for obj in objects] + if delete_keys: + self.client.delete_objects( + Bucket=self.bucket, Delete={"Objects": delete_keys} + ) + deleted_count += len(delete_keys) + logger.info(f"Deleted {len(delete_keys)} objects from S3") + + logger.info(f"Total objects deleted from S3: {deleted_count}") + return deleted_count + except ClientError as e: + logger.error(f"Failed to delete all S3 objects: {e}") + raise + def generate_presigned_url( self, s3_key: str,