mirror of
https://github.com/Cian-H/iform-invenio.git
synced 2026-08-16 16:32:50 +01:00
Significant overhaul of tooling
This commit is contained in:
@@ -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()
|
||||
@@ -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.")
|
||||
@@ -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`."
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -0,0 +1,8 @@
|
||||
[
|
||||
{
|
||||
"AllowedHeaders": [],
|
||||
"AllowedMethods": ["GET"],
|
||||
"AllowedOrigins": ["*"],
|
||||
"ExposeHeaders": []
|
||||
}
|
||||
]
|
||||
+261
@@ -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
@@ -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
@@ -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}")
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user