diff --git a/sk1 b/sk1 deleted file mode 160000 index 4a6b3a9..0000000 --- a/sk1 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 4a6b3a95ecefc48c2f0be168e3a76be45bb02c78 diff --git a/sk1/.github/workflows/deploy-summarizer.yml b/sk1/.github/workflows/deploy-summarizer.yml new file mode 100644 index 0000000..2f12be5 --- /dev/null +++ b/sk1/.github/workflows/deploy-summarizer.yml @@ -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 diff --git a/sk1/.github/workflows/deploy-web.yml b/sk1/.github/workflows/deploy-web.yml new file mode 100644 index 0000000..9b45b48 --- /dev/null +++ b/sk1/.github/workflows/deploy-web.yml @@ -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 diff --git a/sk1/.gitignore b/sk1/.gitignore new file mode 100644 index 0000000..02a1609 --- /dev/null +++ b/sk1/.gitignore @@ -0,0 +1,6 @@ +terraform/terraform* +terraform/.terraform* +terraform/security_groups.tf +*.tfstate +*.tfstate.* +.terraform/ diff --git a/sk1/README.md b/sk1/README.md new file mode 100644 index 0000000..3b8f8c5 --- /dev/null +++ b/sk1/README.md @@ -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 -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. diff --git a/sk1/prepare-app.sh b/sk1/prepare-app.sh new file mode 100755 index 0000000..df023e7 --- /dev/null +++ b/sk1/prepare-app.sh @@ -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." diff --git a/sk1/remove-app.sh b/sk1/remove-app.sh new file mode 100755 index 0000000..27dec86 --- /dev/null +++ b/sk1/remove-app.sh @@ -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 diff --git a/sk1/summarizer/Dockerfile b/sk1/summarizer/Dockerfile new file mode 100644 index 0000000..8d1ce56 --- /dev/null +++ b/sk1/summarizer/Dockerfile @@ -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"] diff --git a/sk1/summarizer/main.py b/sk1/summarizer/main.py new file mode 100644 index 0000000..61fb9e0 --- /dev/null +++ b/sk1/summarizer/main.py @@ -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} diff --git a/sk1/summarizer/requirements.txt b/sk1/summarizer/requirements.txt new file mode 100644 index 0000000..79abab1 --- /dev/null +++ b/sk1/summarizer/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.115.0 +uvicorn==0.30.0 +transformers==4.44.0 +torch==2.4.0 diff --git a/sk1/terraform/cloudwatch.tf b/sk1/terraform/cloudwatch.tf new file mode 100644 index 0000000..8a563ef --- /dev/null +++ b/sk1/terraform/cloudwatch.tf @@ -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 +} diff --git a/sk1/terraform/data.tf b/sk1/terraform/data.tf new file mode 100644 index 0000000..476564b --- /dev/null +++ b/sk1/terraform/data.tf @@ -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"] + } +} diff --git a/sk1/terraform/ec2.tf b/sk1/terraform/ec2.tf new file mode 100644 index 0000000..801e1b9 --- /dev/null +++ b/sk1/terraform/ec2.tf @@ -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" + } +} diff --git a/sk1/terraform/iam.tf b/sk1/terraform/iam.tf new file mode 100644 index 0000000..c17cbb8 --- /dev/null +++ b/sk1/terraform/iam.tf @@ -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 +} diff --git a/sk1/terraform/locals.tf b/sk1/terraform/locals.tf new file mode 100644 index 0000000..16e39e4 --- /dev/null +++ b/sk1/terraform/locals.tf @@ -0,0 +1,9 @@ +locals { + common_tags = { + Project = var.project_name + Environment = var.environment + ManagedBy = "terraform" + } + + name_prefix = "${var.project_name}-${var.environment}" +} diff --git a/sk1/terraform/outputs.tf b/sk1/terraform/outputs.tf new file mode 100644 index 0000000..bbadbc1 --- /dev/null +++ b/sk1/terraform/outputs.tf @@ -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 +} diff --git a/sk1/terraform/providers.tf b/sk1/terraform/providers.tf new file mode 100644 index 0000000..f170d0d --- /dev/null +++ b/sk1/terraform/providers.tf @@ -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 + } +} diff --git a/sk1/terraform/rds.tf b/sk1/terraform/rds.tf new file mode 100644 index 0000000..b940ecf --- /dev/null +++ b/sk1/terraform/rds.tf @@ -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" + } +} diff --git a/sk1/terraform/route53.tf b/sk1/terraform/route53.tf new file mode 100644 index 0000000..f02ccc5 --- /dev/null +++ b/sk1/terraform/route53.tf @@ -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] +} diff --git a/sk1/terraform/variables.tf b/sk1/terraform/variables.tf new file mode 100644 index 0000000..cc7209b --- /dev/null +++ b/sk1/terraform/variables.tf @@ -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 +} diff --git a/sk1/terraform/vpc.tf b/sk1/terraform/vpc.tf new file mode 100644 index 0000000..1a726d4 --- /dev/null +++ b/sk1/terraform/vpc.tf @@ -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 +} diff --git a/sk1/web/api/Dockerfile b/sk1/web/api/Dockerfile new file mode 100644 index 0000000..10af9be --- /dev/null +++ b/sk1/web/api/Dockerfile @@ -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"] diff --git a/sk1/web/api/main.py b/sk1/web/api/main.py new file mode 100644 index 0000000..e61e318 --- /dev/null +++ b/sk1/web/api/main.py @@ -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) diff --git a/sk1/web/api/requirements.txt b/sk1/web/api/requirements.txt new file mode 100644 index 0000000..863c847 --- /dev/null +++ b/sk1/web/api/requirements.txt @@ -0,0 +1,5 @@ +fastapi==0.115.0 +uvicorn==0.30.0 +asyncpg==0.29.0 +httpx==0.27.0 +trafilatura==1.12.0 diff --git a/sk1/web/docker-compose.yml b/sk1/web/docker-compose.yml new file mode 100644 index 0000000..1bcd694 --- /dev/null +++ b/sk1/web/docker-compose.yml @@ -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 diff --git a/sk1/web/nginx/Dockerfile b/sk1/web/nginx/Dockerfile new file mode 100644 index 0000000..09f2eb4 --- /dev/null +++ b/sk1/web/nginx/Dockerfile @@ -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/ diff --git a/sk1/web/nginx/html/index.html b/sk1/web/nginx/html/index.html new file mode 100644 index 0000000..52fa238 --- /dev/null +++ b/sk1/web/nginx/html/index.html @@ -0,0 +1,92 @@ + + + + + + Read it later + + + +

Read it later

+
+ + +
+
+
+ + + diff --git a/sk1/web/nginx/nginx.conf b/sk1/web/nginx/nginx.conf new file mode 100644 index 0000000..e226f7c --- /dev/null +++ b/sk1/web/nginx/nginx.conf @@ -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; + } +} diff --git a/z2/nginx/Dockerfile b/z2/nginx/Dockerfile index 8157091..7d86a41 100644 --- a/z2/nginx/Dockerfile +++ b/z2/nginx/Dockerfile @@ -1,3 +1,2 @@ FROM nginx:latest -COPY ./nginx/nginx.conf /etc/nginx/nginx.conf COPY ./frontend/index.html /usr/share/nginx/html/index.html diff --git a/z2/nginx/nginx.conf b/z2/nginx/nginx.conf deleted file mode 100644 index 5489430..0000000 --- a/z2/nginx/nginx.conf +++ /dev/null @@ -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; - } - - - } - -} \ No newline at end of file