diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml new file mode 100644 index 0000000..885fdcb --- /dev/null +++ b/.github/workflows/deploy.yaml @@ -0,0 +1,62 @@ +# Packs the rock and the charm +# +# Only the packing jobs are included for now. The deploy jobs (publish the rock +# to a registry and `juju deploy`/`refresh` the charm) will be added in a +# follow-up PR once PS7 resources are provisioned by IS. +# +# NOTE: when deploy jobs are added here, gate them to the default branch +# (e.g. `if: github.ref == 'refs/heads/main'`) so they never run from forks/PRs. + +name: Pack + +on: + push: + branches: + - main + workflow_dispatch: + +jobs: + pack-rock: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up LXD + uses: canonical/setup-lxd@main + + - name: Set up Rockcraft + run: sudo snap install rockcraft --classic --channel=latest/stable + + - name: Pack rock + run: rockcraft pack -v + + - name: Upload rock artifact + uses: actions/upload-artifact@v4 + with: + name: products-api-rock + path: "*.rock" + if-no-files-found: error + + pack-charm: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up LXD + uses: canonical/setup-lxd@main + + - name: Set up Charmcraft + run: sudo snap install charmcraft --classic --channel=latest/stable + + - name: Pack charm + working-directory: charm + run: charmcraft pack -v + + - name: Upload charm artifact + uses: actions/upload-artifact@v4 + with: + name: products-api-charm + path: "charm/*.charm" + if-no-files-found: error diff --git a/charm/.gitignore b/charm/.gitignore new file mode 100644 index 0000000..232e8fb --- /dev/null +++ b/charm/.gitignore @@ -0,0 +1,12 @@ +venv/ +build/ +*.charm +.tox/ +.coverage +__pycache__/ +*.py[cod] +.idea +.vscode/ + +# Charm libraries are fetched by `charmcraft pack` (flask-framework extension) +lib/ diff --git a/charm/charmcraft.yaml b/charm/charmcraft.yaml new file mode 100644 index 0000000..1611289 --- /dev/null +++ b/charm/charmcraft.yaml @@ -0,0 +1,26 @@ +name: products-api + +type: charm + +base: ubuntu@24.04 + +platforms: + amd64: + +# (Required) +summary: REST API for Canonical product, deployment and version metadata + +# (Required) +description: | + products-api is a Flask REST API that stores and serves Canonical product, + deployment, and version metadata, backed by PostgreSQL. + +extensions: + - flask-framework + +# products-api requires a PostgreSQL database; block until it is integrated. +requires: + postgresql: + interface: postgresql_client + optional: false + limit: 1 diff --git a/charm/pyproject.toml b/charm/pyproject.toml new file mode 100644 index 0000000..660ac99 --- /dev/null +++ b/charm/pyproject.toml @@ -0,0 +1,41 @@ +# Testing tools configuration +[tool.coverage.run] +branch = true + +[tool.coverage.report] +show_missing = true + +[tool.pytest.ini_options] +minversion = "6.0" +log_cli_level = "INFO" + +# Linting tools configuration +[tool.ruff] +line-length = 99 +lint.select = ["E", "W", "F", "C", "N", "D", "I001"] +lint.ignore = [ + "D105", + "D107", + "D203", + "D204", + "D213", + "D215", + "D400", + "D404", + "D406", + "D407", + "D408", + "D409", + "D413", +] +extend-exclude = ["__pycache__", "*.egg_info"] +lint.per-file-ignores = {"tests/*" = ["D100","D101","D102","D103","D104"]} + +[tool.ruff.lint.mccabe] +max-complexity = 10 + +[tool.codespell] +skip = "build,lib,venv,icon.svg,.tox,.git,.mypy_cache,.ruff_cache,.coverage" + +[tool.pyright] +include = ["src/**.py"] diff --git a/charm/requirements.txt b/charm/requirements.txt new file mode 100644 index 0000000..d58a30c --- /dev/null +++ b/charm/requirements.txt @@ -0,0 +1,2 @@ +ops ~= 2.17 +paas-charm>=1.0,<2 diff --git a/charm/src/charm.py b/charm/src/charm.py new file mode 100755 index 0000000..ef59cf8 --- /dev/null +++ b/charm/src/charm.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +# Copyright 2026 atakan.saglam@canonical.com +# See LICENSE file for licensing details. + +"""Flask Charm entrypoint.""" + +import logging +import typing + +import ops + +import paas_charm.flask + +logger = logging.getLogger(__name__) + + +class ProductsApiCharm(paas_charm.flask.Charm): + """Flask Charm service.""" + + def __init__(self, *args: typing.Any) -> None: + """Initialize the instance. + + Args: + args: passthrough to CharmBase. + """ + super().__init__(*args) + + +if __name__ == "__main__": + ops.main(ProductsApiCharm) diff --git a/charm/tox.ini b/charm/tox.ini new file mode 100644 index 0000000..7e800ac --- /dev/null +++ b/charm/tox.ini @@ -0,0 +1,80 @@ +# Copyright 2026 atakan.saglam@canonical.com +# See LICENSE file for licensing details. + +[tox] +no_package = True +skip_missing_interpreters = True +env_list = format, lint, static +min_version = 4.0.0 + +[vars] +src_path = {tox_root}/src +;tests_path = {tox_root}/tests +all_path = {[vars]src_path} + +[testenv] +set_env = + PYTHONPATH = {tox_root}/lib:{[vars]src_path} + PYTHONBREAKPOINT=pdb.set_trace + PY_COLORS=1 +pass_env = + PYTHONPATH + CHARM_BUILD_DIR + MODEL_SETTINGS + +[testenv:format] +description = Apply coding style standards to code +deps = + ruff +commands = + ruff format {[vars]all_path} + ruff check --fix {[vars]all_path} + +[testenv:lint] +description = Check code against coding style standards +deps = + ruff + codespell +commands = + codespell {tox_root} + ruff check {[vars]all_path} + ruff format --check --diff {[vars]all_path} + +[testenv:unit] +description = Run unit tests +deps = + pytest + coverage[toml] + -r {tox_root}/requirements.txt +commands = + coverage run --source={[vars]src_path} \ + -m pytest \ + --tb native \ + -v \ + -s \ + {posargs} \ + {[vars]tests_path}/unit + coverage report + +[testenv:static] +description = Run static type checks +deps = + pyright + -r {tox_root}/requirements.txt +commands = + pyright {posargs} + +[testenv:integration] +description = Run integration tests +deps = + pytest + juju + pytest-operator + -r {tox_root}/requirements.txt +commands = + pytest -v \ + -s \ + --tb native \ + --log-cli-level=INFO \ + {posargs} \ + {[vars]tests_path}/integration diff --git a/migrate.sh b/migrate.sh new file mode 100755 index 0000000..077b02a --- /dev/null +++ b/migrate.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash + +set -e + +# Database migration hook for the charm (paas-charm) deployment. +# +# Under the charm, ./entrypoint is not used: paas-charm launches gunicorn +# directly and runs this script once, on a single unit, before the web service +# starts. It is the only place schema migrations happen in that deployment. +# +# `flask db upgrade` is idempotent, so it is safe across restarts and multiple +# units. The DB URL is resolved by webapp.app (DATABASE_URL or the charm's +# POSTGRESQL_DB_CONNECT_STRING). +python3 -m flask --app webapp.app db upgrade diff --git a/migrations/env.py b/migrations/env.py index b391412..a6ae8cc 100644 --- a/migrations/env.py +++ b/migrations/env.py @@ -1,5 +1,4 @@ from logging.config import fileConfig -import os import sys from sqlalchemy import engine_from_config from sqlalchemy import pool @@ -23,10 +22,13 @@ # Logging setup is non-critical so we skip it rather than block migrations. pass -# Set SQLAlchemy URL from Flask config or environment -db_url = os.environ.get("DATABASE_URL", app.config.get("SQLALCHEMY_DATABASE_URI")) +# Resolve the database URL from the Flask app config. webapp/app.py already +# bridges DATABASE_URL (local/dotrun) and the charm-injected +# POSTGRESQL_DB_CONNECT_STRING, so we reuse that single source here instead of +# reading os.environ directly. +db_url = app.config.get("SQLALCHEMY_DATABASE_URI") if not db_url: - raise RuntimeError("DATABASE_URL must be set for migrations.") + raise RuntimeError("No database URL configured for migrations.") config.set_main_option("sqlalchemy.url", db_url) # add your model's MetaData object here diff --git a/requirements.txt b/requirements.txt index 250c1eb..98f6137 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,5 +11,5 @@ psycopg2-binary>=2.9.9 SQLAlchemy==2.0.23 SQLAlchemy-Utils>=0.41.2 Talisker>=0.16.0 -gunicorn>=20.0.4 +gunicorn[gevent]>=20.0.4 setuptools diff --git a/rockcraft.yaml b/rockcraft.yaml new file mode 100644 index 0000000..60f2cdc --- /dev/null +++ b/rockcraft.yaml @@ -0,0 +1,25 @@ +name: products-api +base: bare +build-base: ubuntu@24.04 +version: "0.1" # just for humans. Semantic versioning is recommended +summary: REST API for Canonical product, deployment and version metadata +description: | + products-api is a Flask REST API that stores and serves Canonical product, + deployment, and version metadata, backed by PostgreSQL. +platforms: + amd64: + +extensions: + - flask-framework + +parts: + flask-framework/install-app: + prime: + # A Flask prime override fully replaces the default file list. Application + # code is under webapp/ (not app/), so it must be listed explicitly, along + # with the migration entrypoint and Alembic assets. + - flask/app/app.py + - flask/app/webapp + - flask/app/migrations + - flask/app/migrate.sh + - flask/app/alembic.ini \ No newline at end of file diff --git a/webapp/app.py b/webapp/app.py index 9f5fffc..25090bb 100644 --- a/webapp/app.py +++ b/webapp/app.py @@ -9,7 +9,12 @@ app.json.sort_keys = False # Database Configuration -app.config["SQLALCHEMY_DATABASE_URI"] = get_flask_env("DATABASE_URL") +# When deployed via the 12-factor charm, paas-charm's PostgreSQL integration +# injects POSTGRESQL_DB_CONNECT_STRING. Fall back to it so the same code runs +# unchanged under local/dotrun (DATABASE_URL) and the charm runtime. +app.config["SQLALCHEMY_DATABASE_URI"] = get_flask_env( + "DATABASE_URL" +) or get_flask_env("POSTGRESQL_DB_CONNECT_STRING") app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False # API Documentation