main commit
This commit is contained in:
parent
436ccb2e53
commit
0506748dee
1
sk1
1
sk1
@ -1 +0,0 @@
|
|||||||
Subproject commit 4a6b3a95ecefc48c2f0be168e3a76be45bb02c78
|
|
||||||
55
sk1/.github/workflows/deploy-summarizer.yml
vendored
Normal file
55
sk1/.github/workflows/deploy-summarizer.yml
vendored
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
name: Deploy Summarizer
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'summarizer/**'
|
||||||
|
- '.github/workflows/deploy-summarizer.yml'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
IMAGE: ghcr.io/${{ github.repository_owner }}/readitlater-summarizer
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- uses: docker/login-action@v4
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- uses: docker/build-push-action@v7
|
||||||
|
with:
|
||||||
|
context: summarizer
|
||||||
|
push: true
|
||||||
|
tags: ${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.sha }}
|
||||||
|
|
||||||
|
- name: Deploy to instance
|
||||||
|
uses: appleboy/ssh-action@v1
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.SUMMARIZER_HOST }}
|
||||||
|
username: ec2-user
|
||||||
|
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
|
proxy_host: ${{ secrets.WEB_HOST }}
|
||||||
|
proxy_username: ec2-user
|
||||||
|
proxy_key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
|
script: |
|
||||||
|
echo '${{ secrets.GITHUB_TOKEN }}' | sudo docker login ghcr.io -u ${{ github.actor }} --password-stdin
|
||||||
|
sudo docker pull ${{ env.IMAGE }}:latest
|
||||||
|
sudo docker stop summarizer || true
|
||||||
|
sudo docker rm summarizer || true
|
||||||
|
sudo docker run -d \
|
||||||
|
--name summarizer \
|
||||||
|
--restart always \
|
||||||
|
-p 8000:8000 \
|
||||||
|
${{ env.IMAGE }}:latest
|
||||||
81
sk1/.github/workflows/deploy-web.yml
vendored
Normal file
81
sk1/.github/workflows/deploy-web.yml
vendored
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
name: Deploy Web
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'web/**'
|
||||||
|
- '.github/workflows/deploy-web.yml'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
NGINX_IMAGE: ghcr.io/${{ github.repository_owner }}/readitlater-nginx
|
||||||
|
API_IMAGE: ghcr.io/${{ github.repository_owner }}/readitlater-api
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- uses: docker/login-action@v4
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- uses: docker/build-push-action@v7
|
||||||
|
with:
|
||||||
|
context: web/nginx
|
||||||
|
push: true
|
||||||
|
tags: ${{ env.NGINX_IMAGE }}:latest,${{ env.NGINX_IMAGE }}:${{ github.sha }}
|
||||||
|
|
||||||
|
- uses: docker/build-push-action@v7
|
||||||
|
with:
|
||||||
|
context: web/api
|
||||||
|
push: true
|
||||||
|
tags: ${{ env.API_IMAGE }}:latest,${{ env.API_IMAGE }}:${{ github.sha }}
|
||||||
|
|
||||||
|
- name: Deploy to instance
|
||||||
|
uses: appleboy/ssh-action@v1
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.WEB_HOST }}
|
||||||
|
username: ec2-user
|
||||||
|
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
|
script: |
|
||||||
|
sudo chown -R ec2-user:ec2-user /opt/app
|
||||||
|
echo "GITHUB_OWNER=${{ github.repository_owner }}" > /opt/app/.env
|
||||||
|
echo "DB_HOST=${{ secrets.DB_HOST }}" >> /opt/app/.env
|
||||||
|
echo "DB_PORT=5432" >> /opt/app/.env
|
||||||
|
echo "DB_NAME=${{ secrets.DB_NAME }}" >> /opt/app/.env
|
||||||
|
echo "DB_USER=${{ secrets.DB_USER }}" >> /opt/app/.env
|
||||||
|
echo "DB_PASSWORD=${{ secrets.DB_PASSWORD }}" >> /opt/app/.env
|
||||||
|
echo "SUMMARIZER_URL=http://${{ secrets.SUMMARIZER_HOST }}:8000" >> /opt/app/.env
|
||||||
|
echo '${{ secrets.GITHUB_TOKEN }}' | sudo docker login ghcr.io -u ${{ github.actor }} --password-stdin
|
||||||
|
sudo docker pull ${{ env.NGINX_IMAGE }}:latest
|
||||||
|
sudo docker pull ${{ env.API_IMAGE }}:latest
|
||||||
|
cd /opt/app
|
||||||
|
cat > docker-compose.yml << 'COMPOSE'
|
||||||
|
services:
|
||||||
|
nginx:
|
||||||
|
image: ghcr.io/${{ github.repository_owner }}/readitlater-nginx:latest
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
environment:
|
||||||
|
- CERTBOT_EMAIL=pavelyman76@gmail.com
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
restart: always
|
||||||
|
api:
|
||||||
|
image: ghcr.io/${{ github.repository_owner }}/readitlater-api:latest
|
||||||
|
env_file: .env
|
||||||
|
restart: always
|
||||||
|
COMPOSE
|
||||||
|
sudo /usr/local/bin/docker-compose down || true
|
||||||
|
sudo /usr/local/bin/docker-compose up -d
|
||||||
6
sk1/.gitignore
vendored
Normal file
6
sk1/.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
terraform/terraform*
|
||||||
|
terraform/.terraform*
|
||||||
|
terraform/security_groups.tf
|
||||||
|
*.tfstate
|
||||||
|
*.tfstate.*
|
||||||
|
.terraform/
|
||||||
72
sk1/README.md
Normal file
72
sk1/README.md
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
# Read-it-later AI Summarizer
|
||||||
|
|
||||||
|
## 1. Opis aplikácie
|
||||||
|
**Read-it-later** je moderná mikroslužbová webová aplikácia, ktorá slúži ako inteligentný archív článkov. Používateľ vloží URL adresu dlhého článku, aplikácia ho automaticky stiahne a pomocou modelu umelej inteligencie (AI) vygeneruje jeho stručné zhrnutie. Výsledok si používateľ môže neskôr kedykoľvek prečítať v responzívnom webovom rozhraní.
|
||||||
|
|
||||||
|
## 2. Architektúra a využité Cloud technológie
|
||||||
|
Aplikácia je nasadená v prostredí verejného cloudu **AWS (Amazon Web Services)** a využíva plne automatizovaný prístup Infrastructure as Code (IaC) cez Terraform.
|
||||||
|
* **Virtual Private Cloud (VPC):** Izolovaná sieť s nakonfigurovanými podsúbormi a bezpečnostnými skupinami pre riadenie sieťovej prevádzky.
|
||||||
|
* **EC2 (Elastic Compute Cloud) & Docker:** Dva dedikované virtuálne servery bežiace na OS Amazon Linux 2023. Jeden zabezpečuje web (Nginx a FastAPI) a druhý slúži pre výpočetne náročnejší AI model. Samotné nasadenie beží vo vnútri **Docker** kontajnerov orchestráciou cez Docker Compose.
|
||||||
|
* **Amazon RDS pre PostgreSQL:** Plne spravovaná relačná databáza, do ktorej sa ukladajú používateľské záznamy, originálne texty a AI zhrnutia. Trvalé zväzky (Persistent Storage - EBS a RDS Storage) sú šifrované a zabezpečujú trvalé uchovanie dát aj po reštarte služieb.
|
||||||
|
* **AWS Route 53:** Služba DNS, ktorá automaticky spravuje doménové záznamy a prekladá doménu na verejnú IP adresu (Elastic IP) hlavného servera.
|
||||||
|
* **SSL a HTTPS:** Smerovanie z verejného internetu je zabezpečené certifikátmi od Let's Encrypt, pričom Nginx slúži ako reverzný proxy server (`jonasal/nginx-certbot`).
|
||||||
|
|
||||||
|
## 3. Analýza nákladov (1 rok)
|
||||||
|
Odhadovaný prevádzkový profil: **1000 používateľov za deň** a maximálna veľkosť databázy **50 GB**.
|
||||||
|
Keďže prevádzkujeme model umelej inteligencie, je vyžadovaný aspoň jeden výkonnejší stroj (`t3.medium`). Kalkulácia nezahŕňa doménový poplatok u registrátora tretej strany.
|
||||||
|
|
||||||
|
| Služba AWS | Typ / Špecifikácia | Fakturačný interval | Cena za mesiac | Cena za 1 rok |
|
||||||
|
| :--- | :--- | :--- | :--- | :--- |
|
||||||
|
| **EC2 (Web Server)** | `t3.micro` (2 vCPU, 1 GiB RAM) + 30GB EBS gp3 | Hodinový | ~ $7.60 | ~ $91.20 |
|
||||||
|
| **EC2 (AI Server)** | `t3.medium` (2 vCPU, 4 GiB RAM) + 30GB EBS gp3 | Hodinový | ~ $30.40 | ~ $364.80 |
|
||||||
|
| **Amazon RDS** | `db.t4g.micro` (PostgreSQL) | Hodinový | ~ $11.90 | ~ $142.80 |
|
||||||
|
| **RDS Storage** | 50 GB `gp2` zväzok pre databázu | Mesačný GB | ~ $5.75 | ~ $69.00 |
|
||||||
|
| **Route 53** | 1 Hosted Zone + DNS dopyty | Mesačný | ~ $0.50 | ~ $6.00 |
|
||||||
|
| **Sieť (Data Transfer)** | Odhadovaný odchádzajúci prenos dát | Za GB | ~ $2.00 | ~ $24.00 |
|
||||||
|
| **Spolu (Odhad)** | | | **~ $58.15** | **~ $697.80** |
|
||||||
|
|
||||||
|
## 4. Opis odovzdaných súborov
|
||||||
|
* `prepare-app.sh`: Skript pre plne automatické vytvorenie klaudového prostredia.
|
||||||
|
* `remove-app.sh`: Skript pre deštrukciu všetkých cloudových služieb.
|
||||||
|
* `terraform/`: Priečinok obsahujúci kompletnú deklaratívnu definíciu AWS infraštruktúry (IaC). Obsahuje `.tf` súbory pre siete, servery, databázu a bezpečnosť.
|
||||||
|
* `web/`: Zdrojové kódy webovej časti (HTML/CSS), backendu (FastAPI v Pythone), konfiguračný súbor pre Nginx a Docker prostredie.
|
||||||
|
* `summarizer/`: Kód PyTorch AI mikroslužby vrátane `Dockerfile` a `requirements.txt` pre stiahnutie modelu BART.
|
||||||
|
* `.github/workflows/`: Skripty pre automatické CI/CD (kontinuálna integrácia a doručovanie), ktoré aplikáciu zostavia a nasadia po každej zmene v zdrojovom kóde.
|
||||||
|
|
||||||
|
## 5. Konfigurácia (Terraform & CI/CD)
|
||||||
|
Infraštruktúra je definovaná formou kódu cez Terraform. Terraform konfigurácia (`ec2.tf`, `vpc.tf`, `rds.tf`) automaticky prepojuje sieťové rozhrania, nastavuje pravidlá Firewallu (Security Groups) a vytvorí kľúče pre SSH prístup.
|
||||||
|
Softvérové nasadenie zabezpečujú bez-agentové GitHub Actions. Aplikácie sa po commite zabalia do Docker imidžov, uložia sa do GitHub Container Registry (GHCR) a následne sa prostredníctvom SSH tunelu automaticky stiahnu a spustia cez `docker-compose` na cieľových virtuálnych strojoch v AWS.
|
||||||
|
|
||||||
|
## 6. Návod na použitie
|
||||||
|
1. Otvorte ľubovoľný webový prehliadač a prejdite na adresu URL poskytnutú po inštalácii (napríklad `https://devopspavel.me`).
|
||||||
|
2. Do vstupného poľa vložte odkaz na dlhý internetový článok, ktorý si prajete zosumarizovať.
|
||||||
|
3. Kliknite na tlačidlo **Save**.
|
||||||
|
4. Systém odošle požiadavku do umelej inteligencie a o niekoľko sekúnd zobrazí originálny link spoločne so skráteným AI textom.
|
||||||
|
|
||||||
|
## 7. Záloha dát (Backup)
|
||||||
|
Záloha používateľských dát je spravovaná automaticky priamo na úrovni Amazon RDS (Automated Backups). Databáza má predvolene nastavené 7-dňové retenčné obdobie, počas ktorého AWS vykonáva denné kópie (snapshots).
|
||||||
|
V prípade potreby manuálnej zálohy (Full Backup) do súboru je nutné:
|
||||||
|
1. Pripojiť sa cez SSH na Web Server inštanciu (`t3.micro`), ktorá má prístup do privátnej siete RDS.
|
||||||
|
2. Vykonať príkaz: `pg_dump -h <adresa_rds> -U appuser -d aisummarizer -F c -f zaloha.dump`.
|
||||||
|
|
||||||
|
## 8. Záznamy o prístupoch z internetu
|
||||||
|
Logy o pripojeniach používateľov je možné sledovať priamo z Nginx kontajnera. Pre zobrazenie najnovších prístupov z internetu:
|
||||||
|
1. Pripojte sa na Web Server prostredníctvom priloženého SSH kľúča.
|
||||||
|
2. Pre zobrazenie "live" prístupov spustite: `sudo docker logs -f nginx`
|
||||||
|
Tieto logy zaznamenávajú zdrojovú IP adresu, prehliadač a typ dopytu (GET/POST).
|
||||||
|
|
||||||
|
## 9. Podmienky spustenia skriptov
|
||||||
|
Pre úspešné spustenie skriptov `prepare-app.sh` a `remove-app.sh` je nutné:
|
||||||
|
1. Mať v operačnom systéme nainštalovaný nástroj **Terraform** (verzia >= 1.0).
|
||||||
|
2. Mať nainštalovaný balík **AWS CLI**.
|
||||||
|
3. Mať v systéme nakonfigurované platné prístupové kľúče `AWS_ACCESS_KEY_ID` a `AWS_SECRET_ACCESS_KEY` do prostredia AWS.
|
||||||
|
4. Používať Unix/Linux/macOS terminál pre bezproblémové spustenie `.sh` súborov.
|
||||||
|
|
||||||
|
## 10. Zoznam externých zdrojov a využitie Generatívnej AI
|
||||||
|
Pri návrhu architektúry, vývoji aplikácie a tvorbe infraštruktúry bol využitý generatívny model umelej inteligencie **Antigravity (Google DeepMind)**.
|
||||||
|
Model bol použitý ako inteligentný agent (Pair-programming) predovšetkým na:
|
||||||
|
- Tvorbu a optimalizáciu **Python skriptov** (FastAPI backend a PyTorch AI Summarizer).
|
||||||
|
- Pripravenie deklaratívnych `.tf` konfigurácií a GitHub Actions Pipelines.
|
||||||
|
- Štruktúrovanie a formátovanie tejto dokumentácie v slovenskom jazyku.
|
||||||
|
|
||||||
|
Aplikácia interne využíva Open-Source zmenšený model `facebook/bart-large-cnn` zverejnený na platforme HuggingFace pre úlohy sumarizácie textu.
|
||||||
14
sk1/prepare-app.sh
Executable file
14
sk1/prepare-app.sh
Executable file
@ -0,0 +1,14 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
echo "Začínam prípravu infraštruktúry pre aplikáciu Read-it-later..."
|
||||||
|
|
||||||
|
cd terraform || exit 1
|
||||||
|
|
||||||
|
echo "Inicializujem Terraform prostredie..."
|
||||||
|
terraform init
|
||||||
|
|
||||||
|
echo "Vytváram a nasadzujem všetky služby (EC2, RDS, Sieť)..."
|
||||||
|
terraform apply -auto-approve
|
||||||
|
|
||||||
|
echo "Infraštruktúra je úspešne nasadená!"
|
||||||
|
echo "Nezabudnite skopírovať vygenerované tajné kľúče (secrets) do GitHub Actions pre automatický deploing."
|
||||||
13
sk1/remove-app.sh
Executable file
13
sk1/remove-app.sh
Executable file
@ -0,0 +1,13 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
echo "Varovanie: Tento skript natrvalo vymaže celú infraštruktúru aplikácie vrátane databázy!"
|
||||||
|
read -p "Ste si istý, že chcete pokračovať? (y/N): " confirm
|
||||||
|
|
||||||
|
if [[ $confirm == [yY] || $confirm == [yY][eE][sS] ]]; then
|
||||||
|
cd terraform || exit 1
|
||||||
|
echo "Ruším všetky cloudové služby..."
|
||||||
|
terraform destroy -auto-approve
|
||||||
|
echo "Aplikácia a cloudové zdroje boli úspešne odstránené."
|
||||||
|
else
|
||||||
|
echo "Operácia zrušená."
|
||||||
|
fi
|
||||||
7
sk1/summarizer/Dockerfile
Normal file
7
sk1/summarizer/Dockerfile
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
COPY main.py .
|
||||||
|
RUN python -c "from transformers import AutoTokenizer, AutoModelForSeq2SeqLM; AutoTokenizer.from_pretrained('facebook/bart-large-cnn'); AutoModelForSeq2SeqLM.from_pretrained('facebook/bart-large-cnn')"
|
||||||
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
37
sk1/summarizer/main.py
Normal file
37
sk1/summarizer/main.py
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from transformers import AutoTokenizer, AutoModelForSeq2SeqLM
|
||||||
|
import torch
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
tokenizer = AutoTokenizer.from_pretrained("facebook/bart-large-cnn")
|
||||||
|
model = AutoModelForSeq2SeqLM.from_pretrained("facebook/bart-large-cnn")
|
||||||
|
|
||||||
|
|
||||||
|
class TextRequest(BaseModel):
|
||||||
|
text: str
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/summarize")
|
||||||
|
def summarize(req: TextRequest):
|
||||||
|
inputs = tokenizer(
|
||||||
|
req.text,
|
||||||
|
max_length=1024,
|
||||||
|
truncation=True,
|
||||||
|
return_tensors="pt",
|
||||||
|
)
|
||||||
|
summary_ids = model.generate(
|
||||||
|
**inputs,
|
||||||
|
max_length=150,
|
||||||
|
min_length=40,
|
||||||
|
num_beams=4,
|
||||||
|
length_penalty=2.0,
|
||||||
|
)
|
||||||
|
summary = tokenizer.decode(summary_ids[0], skip_special_tokens=True)
|
||||||
|
return {"summary": summary}
|
||||||
4
sk1/summarizer/requirements.txt
Normal file
4
sk1/summarizer/requirements.txt
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
fastapi==0.115.0
|
||||||
|
uvicorn==0.30.0
|
||||||
|
transformers==4.44.0
|
||||||
|
torch==2.4.0
|
||||||
9
sk1/terraform/cloudwatch.tf
Normal file
9
sk1/terraform/cloudwatch.tf
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
resource "aws_cloudwatch_log_group" "nginx" {
|
||||||
|
name = "/ec2/${local.name_prefix}/nginx"
|
||||||
|
retention_in_days = var.log_retention_days
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_cloudwatch_log_group" "app" {
|
||||||
|
name = "/ec2/${local.name_prefix}/app"
|
||||||
|
retention_in_days = var.log_retention_days
|
||||||
|
}
|
||||||
20
sk1/terraform/data.tf
Normal file
20
sk1/terraform/data.tf
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
data "aws_availability_zones" "available" {
|
||||||
|
state = "available"
|
||||||
|
}
|
||||||
|
|
||||||
|
data "aws_caller_identity" "current" {}
|
||||||
|
|
||||||
|
data "aws_ami" "amazon_linux" {
|
||||||
|
most_recent = true
|
||||||
|
owners = ["amazon"]
|
||||||
|
|
||||||
|
filter {
|
||||||
|
name = "name"
|
||||||
|
values = ["al2023-ami-*-x86_64"]
|
||||||
|
}
|
||||||
|
|
||||||
|
filter {
|
||||||
|
name = "virtualization-type"
|
||||||
|
values = ["hvm"]
|
||||||
|
}
|
||||||
|
}
|
||||||
82
sk1/terraform/ec2.tf
Normal file
82
sk1/terraform/ec2.tf
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
resource "tls_private_key" "main" {
|
||||||
|
algorithm = "RSA"
|
||||||
|
rsa_bits = 4096
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_key_pair" "main" {
|
||||||
|
key_name = "${local.name_prefix}-key"
|
||||||
|
public_key = tls_private_key.main.public_key_openssh
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_eip" "nginx" {
|
||||||
|
domain = "vpc"
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = "${local.name_prefix}-nginx-eip"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_eip_association" "nginx" {
|
||||||
|
instance_id = aws_instance.nginx.id
|
||||||
|
allocation_id = aws_eip.nginx.id
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_instance" "nginx" {
|
||||||
|
ami = data.aws_ami.amazon_linux.id
|
||||||
|
instance_type = var.nginx_instance_type
|
||||||
|
subnet_id = aws_subnet.public[0].id
|
||||||
|
vpc_security_group_ids = [aws_security_group.nginx.id]
|
||||||
|
key_name = aws_key_pair.main.key_name
|
||||||
|
iam_instance_profile = aws_iam_instance_profile.ec2.name
|
||||||
|
|
||||||
|
root_block_device {
|
||||||
|
volume_size = 30
|
||||||
|
volume_type = "gp3"
|
||||||
|
encrypted = true
|
||||||
|
}
|
||||||
|
|
||||||
|
user_data = <<-EOF
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
dnf update -y
|
||||||
|
dnf install -y docker
|
||||||
|
systemctl enable docker
|
||||||
|
systemctl start docker
|
||||||
|
curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
|
||||||
|
chmod +x /usr/local/bin/docker-compose
|
||||||
|
mkdir -p /opt/app
|
||||||
|
EOF
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = "${local.name_prefix}-nginx"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_instance" "app" {
|
||||||
|
ami = data.aws_ami.amazon_linux.id
|
||||||
|
instance_type = var.app_instance_type
|
||||||
|
subnet_id = aws_subnet.public[0].id
|
||||||
|
vpc_security_group_ids = [aws_security_group.app.id]
|
||||||
|
key_name = aws_key_pair.main.key_name
|
||||||
|
iam_instance_profile = aws_iam_instance_profile.ec2.name
|
||||||
|
|
||||||
|
root_block_device {
|
||||||
|
volume_size = 30
|
||||||
|
volume_type = "gp3"
|
||||||
|
encrypted = true
|
||||||
|
}
|
||||||
|
|
||||||
|
user_data = <<-EOF
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
dnf update -y
|
||||||
|
dnf install -y docker aws-cli
|
||||||
|
systemctl enable docker
|
||||||
|
systemctl start docker
|
||||||
|
mkdir -p /opt/app
|
||||||
|
EOF
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = "${local.name_prefix}-app"
|
||||||
|
}
|
||||||
|
}
|
||||||
60
sk1/terraform/iam.tf
Normal file
60
sk1/terraform/iam.tf
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
resource "aws_iam_role" "ec2" {
|
||||||
|
name = "${local.name_prefix}-ec2-role"
|
||||||
|
|
||||||
|
assume_role_policy = jsonencode({
|
||||||
|
Version = "2012-10-17"
|
||||||
|
Statement = [
|
||||||
|
{
|
||||||
|
Action = "sts:AssumeRole"
|
||||||
|
Effect = "Allow"
|
||||||
|
Principal = {
|
||||||
|
Service = "ec2.amazonaws.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_role_policy_attachment" "ssm_core" {
|
||||||
|
role = aws_iam_role.ec2.name
|
||||||
|
policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_policy" "ec2_app" {
|
||||||
|
name = "${local.name_prefix}-ec2-app"
|
||||||
|
|
||||||
|
policy = jsonencode({
|
||||||
|
Version = "2012-10-17"
|
||||||
|
Statement = [
|
||||||
|
{
|
||||||
|
Effect = "Allow"
|
||||||
|
Action = [
|
||||||
|
"ssm:GetParameter",
|
||||||
|
"ssm:GetParameters"
|
||||||
|
]
|
||||||
|
Resource = [
|
||||||
|
aws_ssm_parameter.db_password.arn
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Effect = "Allow"
|
||||||
|
Action = [
|
||||||
|
"logs:CreateLogStream",
|
||||||
|
"logs:PutLogEvents",
|
||||||
|
"logs:DescribeLogStreams"
|
||||||
|
]
|
||||||
|
Resource = "arn:aws:logs:${var.aws_region}:${data.aws_caller_identity.current.account_id}:log-group:/ec2/${local.name_prefix}*"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_role_policy_attachment" "ec2_app" {
|
||||||
|
role = aws_iam_role.ec2.name
|
||||||
|
policy_arn = aws_iam_policy.ec2_app.arn
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_instance_profile" "ec2" {
|
||||||
|
name = "${local.name_prefix}-ec2-profile"
|
||||||
|
role = aws_iam_role.ec2.name
|
||||||
|
}
|
||||||
9
sk1/terraform/locals.tf
Normal file
9
sk1/terraform/locals.tf
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
locals {
|
||||||
|
common_tags = {
|
||||||
|
Project = var.project_name
|
||||||
|
Environment = var.environment
|
||||||
|
ManagedBy = "terraform"
|
||||||
|
}
|
||||||
|
|
||||||
|
name_prefix = "${var.project_name}-${var.environment}"
|
||||||
|
}
|
||||||
49
sk1/terraform/outputs.tf
Normal file
49
sk1/terraform/outputs.tf
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
output "vpc_id" {
|
||||||
|
value = aws_vpc.main.id
|
||||||
|
}
|
||||||
|
|
||||||
|
output "nginx_public_ip" {
|
||||||
|
value = aws_eip.nginx.public_ip
|
||||||
|
}
|
||||||
|
|
||||||
|
output "nginx_instance_id" {
|
||||||
|
value = aws_instance.nginx.id
|
||||||
|
}
|
||||||
|
|
||||||
|
output "app_instance_id" {
|
||||||
|
value = aws_instance.app.id
|
||||||
|
}
|
||||||
|
|
||||||
|
output "app_private_ip" {
|
||||||
|
value = aws_instance.app.private_ip
|
||||||
|
}
|
||||||
|
|
||||||
|
output "app_url" {
|
||||||
|
value = "https://${var.domain_name}"
|
||||||
|
}
|
||||||
|
|
||||||
|
output "rds_endpoint" {
|
||||||
|
value = aws_db_instance.main.endpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
output "rds_db_name" {
|
||||||
|
value = aws_db_instance.main.db_name
|
||||||
|
}
|
||||||
|
|
||||||
|
output "rds_db_user" {
|
||||||
|
value = aws_db_instance.main.username
|
||||||
|
}
|
||||||
|
|
||||||
|
output "rds_db_password" {
|
||||||
|
value = random_password.db.result
|
||||||
|
sensitive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
output "name_servers" {
|
||||||
|
value = aws_route53_zone.main.name_servers
|
||||||
|
}
|
||||||
|
|
||||||
|
output "ssh_private_key" {
|
||||||
|
value = tls_private_key.main.private_key_pem
|
||||||
|
sensitive = true
|
||||||
|
}
|
||||||
28
sk1/terraform/providers.tf
Normal file
28
sk1/terraform/providers.tf
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
terraform {
|
||||||
|
required_version = ">= 1.5.0"
|
||||||
|
|
||||||
|
required_providers {
|
||||||
|
aws = {
|
||||||
|
source = "hashicorp/aws"
|
||||||
|
version = "~> 5.0"
|
||||||
|
}
|
||||||
|
random = {
|
||||||
|
source = "hashicorp/random"
|
||||||
|
version = "~> 3.5"
|
||||||
|
}
|
||||||
|
tls = {
|
||||||
|
source = "hashicorp/tls"
|
||||||
|
version = "~> 4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
provider "aws" {
|
||||||
|
region = var.aws_region
|
||||||
|
access_key = var.terraform_aws_access_key
|
||||||
|
secret_key = var.terraform_aws_secret_key
|
||||||
|
|
||||||
|
default_tags {
|
||||||
|
tags = local.common_tags
|
||||||
|
}
|
||||||
|
}
|
||||||
54
sk1/terraform/rds.tf
Normal file
54
sk1/terraform/rds.tf
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
resource "random_password" "db" {
|
||||||
|
length = 24
|
||||||
|
special = false
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_ssm_parameter" "db_password" {
|
||||||
|
name = "/${var.project_name}/${var.environment}/db-password"
|
||||||
|
type = "SecureString"
|
||||||
|
value = random_password.db.result
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_db_subnet_group" "main" {
|
||||||
|
name = "${local.name_prefix}-db-subnet"
|
||||||
|
subnet_ids = aws_subnet.private[*].id
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = "${local.name_prefix}-db-subnet"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_db_instance" "main" {
|
||||||
|
identifier = "${local.name_prefix}-db"
|
||||||
|
|
||||||
|
engine = "postgres"
|
||||||
|
engine_version = var.db_engine_version
|
||||||
|
instance_class = var.db_instance_class
|
||||||
|
|
||||||
|
allocated_storage = var.db_allocated_storage
|
||||||
|
storage_type = "gp2"
|
||||||
|
storage_encrypted = true
|
||||||
|
|
||||||
|
db_name = var.db_name
|
||||||
|
username = var.db_username
|
||||||
|
password = random_password.db.result
|
||||||
|
|
||||||
|
db_subnet_group_name = aws_db_subnet_group.main.name
|
||||||
|
vpc_security_group_ids = [aws_security_group.rds.id]
|
||||||
|
|
||||||
|
multi_az = false
|
||||||
|
publicly_accessible = false
|
||||||
|
|
||||||
|
backup_retention_period = 7
|
||||||
|
backup_window = "03:00-04:00"
|
||||||
|
maintenance_window = "mon:04:00-mon:05:00"
|
||||||
|
|
||||||
|
skip_final_snapshot = true
|
||||||
|
final_snapshot_identifier = "${local.name_prefix}-db-final"
|
||||||
|
|
||||||
|
deletion_protection = false
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = "${local.name_prefix}-db"
|
||||||
|
}
|
||||||
|
}
|
||||||
11
sk1/terraform/route53.tf
Normal file
11
sk1/terraform/route53.tf
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
resource "aws_route53_zone" "main" {
|
||||||
|
name = var.domain_name
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_route53_record" "app" {
|
||||||
|
zone_id = aws_route53_zone.main.zone_id
|
||||||
|
name = var.domain_name
|
||||||
|
type = "A"
|
||||||
|
ttl = 300
|
||||||
|
records = [aws_eip.nginx.public_ip]
|
||||||
|
}
|
||||||
83
sk1/terraform/variables.tf
Normal file
83
sk1/terraform/variables.tf
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
variable "project_name" {
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "environment" {
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "aws_region" {
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "vpc_cidr" {
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "public_subnet_cidrs" {
|
||||||
|
type = list(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "private_subnet_cidrs" {
|
||||||
|
type = list(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "domain_name" {
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
variable "db_name" {
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "db_username" {
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "db_instance_class" {
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "db_allocated_storage" {
|
||||||
|
type = number
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "db_engine_version" {
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "nginx_instance_type" {
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "app_instance_type" {
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "app_port" {
|
||||||
|
type = number
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
variable "allowed_ssh_cidr" {
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "log_retention_days" {
|
||||||
|
type = number
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "terraform_aws_access_key" {
|
||||||
|
type = string
|
||||||
|
sensitive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "terraform_aws_secret_key" {
|
||||||
|
type = string
|
||||||
|
sensitive = true
|
||||||
|
}
|
||||||
|
variable "admin_email" {
|
||||||
|
type = string
|
||||||
|
}
|
||||||
80
sk1/terraform/vpc.tf
Normal file
80
sk1/terraform/vpc.tf
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
resource "aws_vpc" "main" {
|
||||||
|
cidr_block = var.vpc_cidr
|
||||||
|
enable_dns_support = true
|
||||||
|
enable_dns_hostnames = true
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = "${local.name_prefix}-vpc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_internet_gateway" "main" {
|
||||||
|
vpc_id = aws_vpc.main.id
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = "${local.name_prefix}-igw"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_subnet" "public" {
|
||||||
|
count = length(var.public_subnet_cidrs)
|
||||||
|
|
||||||
|
vpc_id = aws_vpc.main.id
|
||||||
|
cidr_block = var.public_subnet_cidrs[count.index]
|
||||||
|
availability_zone = data.aws_availability_zones.available.names[count.index]
|
||||||
|
map_public_ip_on_launch = true
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = "${local.name_prefix}-public-${count.index + 1}"
|
||||||
|
Tier = "public"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_subnet" "private" {
|
||||||
|
count = length(var.private_subnet_cidrs)
|
||||||
|
|
||||||
|
vpc_id = aws_vpc.main.id
|
||||||
|
cidr_block = var.private_subnet_cidrs[count.index]
|
||||||
|
availability_zone = data.aws_availability_zones.available.names[count.index]
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = "${local.name_prefix}-private-${count.index + 1}"
|
||||||
|
Tier = "private"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
resource "aws_route_table" "public" {
|
||||||
|
vpc_id = aws_vpc.main.id
|
||||||
|
|
||||||
|
route {
|
||||||
|
cidr_block = "0.0.0.0/0"
|
||||||
|
gateway_id = aws_internet_gateway.main.id
|
||||||
|
}
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = "${local.name_prefix}-public-rt"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_route_table" "private" {
|
||||||
|
vpc_id = aws_vpc.main.id
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = "${local.name_prefix}-private-rt"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_route_table_association" "public" {
|
||||||
|
count = length(aws_subnet.public)
|
||||||
|
|
||||||
|
subnet_id = aws_subnet.public[count.index].id
|
||||||
|
route_table_id = aws_route_table.public.id
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_route_table_association" "private" {
|
||||||
|
count = length(aws_subnet.private)
|
||||||
|
|
||||||
|
subnet_id = aws_subnet.private[count.index].id
|
||||||
|
route_table_id = aws_route_table.private.id
|
||||||
|
}
|
||||||
6
sk1/web/api/Dockerfile
Normal file
6
sk1/web/api/Dockerfile
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
COPY main.py .
|
||||||
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
89
sk1/web/api/main.py
Normal file
89
sk1/web/api/main.py
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import os
|
||||||
|
import httpx
|
||||||
|
import trafilatura
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
import asyncpg
|
||||||
|
import asyncio
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
DB_DSN = "postgresql://{user}:{password}@{host}:{port}/{dbname}".format(
|
||||||
|
user=os.environ["DB_USER"],
|
||||||
|
password=os.environ["DB_PASSWORD"],
|
||||||
|
host=os.environ["DB_HOST"],
|
||||||
|
port=os.environ.get("DB_PORT", "5432"),
|
||||||
|
dbname=os.environ["DB_NAME"],
|
||||||
|
)
|
||||||
|
SUMMARIZER_URL = os.environ.get("SUMMARIZER_URL", "http://summarizer:8000")
|
||||||
|
|
||||||
|
pool = None
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
global pool
|
||||||
|
pool = await asyncpg.create_pool(DB_DSN)
|
||||||
|
await pool.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS articles (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
title TEXT DEFAULT '',
|
||||||
|
full_text TEXT DEFAULT '',
|
||||||
|
summary TEXT DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
yield
|
||||||
|
await pool.close()
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(lifespan=lifespan)
|
||||||
|
|
||||||
|
|
||||||
|
class URLRequest(BaseModel):
|
||||||
|
url: str
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
async def health():
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/articles")
|
||||||
|
async def list_articles():
|
||||||
|
rows = await pool.fetch(
|
||||||
|
"SELECT id, url, title, summary, created_at "
|
||||||
|
"FROM articles ORDER BY created_at DESC LIMIT 50"
|
||||||
|
)
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/articles")
|
||||||
|
async def create_article(req: URLRequest):
|
||||||
|
downloaded = await asyncio.to_thread(trafilatura.fetch_url, req.url)
|
||||||
|
if not downloaded:
|
||||||
|
raise HTTPException(400, "Failed to fetch URL")
|
||||||
|
|
||||||
|
result = await asyncio.to_thread(trafilatura.bare_extraction, downloaded, include_comments=False)
|
||||||
|
if not result or not result.get("text"):
|
||||||
|
raise HTTPException(400, "Failed to extract text")
|
||||||
|
|
||||||
|
text = result["text"]
|
||||||
|
title = result.get("title", "")
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{SUMMARIZER_URL}/summarize", json={"text": text}
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise HTTPException(502, "Summarizer service unavailable")
|
||||||
|
summary = resp.json()["summary"]
|
||||||
|
|
||||||
|
row = await pool.fetchrow(
|
||||||
|
"INSERT INTO articles (url, title, full_text, summary) "
|
||||||
|
"VALUES ($1, $2, $3, $4) "
|
||||||
|
"RETURNING id, url, title, summary, created_at",
|
||||||
|
req.url, title, text, summary,
|
||||||
|
)
|
||||||
|
return dict(row)
|
||||||
5
sk1/web/api/requirements.txt
Normal file
5
sk1/web/api/requirements.txt
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
fastapi==0.115.0
|
||||||
|
uvicorn==0.30.0
|
||||||
|
asyncpg==0.29.0
|
||||||
|
httpx==0.27.0
|
||||||
|
trafilatura==1.12.0
|
||||||
13
sk1/web/docker-compose.yml
Normal file
13
sk1/web/docker-compose.yml
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
services:
|
||||||
|
nginx:
|
||||||
|
image: ghcr.io/${GITHUB_OWNER}/readitlater-nginx:latest
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
restart: always
|
||||||
|
|
||||||
|
api:
|
||||||
|
image: ghcr.io/${GITHUB_OWNER}/readitlater-api:latest
|
||||||
|
env_file: .env
|
||||||
|
restart: always
|
||||||
3
sk1/web/nginx/Dockerfile
Normal file
3
sk1/web/nginx/Dockerfile
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
FROM jonasal/nginx-certbot:latest
|
||||||
|
COPY nginx.conf /etc/nginx/user_conf.d/default.conf
|
||||||
|
COPY html/ /usr/share/nginx/html/
|
||||||
92
sk1/web/nginx/html/index.html
Normal file
92
sk1/web/nginx/html/index.html
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Read it later</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||||
|
background: #fff;
|
||||||
|
color: #1a1a1a;
|
||||||
|
max-width: 640px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 48px 24px;
|
||||||
|
}
|
||||||
|
h1 { font-size: 20px; font-weight: 600; margin-bottom: 32px; }
|
||||||
|
.input-row { display: flex; gap: 8px; margin-bottom: 12px; }
|
||||||
|
input[type="url"] {
|
||||||
|
flex: 1; padding: 10px 14px; border: 1px solid #ddd;
|
||||||
|
border-radius: 8px; font-size: 14px; outline: none;
|
||||||
|
}
|
||||||
|
input[type="url"]:focus { border-color: #999; }
|
||||||
|
button {
|
||||||
|
padding: 10px 20px; background: #1a1a1a; color: #fff;
|
||||||
|
border: none; border-radius: 8px; font-size: 14px; cursor: pointer;
|
||||||
|
}
|
||||||
|
button:hover { background: #333; }
|
||||||
|
button:disabled { background: #999; cursor: not-allowed; }
|
||||||
|
.error { color: #c00; font-size: 13px; margin-bottom: 24px; min-height: 18px; }
|
||||||
|
.articles { display: flex; flex-direction: column; gap: 24px; margin-top: 24px; }
|
||||||
|
.article { padding-bottom: 24px; border-bottom: 1px solid #eee; }
|
||||||
|
.article:last-child { border-bottom: none; }
|
||||||
|
.article-url { font-size: 12px; color: #999; word-break: break-all; margin-bottom: 4px; }
|
||||||
|
.article-title { font-size: 16px; font-weight: 600; margin-bottom: 8px; }
|
||||||
|
.article-summary { font-size: 14px; color: #555; line-height: 1.6; }
|
||||||
|
.article-date { font-size: 12px; color: #bbb; margin-top: 8px; }
|
||||||
|
.empty { color: #bbb; font-size: 14px; text-align: center; padding: 48px 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Read it later</h1>
|
||||||
|
<form class="input-row" id="form">
|
||||||
|
<input type="url" id="url" placeholder="Paste article URL..." required>
|
||||||
|
<button type="submit" id="btn">Save</button>
|
||||||
|
</form>
|
||||||
|
<div id="error" class="error"></div>
|
||||||
|
<div class="articles" id="articles"></div>
|
||||||
|
<script>
|
||||||
|
const API = '/api';
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const r = await fetch(API + '/articles');
|
||||||
|
const data = await r.json();
|
||||||
|
const el = document.getElementById('articles');
|
||||||
|
if (!data.length) { el.innerHTML = '<div class="empty">No articles yet</div>'; return; }
|
||||||
|
el.innerHTML = data.map(a => `
|
||||||
|
<div class="article">
|
||||||
|
<div class="article-url">${a.url}</div>
|
||||||
|
<div class="article-title">${a.title || 'Untitled'}</div>
|
||||||
|
<div class="article-summary">${a.summary || 'Processing...'}</div>
|
||||||
|
<div class="article-date">${new Date(a.created_at).toLocaleDateString()}</div>
|
||||||
|
</div>`).join('');
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('articles').innerHTML = '<div class="empty">Failed to load</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const btn = document.getElementById('btn');
|
||||||
|
const inp = document.getElementById('url');
|
||||||
|
const err = document.getElementById('error');
|
||||||
|
btn.disabled = true; btn.textContent = '...'; err.textContent = '';
|
||||||
|
try {
|
||||||
|
const r = await fetch(API + '/articles', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({url: inp.value})
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error((await r.json()).detail || 'Error');
|
||||||
|
inp.value = '';
|
||||||
|
await load();
|
||||||
|
} catch (e) { err.textContent = e.message; }
|
||||||
|
finally { btn.disabled = false; btn.textContent = 'Save'; }
|
||||||
|
});
|
||||||
|
|
||||||
|
load();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
45
sk1/web/nginx/nginx.conf
Normal file
45
sk1/web/nginx/nginx.conf
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name devopspavel.me;
|
||||||
|
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/certbot;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name devopspavel.me;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/devopspavel.me/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/devopspavel.me/privkey.pem;
|
||||||
|
|
||||||
|
# SSL Best Practices
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_prefer_server_ciphers on;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://api:8000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 120s;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /health {
|
||||||
|
return 200 'OK';
|
||||||
|
add_header Content-Type text/plain;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,3 +1,2 @@
|
|||||||
FROM nginx:latest
|
FROM nginx:latest
|
||||||
COPY ./nginx/nginx.conf /etc/nginx/nginx.conf
|
|
||||||
COPY ./frontend/index.html /usr/share/nginx/html/index.html
|
COPY ./frontend/index.html /usr/share/nginx/html/index.html
|
||||||
|
|||||||
@ -1,66 +0,0 @@
|
|||||||
worker_processes auto;
|
|
||||||
|
|
||||||
worker_rlimit_nofile 1035;
|
|
||||||
pid /var/run/nginx.pid;
|
|
||||||
|
|
||||||
events {
|
|
||||||
worker_connections 1024;
|
|
||||||
multi_accept on;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
http {
|
|
||||||
|
|
||||||
upstream backend {
|
|
||||||
server backend:5000;
|
|
||||||
}
|
|
||||||
|
|
||||||
charset utf-8;
|
|
||||||
sendfile on;
|
|
||||||
tcp_nopush on;
|
|
||||||
tcp_nodelay on;
|
|
||||||
keepalive_timeout 65;
|
|
||||||
types_hash_max_size 2048;
|
|
||||||
|
|
||||||
open_file_cache max=1000 inactive=20s;
|
|
||||||
open_file_cache_valid 30s;
|
|
||||||
open_file_cache_min_uses 2;
|
|
||||||
open_file_cache_errors on;
|
|
||||||
|
|
||||||
client_max_body_size 10m;
|
|
||||||
client_body_buffer_size 16k;
|
|
||||||
client_header_buffer_size 1k;
|
|
||||||
large_client_header_buffers 2 1k;
|
|
||||||
|
|
||||||
client_body_timeout 12;
|
|
||||||
client_header_timeout 12;
|
|
||||||
|
|
||||||
send_timeout 10;
|
|
||||||
|
|
||||||
server_tokens off;
|
|
||||||
|
|
||||||
include /etc/nginx/mime.types;
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name localhost;
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
error_log /var/log/nginx/static_errors.log debug;
|
|
||||||
access_log off;
|
|
||||||
try_files $uri $uri/ /index.html;
|
|
||||||
}
|
|
||||||
location /api/ {
|
|
||||||
proxy_pass http://backend/;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user