338 lines
14 KiB
Python
338 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""Upload firmware, manifest, and data assets to a MinIO (S3-compatible) bucket.
|
|
|
|
Features preserved from original GCS script:
|
|
- Optional backup (copies existing objects under destination prefix to timestamped folder under backups/)
|
|
- Upload firmware.bin, update.json, and recursively mirror a data directory
|
|
- Cache-Control set to disable caching on clients
|
|
|
|
Switches from google.cloud.storage to boto3 (S3 API) for MinIO compatibility.
|
|
"""
|
|
import os
|
|
import sys
|
|
import datetime
|
|
import json
|
|
from pathlib import Path
|
|
|
|
try:
|
|
import boto3
|
|
from botocore.exceptions import ClientError
|
|
from botocore.config import Config
|
|
except ImportError:
|
|
print("ERROR: boto3 is required. Install with: pip install boto3")
|
|
sys.exit(1)
|
|
|
|
# =============================================================================
|
|
# CONFIGURATION CONSTANTS (edit as needed or supply via environment variables)
|
|
# =============================================================================
|
|
|
|
CREATE_BACKUP = False
|
|
UPLOAD_FIRMWARE = False
|
|
UPLOAD_MANIFEST = True
|
|
UPLOAD_DATA = False
|
|
|
|
# Bucket / endpoint configuration
|
|
BUCKET_NAME = os.getenv('MINIO_BUCKET', 'boothifier')
|
|
DESTINATION_DIR = os.getenv('MINIO_DEST_PREFIX', 'latest') # prefix inside bucket
|
|
BACKUPS_DIR = os.getenv('MINIO_BACKUPS_PREFIX', 'backups')
|
|
|
|
LOCAL_ROOT_PATH = Path(__file__).parent.resolve()
|
|
|
|
# Optional service account style JSON key (generated by MinIO Console). Expected fields:
|
|
# {"url":"https://minio.example.com/api/v1/service-account-credentials","accessKey":"...","secretKey":"...","api":"s3v4","path":"auto"}
|
|
MINIO_KEY_FILE = LOCAL_ROOT_PATH / 'minio-boothifier-key.json'
|
|
|
|
# Defaults before loading file / env
|
|
_json_access = None
|
|
_json_secret = None
|
|
_json_url = None
|
|
|
|
def _load_json_key():
|
|
global _json_access, _json_secret, _json_url
|
|
try:
|
|
if MINIO_KEY_FILE.is_file():
|
|
with open(MINIO_KEY_FILE, 'r', encoding='utf-8') as fh:
|
|
data = json.load(fh)
|
|
_json_access = data.get('accessKey') or None
|
|
_json_secret = data.get('secretKey') or None
|
|
_json_url = data.get('url') or None
|
|
except Exception as e:
|
|
print(f"WARN: Failed to load MinIO key file '{MINIO_KEY_FILE.name}': {e}")
|
|
|
|
_load_json_key()
|
|
|
|
def _derive_endpoint(url_value: str) -> str:
|
|
if not url_value:
|
|
return 'https://s3-minio.boothwizard.com'
|
|
# Remove known API suffix if present (/api/...)
|
|
# e.g. https://s3-minio.boothwizard.com/api/v1/service-account-credentials -> https://s3-minio.boothwizard.com
|
|
parts = url_value.split('/api/')
|
|
return parts[0] if parts else url_value
|
|
|
|
# MinIO credentials with precedence: ENV > JSON file > fallback
|
|
MINIO_ENDPOINT = os.getenv('MINIO_ENDPOINT') or _derive_endpoint(_json_url)
|
|
MINIO_ACCESS_KEY = os.getenv('MINIO_ACCESS_KEY') or _json_access or 'CHANGE_ME_ACCESS'
|
|
MINIO_SECRET_KEY = os.getenv('MINIO_SECRET_KEY') or _json_secret or 'CHANGE_ME_SECRET'
|
|
MINIO_REGION = os.getenv('MINIO_REGION', 'us-east-1') # MinIO ignores but boto3 wants some value
|
|
|
|
# Addressing / SSL options
|
|
MINIO_ADDRESSING = os.getenv('MINIO_ADDRESSING_STYLE', 'path').lower() # 'path' or 'virtual'
|
|
MINIO_VERIFY_SSL = os.getenv('MINIO_TLS_VERIFY', '1') not in ('0','false','no')
|
|
MINIO_DEBUG = os.getenv('MINIO_DEBUG', '0') in ('1','true','yes')
|
|
MINIO_ALLOW_VARIANTS = os.getenv('MINIO_ALLOW_ENDPOINT_VARIANTS', '0') in ('1','true','yes') # normally false with nginx redirect
|
|
|
|
LOCAL_FIRMWARE_PATH = str(LOCAL_ROOT_PATH / 'latest' / 'firmware.bin')
|
|
LOCAL_MANIFEST_PATH = str(LOCAL_ROOT_PATH / 'latest' / 'update.json')
|
|
LOCAL_DATA_DIRECTORY = str(LOCAL_ROOT_PATH / 'latest' / 'data')
|
|
|
|
# =============================================================================
|
|
# HELPERS
|
|
# =============================================================================
|
|
|
|
def s3_client():
|
|
"""Create an S3 client pointed at MinIO endpoint, forcing path-style unless overridden, with short timeouts."""
|
|
addressing = 'path' if MINIO_ADDRESSING not in ('virtual','auto') else 'virtual'
|
|
cfg = Config(
|
|
s3={'addressing_style': addressing},
|
|
signature_version='s3v4',
|
|
connect_timeout=3,
|
|
read_timeout=5,
|
|
retries={'max_attempts': 2}
|
|
)
|
|
if MINIO_DEBUG:
|
|
masked_key = (MINIO_ACCESS_KEY[:3] + '...' + MINIO_ACCESS_KEY[-3:]) if MINIO_ACCESS_KEY else 'None'
|
|
print(f"[DEBUG] Creating client: endpoint={MINIO_ENDPOINT} addressing={addressing} verifySSL={MINIO_VERIFY_SSL} region={MINIO_REGION} accessKey={masked_key}")
|
|
return boto3.client(
|
|
's3',
|
|
endpoint_url=MINIO_ENDPOINT,
|
|
aws_access_key_id=MINIO_ACCESS_KEY,
|
|
aws_secret_access_key=MINIO_SECRET_KEY,
|
|
region_name=MINIO_REGION,
|
|
verify=MINIO_VERIFY_SSL,
|
|
config=cfg,
|
|
)
|
|
|
|
def _endpoint_variants(base: str):
|
|
"""Return endpoint variants only if explicitly allowed; otherwise just the base (nginx handles forwarding)."""
|
|
if not MINIO_ALLOW_VARIANTS:
|
|
return [base]
|
|
# Fallback to previous expanded logic if variants are enabled
|
|
try:
|
|
variants = []
|
|
if not base:
|
|
return variants
|
|
base = base.rstrip('/')
|
|
proto_sep = '://'
|
|
if proto_sep in base:
|
|
scheme, rest = base.split(proto_sep,1)
|
|
else:
|
|
scheme, rest = 'https', base
|
|
host_port = rest
|
|
if ':' in host_port:
|
|
host, port = host_port.split(':',1)
|
|
else:
|
|
host, port = host_port, ''
|
|
variants.append(f"{scheme}://{host_port}")
|
|
common_ports = ['9000','443','80']
|
|
for p in common_ports:
|
|
if port != p:
|
|
variants.append(f"{scheme}://{host}:{p}")
|
|
alt_scheme = 'http' if scheme == 'https' else 'https'
|
|
variants.append(f"{alt_scheme}://{host_port}")
|
|
for p in common_ports:
|
|
if port != p:
|
|
variants.append(f"{alt_scheme}://{host}:{p}")
|
|
seen = set()
|
|
uniq = []
|
|
for v in variants:
|
|
if v not in seen:
|
|
uniq.append(v)
|
|
seen.add(v)
|
|
return uniq
|
|
except Exception:
|
|
return [base]
|
|
|
|
def create_validated_client():
|
|
"""Validate (or create) client using only provided endpoint unless variants enabled."""
|
|
global MINIO_ENDPOINT
|
|
primary = MINIO_ENDPOINT
|
|
variants = _endpoint_variants(primary) or [primary]
|
|
errors = []
|
|
probe_bucket = BUCKET_NAME # we will head the target bucket directly
|
|
for candidate in variants:
|
|
saved = MINIO_ENDPOINT
|
|
MINIO_ENDPOINT = candidate
|
|
if MINIO_DEBUG:
|
|
print(f"[DEBUG] Probing endpoint candidate: {candidate}")
|
|
try:
|
|
c = s3_client()
|
|
try:
|
|
c.head_bucket(Bucket=probe_bucket)
|
|
if MINIO_DEBUG:
|
|
print(f"[DEBUG] head_bucket succeeded on {candidate} for '{probe_bucket}'.")
|
|
return c
|
|
except ClientError as e:
|
|
msg = str(e)
|
|
# Acceptable if bucket not found (we can create later)
|
|
if any(code in msg for code in ('404', 'NoSuchBucket', 'NotFound')):
|
|
if MINIO_DEBUG:
|
|
print(f"[DEBUG] Bucket not found on {candidate} (expected if first deploy). Using this endpoint.")
|
|
return c
|
|
if 'API Requests must be made to API port' in msg:
|
|
errors.append(f"{candidate}: wrong port (console endpoint)")
|
|
else:
|
|
errors.append(f"{candidate}: {msg}")
|
|
MINIO_ENDPOINT = saved
|
|
except Exception as ex:
|
|
errors.append(f"{candidate}: {ex}")
|
|
MINIO_ENDPOINT = saved
|
|
continue
|
|
print("ERROR: Could not validate any endpoint candidate.")
|
|
for e in errors:
|
|
print(' - ' + e)
|
|
print("Provide correct API endpoint (e.g. https://host:9000) via MINIO_ENDPOINT env var.")
|
|
sys.exit(3)
|
|
|
|
def list_objects(client, prefix: str):
|
|
"""Generator yielding object keys under a prefix (non-recursive listing with pagination)."""
|
|
kwargs = {'Bucket': BUCKET_NAME, 'Prefix': prefix}
|
|
while True:
|
|
resp = client.list_objects_v2(**kwargs)
|
|
for obj in resp.get('Contents', []):
|
|
yield obj['Key']
|
|
if not resp.get('IsTruncated'):
|
|
break
|
|
kwargs['ContinuationToken'] = resp['NextContinuationToken']
|
|
|
|
def normalize_prefix(p: str) -> str:
|
|
p = p.strip('/')
|
|
return p
|
|
|
|
def join_key(*parts: str) -> str:
|
|
parts_clean = [p.strip('/') for p in parts if p is not None and p != '']
|
|
return '/'.join(parts_clean)
|
|
|
|
def backup_existing_files(client, destination_prefix: str, backups_prefix: str, backup_folder: str):
|
|
if not destination_prefix:
|
|
prefix = ''
|
|
else:
|
|
prefix = destination_prefix + '/'
|
|
print(f"Scanning existing objects under '{prefix}' for backup...")
|
|
for key in list_objects(client, prefix):
|
|
if backups_prefix and key.startswith(backups_prefix + '/'): # Skip prior backups
|
|
continue
|
|
# relative path within destination
|
|
relative = key[len(prefix):] if prefix and key.startswith(prefix) else key
|
|
backup_key = join_key(backups_prefix, backup_folder, relative)
|
|
print(f"Backup copy: {key} -> {backup_key}")
|
|
client.copy_object(
|
|
Bucket=BUCKET_NAME,
|
|
CopySource={'Bucket': BUCKET_NAME, 'Key': key},
|
|
Key=backup_key,
|
|
MetadataDirective='COPY'
|
|
)
|
|
|
|
def upload_file(client, local_path: str, key: str, cache_control: str = 'private, max-age=0, no-transform'):
|
|
if not os.path.isfile(local_path):
|
|
print(f"WARN: File missing, skipping: {local_path}")
|
|
return
|
|
print(f"Upload: {local_path} -> s3://{BUCKET_NAME}/{key}")
|
|
extra_args = { 'CacheControl': cache_control }
|
|
client.upload_file(local_path, BUCKET_NAME, key, ExtraArgs=extra_args)
|
|
|
|
def upload_directory(client, local_directory: str, destination_prefix: str):
|
|
if not os.path.isdir(local_directory):
|
|
print(f"WARN: Data directory missing: {local_directory}")
|
|
return
|
|
for root, _, files in os.walk(local_directory):
|
|
for fname in files:
|
|
full = os.path.join(root, fname)
|
|
rel = os.path.relpath(full, local_directory)
|
|
key = join_key(destination_prefix, rel)
|
|
upload_file(client, full, key)
|
|
|
|
def ensure_bucket(client):
|
|
"""Ensure bucket exists; provide diagnostics if HeadBucket returns 400/other errors."""
|
|
try:
|
|
client.head_bucket(Bucket=BUCKET_NAME)
|
|
if MINIO_DEBUG:
|
|
print(f"[DEBUG] Bucket '{BUCKET_NAME}' exists.")
|
|
return
|
|
except ClientError as e:
|
|
code = e.response.get('Error', {}).get('Code')
|
|
status = e.response.get('ResponseMetadata', {}).get('HTTPStatusCode')
|
|
print(f"HeadBucket failed (code={code}, status={status}).")
|
|
|
|
# List buckets for diagnostics
|
|
try:
|
|
resp = client.list_buckets()
|
|
bucket_names = [b['Name'] for b in resp.get('Buckets', [])]
|
|
print(f"Available buckets: {bucket_names or 'None'}")
|
|
except Exception as le:
|
|
print(f"WARN: list_buckets failed: {le}")
|
|
|
|
if code in ('404', 'NoSuchBucket', 'NotFound'):
|
|
print(f"Bucket '{BUCKET_NAME}' not found. Attempting to create...")
|
|
try:
|
|
client.create_bucket(Bucket=BUCKET_NAME)
|
|
print(f"Created bucket '{BUCKET_NAME}'.")
|
|
return
|
|
except ClientError as ce:
|
|
print(f"ERROR: Cannot create bucket: {ce}")
|
|
sys.exit(2)
|
|
|
|
if status == 400:
|
|
print("HINTS: \n - Verify endpoint URL (MINIO_ENDPOINT).\n - Ensure no trailing slash in endpoint.\n - Check that TLS verify matches server cert (set MINIO_TLS_VERIFY=0 to test).\n - Confirm bucket name is correct and DNS compatible.\n - Credentials may lack permission: verify access key policies.")
|
|
|
|
# Retry once forcing path style if not already
|
|
if MINIO_ADDRESSING != 'path':
|
|
print("Retrying with path-style addressing...")
|
|
os.environ['MINIO_ADDRESSING_STYLE'] = 'path'
|
|
new_client = s3_client()
|
|
try:
|
|
new_client.head_bucket(Bucket=BUCKET_NAME)
|
|
print("Second attempt succeeded with path-style addressing.")
|
|
return
|
|
except ClientError as e2:
|
|
print(f"Second HeadBucket attempt failed: {e2}")
|
|
print(f"ERROR: head_bucket ultimately failed: {e}")
|
|
sys.exit(2)
|
|
|
|
# =============================================================================
|
|
# MAIN
|
|
# =============================================================================
|
|
|
|
def main():
|
|
dest_prefix = normalize_prefix(DESTINATION_DIR)
|
|
backups_prefix = normalize_prefix(BACKUPS_DIR) if BACKUPS_DIR else ''
|
|
client = create_validated_client()
|
|
if MINIO_DEBUG:
|
|
print("[DEBUG] Starting ensure_bucket phase...")
|
|
ensure_bucket(client)
|
|
|
|
if CREATE_BACKUP:
|
|
ts = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
|
|
backup_folder = f"backup_{ts}"
|
|
print(f"Creating backup under '{backups_prefix}/{backup_folder}' from prefix '{dest_prefix}'")
|
|
backup_existing_files(client, dest_prefix, backups_prefix, backup_folder)
|
|
|
|
# Firmware
|
|
if UPLOAD_FIRMWARE:
|
|
firmware_key = join_key(dest_prefix, 'firmware.bin') if dest_prefix else 'firmware.bin'
|
|
upload_file(client, LOCAL_FIRMWARE_PATH, firmware_key)
|
|
|
|
# Manifest
|
|
if UPLOAD_MANIFEST:
|
|
manifest_key = join_key(dest_prefix, 'update.json') if dest_prefix else 'update.json'
|
|
upload_file(client, LOCAL_MANIFEST_PATH, manifest_key)
|
|
|
|
# Data directory
|
|
if UPLOAD_DATA:
|
|
data_prefix = join_key(dest_prefix, 'data') if dest_prefix else 'data'
|
|
upload_directory(client, LOCAL_DATA_DIRECTORY, data_prefix)
|
|
|
|
print("All uploads complete.")
|
|
|
|
if __name__ == '__main__':
|
|
main()
|