diff --git a/.gitignore b/.gitignore index 8d6b068..9ebd609 100644 --- a/.gitignore +++ b/.gitignore @@ -31,4 +31,8 @@ yarn-error.log* __pycache__/ # Secrets -secrets/ \ No newline at end of file +secrets/ + +.idea/ + +k8s/secrets.yaml \ No newline at end of file diff --git a/apps/agent-backend/Dockerfile b/apps/agent-backend/Dockerfile index e8e2ecc..688a207 100644 --- a/apps/agent-backend/Dockerfile +++ b/apps/agent-backend/Dockerfile @@ -20,4 +20,4 @@ RUN poetry install EXPOSE 8002 -CMD ["uvicorn", "server.serve:app", "--host", "0.0.0.0", "--port", "8002"] +CMD ["uvicorn", "server.serve:app", "--host", "0.0.0.0", "--port", "8002", "--proxy-headers", "--forwarded-allow-ips", "*"] diff --git a/apps/agent-backend/server/serve.py b/apps/agent-backend/server/serve.py index 5e83041..2d9bdc9 100644 --- a/apps/agent-backend/server/serve.py +++ b/apps/agent-backend/server/serve.py @@ -1,7 +1,7 @@ import os import uvicorn -from fastapi import Depends, FastAPI, Request +from fastapi import APIRouter, Depends, FastAPI, Request from loguru import logger from slowapi import Limiter, _rate_limit_exceeded_handler from slowapi.errors import RateLimitExceeded @@ -33,7 +33,10 @@ def get_real_ip(request: Request) -> str: add_cors_middleware(app) -@app.post("/chat") +agent_router = APIRouter(prefix="/agent", tags=["agent"]) + + +@agent_router.post("/chat") @limiter.limit("7/minute") def chat_endpoint( request: Request, @@ -44,11 +47,14 @@ def chat_endpoint( Process chat questions using the agent and return structured responses. Rate limited to 7 requests per minute per IP. """ - logger.info("Received request to /chat endpoint") + logger.info("Received request to /agent/chat endpoint") response_data: ChatTypeOut = chat_ask_question(body) logger.info("Sending response back to client") return response_data + +app.include_router(agent_router) + def main() -> None: """ Main function to run the FastAPI app using Uvicorn. diff --git a/apps/blogpost-backend/Dockerfile b/apps/blogpost-backend/Dockerfile index c2c5daf..ffd2958 100644 --- a/apps/blogpost-backend/Dockerfile +++ b/apps/blogpost-backend/Dockerfile @@ -18,4 +18,4 @@ RUN poetry install EXPOSE 8001 -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8001"] +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8001", "--proxy-headers", "--forwarded-allow-ips", "*"] diff --git a/apps/blogpost-backend/app/core/aws.py b/apps/blogpost-backend/app/core/aws.py index 2d3ebbe..01da0e1 100644 --- a/apps/blogpost-backend/app/core/aws.py +++ b/apps/blogpost-backend/app/core/aws.py @@ -17,11 +17,9 @@ dynamodb = boto3.resource('dynamodb', **dynamodb_args) # Configure the S3 client (for future image uploads) -s3_args = { - "region_name": AWS_REGION, - "config": Config(s3={'addressing_style': 'path'}) # Forces LocalStack compatibility -} +s3_args: dict[str, object] = {"region_name": AWS_REGION} if S3_ENDPOINT: - s3_args["endpoint_url"] = S3_ENDPOINT # Route traffic to LocalStack! + s3_args["endpoint_url"] = S3_ENDPOINT + s3_args["config"] = Config(s3={"addressing_style": "path"}) -s3_client = boto3.client('s3', **s3_args) \ No newline at end of file +s3_client = boto3.client("s3", **s3_args) \ No newline at end of file diff --git a/apps/blogpost-backend/app/services/blog_service.py b/apps/blogpost-backend/app/services/blog_service.py index fd6d491..b0d92f8 100644 --- a/apps/blogpost-backend/app/services/blog_service.py +++ b/apps/blogpost-backend/app/services/blog_service.py @@ -36,7 +36,13 @@ def fetch_all_blogs(self) -> list: """ Fetches all blog posts from the repository. """ - return self.repo.get_all_posts() + posts = self.repo.get_all_posts() + for post in posts: + post["image_urls"] = [ + self.storage_service.presign_from_url(url) + for url in post.get("image_urls", []) + ] + return posts def delete_blog(self, blog_id: str, requesting_author: str) -> dict: """ diff --git a/apps/blogpost-backend/app/services/storage_service.py b/apps/blogpost-backend/app/services/storage_service.py index 7a45b34..6c7e6d9 100644 --- a/apps/blogpost-backend/app/services/storage_service.py +++ b/apps/blogpost-backend/app/services/storage_service.py @@ -1,6 +1,7 @@ import os import uuid from typing import BinaryIO +from urllib.parse import urlparse from botocore.exceptions import ClientError @@ -41,10 +42,51 @@ def _object_exists(self, key: str) -> bool: def _upload_extra_args(self, content_type: str) -> dict[str, str]: extra_args: dict[str, str] = {"ContentType": content_type} - if not self.s3_endpoint: + if self.s3_endpoint: extra_args["ACL"] = "public-read" return extra_args + def _canonical_url(self, s3_key: str) -> str: + return f"{self._get_base_url()}/{s3_key}" + + def _presign_url(self, s3_key: str, expires_in: int = 86400) -> str: + if self.s3_endpoint: + return self._canonical_url(s3_key) + return s3_client.generate_presigned_url( + "get_object", + Params={"Bucket": self.bucket_name, "Key": s3_key}, + ExpiresIn=expires_in, + ) + + def presign_from_url(self, image_url: str, expires_in: int = 86400) -> str: + key = self._url_to_key(image_url) + return self._presign_url(key, expires_in=expires_in) + + def _url_to_key(self, image_url: str) -> str: + url_without_query = image_url.split("?", 1)[0].rstrip("/") + parsed = urlparse(url_without_query) + host = parsed.netloc + path = parsed.path.lstrip("/") + + if host.startswith("s3.") or host == "s3.amazonaws.com": + bucket, _, key = path.partition("/") + if bucket == self.bucket_name and key: + return key + + if host.startswith(f"{self.bucket_name}."): + if path: + return path + + if self.s3_public_endpoint: + localstack_host = urlparse(self.s3_public_endpoint.rstrip("/")).netloc + if host == localstack_host and path.startswith(f"{self.bucket_name}/"): + return path[len(self.bucket_name) + 1 :] + + raise ImagePromotionError( + temp_image_url=image_url, + message="Unrecognized image URL — only uploaded images are accepted.", + ) + def upload_image(self, file_obj: BinaryIO, original_filename: str, content_type: str) -> str: """ Uploads an image to S3 under the "temp/" folder and returns its URL. @@ -67,24 +109,17 @@ def upload_image(self, file_obj: BinaryIO, original_filename: str, content_type: message="Image upload did not persist in storage. Please try again.", ) - return f"{self._get_base_url()}/{s3_key}" + return self._presign_url(s3_key) def promote_image(self, temp_image_url: str) -> str: """ Moves an image from temp/ to published/ and returns the new URL. All URLs are guaranteed to come from our own upload endpoint. """ - base = f"{self._get_base_url()}/" - if not temp_image_url.startswith(base): - raise ImagePromotionError( - temp_image_url=temp_image_url, - message="Unrecognized image URL — only uploaded images are accepted.", - ) - - source_key = temp_image_url[len(base):] + source_key = self._url_to_key(temp_image_url) if source_key.startswith("published/"): - return temp_image_url + return self._canonical_url(source_key) if not source_key.startswith("temp/"): raise ImagePromotionError( @@ -96,7 +131,7 @@ def promote_image(self, temp_image_url: str) -> str: new_key = f"published/{filename}" if self._object_exists(new_key): - return f"{self._get_base_url()}/{new_key}" + return self._canonical_url(new_key) if not self._object_exists(source_key): raise ImagePromotionError( @@ -113,9 +148,9 @@ def promote_image(self, temp_image_url: str) -> str: "Bucket": self.bucket_name, "Key": new_key, } - if not self.s3_endpoint: + if self.s3_endpoint: copy_args["ACL"] = "public-read" s3_client.copy_object(**copy_args) s3_client.delete_object(Bucket=self.bucket_name, Key=source_key) - return f"{self._get_base_url()}/{new_key}" \ No newline at end of file + return self._canonical_url(new_key) diff --git a/apps/core-backend/Dockerfile b/apps/core-backend/Dockerfile index 9c1f8ba..25f39eb 100644 --- a/apps/core-backend/Dockerfile +++ b/apps/core-backend/Dockerfile @@ -21,4 +21,4 @@ RUN poetry install EXPOSE 8000 -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers", "--forwarded-allow-ips", "*"] diff --git a/apps/frontend/.env.production b/apps/frontend/.env.production new file mode 100644 index 0000000..4f17776 --- /dev/null +++ b/apps/frontend/.env.production @@ -0,0 +1,21 @@ +# Production build (yarn build) — loaded ON TOP of .env when Vite is in +# `production` mode. Keep secrets in .env (gitignored); this file is safe +# to commit because it only contains routing paths. +# +# Relative paths make every API call same-origin via the Traefik Ingress: +# browser → http:///auth/... → auth-service (k8s Service) +# browser → http:///blogs/... → blog-service +# browser → http:///agent/... → agent-service +# +# Same-origin removes CORS entirely and means the same image works behind +# any DNS name without rebuilding. + +# auth.api.ts and blogposts.api.ts already include "/auth/..." and "/blogs/..." +# in every call, so their base URL must be "/" (NOT "/auth" or "/blogs", which +# would produce "/auth/auth/register" → 404). +# +# agent.api.ts only calls "/chat", so its base needs the "/agent" prefix. + +VITE_CORE_API_BASE_URL=/ +VITE_BLOGPOSTS_API_BASE_URL=/ +VITE_AGENT_API_BASE_URL=/agent diff --git a/apps/frontend/nginx.conf b/apps/frontend/nginx.conf index eadc886..aa75871 100644 --- a/apps/frontend/nginx.conf +++ b/apps/frontend/nginx.conf @@ -1,5 +1,6 @@ server { listen 80; + listen [::]:80; server_name localhost; root /usr/share/nginx/html; index index.html; diff --git a/apps/frontend/src/api/auth.api.ts b/apps/frontend/src/api/auth.api.ts index 4c67bc0..5e278c8 100644 --- a/apps/frontend/src/api/auth.api.ts +++ b/apps/frontend/src/api/auth.api.ts @@ -25,14 +25,18 @@ export const coreAPI = axios.create({ withCredentials: true, }); +const AUTH_FLOW_ENDPOINTS = new Set(['/auth/login', '/auth/register', '/auth/logout', '/auth/me']); + coreAPI.interceptors.response.use( (response) => { return response; }, async (error: AxiosError) => { const originalRequest = error.config; + const requestUrl = originalRequest?.url ?? ''; + const isAuthFlowRequest = AUTH_FLOW_ENDPOINTS.has(requestUrl); - if (error.response?.status === 401 && originalRequest?.url !== '/auth/me') { + if (error.response?.status === 401 && !isAuthFlowRequest) { console.warn('Session expired. Logging out...'); try { await coreAPI.post('/auth/logout'); diff --git a/apps/frontend/src/api/blogposts.api.ts b/apps/frontend/src/api/blogposts.api.ts index 4609e70..b70834c 100644 --- a/apps/frontend/src/api/blogposts.api.ts +++ b/apps/frontend/src/api/blogposts.api.ts @@ -17,12 +17,12 @@ export const blogPostsApi = axios.create({ }); export async function getAllBlogPosts(): Promise { - const res = await blogPostsApi.get('/blogs'); + const res = await blogPostsApi.get('/blogs/'); return res.data; } export async function createBlogPosts(newBlogPost: BlogPostTypeOut): Promise { - const res = await blogPostsApi.post('/blogs', newBlogPost); + const res = await blogPostsApi.post('/blogs/', newBlogPost); return res.data; } diff --git a/apps/frontend/src/context/AuthContext.tsx b/apps/frontend/src/context/AuthContext.tsx index bc9569a..50adaff 100644 --- a/apps/frontend/src/context/AuthContext.tsx +++ b/apps/frontend/src/context/AuthContext.tsx @@ -53,9 +53,17 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children const handleLogin: AuthContextType['login'] = async (email, password) => { try { const userCredential = await signInWithEmailAndPassword(auth, email, password); - await sendLoginRequest(userCredential); + const response = await sendLoginRequest(userCredential); + if (response.status !== 'success') { + throw new Error(response.message || 'Login failed.'); + } await refreshUser(); } catch (error) { + try { + await signOut(auth); + } catch (signOutError) { + console.warn('Failed signing out firebase after login failure:', signOutError); + } throw new Error(getApiErrorMessage(error, 'Login failed.')); } }; diff --git a/apps/frontend/src/pages/LoginPage.tsx b/apps/frontend/src/pages/LoginPage.tsx index c75c57b..95d64c7 100644 --- a/apps/frontend/src/pages/LoginPage.tsx +++ b/apps/frontend/src/pages/LoginPage.tsx @@ -16,6 +16,7 @@ function LoginPage() { const onSubmitHandler = async (e: React.SubmitEvent) => { e.preventDefault(); + setError(''); try { await login(email, password); diff --git a/k8s/.gitignore b/k8s/.gitignore new file mode 100644 index 0000000..c03ca4c --- /dev/null +++ b/k8s/.gitignore @@ -0,0 +1,2 @@ +doc.md +secrets.yaml \ No newline at end of file diff --git a/k8s/agent-service.yaml b/k8s/agent-service.yaml new file mode 100644 index 0000000..4a5a12f --- /dev/null +++ b/k8s/agent-service.yaml @@ -0,0 +1,52 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: agent-service +spec: + replicas: 1 + selector: + matchLabels: + app: agent-service + template: + metadata: + labels: + app: agent-service + spec: + imagePullSecrets: + - name: registry-credentials + containers: + - name: agent-service + image: leventedaroczi/galactic-private:agent-latest + imagePullPolicy: Always + ports: + - containerPort: 8002 + env: + - name: ENVIRONMENT + value: "prod" + - name: LOGGING_LEVEL + value: "INFO" + - name: CORE_BACKEND_URL + value: "http://auth-service" + - name: GROQ_API_KEY + valueFrom: + secretKeyRef: + name: galactic-secrets + key: GROQ_API_KEY + - name: TAVILY_API_KEY + valueFrom: + secretKeyRef: + name: galactic-secrets + key: TAVILY_API_KEY + optional: true +--- +apiVersion: v1 +kind: Service +metadata: + name: agent-service +spec: + selector: + app: agent-service + ports: + - protocol: TCP + port: 80 + targetPort: 8002 diff --git a/k8s/auth-service.yaml b/k8s/auth-service.yaml new file mode 100644 index 0000000..af98ef0 --- /dev/null +++ b/k8s/auth-service.yaml @@ -0,0 +1,61 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: auth-service +spec: + replicas: 1 + selector: + matchLabels: + app: auth-service + template: + metadata: + labels: + app: auth-service + spec: + imagePullSecrets: + - name: registry-credentials + containers: + - name: auth-service + image: leventedaroczi/galactic-private:auth-latest + imagePullPolicy: Always + ports: + - containerPort: 8000 + env: + - name: POSTGRESQL_HOST + value: "galactic-prod-postgres.ctu6e660itca.eu-central-1.rds.amazonaws.com" + - name: POSTGRESQL_PORT + value: "5432" + - name: POSTGRESQL_DB + value: "galacticview" + - name: POSTGRESQL_USER + value: "galactic_admin" + - name: POSTGRESQL_PASSWORD + valueFrom: + secretKeyRef: + name: galactic-secrets + key: POSTGRESQL_PASSWORD + - name: ENVIRONMENT + value: "prod" + - name: GOOGLE_APPLICATION_CREDENTIALS + value: /run/secrets/firebase/firebase-service-account.json + volumeMounts: + - name: firebase-credentials + mountPath: /run/secrets/firebase + readOnly: true + volumes: + - name: firebase-credentials + secret: + secretName: firebase-credentials +--- + +apiVersion: v1 +kind: Service +metadata: + name: auth-service +spec: + selector: + app: auth-service + ports: + - protocol: TCP + port: 80 + targetPort: 8000 \ No newline at end of file diff --git a/k8s/blog-service.yaml b/k8s/blog-service.yaml new file mode 100644 index 0000000..e6c229a --- /dev/null +++ b/k8s/blog-service.yaml @@ -0,0 +1,54 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: blog-service +spec: + replicas: 1 + selector: + matchLabels: + app: blog-service + template: + metadata: + labels: + app: blog-service + spec: + imagePullSecrets: + - name: registry-credentials + containers: + - name: blog-service + image: leventedaroczi/galactic-private:blog-latest + imagePullPolicy: Always + ports: + - containerPort: 8001 + env: + - name: AWS_REGION + value: "eu-central-1" + - name: S3_BUCKET_NAME + value: "galactic-blog-images-a54ebf00" + - name: DYNAMODB_TABLE_NAME + value: "GalacticBlogPosts" + - name: CORE_BACKEND_URL + value: "http://auth-service" + + - name: AWS_ACCESS_KEY_ID + valueFrom: + secretKeyRef: + name: galactic-secrets + key: AWS_ACCESS_KEY_ID + - name: AWS_SECRET_ACCESS_KEY + valueFrom: + secretKeyRef: + name: galactic-secrets + key: AWS_SECRET_ACCESS_KEY +--- +apiVersion: v1 +kind: Service +metadata: + name: blog-service +spec: + selector: + app: blog-service + ports: + - protocol: TCP + port: 80 + targetPort: 8001 \ No newline at end of file diff --git a/k8s/frontend.yaml b/k8s/frontend.yaml new file mode 100644 index 0000000..7496834 --- /dev/null +++ b/k8s/frontend.yaml @@ -0,0 +1,34 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: frontend +spec: + replicas: 1 + selector: + matchLabels: + app: frontend + template: + metadata: + labels: + app: frontend + spec: + imagePullSecrets: + - name: registry-credentials + containers: + - name: frontend + image: leventedaroczi/galactic-private:frontend-latest + imagePullPolicy: Always + ports: + - containerPort: 80 +--- +apiVersion: v1 +kind: Service +metadata: + name: frontend +spec: + selector: + app: frontend + ports: + - protocol: TCP + port: 80 + targetPort: 80 diff --git a/k8s/ingress.yaml b/k8s/ingress.yaml new file mode 100644 index 0000000..cfa5449 --- /dev/null +++ b/k8s/ingress.yaml @@ -0,0 +1,40 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: galactic-ingress +spec: + ingressClassName: traefik + rules: + - http: + paths: + - path: /auth + pathType: Prefix + backend: + service: + name: auth-service + port: + number: 80 + + - path: /blogs + pathType: Prefix + backend: + service: + name: blog-service + port: + number: 80 + + - path: /agent + pathType: Prefix + backend: + service: + name: agent-service + port: + number: 80 + + - path: / + pathType: Prefix + backend: + service: + name: frontend + port: + number: 80 diff --git a/k8s/secrets.example.yaml b/k8s/secrets.example.yaml new file mode 100644 index 0000000..efe5a58 --- /dev/null +++ b/k8s/secrets.example.yaml @@ -0,0 +1,16 @@ +# Create the Firebase Admin secret separately (do NOT commit the JSON file): +# +# kubectl create secret generic firebase-credentials \ +# --from-file=firebase-service-account.json=./secrets/firebase-service-account.json +# +apiVersion: v1 +kind: Secret +metadata: + name: galactic-secrets +type: Opaque +stringData: + POSTGRESQL_PASSWORD: "" + AWS_ACCESS_KEY_ID: "" + AWS_SECRET_ACCESS_KEY: "" + GROQ_API_KEY: "" + TAVILY_API_KEY: "" diff --git a/terraform/.gitignore b/terraform/.gitignore new file mode 100644 index 0000000..d9a21d7 --- /dev/null +++ b/terraform/.gitignore @@ -0,0 +1,35 @@ +# Local .terraform directories +**/.terraform/* + +# Terraform state files (may contain secrets) +*.tfstate +*.tfstate.* +*.tfstate.backup + +# Crash log files +crash.log +crash.*.log + +# Variable files with real secrets (keep the example file in git) +*.tfvars +*.tfvars.json +!*.tfvars.example + +# Override files for local-only changes +override.tf +override.tf.json +*_override.tf +*_override.tf.json + +# CLI configuration / plan output +.terraformrc +terraform.rc +tfplan +*.tfplan + +doc.md + + +*.pem + +k3s-remote.yaml \ No newline at end of file diff --git a/terraform/.terraform.lock.hcl b/terraform/.terraform.lock.hcl new file mode 100644 index 0000000..d288b11 --- /dev/null +++ b/terraform/.terraform.lock.hcl @@ -0,0 +1,86 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "5.100.0" + constraints = "~> 5.0" + hashes = [ + "h1:Ijt7pOlB7Tr7maGQIqtsLFbl7pSMIj06TVdkoSBcYOw=", + "zh:054b8dd49f0549c9a7cc27d159e45327b7b65cf404da5e5a20da154b90b8a644", + "zh:0b97bf8d5e03d15d83cc40b0530a1f84b459354939ba6f135a0086c20ebbe6b2", + "zh:1589a2266af699cbd5d80737a0fe02e54ec9cf2ca54e7e00ac51c7359056f274", + "zh:6330766f1d85f01ae6ea90d1b214b8b74cc8c1badc4696b165b36ddd4cc15f7b", + "zh:7c8c2e30d8e55291b86fcb64bdf6c25489d538688545eb48fd74ad622e5d3862", + "zh:99b1003bd9bd32ee323544da897148f46a527f622dc3971af63ea3e251596342", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:9f8b909d3ec50ade83c8062290378b1ec553edef6a447c56dadc01a99f4eaa93", + "zh:aaef921ff9aabaf8b1869a86d692ebd24fbd4e12c21205034bb679b9caf883a2", + "zh:ac882313207aba00dd5a76dbd572a0ddc818bb9cbf5c9d61b28fe30efaec951e", + "zh:bb64e8aff37becab373a1a0cc1080990785304141af42ed6aa3dd4913b000421", + "zh:dfe495f6621df5540d9c92ad40b8067376350b005c637ea6efac5dc15028add4", + "zh:f0ddf0eaf052766cfe09dea8200a946519f653c384ab4336e2a4a64fdd6310e9", + "zh:f1b7e684f4c7ae1eed272b6de7d2049bb87a0275cb04dbb7cda6636f600699c9", + "zh:ff461571e3f233699bf690db319dfe46aec75e58726636a0d97dd9ac6e32fb70", + ] +} + +provider "registry.terraform.io/hashicorp/local" { + version = "2.9.0" + hashes = [ + "h1:m24fjcInWvTVZ1XSo2MaNuKPe+X/gfG8SIi09rA7a7M=", + "zh:0baa4566cf77f1ff52f4293d1c8536202dd23edc197c3196413a28343c3ac3a0", + "zh:16b5559c3c07088ddad11a9bb9e9c0799999363c2958e9a5be2bcbbf2cd9ca64", + "zh:197c79015a10d1cce904a8ea722cbc750c42aeae2da53f44a6a0751d9fd1aa90", + "zh:29d0b03e5343a80677ebfeb2e2c31cbe4b1f65e736e53417454a4277fec2544c", + "zh:4896bfa6cf1d2fd562b47ef2e87f47862ae92a04f8ad5d764380f0c6653473b8", + "zh:531f8529cbca49f681883e57761a05a8398afaef6d1ab0d205d26bf12f4428e8", + "zh:6aaf5011d83161c86d2bfb80c0923ec934e578288758da2f37acb7aec129004b", + "zh:7430275253d3d3c40aa6179e0ec0d63212874dbbc06c5a51b9d07ec590f9756c", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:be17dc611e95e26cdf6cad79dfccf1064f0e32032a2efeb939a9bbe7fb1cbfe9", + "zh:f0e3b0aa644202e1d79d2000dca91f6019425da71e9800fa23f27e51c034f195", + "zh:f62bae4519e4ead49182ddc8afe8cf61e2a4c3ba3973b0fbba967736a2696aa3", + "zh:fcafa360a5b0b96244f26f4e3a6d642b716a376557142c2442ff2fb12d11da18", + ] +} + +provider "registry.terraform.io/hashicorp/random" { + version = "3.9.0" + constraints = "~> 3.5" + hashes = [ + "h1:OO+IuvQJSPmWdN8AyyIEvPJbLvDQpgX/zbktoa9KsJE=", + "zh:161ad0bd9a75768c82f53fb6e7172a9d8be2d4889b012645a34795031aaf1bf1", + "zh:19dc9a5b17729725ccfc4f45b0500af0ee5bc6b6b160c7adb8f2bf617d2c80ea", + "zh:269eda8fe42daa7974d5a34d166c3ba9defe80cde86c01e4dadcfdf2e1f05e5f", + "zh:373f7c65566f8f2cc7f45d698654feb9d988996957e1266a69ca00c52d6d16d0", + "zh:5599d16804c41c83009ec621b6d6b6f74e102f5827678a4750f8809055546b61", + "zh:583be0440469a22bff70dcfa56593b01566860b29607437264adb51060cf46fc", + "zh:5f211d8ec3f2e1f414870d9584bfe26e6995560ef81c748f8447a48164767398", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:7b547fd16216761ef86efc3ed516ac5ac0c5c42b7c7eb24a08cef2d93f69ed5e", + "zh:7e7c0679daf2a382151d05068c8c3f0dae6b7b7dccf818827b73dd08638df2ef", + "zh:8089dec888a8038b9b4fb23b3df7e1057293dbc5b60b42cc47ff690d69d4b61b", + "zh:c51f15a031edfd6f23ce8ced3446ca7f8d8d647e2499890d7d5d10d5016d7257", + "zh:c94784f005708890dc6895afd53636ec00ec1e430b15d41e5aebfb1d4b39bd04", + ] +} + +provider "registry.terraform.io/hashicorp/tls" { + version = "4.3.0" + hashes = [ + "h1:5bCU/c+2HUh7GhclzNSH6gAuoCS4inW3obEtRAwu6WQ=", + "zh:0ab58d6f8991d436c7d2dbd89ed814709b949b07ac5a54ee53b0aec1fa772a8b", + "zh:60b347abcb56f45d97c56f14d895069cd15a83993f199777f571b79fea3642ee", + "zh:6889be32640349230de3f23856e6f04e0e9ced4a84a27d3f552fa54684448218", + "zh:73f8e1ecf7135033165fb14b7e8bf4d656f3ce13065ec35762ea0481975328c7", + "zh:94ce25ee253eca0b42cae9c856b36bca8103b6453012d1b279c3623c805f2d42", + "zh:96bc6de9fd67bc446fd11257872e1ffb1029a996ed1d65a3f6b43f6d408ad9ab", + "zh:97c609a310a51bfd504d704e036d72064a84bf0bdb36cc08cd4cc66098212b41", + "zh:a12c16e94533c5bd123f75032576b9dc91dd5d5ccd5f7cf331d0f2e1adc55cf8", + "zh:c4f014f876adf7af57188795050bda5b0029d8c7d7773031102b6c36dcf1fc21", + "zh:d9b0a21583aaa3df3a95394fb949a3c515ff71c2ff5a1fc4a73d364aa90bfca5", + "zh:da510d22f0c6d71ad19a76406f106b782448f512375787ecfabb338ed1e311a7", + "zh:f0e9447a9ce3a24cdaa113089e65663c836d8b9bfdb915a1c0284e0112cab5c0", + "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + ] +} diff --git a/terraform/compute.tf b/terraform/compute.tf new file mode 100644 index 0000000..b3b0ed3 --- /dev/null +++ b/terraform/compute.tf @@ -0,0 +1,94 @@ +resource "tls_private_key" "pk" { + algorithm = "RSA" + rsa_bits = 4048 +} + +resource "aws_key_pair" "kp" { + key_name = "galactic-prod-key" + public_key = tls_private_key.pk.public_key_openssh +} + + +resource "local_file" "ssh_key" { + filename = "${path.module}/galactic-key.pem" + content = tls_private_key.pk.private_key_pem + file_permission = "0400" +} + +data "aws_ami" "ubuntu_22_04" { + most_recent = true + owners = ["099720109477"] # Canonical + + filter { + name = "name" + values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"] + } + + filter { + name = "virtualization-type" + values = ["hvm"] + } + + filter { + name = "root-device-type" + values = ["ebs"] + } + + filter { + name = "architecture" + values = ["x86_64"] + } +} + +locals { + user_data = <<-EOT + #!/bin/bash + set -euxo pipefail + + export DEBIAN_FRONTEND=noninteractive + apt-get update -y + apt-get install -y curl ca-certificates + + curl -sfL https://get.k3s.io | sh - + + mkdir -p /home/ubuntu/.kube + cp /etc/rancher/k3s/k3s.yaml /home/ubuntu/.kube/config + chown -R ubuntu:ubuntu /home/ubuntu/.kube + chmod 600 /home/ubuntu/.kube/config + EOT +} + +resource "aws_instance" "web" { + ami = data.aws_ami.ubuntu_22_04.id + instance_type = var.ec2_instance_type + + key_name = aws_key_pair.kp.key_name + subnet_id = aws_subnet.public[0].id + vpc_security_group_ids = [aws_security_group.web.id] + associate_public_ip_address = true + + user_data = local.user_data + user_data_replace_on_change = false + + root_block_device { + volume_size = 30 + volume_type = "gp3" + encrypted = true + delete_on_termination = true + } + + metadata_options { + http_tokens = "required" + http_endpoint = "enabled" + http_put_response_hop_limit = 2 + } + + tags = { + Name = "${local.name_prefix}-web" + Role = "k3s-node" + } + + lifecycle { + ignore_changes = [ami] + } +} diff --git a/terraform/database.tf b/terraform/database.tf new file mode 100644 index 0000000..8843344 --- /dev/null +++ b/terraform/database.tf @@ -0,0 +1,104 @@ +resource "aws_db_subnet_group" "main" { + name = "${local.name_prefix}-db-subnet-group" + description = "Subnet group spanning two AZs for the ${local.name_prefix} RDS instance." + subnet_ids = aws_subnet.public[*].id + + tags = { + Name = "${local.name_prefix}-db-subnet-group" + } +} + +resource "aws_db_instance" "postgres" { + identifier = "${local.name_prefix}-postgres" + engine = "postgres" + engine_version = var.db_engine_version + instance_class = var.db_instance_class + + allocated_storage = var.db_allocated_storage + max_allocated_storage = 100 + storage_type = "gp3" + storage_encrypted = true + + db_name = var.db_name + username = var.db_username + password = var.db_password + port = 5432 + + db_subnet_group_name = aws_db_subnet_group.main.name + vpc_security_group_ids = [aws_security_group.rds.id] + publicly_accessible = false + multi_az = false + + backup_retention_period = 7 + auto_minor_version_upgrade = true + skip_final_snapshot = true + deletion_protection = false + apply_immediately = true + + tags = { + Name = "${local.name_prefix}-postgres" + } +} + +resource "aws_dynamodb_table" "blog_posts" { + name = var.dynamodb_table_name + billing_mode = "PAY_PER_REQUEST" + hash_key = "id" + + attribute { + name = "id" + type = "S" + } + + point_in_time_recovery { + enabled = true + } + + server_side_encryption { + enabled = true + } + + tags = { + Name = var.dynamodb_table_name + } +} + +resource "random_id" "bucket_suffix" { + byte_length = 4 +} + +resource "aws_s3_bucket" "blog_images" { + bucket = "${var.project_prefix}-blog-images-${random_id.bucket_suffix.hex}" + force_destroy = true + + tags = { + Name = "${var.project_prefix}-blog-images" + } +} + +resource "aws_s3_bucket_public_access_block" "blog_images" { + bucket = aws_s3_bucket.blog_images.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +resource "aws_s3_bucket_versioning" "blog_images" { + bucket = aws_s3_bucket.blog_images.id + + versioning_configuration { + status = "Enabled" + } +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "blog_images" { + bucket = aws_s3_bucket.blog_images.id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + } +} diff --git a/terraform/main.tf b/terraform/main.tf new file mode 100644 index 0000000..fde342c --- /dev/null +++ b/terraform/main.tf @@ -0,0 +1,36 @@ +terraform { + required_version = ">= 1.5.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + random = { + source = "hashicorp/random" + version = "~> 3.5" + } + } +} + +provider "aws" { + region = var.aws_region + + default_tags { + tags = { + Project = var.project_prefix + Environment = var.environment + ManagedBy = "Terraform" + } + } +} + +locals { + name_prefix = "${var.project_prefix}-${var.environment}" + + common_tags = { + Project = var.project_prefix + Environment = var.environment + ManagedBy = "Terraform" + } +} diff --git a/terraform/network.tf b/terraform/network.tf new file mode 100644 index 0000000..dda7429 --- /dev/null +++ b/terraform/network.tf @@ -0,0 +1,131 @@ +data "aws_availability_zones" "available" { + state = "available" +} + +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_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_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_security_group" "web" { + name = "${local.name_prefix}-web-sg" + description = "Allow SSH, HTTP and HTTPS inbound traffic to the web/k3s host." + vpc_id = aws_vpc.main.id + + ingress { + description = "Kubernetes API" + from_port = 6443 + to_port = 6443 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + ingress { + description = "SSH" + from_port = 22 + to_port = 22 + protocol = "tcp" + cidr_blocks = [var.ssh_allowed_cidr] + } + + ingress { + description = "HTTP" + from_port = 80 + to_port = 80 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + ingress { + description = "HTTPS" + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + egress { + description = "Allow all outbound traffic" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { + Name = "${local.name_prefix}-web-sg" + } +} + +resource "aws_security_group" "rds" { + name = "${local.name_prefix}-rds-sg" + description = "Allow PostgreSQL traffic only from the web security group." + vpc_id = aws_vpc.main.id + + ingress { + description = "PostgreSQL from web SG" + from_port = 5432 + to_port = 5432 + protocol = "tcp" + security_groups = [aws_security_group.web.id] + } + + egress { + description = "Allow all outbound traffic" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { + Name = "${local.name_prefix}-rds-sg" + } +} diff --git a/terraform/outputs.tf b/terraform/outputs.tf new file mode 100644 index 0000000..fb6c57e --- /dev/null +++ b/terraform/outputs.tf @@ -0,0 +1,39 @@ +output "ec2_public_ip" { + description = "Public IP address of the EC2 web/k3s instance." + value = aws_instance.web.public_ip +} + +output "ec2_public_dns" { + description = "Public DNS name of the EC2 web/k3s instance." + value = aws_instance.web.public_dns +} + +output "rds_endpoint" { + description = "PostgreSQL RDS endpoint URL (host:port)." + value = aws_db_instance.postgres.endpoint +} + +output "rds_address" { + description = "PostgreSQL RDS hostname (without port)." + value = aws_db_instance.postgres.address +} + +output "s3_bucket_name" { + description = "Name of the S3 bucket created for blog images." + value = aws_s3_bucket.blog_images.bucket +} + +output "dynamodb_table_name" { + description = "Name of the DynamoDB table used for blog post metadata." + value = aws_dynamodb_table.blog_posts.name +} + +output "vpc_id" { + description = "ID of the created VPC." + value = aws_vpc.main.id +} + +output "public_subnet_ids" { + description = "IDs of the two public subnets." + value = aws_subnet.public[*].id +} diff --git a/terraform/terraform.tfvars.example b/terraform/terraform.tfvars.example new file mode 100644 index 0000000..d1e4727 --- /dev/null +++ b/terraform/terraform.tfvars.example @@ -0,0 +1,9 @@ +aws_region = "eu-central-1" +project_prefix = "galactic" +environment = "prod" + +db_username = "galactic_admin" +db_name = "galacticview" +db_password = "change-me-to-a-strong-password" + +ssh_allowed_cidr = "0.0.0.0/0" diff --git a/terraform/variables.tf b/terraform/variables.tf new file mode 100644 index 0000000..b820480 --- /dev/null +++ b/terraform/variables.tf @@ -0,0 +1,93 @@ +variable "aws_region" { + description = "AWS region where all resources will be deployed." + type = string + default = "eu-central-1" +} + +variable "project_prefix" { + description = "Short project name used as a prefix for resource names and tags." + type = string + default = "galactic" + + validation { + condition = can(regex("^[a-z][a-z0-9-]*$", var.project_prefix)) + error_message = "project_prefix must start with a lowercase letter and contain only lowercase letters, digits, and hyphens." + } +} + +variable "environment" { + description = "Deployment environment (e.g. dev, staging, prod)." + type = string + default = "prod" +} + +variable "vpc_cidr" { + description = "CIDR block for the VPC." + type = string + default = "10.0.0.0/16" +} + +variable "public_subnet_cidrs" { + description = "CIDR blocks for the public subnets (one per AZ)." + type = list(string) + default = ["10.0.1.0/24", "10.0.2.0/24"] +} + +variable "ssh_allowed_cidr" { + description = "CIDR block allowed to reach the EC2 instance on port 22." + type = string + default = "0.0.0.0/0" +} + +variable "ec2_instance_type" { + description = "EC2 instance type for the k3s web node (t3.small recommended for k3s memory)." + type = string + default = "t3.small" +} + +variable "db_engine_version" { + description = "PostgreSQL major engine version." + type = string + default = "15" +} + +variable "db_instance_class" { + description = "RDS instance class." + type = string + default = "db.t3.micro" +} + +variable "db_allocated_storage" { + description = "Allocated storage for the RDS instance, in GB." + type = number + default = 20 +} + +variable "db_name" { + description = "Initial PostgreSQL database name." + type = string + default = "galacticview" +} + +variable "db_username" { + description = "Master username for the PostgreSQL database." + type = string + default = "galactic_admin" +} + +variable "db_password" { + description = "Master password for the PostgreSQL database. Provide via TF_VAR_db_password or terraform.tfvars." + type = string + sensitive = true + + validation { + condition = length(var.db_password) >= 8 + error_message = "db_password must be at least 8 characters long." + } +} + +variable "dynamodb_table_name" { + description = "Name of the DynamoDB table for blog post metadata." + type = string + default = "GalacticBlogPosts" +}