diff --git a/.gitignore b/.gitignore index 5d381cc..9387af5 100644 --- a/.gitignore +++ b/.gitignore @@ -160,3 +160,6 @@ cython_debug/ # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ + +test-data +build diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f568046 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,53 @@ +# REF https://github.com/scratchmex/poetry-docker-template/blob/main/dockerfile.jinja + +FROM python:3.11-slim-bookworm as base-python + +#################### +# - Build Variables +#################### +ARG PIP_DISABLE_PIP_VERSION_CHECK 1 +ARG PIP_NO_CACHE_DIR 1 +## Disable Non-Reproducible pip + +#################### +# - Environment Variables +#################### +ENV PYTHONOPTIMIZE 1 +## Strip __debug__ + +ENV PYTHONUNBUFFERED 1 +## Disable stdout/stderr Buffering + + + +#################### +# - Stage: Builder +#################### +FROM base-python AS base-builder +WORKDIR /app-src + +RUN pip install -r requirements.txt + + + +#################### +# - Stage: Production +#################### +FROM base-python as production + +RUN apt-get update \ + && apt-get install --no-install-recommends -y \ + curl + +RUN groupadd -g 1500 pyrunner && \ + useradd -m -u 1500 -g pyrunner pyrunner + +COPY --chown=pyrunner:pyrunner ./app /app +COPY --from=builder-base --chown=poetry:poetry /app-src/.venv /app/.venv + +USER poetry +WORKDIR /app + +HEALTHCHECK CMD curl --fail http://localhost:3000 || exit 1 + +CMD poetry run uvicorn --proxy-headers --host=0.0.0.0 --port=3000 app.main:app diff --git a/app/main.py b/app/main.py index e4d356b..687e21c 100644 --- a/app/main.py +++ b/app/main.py @@ -1,16 +1,38 @@ +import tomllib +from datetime import datetime +from pathlib import Path +import json +import enum +from pydantic import BaseModel, SecretStr, constr import secrets from fastapi import FastAPI, HTTPException, Security from fastapi.openapi.models import APIKey +from fastapi.security.api_key import APIKeyHeader +from starlette.status import HTTP_403_FORBIDDEN +import sys + +class Settings(BaseModel): + api_key: SecretStr + path_support_reports: Path + +with open(Path(__file__).parent / 'settings.toml', 'r') as f: + settings_dict = tomllib.loads(f.read()) + SETTINGS = Settings(**settings_dict) #################### # - Utilities #################### +api_key_header = APIKeyHeader( + name="access_token", + auto_error=False, +) + async def g_api_key( api_key_header: APIKey = Security(api_key_header), ) -> APIKey: if api_key_header and secrets.compare_digest( str(api_key_header), - settings.api_key.get_secret_value(), + SETTINGS.api_key.get_secret_value(), ): return api_key_header @@ -22,20 +44,93 @@ async def g_api_key( #################### # - Types #################### -class PInstSupCategory(Enum) +class PyInstSupTag(enum.StrEnum): + INST_WIN_OFFICIAL = enum.auto() + INST_WIN_WINSTORE = enum.auto() + INST_MAC_OFFICIAL = enum.auto() + INST_MAC_HOMEBREW = enum.auto() + INST_LIN_OFFICIAL = enum.auto() + INST_LIN_PKG = enum.auto() + COURSE_MATH1A = enum.auto() + COURSE_INTROPROG = enum.auto() + ENV_PIP = enum.auto() + ENV_CONDA = enum.auto() + IDE_VSCODE = enum.auto() + IDE_PYCHARM = enum.auto() + IDE_SPYDER = enum.auto() + IDE_NOTEPADPP = enum.auto() + +class ClockOption(enum.IntEnum): + CLOCK_IN = enum.auto() + CLOCK_OUT = enum.auto() + +class AttendanceEntry(BaseModel): + at: datetime + status: ClockOption + +class SupportEntry(BaseModel): + study_id: constr(pattern = r'^s[0-9]{6}$') + tags: list[PyInstSupTag] + description: str #################### # - FastAPI App #################### app = FastAPI( - prefix="/v1", + #prefix="/v1", dependencies=[Security(g_api_key)], ) -@app.post("/report/support") -async def report_support( - category: - description: str, -) -> bool: +#################### +# - Support +#################### +@app.get("/report/support") +async def g_support_entry() -> list[SupportEntry]: """Report an instance of granted Python Installation support.""" - return await d_old_app_passes() + + with open(SETTINGS.path_support_reports, 'r') as f: + return [ + SupportEntry(support_entry_snippet) + for support_entry_snippet in f.readlines() + ] + +@app.post("/report/support") +async def mk_support_entry( + support_entry: SupportEntry, +) -> bool: + """Print granted Python Support.""" + + with open(SETTINGS.path_support_reports, 'a') as f: + print( + json.dumps(support_entry.model_dump()), + file = f, + ) + return True + +#################### +# - Hours +#################### +@app.get("/report/hours") +async def g_hours_entry() -> list[SupportEntry]: + """Print support attendance record.""" + + with open(SETTINGS.path_support_reports, 'r') as f: + return [ + SupportEntry(support_entry_snippet) + for support_entry_snippet in f.readlines() + ] + +@app.post("/report/hours") +async def mk_hours_entry( + attendance_entry: AttendanceEntry, +) -> bool: + """Clock in / out to Python support.""" + + with open(SETTINGS.path_support_reports, 'a') as f: + print( + json.dumps(attendance_entry.model_dump()), + file = f, + ) + + return True + diff --git a/app/settings.toml b/app/settings.toml new file mode 100644 index 0000000..b478355 --- /dev/null +++ b/app/settings.toml @@ -0,0 +1,2 @@ +api_key = "TYJ80udNGIoOzIfKN3C66M732VI0Oh3jANqpcuxDRFQ=" +path_support_reports = "/data/support_reports.json" diff --git a/pyproject.toml b/pyproject.toml index 096c90a..abf6bfa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,24 +1,26 @@ -[tool.poetry] -name = "docker-mailserver-gate" -version = "0.0.1" -description = "Low-level API abstraction of setup.sh in docker-mailserver, accessible by API key." -authors = ["Sofus Albert Høgsbro Rose "] -license = "AGPL3+" +[project] +name = "support-ticker" +version = "0.1.0" +description = "Little tracker for support given as Python Installation Support." +#authors = ["Sofus Albert Høgsbro Rose "] +#license = "AGPL3" -[tool.poetry.dependencies] -python = "3.11.*" -fastapi = "^0.94.0" -uvicorn = "^0.21.0" -pydantic = {extras = ["email"], version = "^1.10.6"} -docker = "^6.0.1" +requires-python = "==3.11.*" +dependencies = [ + "fastapi ~= 0.94", + "uvicorn ~= 0.21", + "pydantic[email] ~= 2.1", +] -[tool.poetry.group.dev.dependencies] -devtools = "^0.10.0" -schemathesis = "^3.19.0" -pre-commit = "^3.2.1" -ruff = "^0.0.259" -tan = "^22.12.1" -mypy = "^1.1.1" +[project.optional-dependencies] +dev = [ + "devtools ~= 0.10.0", + "schemathesis ~= 3.19.0", + "pre-commit ~= 3.2.1", + "ruff ~= 0.0.259", + "tan ~= 22.12.1", + "mypy ~= 1.1.1", +] @@ -63,7 +65,7 @@ select = [ "Q", # flake8-quotes ## Finally - Quoting Style! "PTH", # flake8-use-pathlib ## Enforce pathlib usage "A", # flake8-builtins ## Prevent Builtin Shadowing - "C4", # flake8-comprehensions ## Check Compehension Appropriateness + "C4", # flake9-comprehensions ## Check Compehension Appropriateness "DTZ", # flake8-datetimez ## Ban naive Datetime Creation "EM", # flake8-errmsg ## Check Exception String Formatting "ISC", # flake8-implicit-str-concat ## Enforce Good String Literal Concat @@ -129,5 +131,4 @@ warn_untyped_fields = true # - Poetry Integration #################### [build-system] -requires = ["poetry>=1.0"] -build-backend = "poetry.masonry.api" +requires = ["setuptools_scm[toml]>=6.2"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e766b29 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,15 @@ +annotated-types==0.5.0 +anyio==3.7.1 +click==8.1.7 +dnspython==2.4.2 +email-validator==2.0.0.post2 +fastapi==0.103.0 +h11==0.14.0 +idna==3.4 +pydantic==2.3.0 +pydantic_core==2.6.3 +sniffio==1.3.0 +starlette==0.27.0 +support-ticker @ file:///app-root +typing_extensions==4.7.1 +uvicorn==0.23.2 diff --git a/run.py b/run.py new file mode 100755 index 0000000..8884320 --- /dev/null +++ b/run.py @@ -0,0 +1,280 @@ +#!/usr/bin/python3 +# Copyright (C) 2023 Sofus Albert Høgsbro Rose +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import os +import sys +if not all([ + sys.version_info.major == 3, + sys.version_info.minor in [9, 10, 11, 12, 13], +]): + sys.exit(1) + +from pathlib import Path +import platform +import shutil +import subprocess +import contextlib + +#################### +# - Constants +#################### +SCRIPT_PATH = Path(__file__).resolve().parent + +CMD_DEPENDENCIES = [ + 'podman', + 'git', + 'pre-commit' +] + +IMAGE_NAME = "support-ticker" +IMAGE_VERSION = ("0", "1", "0") + +REGISTRY_HOST = "git.sofus.io" +REGISTRY_USER = "so-rose" +REGISTRY_PASSWORD = subprocess.check_output( + ['pass', 'services/home/git.sofus.io/container-registry-token'] +).decode('ascii').strip() + + +#################### +# - Help Text +#################### +def action_help() -> None: + print("""This script provides one-click development, CI, and deployment. + + Usage: + echo -e "./run.py [OPTION] [EXTRAS]" + + The following commands must be available: + podman + => The project is developed and run in podman containers. + git + => This project uses git for versioning, and collaboration. + pre-commit + => Enforces that certain checks pass at each commit. + + Options: + ./run.sh + => Equivilant to run.sh help. + + ./run.sh -h|h|help + => Shows this text. + + Options, Run Locally: + ./run.sh dev + => Will run the app on port 3000, for local development. + + Options, Check: + ./run.sh check + => Will run all checks, including static analysis and tests. + + ./run.sh analyze-quality [OPTIONS] + => Will run the static code-quality analysis suite. + => OPTIONS will be passed directly to the tool (ruff). + + ./run.sh analyze-types [OPTIONS] + => Will run the static type checking suite. + => OPTIONS will be passed directly to the tool (pyright). + + ./run.sh analyze-security + => Will run the static security analysis suite. + + ./run.sh analyze-format [--overwrite] [OPTIONS] + => Will run the code formatter in read-only omde. + => '--overwrite' will cause the formater to reformat all files. + => OPTIONS will be passed directly to the tool (tan). + + ./run.sh test [OPTIONS] + => Will run the test suite locally. + => OPTIONS will be passed directly to the tool (pytest). + + Options, Build & Deploy: + ./run.sh build + => Will build, tag and upload a docker image appropriately. + + ./run.sh build-release + => Will build, tag and upload a docker image appropriate for release. + + Options, Housekeeping: + ./run.sh clean + => Will delete all data caused by this project's presence on your system. + """) + + +#################### +# - Utilities +#################### +@contextlib.contextmanager +def cd_script_dir() -> None: + cwd_orig = Path.cwd() + + os.chdir(SCRIPT_PATH) + try: + yield + finally: + os.chdir(cwd_orig) + +def get_git_revision_hash(short = True) -> str: + commit_id = subprocess.check_output( + ['git', 'rev-parse', 'HEAD'] + ).decode('ascii').strip() + + return commit_id[:16] if short else commit_id + +def cmd_exists(cmd: str) -> bool: + """Checks if a command exists. Supports Linux, Mac, Windows. + """ + return shutil.which(cmd) is not None + + +#################### +# - Actions +#################### +def action_build() -> None: + subprocess.run([ + "podman", "build", + ".", + + # : - Tag Commit ID + "--tag", + f"{IMAGE_NAME}:{get_git_revision_hash()}", + + ]) + + +#################### +# - Actions - Runners +#################### +def action_run(image_override = None, cli_cmd = None) -> None: + if cli_cmd is None: + cli_cmd = sys.argv[2:] + + if image_override is not None: + image_name = image_override + else: + image_name = IMAGE_NAME + ":dev" + + subprocess.run([ + "podman", "run", "--rm", "-it", + "--volume", ".:/app-root", + "--volume", "./test-data:/data", + "--workdir", "/app-root", + "--publish", "3000:3000", + f"{image_name}", + *cli_cmd, + ]) + +def action_dev() -> None: + action_run( + image_override="docker.io/python:3.11-slim-bookworm", + cli_cmd = [ + "bash", "-xc", """ +if [ ! -d .venv ]; then + python -m venv .venv + . .venv/bin/activate + pip install . + pip freeze > requirements.txt + deactivate +fi +. .venv/bin/activate +uvicorn --host=0.0.0.0 --port=3000 app.main:app --reload""" + ] + ) + +#################### +# - Actions - Helpers +#################### +def action_update_deps() -> None: + action_run( + image_override="docker.io/python:3.11-slim-bookworm", + cli_cmd = [ + "bash", "-c", """ +if [ ! -d .venv ]; then + python -m venv .venv +fi +. .venv/bin/activate +pip install . +pip freeze > requirements.txt""" + ] + ) + +#################### +# - Actions - Publish +#################### +def action_publish() -> None: + action_build() + + subprocess.run([ + "podman", "login", REGISTRY_HOST, + "--username", REGISTRY_USER, + "--password", REGISTRY_PASSWORD, + ]) + + # Publish Image @ : + subprocess.run([ + "podman", "image", "push", + f"{IMAGE_NAME}:{get_git_revision_hash()}", + f"{REGISTRY_HOST}/{REGISTRY_USER}/{IMAGE_NAME}:{get_git_revision_hash()}", + ]) + + # Publish Image @ :M, :M.m, :M.m.p + for image_tag in [ + f"{IMAGE_NAME}:{'.'.join(IMAGE_VERSION)}", + f"{IMAGE_NAME}:{'.'.join(IMAGE_VERSION[:2])}", + f"{IMAGE_NAME}:{IMAGE_VERSION[0]}", + ]: + subprocess.run([ + "podman", "tag", + f"{IMAGE_NAME}:{get_git_revision_hash()}", + image_tag + ]) + + # Publish Image + subprocess.run([ + "podman", "image", "push", + image_tag, + f"{REGISTRY_HOST}/{REGISTRY_USER}/{image_tag}", + ]) + + +#################### +# - Script +#################### +if __name__ == "__main__": + # Check Available Commands + for cmd in CMD_DEPENDENCIES: + if not cmd_exists(cmd) : + print("One or more dependencies are not installed. Please see --help.") + sys.exit(1) + + with cd_script_dir(): + if len(sys.argv) > 1: + { + "dev": action_dev, + + "update-deps": action_update_deps, + + "dev": action_dev, + + "build": action_build, + "publish": action_publish, + + "help": action_help, + "-h": action_help, + "--help": action_help, + }[sys.argv[1]]() + else: + action_help()