From 7fb575a989ebfe8e0549cbea0f5e9345e4d77c37 Mon Sep 17 00:00:00 2001 From: Charles OuGuo Date: Mon, 15 Jul 2024 15:59:13 -0400 Subject: [PATCH] Add Ark Nova stats skeleton (#241) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .github/workflows/ci.bazelrc | 2 + ark_nova_stats/BUILD.bazel | 27 +++++ ark_nova_stats/__init__.py | 0 ark_nova_stats/api/BUILD.bazel | 23 ++++ ark_nova_stats/api/__init__.py | 0 ark_nova_stats/api/app.py | 15 +++ ark_nova_stats/api/gql/BUILD.bazel | 12 ++ ark_nova_stats/api/gql/__init__.py | 0 ark_nova_stats/api/gql/schema.py | 15 +++ ark_nova_stats/api/gql/types/BUILD.bazel | 13 +++ ark_nova_stats/api/gql/types/__init__.py | 0 ark_nova_stats/api/gql/types/example_model.py | 54 +++++++++ ark_nova_stats/api/migrations/BUILD.bazel | 76 ++++++++++++ ark_nova_stats/api/migrations/README | 1 + ark_nova_stats/api/migrations/__main__.py | 15 +++ ark_nova_stats/api/migrations/alembic.ini | 51 ++++++++ ark_nova_stats/api/migrations/env.py | 110 ++++++++++++++++++ ark_nova_stats/api/migrations/script.py.mako | 24 ++++ ark_nova_stats/api/tests/BUILD.bazel | 22 ++++ ark_nova_stats/api/tests/__init__.py | 0 ark_nova_stats/api/tests/example_test.py | 18 +++ ark_nova_stats/api/tests/fixtures.py | 27 +++++ ark_nova_stats/bin/create-migration | 6 + ark_nova_stats/bin/rebuild-and-run | 11 ++ ark_nova_stats/bin/reload-prod | 9 ++ ark_nova_stats/bin/run-migrations | 5 + ark_nova_stats/bin/snapshot-prod | 58 +++++++++ ark_nova_stats/bin/test | 5 + ark_nova_stats/config.py | 5 + ark_nova_stats/docker-compose.override.yaml | 26 +++++ ark_nova_stats/docker-compose.prod.yaml | 25 ++++ ark_nova_stats/docker-compose.yaml | 31 +++++ ark_nova_stats/models.py | 22 ++++ ark_nova_stats/models_test.py | 10 ++ base/BUILD.bazel | 1 + tools/build_rules/api_image.bzl | 1 + tools/build_rules/cross_platform_image.bzl | 2 + tools/build_rules/frontend_image.bzl | 2 + 38 files changed, 724 insertions(+) create mode 100644 ark_nova_stats/BUILD.bazel create mode 100644 ark_nova_stats/__init__.py create mode 100644 ark_nova_stats/api/BUILD.bazel create mode 100644 ark_nova_stats/api/__init__.py create mode 100644 ark_nova_stats/api/app.py create mode 100644 ark_nova_stats/api/gql/BUILD.bazel create mode 100644 ark_nova_stats/api/gql/__init__.py create mode 100644 ark_nova_stats/api/gql/schema.py create mode 100644 ark_nova_stats/api/gql/types/BUILD.bazel create mode 100644 ark_nova_stats/api/gql/types/__init__.py create mode 100644 ark_nova_stats/api/gql/types/example_model.py create mode 100644 ark_nova_stats/api/migrations/BUILD.bazel create mode 100644 ark_nova_stats/api/migrations/README create mode 100644 ark_nova_stats/api/migrations/__main__.py create mode 100644 ark_nova_stats/api/migrations/alembic.ini create mode 100644 ark_nova_stats/api/migrations/env.py create mode 100644 ark_nova_stats/api/migrations/script.py.mako create mode 100644 ark_nova_stats/api/tests/BUILD.bazel create mode 100644 ark_nova_stats/api/tests/__init__.py create mode 100644 ark_nova_stats/api/tests/example_test.py create mode 100644 ark_nova_stats/api/tests/fixtures.py create mode 100755 ark_nova_stats/bin/create-migration create mode 100755 ark_nova_stats/bin/rebuild-and-run create mode 100755 ark_nova_stats/bin/reload-prod create mode 100755 ark_nova_stats/bin/run-migrations create mode 100755 ark_nova_stats/bin/snapshot-prod create mode 100755 ark_nova_stats/bin/test create mode 100644 ark_nova_stats/config.py create mode 100644 ark_nova_stats/docker-compose.override.yaml create mode 100644 ark_nova_stats/docker-compose.prod.yaml create mode 100644 ark_nova_stats/docker-compose.yaml create mode 100644 ark_nova_stats/models.py create mode 100644 ark_nova_stats/models_test.py diff --git a/.github/workflows/ci.bazelrc b/.github/workflows/ci.bazelrc index e9be9934..0f9a7ec1 100644 --- a/.github/workflows/ci.bazelrc +++ b/.github/workflows/ci.bazelrc @@ -3,5 +3,7 @@ build --announce_rc build --disk_cache=$HOME/.cache/bazel build --repository_cache=$HOME/.cache/bazel-repo +build --build_tag_filters=-manual test --test_output=errors +test --test_tag_filters=-manual diff --git a/ark_nova_stats/BUILD.bazel b/ark_nova_stats/BUILD.bazel new file mode 100644 index 00000000..41a3fb57 --- /dev/null +++ b/ark_nova_stats/BUILD.bazel @@ -0,0 +1,27 @@ +load("@rules_python//python:defs.bzl", "py_library", "py_test") + +py_library( + name = "models_py", + srcs = ["models.py"], + visibility = ["//ark_nova_stats:__subpackages__"], + deps = [ + ":config_py", + "@py_deps//requests", + "@py_deps//sqlalchemy", + ], +) + +py_library( + name = "config_py", + srcs = ["config.py"], + visibility = ["//ark_nova_stats:__subpackages__"], + deps = [ + "//base:flask_app_py", + ], +) + +py_test( + name = "models_test", + srcs = ["models_test.py"], + deps = [":models_py"], +) diff --git a/ark_nova_stats/__init__.py b/ark_nova_stats/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ark_nova_stats/api/BUILD.bazel b/ark_nova_stats/api/BUILD.bazel new file mode 100644 index 00000000..29fd346f --- /dev/null +++ b/ark_nova_stats/api/BUILD.bazel @@ -0,0 +1,23 @@ +load("@rules_python//python:defs.bzl", "py_library") +load("//tools/build_rules:api_image.bzl", "api_image") + +py_library( + name = "app", + srcs = ["app.py"], + visibility = ["//:__subpackages__"], + deps = [ + "//ark_nova_stats:config_py", + "//ark_nova_stats:models_py", + "//ark_nova_stats/api/gql:schema", + "@py_deps//flask", + "@py_deps//graphql_server", + ], +) + +api_image( + name = "api_image", + app_package = "ark_nova_stats.api.app", + docker_hub_repository = "docker.io/shaldengeki/ark-nova-stats-api", + repo_tags = ["shaldengeki/ark-nova-stats-api:latest"], + deps = [":app"], +) diff --git a/ark_nova_stats/api/__init__.py b/ark_nova_stats/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ark_nova_stats/api/app.py b/ark_nova_stats/api/app.py new file mode 100644 index 00000000..c753cc1b --- /dev/null +++ b/ark_nova_stats/api/app.py @@ -0,0 +1,15 @@ +import datetime +from datetime import timezone +from typing import Optional + +from flask import abort, redirect, request, session +from graphql_server.flask import GraphQLView # type: ignore + +from ark_nova_stats import models +from ark_nova_stats.api.gql import schema +from ark_nova_stats.config import app, db + +app.add_url_rule( + "/graphql", + view_func=GraphQLView.as_view("graphql", schema=schema.Schema(app), graphiql=True), +) diff --git a/ark_nova_stats/api/gql/BUILD.bazel b/ark_nova_stats/api/gql/BUILD.bazel new file mode 100644 index 00000000..f61feea3 --- /dev/null +++ b/ark_nova_stats/api/gql/BUILD.bazel @@ -0,0 +1,12 @@ +load("@rules_python//python:defs.bzl", "py_library") + +py_library( + name = "schema", + srcs = ["schema.py"], + visibility = ["//ark_nova_stats/api:__subpackages__"], + deps = [ + "//ark_nova_stats:models_py", + "//ark_nova_stats/api/gql/types:example_model", + "@py_deps//graphql_core", + ], +) diff --git a/ark_nova_stats/api/gql/__init__.py b/ark_nova_stats/api/gql/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ark_nova_stats/api/gql/schema.py b/ark_nova_stats/api/gql/schema.py new file mode 100644 index 00000000..94c5858c --- /dev/null +++ b/ark_nova_stats/api/gql/schema.py @@ -0,0 +1,15 @@ +from graphql import GraphQLObjectType, GraphQLSchema + +from ark_nova_stats.api.gql.types.example_model import example_model_field +from ark_nova_stats.models import ExampleModel + + +def Schema(app): + return GraphQLSchema( + query=GraphQLObjectType( + name="Query", + fields={ + "testModel": example_model_field(ExampleModel), + }, + ), + ) diff --git a/ark_nova_stats/api/gql/types/BUILD.bazel b/ark_nova_stats/api/gql/types/BUILD.bazel new file mode 100644 index 00000000..336fcf73 --- /dev/null +++ b/ark_nova_stats/api/gql/types/BUILD.bazel @@ -0,0 +1,13 @@ +load("@rules_python//python:defs.bzl", "py_library") + +py_library( + name = "example_model", + srcs = ["example_model.py"], + visibility = ["//:__subpackages__"], + deps = [ + "//ark_nova_stats:config_py", + "//ark_nova_stats:models_py", + "@py_deps//flask", + "@py_deps//graphql_core", + ], +) diff --git a/ark_nova_stats/api/gql/types/__init__.py b/ark_nova_stats/api/gql/types/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ark_nova_stats/api/gql/types/example_model.py b/ark_nova_stats/api/gql/types/example_model.py new file mode 100644 index 00000000..15af7b34 --- /dev/null +++ b/ark_nova_stats/api/gql/types/example_model.py @@ -0,0 +1,54 @@ +from typing import Any, Optional, Type + +from flask import Flask +from graphql import ( + GraphQLArgument, + GraphQLBoolean, + GraphQLField, + GraphQLFloat, + GraphQLInt, + GraphQLList, + GraphQLNonNull, + GraphQLObjectType, +) + +from ark_nova_stats.config import app, db +from ark_nova_stats.models import ExampleModel + + +def example_model_fields() -> dict[str, GraphQLField]: + return { + "id": GraphQLField( + GraphQLNonNull(GraphQLInt), + description="The id of the example model.", + ), + } + + +example_model_type = GraphQLObjectType( + "ExampleModel", + description="A example model.", + fields=example_model_fields, +) + + +def fetch_example_model( + example_model: Type[ExampleModel], params: dict[str, Any] +) -> Optional[ExampleModel]: + return (example_model.query.filter(example_model.id == params["id"])).first() + + +example_model_filters: dict[str, GraphQLArgument] = { + "id": GraphQLArgument( + GraphQLNonNull(GraphQLInt), + description="ID of the example model.", + ), +} + + +def example_model_field(example_model: type[ExampleModel]) -> GraphQLField: + return GraphQLField( + example_model_type, + args=example_model_filters, + resolve=lambda root, info, **args: fetch_example_model(example_model, args), + ) diff --git a/ark_nova_stats/api/migrations/BUILD.bazel b/ark_nova_stats/api/migrations/BUILD.bazel new file mode 100644 index 00000000..382d88a6 --- /dev/null +++ b/ark_nova_stats/api/migrations/BUILD.bazel @@ -0,0 +1,76 @@ +load("@py_deps//:requirements.bzl", "requirement") +load("@rules_python//python:defs.bzl", "py_binary", "py_library") +load("//tools/build_rules:cross_platform_image.bzl", "cross_platform_image") +load("//tools/build_rules:py_layer.bzl", "py_oci_image") + +py_library( + name = "migrate_lib", + srcs = glob(["**/*.py"]), # keep + imports = [".."], + visibility = ["//:__subpackages__"], + deps = [ + requirement("alembic"), + requirement("Flask"), + ], +) + +py_binary( + name = "binary", + srcs = glob(["**/*.py"]), # keep + data = ["alembic.ini"], + imports = [".."], + main = "__main__.py", + visibility = ["//:__subpackages__"], + deps = [ + "//ark_nova_stats:config_py", + "//scripts:wait_for_postgres", # keep + "@py_deps//flask_migrate", + "@rules_python//python/runfiles", + ], +) + +py_oci_image( + name = "base_image", + base = "@python3_image", + binary = ":binary", + cmd = [ + "/ark_nova_stats/api/migrations/binary.runfiles/_main/scripts/wait_for_postgres", + "/ark_nova_stats/api/migrations/binary", + ], + env = { + "FLASK_APP": "app.py", + "FLASK_DEBUG": "True", + "API_PORT": "5000", + "FRONTEND_PROTOCOL": "http", + "FRONTEND_HOST": "frontend", + "FRONTEND_PORT": "5001", + "DB_HOST": "pg", + "DB_USERNAME": "admin", + "DB_PASSWORD": "development", + "DATABASE_NAME": "api_development", + "FITBIT_CLIENT_ID": "testing", + "FITBIT_CLIENT_SECRET": "testing", + "FITBIT_VERIFICATION_CODE": "testing", + "FLASK_SECRET_KEY": "testing", + }, +) + +# $ bazel run //ark_nova_stats/api/migrations:image_tarball +# $ docker run --rm shaldengeki/ark-nova-stats-api-migrations:latest +cross_platform_image( + name = "image", + image = ":base_image", + repo_tags = ["shaldengeki/ark-nova-stats-api-migrations:latest"], + repository = "docker.io/shaldengeki/ark-nova-stats-api-migrations", + visibility = ["//ark_nova_stats/api/migrations:__subpackages__"], +) + +py_library( + name = "env", + srcs = ["env.py"], + visibility = ["//:__subpackages__"], + deps = [ + "@py_deps//alembic", + "@py_deps//flask", + ], +) diff --git a/ark_nova_stats/api/migrations/README b/ark_nova_stats/api/migrations/README new file mode 100644 index 00000000..0e048441 --- /dev/null +++ b/ark_nova_stats/api/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/ark_nova_stats/api/migrations/__main__.py b/ark_nova_stats/api/migrations/__main__.py new file mode 100644 index 00000000..972889e5 --- /dev/null +++ b/ark_nova_stats/api/migrations/__main__.py @@ -0,0 +1,15 @@ +import shutil + +from flask_migrate import upgrade +from python.runfiles import Runfiles + +from ark_nova_stats.config import app + +if __name__ == "__main__": + # Copy the alembic.ini. + r = Runfiles.Create() + alembic_ini_src = r.Rlocation("_main/ark_nova_stats/api/migrations/alembic.ini") + shutil.copyfile(alembic_ini_src, "/ark_nova_stats/api/migrations/alembic.ini") + + with app.app_context(): + upgrade(directory="/ark_nova_stats/api/migrations") diff --git a/ark_nova_stats/api/migrations/alembic.ini b/ark_nova_stats/api/migrations/alembic.ini new file mode 100644 index 00000000..13c7ae4a --- /dev/null +++ b/ark_nova_stats/api/migrations/alembic.ini @@ -0,0 +1,51 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +script_location = . + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic,flask_migrate + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[logger_flask_migrate] +level = INFO +handlers = +qualname = flask_migrate + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/ark_nova_stats/api/migrations/env.py b/ark_nova_stats/api/migrations/env.py new file mode 100644 index 00000000..78d62879 --- /dev/null +++ b/ark_nova_stats/api/migrations/env.py @@ -0,0 +1,110 @@ +import logging +import os +from logging.config import fileConfig + +from alembic import context +from flask import current_app + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) # type: ignore +logger = logging.getLogger("alembic.env") + + +def get_engine(): + try: + # this works with Flask-SQLAlchemy<3 and Alchemical + return current_app.extensions["migrate"].db.get_engine() + except TypeError: + # this works with Flask-SQLAlchemy>=3 + return current_app.extensions["migrate"].db.engine + + +def get_engine_url(): + # Set the database URL directly from environment variables. + return f"postgresql://{os.environ.get('DB_USERNAME', 'admin')}:{os.environ.get('DB_PASSWORD', 'development')}@{os.environ.get('DB_HOST', 'pg')}/{os.environ.get('DATABASE_NAME', 'api_development')}" + # try: + # return get_engine().url.render_as_string(hide_password=False).replace( + # '%', '%%') + # except AttributeError: + # return str(get_engine().url).replace('%', '%%') + + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +config.set_main_option("sqlalchemy.url", get_engine_url()) +target_db = current_app.extensions["migrate"].db + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def get_metadata(): + if hasattr(target_db, "metadatas"): + return target_db.metadatas[None] + return target_db.metadata + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure(url=url, target_metadata=get_metadata(), literal_binds=True) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, "autogenerate", False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info("No changes in schema detected.") + + connectable = get_engine() + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=get_metadata(), + process_revision_directives=process_revision_directives, + **current_app.extensions["migrate"].configure_args, + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/ark_nova_stats/api/migrations/script.py.mako b/ark_nova_stats/api/migrations/script.py.mako new file mode 100644 index 00000000..2c015630 --- /dev/null +++ b/ark_nova_stats/api/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/ark_nova_stats/api/tests/BUILD.bazel b/ark_nova_stats/api/tests/BUILD.bazel new file mode 100644 index 00000000..62cb9183 --- /dev/null +++ b/ark_nova_stats/api/tests/BUILD.bazel @@ -0,0 +1,22 @@ +load("@py_deps//:requirements.bzl", "requirement") +load("@rules_python//python:defs.bzl", "py_library", "py_test") + +py_library( + name = "fixtures", + srcs = ["fixtures.py"], + visibility = ["//:__subpackages__"], + deps = [ + "//ark_nova_stats/api:app", + "@py_deps//flask", + "@py_deps//pytest", + ], +) + +py_test( + name = "example_test", + srcs = ["example_test.py"], + deps = [ + ":fixtures", + "@py_deps//flask", + ], +) diff --git a/ark_nova_stats/api/tests/__init__.py b/ark_nova_stats/api/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ark_nova_stats/api/tests/example_test.py b/ark_nova_stats/api/tests/example_test.py new file mode 100644 index 00000000..ff84c7ac --- /dev/null +++ b/ark_nova_stats/api/tests/example_test.py @@ -0,0 +1,18 @@ +from flask.testing import FlaskClient + +from ark_nova_stats.api.tests.fixtures import app, client # noqa + + +def test_example_request(client: FlaskClient) -> None: + # response = client.post( + # "/graphql", + # json={ + # "query": """ + # query { + # test + # } + # """ + # }, + # ) + # assert response.json["data"]["test"] == "hello world!" + assert True diff --git a/ark_nova_stats/api/tests/fixtures.py b/ark_nova_stats/api/tests/fixtures.py new file mode 100644 index 00000000..4aba35f9 --- /dev/null +++ b/ark_nova_stats/api/tests/fixtures.py @@ -0,0 +1,27 @@ +from typing import Iterator + +import flask +import pytest +from flask.testing import FlaskClient, FlaskCliRunner + +from ark_nova_stats.api.app import app as base_app + + +@pytest.fixture +def app() -> Iterator[flask.Flask]: + base_app.config.update( + { + "TESTING": True, + } + ) + yield base_app + + +@pytest.fixture +def client(app: flask.Flask) -> FlaskClient: + return app.test_client() + + +@pytest.fixture +def runner(app: flask.Flask) -> FlaskCliRunner: + return app.test_cli_runner() diff --git a/ark_nova_stats/bin/create-migration b/ark_nova_stats/bin/create-migration new file mode 100755 index 00000000..f2f0f04c --- /dev/null +++ b/ark_nova_stats/bin/create-migration @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +set -ex + +cd api/migrations +alembic revision -m "$1" diff --git a/ark_nova_stats/bin/rebuild-and-run b/ark_nova_stats/bin/rebuild-and-run new file mode 100755 index 00000000..66bf30d3 --- /dev/null +++ b/ark_nova_stats/bin/rebuild-and-run @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +# For development use. +# Rebuilds all the containers for the app and reloads any running containers. + +set -euxo pipefail + +bazel run //ark_nova_stats/api:api_image_image_tarball +bazel run //ark_nova_stats/api/migrations:image_tarball + +docker compose -f docker-compose.yaml -f docker-compose.override.yaml up --no-deps -d api migration pg diff --git a/ark_nova_stats/bin/reload-prod b/ark_nova_stats/bin/reload-prod new file mode 100755 index 00000000..943573d1 --- /dev/null +++ b/ark_nova_stats/bin/reload-prod @@ -0,0 +1,9 @@ +#1/usr/bin/env bash + +set -ex + +git reset --hard +git switch main +git pull origin main +docker compose -f docker-compose.yaml -f docker-compose.prod.yaml pull api frontend +docker compose -f docker-compose.yaml -f docker-compose.prod.yaml up --no-deps -d api frontend migration worker diff --git a/ark_nova_stats/bin/run-migrations b/ark_nova_stats/bin/run-migrations new file mode 100755 index 00000000..c3568f74 --- /dev/null +++ b/ark_nova_stats/bin/run-migrations @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +set -ex + +docker compose up --no-deps migration -d diff --git a/ark_nova_stats/bin/snapshot-prod b/ark_nova_stats/bin/snapshot-prod new file mode 100755 index 00000000..901b3ee4 --- /dev/null +++ b/ark_nova_stats/bin/snapshot-prod @@ -0,0 +1,58 @@ +#!/usr/bin/env bash + +set -e + +# Production hostname to SSH into. +PROD_HOST=$1 + +# Path on the production host where the application is installed. +PROD_PATH=$2 + +PROD_DB_PASSWORD=$3 + +# Optional arguments. +PROD_DATABASE_NAME=${PROD_DATABASE_NAME:-api_development} +PROD_DATABASE_HOST=${PROD_DATABASE_HOST:-pg} +PROD_DB_USERNAME=${PROD_DB_USERNAME:-admin} +LOCAL_DATABASE_NAME=${LOCAL_DATABASE_NAME:-api_development} +LOCAL_DATABASE_HOST=${LOCAL_DATABASE_HOST:-pg} +LOCAL_DB_USERNAME=${LOCAL_DB_USERNAME:-admin} + +# First, bring everything down. +docker compose down + +# Delete the local postgres mount. +sudo rm -rf postgres-data + +# In an API container with a local mount, run pg_dump. +ssh $PROD_HOST " + docker exec \ + -t \ + --env PGPASSFILE=/tmp/.pgpass \ + ark-nova-stats-api-1 \ + /bin/sh -c \" + echo \\\"*:*:*:$PROD_DB_USERNAME:$PROD_DB_PASSWORD\\\" > /tmp/.pgpass ; + chmod 0600 /tmp/.pgpass ; + pg_dump \ + --clean \ + --no-owner \ + --no-privileges \ + --host $PROD_DATABASE_HOST \ + --username $PROD_DB_USERNAME \ + --dbname $PROD_DATABASE_NAME ; + \" \ +" > pg_dump.sql + +# Import the pg_dump output. +docker compose up -d pg +sleep 5 +cat pg_dump.sql | docker exec \ + -i \ + ark-nova-stats-pg-1 \ + psql \ + --username $LOCAL_DB_USERNAME \ + --dbname $LOCAL_DATABASE_NAME + + +# Bring everything back up. +docker compose up -d diff --git a/ark_nova_stats/bin/test b/ark_nova_stats/bin/test new file mode 100755 index 00000000..7f072a69 --- /dev/null +++ b/ark_nova_stats/bin/test @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +set -ex + +bazel test //ark_nova_stats/... diff --git a/ark_nova_stats/config.py b/ark_nova_stats/config.py new file mode 100644 index 00000000..542929fe --- /dev/null +++ b/ark_nova_stats/config.py @@ -0,0 +1,5 @@ +import os + +from base.flask_app import FlaskApp + +app, cors, db, migrate = FlaskApp(__name__) diff --git a/ark_nova_stats/docker-compose.override.yaml b/ark_nova_stats/docker-compose.override.yaml new file mode 100644 index 00000000..8b7c989b --- /dev/null +++ b/ark_nova_stats/docker-compose.override.yaml @@ -0,0 +1,26 @@ +version: "3" +services: + api: &api + env_file: + - env/.api.env + ports: + - "5000:5000" + migration: + <<: *api + image: shaldengeki/ark-nova-stats-api-migrations + ports: [] + worker: + <<: *api + image: shaldengeki/ark-nova-stats-worker:latest + ports: [] + deploy: + replicas: 0 + pg: + env_file: + - env/.postgres.env + frontend: + image: shaldengeki/ark-nova-stats-frontend:latest + env_file: + - env/.frontend.env + ports: + - "5001:80" diff --git a/ark_nova_stats/docker-compose.prod.yaml b/ark_nova_stats/docker-compose.prod.yaml new file mode 100644 index 00000000..c091a888 --- /dev/null +++ b/ark_nova_stats/docker-compose.prod.yaml @@ -0,0 +1,25 @@ +version: '3' +services: + api: &api + ports: + - "5000:5000" + env_file: + - env/.api.prod.env + migration: + <<: *api + image: shaldengeki/ark-nova-stats-api-migrations:latest + ports: [] + worker: + <<: *api + image: shaldengeki/ark-nova-stats-worker:latest + ports: [] + pg: + restart: always + env_file: + - env/.postgres.prod.env + frontend: + image: shaldengeki/ark-nova-stats-frontend:latest + ports: + - "5001:80" + env_file: + - env/.frontend.prod.env diff --git a/ark_nova_stats/docker-compose.yaml b/ark_nova_stats/docker-compose.yaml new file mode 100644 index 00000000..7cb28a44 --- /dev/null +++ b/ark_nova_stats/docker-compose.yaml @@ -0,0 +1,31 @@ +version: '3' +services: + api: &api + image: shaldengeki/ark-nova-stats-api:latest + env_file: + - env/.api.env + restart: always + depends_on: + - pg + migration: + <<: *api + image: shaldengeki/ark-nova-stats-api-migrations:latest + restart: no + worker: + <<: *api + image: shaldengeki/ark-nova-stats-worker:latest + pg: + image: postgres:alpine + restart: always + env_file: + - env/.postgres.env + volumes: + - ark-nova-stats-pg:/var/lib/postgresql/data + frontend: + image: shaldengeki/ark-nova-stats-frontend:latest + env_file: + - env/.frontend.env + restart: always +volumes: + ark-nova-stats-pg: + name: ark-nova-stats_ark-nova-stats-pg diff --git a/ark_nova_stats/models.py b/ark_nova_stats/models.py new file mode 100644 index 00000000..8c7a827a --- /dev/null +++ b/ark_nova_stats/models.py @@ -0,0 +1,22 @@ +import dataclasses +import datetime +import decimal +import enum +import itertools +import random +from typing import Generator, Optional + +import requests +from sqlalchemy import ForeignKey, desc +from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.sql import func +from sqlalchemy.sql.functions import now + +from ark_nova_stats.config import db + + +class ExampleModel(db.Model): + __tablename__ = "example_models" + + id: Mapped[int] = mapped_column(primary_key=True) diff --git a/ark_nova_stats/models_test.py b/ark_nova_stats/models_test.py new file mode 100644 index 00000000..09a18f3f --- /dev/null +++ b/ark_nova_stats/models_test.py @@ -0,0 +1,10 @@ +import datetime +import decimal +from typing import Generator + +from ark_nova_stats.models import ExampleModel + + +class TestExampleModel: + def test_sample(self): + assert ExampleModel(id=1) diff --git a/base/BUILD.bazel b/base/BUILD.bazel index 77b38719..ccbe5de6 100644 --- a/base/BUILD.bazel +++ b/base/BUILD.bazel @@ -13,5 +13,6 @@ py_library( requirement("flask-cors"), requirement("Flask-Migrate"), requirement("Flask-SQLAlchemy"), + requirement("pg8000"), ], ) diff --git a/tools/build_rules/api_image.bzl b/tools/build_rules/api_image.bzl index 497495a8..1525ce99 100644 --- a/tools/build_rules/api_image.bzl +++ b/tools/build_rules/api_image.bzl @@ -81,6 +81,7 @@ def api_image( ], env = container_env, visibility = visibility, + tags = ["manual"], ) cross_platform_image( diff --git a/tools/build_rules/cross_platform_image.bzl b/tools/build_rules/cross_platform_image.bzl index 0c026740..d0feb55e 100644 --- a/tools/build_rules/cross_platform_image.bzl +++ b/tools/build_rules/cross_platform_image.bzl @@ -43,6 +43,7 @@ def cross_platform_image( image = name, repo_tags = repo_tags, visibility = visibility, + tags = ["manual"], ) oci_push( @@ -51,4 +52,5 @@ def cross_platform_image( remote_tags = stamp_file, repository = repository, visibility = visibility, + tags = ["manual"], ) diff --git a/tools/build_rules/frontend_image.bzl b/tools/build_rules/frontend_image.bzl index b967636e..c60e8d2d 100644 --- a/tools/build_rules/frontend_image.bzl +++ b/tools/build_rules/frontend_image.bzl @@ -102,6 +102,7 @@ def frontend_image( srcs = [name + "_webpack"], package_dir = "/usr/share/nginx/html", strip_prefix = name + "_webpack", + tags = ["manual"], visibility = visibility, ) @@ -116,6 +117,7 @@ def frontend_image( # Intentionally omit cmd/entrypoint to default to the base nginx container's cmd/entrypoint. # entrypoint = [], # cmd = [], + tags = ["manual"], visibility = visibility, )