feat: First attempt deployment.

main
Sofus Albert Høgsbro Rose 2023-08-30 12:24:25 +02:00
parent 987129920e
commit 1fd616ee53
Signed by: so-rose
GPG Key ID: AD901CB0F3701434
7 changed files with 480 additions and 31 deletions

3
.gitignore vendored
View File

@ -160,3 +160,6 @@ cython_debug/
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
test-data
build

53
Dockerfile 100644
View File

@ -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

View File

@ -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

View File

@ -0,0 +1,2 @@
api_key = "TYJ80udNGIoOzIfKN3C66M732VI0Oh3jANqpcuxDRFQ="
path_support_reports = "/data/support_reports.json"

View File

@ -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 <sofus@dtumasters.org>"]
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 <sofus@sofusrose.com>"]
#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"]

15
requirements.txt 100644
View File

@ -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

280
run.py 100755
View File

@ -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 <https://www.gnu.org/licenses/>.
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",
".",
# :<commit_id> - 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 @ :<commit_id>
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()