Significant overhaul of tooling

This commit is contained in:
2026-08-11 19:29:21 +01:00
parent ed2db90249
commit c4f76d9fda
35 changed files with 2017 additions and 827 deletions
View File
+80
View File
@@ -0,0 +1,80 @@
import typer
from loguru import logger
from plumbum import FG, local
from plumbum.cmd import docker as docker_cmd
from cli.bitwarden import app as bw_app
from cli.bootstrap import app as bootstrap_app
from cli.config import config
from cli.deploy import app as deploy_app
from cli.dev import app as dev_app
from cli.env import app as env_app
from cli.s3 import app as s3_app
from cli.utils import docker_compose, get_repo_dir
app = typer.Typer(help="Unified Repository Automation CLI")
app.add_typer(dev_app, name="dev")
app.add_typer(bw_app, name="bw")
app.add_typer(s3_app, name="s3")
app.add_typer(deploy_app, name="deploy")
app.add_typer(env_app, name="env")
app.add_typer(bootstrap_app, name="bootstrap")
@app.command(
context_settings={"allow_extra_args": True, "ignore_unknown_options": True}
)
def compose(ctx: typer.Context):
"""Passthrough to docker-compose inside the repository directory."""
with local.cwd(get_repo_dir()):
docker_compose(*ctx.args) & FG
@app.command(
context_settings={"allow_extra_args": True, "ignore_unknown_options": True}
)
def invenio(ctx: typer.Context):
"""Passthrough to invenio inside the running worker container."""
with local.cwd(get_repo_dir()):
docker_compose("exec", "worker", "invenio", *ctx.args) & FG
@app.command(
context_settings={"allow_extra_args": True, "ignore_unknown_options": True}
)
def docker(ctx: typer.Context):
"""Passthrough to docker inside the repository directory."""
with local.cwd(get_repo_dir()):
docker_cmd[*ctx.args] & FG
@app.command(
name="invenio-cli",
context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
)
def invenio_cli_cmd(ctx: typer.Context):
"""Passthrough to invenio-cli inside the repository directory."""
from plumbum.cmd import invenio_cli
with local.cwd(get_repo_dir()):
invenio_cli[*ctx.args] & FG
@app.command(
context_settings={"allow_extra_args": True, "ignore_unknown_options": True}
)
def aws(ctx: typer.Context):
"""Passthrough to aws-cli with configured endpoint-url and credentials."""
try:
from plumbum.cmd import aws as aws_cmd
except ImportError:
logger.error("AWS CLI not found in PATH.")
raise typer.Exit(1)
aws_cmd["--endpoint-url", str(config.s3_endpoint_url), *ctx.args] & FG
if __name__ == "__main__":
app()
+134
View File
@@ -0,0 +1,134 @@
import json
import typer
from loguru import logger
from plumbum import FG, ProcessExecutionError, local
from cli.config import config
from cli.utils import get_project_root
app = typer.Typer(help="Bitwarden integration workflows.")
BW_SESSION_FILE = get_project_root() / ".bw_session"
ENV_FILE = get_project_root() / ".env"
def _get_bw():
try:
from plumbum.cmd import bw
return bw
except ImportError:
logger.error(
"Bitwarden CLI ('bw') not found. Please ensure it is installed or "
"run 'direnv allow' if you just updated devenv.nix."
)
raise typer.Exit(1)
@app.command()
def login() -> bool:
"""Log in to Bitwarden and cache the session token. Returns True if a new session was created."""
bw = _get_bw()
if config.bitwarden_server_url:
logger.info(f"Configuring Bitwarden server: {config.bitwarden_server_url}")
bw("config", "server", config.bitwarden_server_url)
logger.info("Checking login status...")
try:
status_json = bw("status")
status = json.loads(status_json)
if status.get("status") == "unlocked":
logger.info("Vault is already unlocked. Using existing session.")
return False
if status.get("status") == "unauthenticated":
logger.info("Not logged in. Initiating login...")
bw["login"] & FG
elif status.get("status") == "locked":
logger.info("Vault is locked. Initiating unlock...")
except ProcessExecutionError:
logger.warning("Failed to check status. Initiating login...")
bw["login"] & FG
logger.info("Unlocking vault and saving session...")
try:
# Prompt for master password and output just the raw session key
session_key = bw("unlock", "--raw").strip()
BW_SESSION_FILE.write_text(session_key)
BW_SESSION_FILE.chmod(0o600)
logger.success("Session unlocked and cached securely in .bw_session")
return True
except ProcessExecutionError as e:
logger.error(f"Failed to unlock vault: {e}")
raise typer.Exit(1)
@app.command()
def pull():
"""Pull secrets from Bitwarden Secure Note and inject into .env"""
bw = _get_bw()
if not BW_SESSION_FILE.exists():
logger.error(
"No active Bitwarden session found. Please run `uv run cli bw login` first."
)
raise typer.Exit(1)
session_key = BW_SESSION_FILE.read_text().strip()
with local.env(BW_SESSION=session_key):
logger.info("Syncing vault...")
try:
bw("sync")
except ProcessExecutionError as e:
logger.warning(f"Failed to sync vault (continuing anyway): {e}")
logger.info(f"Fetching item: {config.bitwarden_item_name}...")
try:
item_json = bw("get", "item", config.bitwarden_item_name)
item = json.loads(item_json)
except ProcessExecutionError as e:
logger.error(
f"Failed to fetch item '{config.bitwarden_item_name}'. Make sure the name is exact."
)
logger.debug(f"Details: {e}")
raise typer.Exit(1)
notes = item.get("notes")
if not notes:
logger.error(
f"The item '{config.bitwarden_item_name}' does not contain any notes."
)
raise typer.Exit(1)
try:
secrets = json.loads(notes)
except json.JSONDecodeError:
logger.error(
f"The notes in '{config.bitwarden_item_name}' are not valid JSON."
)
raise typer.Exit(1)
logger.info(f"Successfully fetched {len(secrets)} secrets from secure note.")
return secrets
@app.command()
def lock():
"""Lock the Bitwarden vault and remove the local session token."""
bw = _get_bw()
if BW_SESSION_FILE.exists():
session_key = BW_SESSION_FILE.read_text().strip()
with local.env(BW_SESSION=session_key):
try:
bw("lock")
logger.success("Bitwarden vault locked successfully.")
except ProcessExecutionError as e:
logger.warning(f"Failed to lock vault cleanly: {e}")
BW_SESSION_FILE.unlink()
logger.success("Local session token removed.")
else:
logger.info("No local session token found.")
+35
View File
@@ -0,0 +1,35 @@
import typer
from loguru import logger
from plumbum.cmd import docker
app = typer.Typer(help="Server Bootstrap Tool")
@app.command()
def server():
"""Bootstrap a fresh production server environment."""
logger.info("Starting fresh server bootstrap process...")
try:
docker_version = docker["--version"]().strip()
logger.success(f"Found {docker_version}")
except Exception:
logger.error("Docker is not installed or not in PATH!")
raise typer.Exit(1)
try:
docker("compose", "version")
logger.success("Found docker compose plugin")
except Exception:
logger.error("Docker Compose plugin is not installed!")
raise typer.Exit(1)
from cli.env import template as env_template
logger.info("Generating .env template for configuration...")
env_template()
logger.success("Bootstrap successful!")
logger.warning(
"Please fill out the generated .env.template and rename it to .env before running `uv run cli deploy prod-deploy`."
)
+67
View File
@@ -0,0 +1,67 @@
from pydantic import AliasChoices, Field, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
from cli.utils import get_project_root
class Config(BaseSettings):
"""Centralized configuration values for the scripts."""
model_config = SettingsConfigDict(
env_file=get_project_root() / ".env", env_file_encoding="utf-8", extra="ignore"
)
s3_endpoint_url: str = Field(
default="https://eu-west-1.storage.impossibleapi.net",
validation_alias=AliasChoices(
"invenio_s3_endpoint_url", "s3_endpoint_url", "aws_endpoint_url"
),
)
s3_access_key_id: str | None = Field(
default=None,
validation_alias=AliasChoices(
"invenio_s3_access_key_id", "s3_access_key_id", "aws_access_key_id"
),
)
s3_secret_access_key: SecretStr | None = Field(
default=None,
validation_alias=AliasChoices(
"invenio_s3_secret_access_key",
"s3_secret_access_key",
"aws_secret_access_key",
),
)
s3_region: str = Field(
default="eu-west-1",
validation_alias=AliasChoices(
"invenio_s3_region_name",
"invenio_s3_region",
"s3_region",
"aws_default_region",
),
)
s3_bucket_name: str = Field(
default="iform-invenio",
validation_alias=AliasChoices(
"invenio_s3_bucket_name", "s3_bucket_name", "aws_s3_bucket"
),
)
# Project Settings
repo_dir_name: str = Field(default="i-form-data-repository")
docker_compose_file: str = Field(default="docker-compose.full.yml")
docker_image_name: str = Field(default="i-form-data-repository:latest")
# Dev Settings
theme_dir_name: str = Field(default="invenio-theme-iform")
config_dir_name: str = Field(default="invenio-config-iform")
local_wheels_dir: str = Field(default="local_wheels")
ssl_cert_name: str = Field(default="nginx.crt")
ssl_key_name: str = Field(default="nginx.key")
# Bitwarden Settings
bitwarden_item_name: str = Field(default="I-Form Invenio S3 Keys")
bitwarden_server_url: str | None = Field(default=None)
config = Config()
+77
View File
@@ -0,0 +1,77 @@
# Invenio-S3 Storage Backend Credentials
INVENIO_S3_ENDPOINT_URL=https://eu-west-1.storage.impossibleapi.net
INVENIO_S3_REGION_NAME=eu-west-1
INVENIO_S3_BUCKET_NAME=iform-invenio
# Invenio-Theme
INVENIO_THEME_LOGO=custom_assets/I-Form_logo.webp
INVENIO_THEME_FRONTPAGE_TITLE="I-Form Invenio Data Repository"
INVENIO_THEME_SITENAME="I-Form Repository"
INVENIO_THEME_FRONTPAGE_SUBTITLE="An Invenio data repository for the I-Form research group."
INVENIO_THEME_SHOW_FRONTPAGE_INTRO_SECTION=false
INVENIO_SECURITY_REGISTERABLE=false # Disable manual user registration
# Database and Flask-SQLAlchemy
POSTGRES_USER=inveniordm
POSTGRES_DB=inveniordm
# Invenio-App
INVENIO_CACHE_TYPE=redis
INVENIO_CACHE_REDIS_URL=redis://cache:6379/0
INVENIO_ACCOUNTS_SESSION_REDIS_URL=redis://cache:6379/1
INVENIO_CELERY_RESULT_BACKEND=redis://cache:6379/2
INVENIO_RATELIMIT_STORAGE_URI=redis://cache:6379/3
INVENIO_COMMUNITIES_IDENTITIES_CACHE_REDIS_URL=redis://cache:6379/4
INVENIO_BROKER_URL=redis://cache:6379/5
INVENIO_CELERY_BROKER_URL=redis://cache:6379/5
# Server settings
INVENIO_WSGI_PROXIES=4
# Invenio-RDM-Records
INVENIO_DATACITE_ENABLED=false
INVENIO_DATACITE_USERNAME=""
INVENIO_DATACITE_PASSWORD=""
INVENIO_DATACITE_PREFIX=""
INVENIO_DATACITE_TEST_MODE=true
INVENIO_DATACITE_DATACENTER_SYMBOL=""
INVENIO_RDM_ALLOW_METADATA_ONLY_RECORDS=true
INVENIO_RDM_ALLOW_RESTRICTED_RECORDS=true
INVENIO_RDM_ALLOW_EXTERNAL_DOI_VERSIONING=true
INVENIO_RDM_CITATION_STYLES_DEFAULT=vancouver
INVENIO_RDM_DEFAULT_CITATION_STYLE=vancouver
# Email config
INVENIO_MAIL_SUPPRESS_SEND=false # Allow server to send emails
INVENIO_SECURITY_EMAIL_SENDER=""
INVENIO_MAIL_SERVER=smtp.gmail.com
INVENIO_MAIL_PORT=465
INVENIO_MAIL_USERNAME=info
INVENIO_MAIL_PASSWORD=changeme
INVENIO_MAIL_USE_SSL=true
# Invenio-Accounts
INVENIO_ACCOUNTS_LOCAL_LOGIN_ENABLED=true
INVENIO_GITHUB_APP_CREDENTIALS=""
# OAI-PMH
INVENIO_OAISERVER_ID_PREFIX=invenio-rdm
# Invenio-Files-REST
INVENIO_FILES_REST_STORAGE_FACTORY=invenio_s3.s3fs_storage_factory
# Invenio-Search
INVENIO_SEARCH_HOSTS=search:9200
INVENIO_SEARCH_INDEX_PREFIX=invenio-rdm-
# Logging
INVENIO_LOGGING_CONSOLE_LEVEL=WARNING
# Theme Configuration for I-Form
INVENIO_THEME_IFORM_PRODUCTION=false
INVENIO_THEME_IFORM_CONTACT_FORM=true
INVENIO_THEME_IFORM_SUPPORT_EMAIL=support@i-form.ie
COMPOSE_FILE=i-form-data-repository/docker-compose.full.yml
+8
View File
@@ -0,0 +1,8 @@
[
{
"AllowedHeaders": [],
"AllowedMethods": ["GET"],
"AllowedOrigins": ["*"],
"ExposeHeaders": []
}
]
+261
View File
@@ -0,0 +1,261 @@
import typer
from loguru import logger
from plumbum import local
from plumbum.cmd import docker, git, uv
from cli.utils import docker_compose, get_project_root, get_repo_dir
app = typer.Typer(help="Deployment Management Tool")
ROOT_DIR = get_project_root()
REPO_DIR = get_repo_dir()
VERSIONS_DIR = ROOT_DIR / "versions"
import time
import requests
def healthcheck():
"""Poll the localhost health endpoint to verify the stack is up."""
logger.info("Running healthcheck...")
for _ in range(3):
try:
response = requests.get("http://localhost/health", timeout=5)
if response.status_code == 200:
logger.success("Healthcheck passed.")
return
except requests.RequestException:
pass
logger.info("Waiting 5 seconds before retrying...")
time.sleep(5)
logger.error("Healthcheck failed.")
raise typer.Exit(1)
@app.command()
def tag_version():
"""Tag current version using bumpver and save docker images state."""
VERSIONS_DIR.mkdir(exist_ok=True)
logger.info("Bumping version with bumpver...")
try:
uv("run", "bumpver", "update")
except Exception as e:
logger.error(f"Bumpver failed: {e}")
current_tag = git("describe", "--tags", "--abbrev=0").strip()
logger.info(f"Current tag is {current_tag}")
with local.cwd(REPO_DIR):
images_output = docker_compose("images")
version_file = VERSIONS_DIR / f"{current_tag}.txt"
with open(version_file, "w") as f:
for line in images_output.splitlines():
if "REPOSITORY" not in line:
f.write(line + "\n")
logger.success(f"Saved image states to {version_file}")
@app.command()
def rollback(version: str = typer.Option(None, help="Specific tag to rollback to")):
"""Rollback to a specific version or latest."""
current_branch = git("branch", "--show-current").strip()
if not version:
tags = git("tag", "--sort=-v:refname").splitlines()
if not tags:
logger.error("No tags found.")
raise typer.Exit(1)
version = tags[0]
version_file = VERSIONS_DIR / f"{version}.txt"
if not version_file.exists():
logger.error(f"No version file found for {version} at {version_file}")
raise typer.Exit(1)
logger.info(f"Pulling old images for {version}...")
with open(version_file, "r") as f:
for line in f:
parts = line.split()
if len(parts) >= 2:
repo, tag = parts[0], parts[1]
logger.info(f"Pulling {repo}:{tag}")
docker("pull", f"{repo}:{tag}")
logger.info(f"Rolling back to {version}...")
git("checkout", version)
try:
with local.cwd(REPO_DIR):
docker_compose("down")
docker_compose("build", "--no-cache")
docker_compose("up", "-d")
finally:
if current_branch:
git("switch", current_branch)
logger.success(
f"Rollback to {version} complete. Returned git to {current_branch}."
)
else:
logger.success(
f"Rollback to {version} complete. Remained in detached HEAD."
)
@app.command()
def cleanup_versions():
"""Keep only the latest 5 version tags and delete older ones."""
tags = git("tag", "--sort=-v:refname").splitlines()
tags_to_delete = tags[5:]
for tag in tags_to_delete:
if not tag:
continue
git("tag", "-d", tag)
version_file = VERSIONS_DIR / f"{tag}.txt"
if version_file.exists():
version_file.unlink()
logger.info(f"Cleaned up {tag}")
@app.command()
def prod_deploy():
"""First-time deployment to production."""
logger.info("Deploying to production (first-time)...")
git("switch", "prod")
git("pull", "origin", "prod")
with local.cwd(REPO_DIR):
docker(
"build",
"-t",
"i-form-data-repository:latest",
"--no-cache",
"--build-arg",
"INSTALL_LOCAL_WHEELS=false",
".",
)
docker_compose("up", "-d", "--wait")
setup_cmd = "invenio db init && invenio db create && invenio alembic upgrade head && invenio collect -v && invenio index init"
docker_compose("exec", "worker", "bash", "-c", setup_cmd)
logger.success("Production deployment complete!")
@app.command()
def prod_update(
auto_rollback: bool = typer.Option(
False, help="Automatically rollback if healthcheck fails"
),
):
"""Update the application stack, with optional auto-rollback."""
lock_file = ROOT_DIR / "update.lock"
if lock_file.exists():
logger.error("Update already in progress (update.lock exists).")
raise typer.Exit(1)
logger.info("Updating production deployment...")
try:
lock_file.touch()
logger.info("Tagging current version for potential rollback...")
tag_version()
git("switch", "prod")
git("pull", "origin", "prod")
with local.cwd(REPO_DIR):
docker_compose("pull")
docker(
"build",
"-t",
"i-form-data-repository:latest",
"--no-cache",
"--build-arg",
"INSTALL_LOCAL_WHEELS=false",
".",
)
docker_compose("up", "-d", "--wait")
update_cmd = "invenio alembic upgrade head && invenio collect -v"
docker_compose("exec", "worker", "bash", "-c", update_cmd)
try:
healthcheck()
except typer.Exit:
if auto_rollback:
logger.warning("Healthcheck failed, triggering auto-rollback...")
rollback(None)
else:
logger.error(
"Healthcheck failed. Consider running 'uv run cli deploy rollback' manually."
)
raise
cleanup_versions()
logger.success("Production update complete!")
finally:
if lock_file.exists():
lock_file.unlink()
@app.command()
def merge_and_push_prod():
"""Merge main into prod and push all branches."""
logger.info("Merging main into prod...")
current = git("branch", "--show-current").strip()
try:
git("switch", "prod")
git("merge", "main")
git("switch", "main")
logger.info("Pushing all branches...")
git("push", "--all")
logger.success("Merged and pushed to production.")
finally:
if current:
git("switch", current)
@app.command()
def prod_clean():
"""Clean all production build artifacts."""
logger.info("Cleaning production build artifacts...")
from plumbum.cmd import find, rm
root = get_project_root()
with local.cwd(root):
rm("-rf", ".venv", "build", "dist")
for egg in local.path(".") // "*.egg-info":
rm("-rf", egg)
try:
find(
".",
"-type",
"d",
"-name",
"__pycache__",
"-exec",
"rm",
"-r",
"{}",
"+",
)
except Exception:
pass
rm("-rf", "static", "node_modules")
logger.success("Clean complete. Re-create environment before next deploy.")
+118
View File
@@ -0,0 +1,118 @@
import trustme
import typer
from loguru import logger
from cli.config import config
from cli.utils import docker_compose, get_project_root, get_repo_dir
app = typer.Typer(help="Tool for generating development SSL certificates.")
@app.command()
def generate():
"""Generates a self-signed localhost certificate using trustme."""
repo_dir = get_repo_dir()
nginx_dir = repo_dir / "docker" / "nginx"
cert_path = repo_dir / config.ssl_cert_name
key_path = repo_dir / config.ssl_key_name
if cert_path.exists() and key_path.exists():
logger.info(f"Certificates already exist at {nginx_dir}. Skipping generation.")
return
logger.info("Generating self-signed dev certificates using trustme...")
ca = trustme.CA()
server_cert = ca.issue_cert("localhost", "127.0.0.1", "::1")
server_cert.private_key_pem.write_to_path(key_path)
server_cert.cert_chain_pems[0].write_to_path(cert_path)
logger.success(f"Successfully generated {cert_path.name} and {key_path.name}.")
@app.command()
def fmt():
"""Format codebase using prettier via npx."""
logger.info("Running prettier via npx...")
from plumbum.cmd import npx
try:
npx(
"prettier",
"--write",
"**/*.{js,jsx,ts,tsx,html,css,scss,sass,svelte,yaml,json,markdown}",
)
logger.success("Formatting complete.")
except Exception as e:
logger.error(f"Formatting failed: {e}")
raise typer.Exit(1)
@app.command()
def build_wheels():
"""Build and copy wheels for local packages (theme and config)."""
from plumbum import local
from plumbum.cmd import cp, direnv, rm
root = get_project_root()
theme_dir = root.parent / config.theme_dir_name
config_dir = root.parent / config.config_dir_name
repo_dir = get_repo_dir()
logger.info("Building theme wheel...")
with local.cwd(theme_dir):
rm("-rf", "dist")
direnv("exec", ".", "uv", "build", "--package", config.theme_dir_name)
logger.info("Building config wheel...")
with local.cwd(config_dir):
rm("-rf", "dist")
direnv("exec", ".", "uv", "build", "--package", config.config_dir_name)
logger.info("Copying wheels to repository...")
wheels_dir = repo_dir / config.local_wheels_dir
wheels_dir.mkdir(exist_ok=True)
rm("-f", local.path(str(wheels_dir)) // "*.whl")
for whl in local.path(str(theme_dir / "dist")) // "*.whl":
cp(whl, wheels_dir)
for whl in local.path(str(config_dir / "dist")) // "*.whl":
cp(whl, wheels_dir)
logger.success("Successfully built and copied fresh wheels.")
@app.command()
def test_local():
"""Rebuild and restart local docker stack with fresh wheels."""
from plumbum import local
from plumbum.cmd import curl, docker
build_wheels()
logger.info("Rebuilding and restarting local docker stack...")
repo_dir = get_repo_dir()
with local.cwd(repo_dir):
docker_compose("down")
docker(
"build",
"-t",
config.docker_image_name,
"--no-cache",
"--build-arg",
"INSTALL_LOCAL_WHEELS=true",
".",
)
docker_compose("up", "-d", "--wait")
setup_cmd = "invenio db init || true; invenio db create || true; invenio alembic upgrade || true; invenio index init || true"
docker_compose("exec", "worker", "bash", "-c", setup_cmd)
try:
curl("-skI", "https://127.0.0.1:8443/")
logger.success("HTTPS verification successful.")
except Exception:
logger.warning("HTTPS verification failed.")
+24
View File
@@ -0,0 +1,24 @@
import typer
from loguru import logger
from cli.utils import get_project_root
app = typer.Typer(help="Environment validation and templating.")
import shutil
@app.command()
def template():
"""Generate a .env.template file."""
logger.info("Generating .env.template...")
source = get_project_root() / "cli" / "data" / "env.template"
target = get_project_root() / ".env.template"
if not source.exists():
logger.error(f"Template file not found at {source}")
raise typer.Exit(1)
shutil.copy2(source, target)
logger.success(f"Generated {target.name}")
+110
View File
@@ -0,0 +1,110 @@
import json
from pathlib import Path
import boto3
import typer
from botocore.client import Config
from loguru import logger
from cli.config import config
from cli.utils import get_project_root
app = typer.Typer(help="Impossible Cloud S3 Management Tool")
def get_s3_client():
if not config.s3_access_key_id or not config.s3_secret_access_key:
logger.warning(
"S3_ACCESS_KEY_ID or S3_SECRET_ACCESS_KEY not found in environment. Boto3 will attempt to use default credential provider chain."
)
kwargs = {
"service_name": "s3",
"endpoint_url": str(config.s3_endpoint_url),
"region_name": config.s3_region,
"config": Config(signature_version="s3v4"),
}
if config.s3_access_key_id and config.s3_secret_access_key:
kwargs["aws_access_key_id"] = config.s3_access_key_id
kwargs["aws_secret_access_key"] = config.s3_secret_access_key.get_secret_value()
else:
logger.info("Using default AWS credential provider chain...")
return boto3.client(**kwargs)
@app.command()
def list_buckets():
"""List all buckets in the S3 account."""
client = get_s3_client()
response = client.list_buckets()
logger.info("Buckets:")
for bucket in response.get("Buckets", []):
logger.info(f" - {bucket['Name']}")
@app.command()
def create_bucket(bucket_name: str):
"""Create a new bucket."""
client = get_s3_client()
try:
if config.s3_region == "us-east-1":
client.create_bucket(Bucket=bucket_name)
else:
client.create_bucket(
Bucket=bucket_name,
CreateBucketConfiguration={"LocationConstraint": config.s3_region},
)
logger.success(f"Successfully created bucket: {bucket_name}")
except Exception as e:
logger.error(f"Error creating bucket {bucket_name}: {e}")
raise typer.Exit(1)
@app.command()
def upload_file(
file_path: Path = typer.Argument(..., help="Path to local file"),
bucket_name: str = typer.Argument(..., help="Target bucket name"),
object_name: str | None = typer.Option(
None, "--object-name", help="S3 object name (defaults to file name)"
),
):
"""Upload a local file to a bucket."""
client = get_s3_client()
if object_name is None:
object_name = file_path.name
try:
client.upload_file(str(file_path), bucket_name, object_name)
logger.success(f"Uploaded {file_path} to {bucket_name}/{object_name}")
except Exception as e:
logger.error(f"Error uploading file: {e}")
raise typer.Exit(1)
@app.command()
def apply_cors(
bucket_name: str = typer.Argument(config.s3_bucket_name, help="Target bucket name"),
cors_file: Path = typer.Option(
get_project_root() / "cli" / "data" / "s3_cors.json",
help="Path to CORS JSON file",
),
):
"""Apply a CORS configuration from a JSON file to a bucket."""
client = get_s3_client()
if not cors_file.exists():
logger.error(f"CORS file not found: {cors_file}")
raise typer.Exit(1)
try:
with open(cors_file, "r") as f:
cors_rules = json.load(f)
client.put_bucket_cors(
Bucket=bucket_name, CORSConfiguration={"CORSRules": cors_rules}
)
logger.success(f"Successfully applied CORS configuration to {bucket_name}")
except Exception as e:
logger.error(f"Failed to apply CORS config: {e}")
raise typer.Exit(1)
+83
View File
@@ -0,0 +1,83 @@
import subprocess
from pathlib import Path
from loguru import logger
def get_project_root() -> Path:
"""Dynamically determine the project root using git."""
try:
result = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
capture_output=True,
text=True,
check=True,
)
return Path(result.stdout.strip())
except subprocess.CalledProcessError:
logger.warning(
"Not inside a git repository, falling back to script path resolution."
)
return Path(__file__).resolve().parent.parent
def get_repo_dir() -> Path:
"""Return the repository directory path."""
from cli.config import config
return get_project_root() / config.repo_dir_name
def get_dynamic_s3_credentials() -> dict:
import atexit
import boto3
from cli.bitwarden import lock as bw_lock
from cli.bitwarden import login as bw_login
from cli.bitwarden import pull as bw_pull
logger.info("Checking for native AWS credentials...")
session = boto3.Session()
creds = session.get_credentials()
if creds and creds.access_key and creds.secret_key:
logger.success("Found existing AWS credentials natively. Skipping Bitwarden.")
return {
"INVENIO_S3_ACCESS_KEY_ID": creds.access_key,
"INVENIO_S3_SECRET_ACCESS_KEY": creds.secret_key,
}
logger.info("No native AWS credentials found. Falling back to Bitwarden...")
if bw_login():
atexit.register(bw_lock)
return bw_pull()
_deploy_env_vars = None
def docker_compose(*args):
global _deploy_env_vars
import typer
from plumbum import local
from plumbum.cmd import docker
from cli.config import config
env_file = get_project_root() / ".env"
if not env_file.exists():
logger.error(
"The .env file does not exist! Please run `uv run cli bootstrap server` first to generate and configure it."
)
raise typer.Exit(1)
if _deploy_env_vars is None:
_deploy_env_vars = get_dynamic_s3_credentials()
compose = docker[
"compose", "-f", config.docker_compose_file, "--env-file", str(env_file)
]
with local.env(**_deploy_env_vars):
return compose(*args)