Add factory reset endpoint for stage environment cleanup (#54)
This commit is contained in:
@@ -11,6 +11,12 @@ variables:
|
|||||||
# Environment URLs (used by deploy and test jobs)
|
# Environment URLs (used by deploy and test jobs)
|
||||||
STAGE_URL: https://orchard-stage.common.global.bsf.tools
|
STAGE_URL: https://orchard-stage.common.global.bsf.tools
|
||||||
PROD_URL: https://orchard.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
|
# Shared pip cache directory
|
||||||
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.pip-cache"
|
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.pip-cache"
|
||||||
|
|
||||||
@@ -141,6 +147,64 @@ integration_test_stage:
|
|||||||
- if: '$CI_COMMIT_BRANCH == "main"'
|
- if: '$CI_COMMIT_BRANCH == "main"'
|
||||||
when: on_success
|
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 tests for feature deployment (full suite)
|
||||||
integration_test_feature:
|
integration_test_feature:
|
||||||
<<: *integration_test_template
|
<<: *integration_test_template
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
### Added
|
### 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 AWS Secrets Manager CSI driver support for database credentials (#54)
|
||||||
- Added SecretProviderClass template for Secrets Manager integration (#54)
|
- Added SecretProviderClass template for Secrets Manager integration (#54)
|
||||||
- Added IRSA service account annotations for prod and stage environments (#54)
|
- Added IRSA service account annotations for prod and stage environments (#54)
|
||||||
|
|||||||
@@ -6390,3 +6390,110 @@ def get_artifact_provenance(
|
|||||||
tags=tag_list,
|
tags=tag_list,
|
||||||
uploads=upload_history,
|
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)}",
|
||||||
|
)
|
||||||
|
|||||||
@@ -835,6 +835,36 @@ class S3Storage:
|
|||||||
except ClientError:
|
except ClientError:
|
||||||
return False
|
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(
|
def generate_presigned_url(
|
||||||
self,
|
self,
|
||||||
s3_key: str,
|
s3_key: str,
|
||||||
|
|||||||
Reference in New Issue
Block a user