feat: First attempt deployment.
parent
987129920e
commit
1fd616ee53
|
@ -160,3 +160,6 @@ cython_debug/
|
|||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
|
||||
test-data
|
||||
build
|
||||
|
|
|
@ -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
|
113
app/main.py
113
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
|
||||
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
api_key = "TYJ80udNGIoOzIfKN3C66M732VI0Oh3jANqpcuxDRFQ="
|
||||
path_support_reports = "/data/support_reports.json"
|
|
@ -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"]
|
||||
|
|
|
@ -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
|
|
@ -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()
|
Loading…
Reference in New Issue