From 1c54be909df16d433503c5237131439730593efc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Atakan=20Sa=C4=9Flam?= Date: Sun, 28 Jun 2026 16:44:37 +0300 Subject: [PATCH 1/6] 12-factor rock packaging --- migrate.sh | 23 +++++++++++++++++++++++ migrations/env.py | 10 ++++++---- requirements.txt | 1 + rockcraft.yaml | 25 +++++++++++++++++++++++++ webapp/app.py | 7 ++++++- 5 files changed, 61 insertions(+), 5 deletions(-) create mode 100755 migrate.sh create mode 100644 rockcraft.yaml diff --git a/migrate.sh b/migrate.sh new file mode 100755 index 0000000..9b9e820 --- /dev/null +++ b/migrate.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash + +set -e + +# Database migration hook for the 12-factor (paas-charm) deployment. +# +# Why this file was added: +# When deployed as a charm, the repo's ./entrypoint is NOT executed. +# paas-charm owns the workload lifecycle and launches gunicorn directly via +# Pebble, which bypasses the `flask db upgrade` step baked into ./entrypoint. +# +# Why it is needed: +# paas-charm runs a dedicated migration script (lookup precedence: migrate, +# migrate.sh, migrate.py, manage.py) once, on a single unit, after the +# database relation is available and before the web service starts. Without +# this file, schema migrations would never run under the charm and the app +# would start against an un-migrated database and fail. +# +# `flask db upgrade` is idempotent: re-running it when already at the latest +# revision is a no-op, so it is safe across restarts and multiple units. The DB +# URL is resolved by webapp.app (DATABASE_URL or the charm-injected +# 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..973bf63 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,4 +12,5 @@ SQLAlchemy==2.0.23 SQLAlchemy-Utils>=0.41.2 Talisker>=0.16.0 gunicorn>=20.0.4 +gevent>=23.9.1 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 From abdc923ce98a2a8e40e5c275230a733e6040ea8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Atakan=20Sa=C4=9Flam?= Date: Sun, 28 Jun 2026 18:03:50 +0300 Subject: [PATCH 2/6] charming --- charm/.gitignore | 12 +++++++ charm/charmcraft.yaml | 75 +++++++++++++++++++++++++++++++++++++++ charm/pyproject.toml | 41 ++++++++++++++++++++++ charm/requirements.txt | 2 ++ charm/src/charm.py | 30 ++++++++++++++++ charm/tox.ini | 80 ++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 240 insertions(+) create mode 100644 charm/.gitignore create mode 100644 charm/charmcraft.yaml create mode 100644 charm/pyproject.toml create mode 100644 charm/requirements.txt create mode 100755 charm/src/charm.py create mode 100644 charm/tox.ini 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..767eef3 --- /dev/null +++ b/charm/charmcraft.yaml @@ -0,0 +1,75 @@ +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 + +# Uncomment the integrations used by your application +# Integrations set to "optional: false" will block the charm +# until the applications are integrated. +# requires: +# mysql: +# interface: mysql_client +# optional: false +# limit: 1 +# postgresql: +# interface: postgresql_client +# optional: false +# limit: 1 +# mongodb: +# interface: mongodb_client +# optional: false +# limit: 1 +# redis: +# interface: redis +# optional: false +# limit: 1 +# s3: +# interface: s3 +# optional: false +# limit: 1 +# saml: +# interface: saml +# optional: false +# limit: 1 +# rabbitmq: +# interface: rabbitmq +# optional: false +# limit: 1 +# tracing: +# interface: tracing +# optional: true +# limit: 1 +# smtp: +# interface: smtp +# optional: false +# limit: 1 +# openfga: +# interface: openfga +# optional: false +# limit: 1 +# http-proxy: +# interface: http_proxy +# optional: true +# 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 From 4d0746a92f0e8dbed052c7037c6d5b98527a4538 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Atakan=20Sa=C4=9Flam?= Date: Tue, 14 Jul 2026 12:07:10 +0300 Subject: [PATCH 3/6] Add GitHub Actions workflow for packing rock and charm --- .github/workflows/deploy.yaml | 62 +++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 .github/workflows/deploy.yaml 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 From 8dc20d282e697edf1aaa6c83fe521fb94615cfdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Atakan=20Sa=C4=9Flam?= Date: Tue, 14 Jul 2026 12:16:04 +0300 Subject: [PATCH 4/6] Add pull_request trigger to Pack workflow --- .github/workflows/deploy.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 885fdcb..fa6aa67 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -10,6 +10,7 @@ name: Pack on: + pull_request: push: branches: - main From a77d3a0f750d75d6bdce1bdd870b10ab9558e20e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Atakan=20Sa=C4=9Flam?= Date: Mon, 20 Jul 2026 19:50:12 +0300 Subject: [PATCH 5/6] Address review comments --- charm/charmcraft.yaml | 49 ------------------------------------------- migrate.sh | 21 ++++++------------- requirements.txt | 3 +-- 3 files changed, 7 insertions(+), 66 deletions(-) diff --git a/charm/charmcraft.yaml b/charm/charmcraft.yaml index 767eef3..1611289 100644 --- a/charm/charmcraft.yaml +++ b/charm/charmcraft.yaml @@ -24,52 +24,3 @@ requires: interface: postgresql_client optional: false limit: 1 - -# Uncomment the integrations used by your application -# Integrations set to "optional: false" will block the charm -# until the applications are integrated. -# requires: -# mysql: -# interface: mysql_client -# optional: false -# limit: 1 -# postgresql: -# interface: postgresql_client -# optional: false -# limit: 1 -# mongodb: -# interface: mongodb_client -# optional: false -# limit: 1 -# redis: -# interface: redis -# optional: false -# limit: 1 -# s3: -# interface: s3 -# optional: false -# limit: 1 -# saml: -# interface: saml -# optional: false -# limit: 1 -# rabbitmq: -# interface: rabbitmq -# optional: false -# limit: 1 -# tracing: -# interface: tracing -# optional: true -# limit: 1 -# smtp: -# interface: smtp -# optional: false -# limit: 1 -# openfga: -# interface: openfga -# optional: false -# limit: 1 -# http-proxy: -# interface: http_proxy -# optional: true -# limit: 1 diff --git a/migrate.sh b/migrate.sh index 9b9e820..077b02a 100755 --- a/migrate.sh +++ b/migrate.sh @@ -2,22 +2,13 @@ set -e -# Database migration hook for the 12-factor (paas-charm) deployment. +# Database migration hook for the charm (paas-charm) deployment. # -# Why this file was added: -# When deployed as a charm, the repo's ./entrypoint is NOT executed. -# paas-charm owns the workload lifecycle and launches gunicorn directly via -# Pebble, which bypasses the `flask db upgrade` step baked into ./entrypoint. +# 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. # -# Why it is needed: -# paas-charm runs a dedicated migration script (lookup precedence: migrate, -# migrate.sh, migrate.py, manage.py) once, on a single unit, after the -# database relation is available and before the web service starts. Without -# this file, schema migrations would never run under the charm and the app -# would start against an un-migrated database and fail. -# -# `flask db upgrade` is idempotent: re-running it when already at the latest -# revision is a no-op, so it is safe across restarts and multiple units. The DB -# URL is resolved by webapp.app (DATABASE_URL or the charm-injected +# `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/requirements.txt b/requirements.txt index 973bf63..98f6137 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,6 +11,5 @@ psycopg2-binary>=2.9.9 SQLAlchemy==2.0.23 SQLAlchemy-Utils>=0.41.2 Talisker>=0.16.0 -gunicorn>=20.0.4 -gevent>=23.9.1 +gunicorn[gevent]>=20.0.4 setuptools From fc0e0b71fcd3ab5c38b192722eeb10201ecae3c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Atakan=20Sa=C4=9Flam?= Date: Tue, 21 Jul 2026 11:18:31 +0300 Subject: [PATCH 6/6] Remove pull_request workflow trigger --- .github/workflows/deploy.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index fa6aa67..885fdcb 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -10,7 +10,6 @@ name: Pack on: - pull_request: push: branches: - main