File size: 15,723 Bytes
7df56c5 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 | #!/usr/bin/env python3
"""
S3 Standalone Test Script for Attestation Store
This script tests S3 operations with presigned URLs:
1. Generates a presigned URL for uploading a file
2. Uploads a test file to the S3 bucket using the presigned URL
3. Generates a presigned URL for downloading the file
4. Downloads the file using the presigned URL
5. Compares the original and downloaded files
The script supports both AWS S3 and MinIO storage backends.
"""
import os
import sys
import uuid
import json
import boto3
import hashlib
import logging
import requests
import tempfile
import argparse
from datetime import timedelta
from botocore.config import Config as BotoConfig
from urllib.parse import urlparse
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger("s3_test")
# Default settings
DEFAULT_EXPIRATION = 3600 # 1 hour
DEFAULT_REGION = "il-central-1"
TEST_CONTENT = json.dumps({"test": "data", "timestamp": str(uuid.uuid4())})
class S3StorageBackend:
"""
AWS S3 storage backend (compatible with the actual implementation in the project)
"""
def __init__(self, access_key, secret_key, region, bucket, session_token=None):
self.bucket = bucket
# Configure boto3 client
cfg = BotoConfig(signature_version="s3v4", s3={"addressing_style": "virtual"})
# If keys are empty, allow the default provider chain (env vars, IAM role, etc.)
client_kwargs = dict(service_name="s3", region_name=region, config=cfg)
if access_key and secret_key:
client_kwargs.update(
aws_access_key_id=access_key,
aws_secret_access_key=secret_key,
)
if session_token: # required for ASIA... keys
client_kwargs.update(aws_session_token=session_token)
self.s3_client = boto3.client(**client_kwargs)
logger.info(f"Initialized S3 client for region {region}, bucket {bucket}")
def _normalize_content_type(self, content_type):
"""Normalize content type"""
if not content_type:
return "application/json"
if content_type == "cyclonedx-json":
return "application/cyclonedx+json"
return "application/json"
def generate_upload_url(self, file_name, content_type=None, session_id=None, expiration=DEFAULT_EXPIRATION):
"""Generate presigned URL for S3 upload"""
file_id = session_id or str(uuid.uuid4())
storage_key = f"attestations/{file_id}/{file_name}"
ct = self._normalize_content_type(content_type)
params = {
"Bucket": self.bucket,
"Key": storage_key,
"ContentType": ct,
}
url = self.s3_client.generate_presigned_url(
"put_object",
Params=params,
ExpiresIn=expiration,
HttpMethod="PUT",
)
logger.info(f"S3 presigned PUT: {url}")
logger.info(f"S3 region: {self.s3_client.meta.region_name}, bucket: {self.bucket}, key: {storage_key}")
return {"upload_url": url, "storage_path": storage_key}
def generate_download_url(self, storage_path, expiration=DEFAULT_EXPIRATION):
"""Generate presigned URL for S3 download"""
url = self.s3_client.generate_presigned_url(
"get_object",
Params={"Bucket": self.bucket, "Key": storage_path},
ExpiresIn=expiration,
)
logger.info(f"S3 presigned GET: {url}")
return url
def delete_file(self, storage_path):
"""Delete S3 object"""
try:
self.s3_client.delete_object(Bucket=self.bucket, Key=storage_path)
logger.info(f"Successfully deleted S3 object: {storage_path}")
return True
except Exception as e:
logger.error(f"Failed to delete S3 object {storage_path}: {e}")
return False
class MinIOStorageBackend:
"""
MinIO storage backend (compatible with the actual implementation in the project)
"""
def __init__(self, endpoint, access_key, secret_key, bucket, secure=True):
try:
from minio import Minio
self.bucket = bucket
self.client = Minio(
endpoint,
access_key=access_key,
secret_key=secret_key,
secure=secure
)
# Create bucket if it doesn't exist
if not self.client.bucket_exists(bucket):
self.client.make_bucket(bucket)
logger.info(f"Created MinIO bucket: {bucket}")
else:
logger.info(f"Using existing MinIO bucket: {bucket}")
except ImportError:
logger.error("MinIO Python client not found. Please install with: pip install minio")
sys.exit(1)
except Exception as e:
logger.error(f"Failed to initialize MinIO client: {e}")
sys.exit(1)
def generate_upload_url(self, file_name, content_type=None, session_id=None, expiration=DEFAULT_EXPIRATION):
"""Generate presigned URL for MinIO upload"""
file_id = session_id or str(uuid.uuid4())
storage_key = f"attestations/{file_id}/{file_name}"
storage_key = storage_key.replace('//', '/')
upload_url = self.client.get_presigned_url(
"PUT",
self.bucket,
storage_key,
expires=timedelta(seconds=expiration)
)
logger.info(f"MinIO presigned PUT: {upload_url}")
return {
'upload_url': upload_url,
'storage_path': storage_key
}
def generate_download_url(self, storage_path, expiration=DEFAULT_EXPIRATION):
"""Generate presigned URL for MinIO download"""
url = self.client.get_presigned_url(
"GET",
self.bucket,
storage_path,
expires=timedelta(seconds=expiration)
)
logger.info(f"MinIO presigned GET: {url}")
return url
def delete_file(self, storage_path):
"""Delete MinIO object"""
try:
self.client.remove_object(self.bucket, storage_path)
logger.info(f"Successfully deleted MinIO object: {storage_path}")
return True
except Exception as e:
logger.error(f"Failed to delete MinIO object {storage_path}: {e}")
return False
def create_test_file(content=None):
"""Create a temporary test file"""
if content is None:
content = TEST_CONTENT
fd, path = tempfile.mkstemp(suffix=".json")
try:
with os.fdopen(fd, 'w') as f:
f.write(content)
return path
except Exception as e:
logger.error(f"Failed to create test file: {e}")
os.unlink(path)
sys.exit(1)
def calculate_file_hash(file_path):
"""Calculate SHA-256 hash of a file"""
sha256 = hashlib.sha256()
with open(file_path, "rb") as f:
for chunk in iter(lambda: f.read(4096), b""):
sha256.update(chunk)
return sha256.hexdigest()
def upload_to_presigned_url(file_path, upload_url, content_type="application/json"):
"""Upload a file using a presigned URL"""
try:
with open(file_path, "rb") as f:
headers = {"Content-Type": content_type}
response = requests.put(upload_url, data=f, headers=headers)
response.raise_for_status()
logger.info(f"Successfully uploaded file to {upload_url}, status: {response.status_code}")
return True
except Exception as e:
logger.error(f"Failed to upload file using presigned URL: {e}")
return False
def download_from_presigned_url(download_url, output_path):
"""Download a file using a presigned URL"""
try:
response = requests.get(download_url)
response.raise_for_status()
with open(output_path, "wb") as f:
f.write(response.content)
logger.info(f"Successfully downloaded file from {download_url} to {output_path}")
return True
except Exception as e:
logger.error(f"Failed to download file using presigned URL: {e}")
return False
def verify_files_match(original_path, downloaded_path):
"""Verify that the original and downloaded files match"""
original_hash = calculate_file_hash(original_path)
downloaded_hash = calculate_file_hash(downloaded_path)
if original_hash == downloaded_hash:
logger.info(f"Files match! Hash: {original_hash}")
return True
else:
logger.error(f"Files do not match! Original hash: {original_hash}, Downloaded hash: {downloaded_hash}")
return False
def parse_args():
"""Parse command line arguments"""
parser = argparse.ArgumentParser(description="Test S3 operations with presigned URLs")
parser.add_argument("--provider", choices=["aws", "minio"], default="aws",
help="Storage provider (aws or minio)")
# AWS S3 options
parser.add_argument("--aws-access-key", help="AWS Access Key ID")
parser.add_argument("--aws-secret-key", help="AWS Secret Access Key")
parser.add_argument("--aws-region", default=DEFAULT_REGION, help="AWS Region")
parser.add_argument("--s3-bucket", help="S3 Bucket name")
# MinIO options
parser.add_argument("--minio-endpoint", help="MinIO endpoint (e.g., 'minio.example.com:9000')")
parser.add_argument("--minio-access-key", help="MinIO Access Key")
parser.add_argument("--minio-secret-key", help="MinIO Secret Key")
parser.add_argument("--minio-bucket", help="MinIO Bucket name")
parser.add_argument("--minio-secure", default=True, type=bool, help="Use secure connection (HTTPS)")
# Common options
parser.add_argument("--file", help="Path to file to upload (creates a test file if not provided)")
parser.add_argument("--expiration", type=int, default=DEFAULT_EXPIRATION,
help="Presigned URL expiration time in seconds")
parser.add_argument("--no-cleanup", action="store_true", help="Do not delete the uploaded file")
return parser.parse_args()
def read_env_vars():
"""Read S3 configuration from environment variables"""
env_vars = {
"aws_access_key": os.environ.get("AWS_ACCESS_KEY_ID"),
"aws_secret_key": os.environ.get("AWS_SECRET_ACCESS_KEY"),
"aws_region": os.environ.get("AWS_DEFAULT_REGION", DEFAULT_REGION),
"s3_bucket": os.environ.get("S3_BUCKET"),
"minio_endpoint": os.environ.get("MINIO_ENDPOINT"),
"minio_access_key": os.environ.get("MINIO_ACCESS_KEY"),
"minio_secret_key": os.environ.get("MINIO_SECRET_KEY"),
"minio_bucket": os.environ.get("MINIO_BUCKET"),
"minio_secure": os.environ.get("MINIO_SECURE", "true").lower() == "true",
}
return env_vars
def extract_hostname_from_url(url):
"""Extract hostname from URL for logging purposes"""
parsed = urlparse(url)
return parsed.netloc
def main():
"""Main function"""
args = parse_args()
env_vars = read_env_vars()
# Combine command line args with env vars (cmd line takes precedence)
config = {k: getattr(args, k) if getattr(args, k) is not None else env_vars.get(k) for k in env_vars}
# Create storage backend
if args.provider == "aws":
required = ["aws_access_key", "aws_secret_key", "aws_region", "s3_bucket"]
if any(config.get(k) is None for k in required):
logger.error("Missing required AWS S3 configuration. Please provide AWS credentials and bucket name.")
sys.exit(1)
storage = S3StorageBackend(
access_key=config["aws_access_key"],
secret_key=config["aws_secret_key"],
region=config["aws_region"],
bucket=config["s3_bucket"]
)
else: # minio
required = ["minio_endpoint", "minio_access_key", "minio_secret_key", "minio_bucket"]
if any(config.get(k) is None for k in required):
logger.error("Missing required MinIO configuration. Please provide MinIO credentials and bucket name.")
sys.exit(1)
storage = MinIOStorageBackend(
endpoint=config["minio_endpoint"],
access_key=config["minio_access_key"],
secret_key=config["minio_secret_key"],
bucket=config["minio_bucket"],
secure=config["minio_secure"]
)
# Create a test file if not provided
input_file = args.file
temp_file_created = False
if not input_file:
input_file = create_test_file()
temp_file_created = True
logger.info(f"Using test file: {input_file}")
file_name = os.path.basename(input_file)
try:
# Step 1: Generate presigned upload URL
logger.info("Step 1: Generating presigned upload URL...")
upload_result = storage.generate_upload_url(
file_name=file_name,
content_type="application/json",
expiration=args.expiration
)
upload_url = upload_result["upload_url"]
storage_path = upload_result["storage_path"]
logger.info(f"Storage path: {storage_path}")
logger.info(f"Upload URL hostname: {extract_hostname_from_url(upload_url)}")
# Step 2: Upload file using presigned URL
logger.info("\nStep 2: Uploading file using presigned URL...")
if not upload_to_presigned_url(input_file, upload_url):
logger.error("Upload failed. Exiting.")
# sys.exit(1)
# Step 3: Generate presigned download URL
logger.info("\nStep 3: Generating presigned download URL...")
download_url = storage.generate_download_url(storage_path, args.expiration)
logger.info(f"Download URL hostname: {extract_hostname_from_url(download_url)}")
# Step 4: Download file using presigned URL
logger.info("\nStep 4: Downloading file using presigned URL...")
download_file = tempfile.mktemp(suffix=".json")
if not download_from_presigned_url(download_url, download_file):
logger.error("Download failed. Exiting.")
# sys.exit(1)
# Step 5: Verify files match
logger.info("\nStep 5: Verifying file integrity...")
if verify_files_match(input_file, download_file):
logger.info("✓ TEST PASSED: Files match perfectly!")
else:
logger.error("✗ TEST FAILED: Files do not match!")
sys.exit(1)
# Step 6: Clean up (delete the uploaded file)
if not args.no_cleanup:
logger.info("\nStep 6: Cleaning up...")
if storage.delete_file(storage_path):
logger.info("Successfully deleted the uploaded file.")
else:
logger.warning("Failed to delete the uploaded file.")
except Exception as e:
logger.error(f"An error occurred: {e}")
# sys.exit(1)
finally:
# Clean up temporary files
if temp_file_created and os.path.exists(input_file):
os.unlink(input_file)
logger.debug(f"Deleted temporary input file: {input_file}")
if 'download_file' in locals() and os.path.exists(download_file):
os.unlink(download_file)
logger.debug(f"Deleted temporary download file: {download_file}")
if __name__ == "__main__":
main()
|