Task 001: dockerized monorepo foundation
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY apps/backend/requirements.txt ./requirements.txt
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY apps/backend/src ./src
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "src.presentation.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,5 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
psycopg[binary]==3.2.3
|
||||
redis==5.2.1
|
||||
boto3==1.35.90
|
||||
@@ -0,0 +1 @@
|
||||
"""Backend service package."""
|
||||
@@ -0,0 +1,3 @@
|
||||
# Application
|
||||
|
||||
Application use cases and workflow orchestration belong in this layer.
|
||||
@@ -0,0 +1 @@
|
||||
"""Application layer for use cases and orchestration."""
|
||||
@@ -0,0 +1,3 @@
|
||||
# Domain
|
||||
|
||||
Business entities, value objects, and domain rules belong in this layer.
|
||||
@@ -0,0 +1 @@
|
||||
"""Domain layer for business concepts and invariants."""
|
||||
@@ -0,0 +1,4 @@
|
||||
# Infrastructure
|
||||
|
||||
Database, cache, object storage, queue, and other external adapters belong in
|
||||
this layer.
|
||||
@@ -0,0 +1 @@
|
||||
"""Infrastructure adapters for external services."""
|
||||
@@ -0,0 +1,74 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
|
||||
import boto3
|
||||
import psycopg
|
||||
from botocore.config import Config
|
||||
from redis import Redis
|
||||
|
||||
|
||||
DependencyStatus = dict[str, str]
|
||||
|
||||
|
||||
def check_dependencies() -> DependencyStatus:
|
||||
checks: dict[str, Callable[[], None]] = {
|
||||
"postgres": _check_postgres,
|
||||
"redis": _check_redis,
|
||||
"object_storage": _check_object_storage,
|
||||
}
|
||||
|
||||
results: DependencyStatus = {}
|
||||
for name, check in checks.items():
|
||||
try:
|
||||
check()
|
||||
except Exception:
|
||||
results[name] = "error"
|
||||
else:
|
||||
results[name] = "ok"
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _check_postgres() -> None:
|
||||
dsn = _required_env("POSTGRES_DSN")
|
||||
with psycopg.connect(dsn, connect_timeout=3) as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("SELECT 1")
|
||||
cursor.fetchone()
|
||||
|
||||
|
||||
def _check_redis() -> None:
|
||||
redis_url = _required_env("REDIS_URL")
|
||||
client = Redis.from_url(redis_url, socket_connect_timeout=3, socket_timeout=3)
|
||||
try:
|
||||
if client.ping() is not True:
|
||||
raise RuntimeError("Redis ping failed")
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def _check_object_storage() -> None:
|
||||
endpoint = _required_env("OBJECT_STORAGE_ENDPOINT")
|
||||
access_key_id = _required_env("OBJECT_STORAGE_ACCESS_KEY_ID")
|
||||
secret_access_key = _required_env("OBJECT_STORAGE_SECRET_ACCESS_KEY")
|
||||
bucket = _required_env("OBJECT_STORAGE_BUCKET")
|
||||
region = os.environ.get("OBJECT_STORAGE_REGION", "us-east-1")
|
||||
|
||||
client = boto3.client(
|
||||
"s3",
|
||||
endpoint_url=endpoint,
|
||||
aws_access_key_id=access_key_id,
|
||||
aws_secret_access_key=secret_access_key,
|
||||
region_name=region,
|
||||
config=Config(signature_version="s3v4", connect_timeout=3, read_timeout=3),
|
||||
)
|
||||
client.head_bucket(Bucket=bucket)
|
||||
|
||||
|
||||
def _required_env(name: str) -> str:
|
||||
value = os.environ.get(name)
|
||||
if not value:
|
||||
raise RuntimeError(f"Missing required environment variable: {name}")
|
||||
return value
|
||||
@@ -0,0 +1,3 @@
|
||||
# Presentation
|
||||
|
||||
HTTP routes, request/response DTOs, and API wiring belong in this layer.
|
||||
@@ -0,0 +1 @@
|
||||
"""Presentation layer for HTTP APIs."""
|
||||
@@ -0,0 +1,27 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from src.infrastructure.dependencies import check_dependencies
|
||||
|
||||
|
||||
app = FastAPI(title="AI Content Pipeline Backend")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict[str, str]:
|
||||
return {"service": "backend", "status": "ok"}
|
||||
|
||||
|
||||
@app.get("/health/dependencies")
|
||||
def dependency_health() -> JSONResponse:
|
||||
dependencies = check_dependencies()
|
||||
is_ok = all(status == "ok" for status in dependencies.values())
|
||||
|
||||
return JSONResponse(
|
||||
status_code=200 if is_ok else 503,
|
||||
content={
|
||||
"service": "backend",
|
||||
"status": "ok" if is_ok else "error",
|
||||
"dependencies": dependencies,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,4 @@
|
||||
# Shared
|
||||
|
||||
Cross-layer backend primitives belong here. Keep this layer small and avoid
|
||||
placing domain rules in shared code.
|
||||
@@ -0,0 +1 @@
|
||||
"""Shared backend primitives."""
|
||||
@@ -0,0 +1,17 @@
|
||||
# Frontend Architecture
|
||||
|
||||
This app uses Next.js App Router in `src/app/` for routing. Feature-Sliced
|
||||
Design layers live next to it under `src/`:
|
||||
|
||||
```text
|
||||
src/app/
|
||||
src/processes/
|
||||
src/pages/
|
||||
src/widgets/
|
||||
src/features/
|
||||
src/entities/
|
||||
src/shared/
|
||||
```
|
||||
|
||||
`src/pages` is an FSD page-composition layer. It is not the legacy Next.js Pages
|
||||
Router; the App Router is `src/app`.
|
||||
@@ -0,0 +1,12 @@
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY apps/frontend/package.json ./package.json
|
||||
RUN npm install
|
||||
|
||||
COPY apps/frontend ./
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["npm", "run", "dev", "--", "--hostname", "0.0.0.0"]
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// This file is generated by Next.js and kept here so TypeScript works before
|
||||
// the first local `next dev` run.
|
||||
@@ -0,0 +1,6 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@pipeline/frontend",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "16.2.6",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "22.10.5",
|
||||
"@types/react": "19.0.2",
|
||||
"@types/react-dom": "19.0.2",
|
||||
"typescript": "5.7.2"
|
||||
},
|
||||
"overrides": {
|
||||
"postcss": "8.5.14"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
font-family:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||
sans-serif;
|
||||
background: #f6f7f9;
|
||||
color: #1d2733;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
main {
|
||||
min-height: 100vh;
|
||||
padding: 48px;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
return Response.json({ service: "frontend", status: "ok" });
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import "./globals.css";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export const metadata = {
|
||||
title: "AI Content Pipeline",
|
||||
description: "Internal editorial workflow foundation",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<main>
|
||||
<h1>AI Content Pipeline</h1>
|
||||
<p>Dockerized monorepo foundation is running.</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# Entities
|
||||
|
||||
FSD layer for domain entity UI and client-side models.
|
||||
@@ -0,0 +1,3 @@
|
||||
# Features
|
||||
|
||||
FSD layer for user-facing actions and feature logic.
|
||||
@@ -0,0 +1,4 @@
|
||||
# Pages
|
||||
|
||||
FSD page-composition layer. This is separate from the Next.js App Router
|
||||
`app/` route layer and does not enable the legacy Pages Router.
|
||||
@@ -0,0 +1,4 @@
|
||||
# Processes
|
||||
|
||||
FSD layer for cross-page user workflows. Placeholder for upcoming article
|
||||
workflow processes.
|
||||
@@ -0,0 +1,3 @@
|
||||
# Shared
|
||||
|
||||
FSD layer for reusable frontend primitives, API clients, and UI foundations.
|
||||
@@ -0,0 +1,3 @@
|
||||
# Widgets
|
||||
|
||||
FSD layer for composed UI blocks used by pages.
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY apps/runner/src ./src
|
||||
|
||||
EXPOSE 8010
|
||||
|
||||
CMD ["python", "-m", "src.presentation.main"]
|
||||
@@ -0,0 +1 @@
|
||||
"""Runner service package."""
|
||||
@@ -0,0 +1,3 @@
|
||||
# Application
|
||||
|
||||
Runner use cases, job orchestration, and command policies belong in this layer.
|
||||
@@ -0,0 +1 @@
|
||||
"""Application layer for runner use cases."""
|
||||
@@ -0,0 +1,3 @@
|
||||
# Domain
|
||||
|
||||
Runner job entities and invariants belong in this layer.
|
||||
@@ -0,0 +1 @@
|
||||
"""Domain layer for runner job concepts."""
|
||||
@@ -0,0 +1,4 @@
|
||||
# Infrastructure
|
||||
|
||||
CLI execution, workspace, filesystem, and artifact storage adapters belong in
|
||||
this layer.
|
||||
@@ -0,0 +1 @@
|
||||
"""Infrastructure adapters for runner execution."""
|
||||
@@ -0,0 +1,3 @@
|
||||
# Presentation
|
||||
|
||||
Runner HTTP routes and API wiring belong in this layer.
|
||||
@@ -0,0 +1 @@
|
||||
"""Presentation layer for runner HTTP APIs."""
|
||||
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
|
||||
class HealthHandler(BaseHTTPRequestHandler):
|
||||
def do_GET(self) -> None:
|
||||
if self.path != "/health":
|
||||
self.send_error(HTTPStatus.NOT_FOUND)
|
||||
return
|
||||
|
||||
body = json.dumps({"service": "runner", "status": "ok"}).encode("utf-8")
|
||||
self.send_response(HTTPStatus.OK)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, format: str, *args: object) -> None:
|
||||
return
|
||||
|
||||
|
||||
def run() -> None:
|
||||
port = int(os.environ.get("PORT", "8010"))
|
||||
server = ThreadingHTTPServer(("0.0.0.0", port), HealthHandler)
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@@ -0,0 +1,3 @@
|
||||
# Shared
|
||||
|
||||
Cross-layer runner primitives belong here. Keep this layer small.
|
||||
@@ -0,0 +1 @@
|
||||
"""Shared runner primitives."""
|
||||
Reference in New Issue
Block a user