diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..e7b6c58 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,160 @@ +name: Deploy to K3s + +on: + workflow_dispatch: + inputs: + backend_image: + description: "Backend image tag (full image reference)" + required: false + frontend_image: + description: "Frontend image tag (full image reference)" + required: false + apply: + description: "Apply changes after plan" + required: false + default: "true" + push: + branches: [ main ] + workflow_run: + workflows: [ "Backend Build & Push", "Frontend Build & Push" ] + types: [ completed ] + +jobs: + terraform-plan: + if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }} + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Terraform + uses: hashicorp/setup-terraform@v4 + + - name: Setup kubectl + uses: azure/setup-kubectl@v5 + + - name: Setup Kubeconfig + run: | + mkdir -p ~/.kube + echo "${{ secrets.KUBECONFIG_DATA }}" | base64 -d > ~/.kube/config + chmod 600 ~/.kube/config + + - name: Validate cluster access + run: | + kubectl cluster-info + kubectl get namespace gif-mood + + - name: Determine deployment values + id: deploy-vars + run: | + backend_image='${{ github.event.inputs.backend_image }}' + frontend_image='${{ github.event.inputs.frontend_image }}' + + latest_backend_tag="$(git tag --list 'api-v*' --sort=-v:refname | tail -n1)" + latest_frontend_tag="$(git tag --list 'app-v*' --sort=-v:refname | tail -n1)" + + if [ -z "$latest_backend_tag" ]; then + echo "No backend tag found matching api-v*" >&2 + exit 1 + fi + + if [ -z "$latest_frontend_tag" ]; then + echo "No frontend tag found matching app-v*" >&2 + exit 1 + fi + + if [ -z "$backend_image" ]; then + backend_image="ghcr.io/${{ github.repository }}/backend:$latest_backend_tag" + fi + if [ -z "$frontend_image" ]; then + frontend_image="ghcr.io/${{ github.repository }}/frontend:$latest_frontend_tag" + fi + + echo "Resolved backend_image=$backend_image" + echo "Resolved migrations_image=$migrations_image" + echo "Resolved frontend_image=$frontend_image" + + echo "backend_image=$backend_image" >> "$GITHUB_OUTPUT" + echo "migrations_image=$migrations_image" >> "$GITHUB_OUTPUT" + echo "frontend_image=$frontend_image" >> "$GITHUB_OUTPUT" + + - name: Terraform Fmt -check -diff + working-directory: terraform + run: terraform fmt -check -diff + + - name: Terraform Init + working-directory: terraform + env: + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + run: terraform init + + - name: Terraform Validate + working-directory: terraform + run: terraform validate + + - name: Terraform Plan + working-directory: terraform + env: + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + run: | + terraform plan -out=tfplan \ + -var="db_user=${{ secrets.DB_USER }}" \ + -var="db_password=${{ secrets.DB_PASSWORD }}" \ + -var="db_name=${{ secrets.DB_NAME }}" \ + -var="security_key=${{ secrets.JWT_SECURITY_KEY }}" \ + -var="mailgun_api_key=${{ secrets.MAILGUN_API_KEY }}" \ + -var="backend_image=${{ steps.deploy-vars.outputs.backend_image }}" \ + -var="frontend_image=${{ steps.deploy-vars.outputs.frontend_image }}" + + - name: Upload plan artifact + uses: actions/upload-artifact@v4 + with: + name: tfplan + path: terraform/tfplan + + terraform-apply: + runs-on: ubuntu-latest + needs: terraform-plan + if: ${{ github.event_name == 'push' || github.event_name == 'workflow_run' || github.event.inputs.apply == 'true' }} + environment: + name: production + permissions: + contents: read + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Setup Terraform + uses: hashicorp/setup-terraform@v3 + + - name: Download plan artifact + uses: actions/download-artifact@v4 + with: + name: tfplan + path: terraform + + - name: Setup Kubeconfig + run: | + mkdir -p ~/.kube + echo "${{ secrets.KUBECONFIG_DATA }}" | base64 -d > ~/.kube/config + chmod 600 ~/.kube/config + + - name: Terraform Init + working-directory: terraform + env: + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + run: terraform init + + - name: Terraform Apply + working-directory: terraform + env: + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + run: timeout 1m terraform apply tfplan diff --git a/terraform/main.tf b/terraform/main.tf new file mode 100644 index 0000000..73d5679 --- /dev/null +++ b/terraform/main.tf @@ -0,0 +1,370 @@ +terraform { + required_providers { + kubernetes = { + source = "hashicorp/kubernetes" + version = ">= 2.0.0" + } + } + + backend "s3" { + bucket = "tasknote" + key = "kubernetes/terraform.tfstate" + region = "auto" + endpoints = { s3 = "https://d17eb09b6bce2f90e16e800bb2a6baf9.r2.cloudflarestorage.com" } + skip_credentials_validation = true + skip_region_validation = true + skip_requesting_account_id = true + skip_metadata_api_check = true + skip_s3_checksum = true + } +} + +provider "kubernetes" { + config_path = "~/.kube/config" +} + +variable "db_user" { + type = string + sensitive = true +} + +variable "db_password" { + type = string + sensitive = true +} + +variable "db_name" { + type = string + sensitive = true +} + +variable "security_key" { + type = string + sensitive = true +} + +variable "mailgun_apikey" { + type = string + sensitive = true +} + +variable "cors_allowed_origins" { + type = string + default = "https://tasknote.darkroasted.vps-kinghost.net" +} + +variable "root_log_level" { + type = string + default = "INFO" +} + +variable "backend_image" { + type = string + default = "pull ghcr.io/rmcampos/tasknote/api:15" +} + +variable "frontend_image" { + type = string + default = "ghcr.io/rmcampos/tasknote/app:app-v2026.03.17.18" +} + +resource "kubernetes_namespace_v1" "tasknote" { + metadata { + name = "tasknote" + } +} + +resource "kubernetes_secret_v1" "tasknote_secrets" { + metadata { + name = "tasknote-secrets" + namespace = kubernetes_namespace_v1.tasknote.metadata[0].name + } + + data = { + postgres_user = var.db_user + postgres_password = var.db_password + postgres_db = var.db_name + security_key = var.security_key + mailgun_apikey = var.mailgun_apikey + } +} + +resource "kubernetes_persistent_volume_claim_v1" "tasknote_db_data" { + metadata { + name = "postgres-data-pvc" + namespace = kubernetes_namespace_v1.tasknote.metadata[0].name + } + spec { + access_modes = ["ReadWriteOnce"] + resources { + requests = { + storage = "1Gi" + } + } + } +} + +resource "kubernetes_deployment_v1" "tasknote_db" { + metadata { + name = "tasknote-db" + namespace = kubernetes_namespace_v1.tasknote.metadata[0].name + } + spec { + replicas = 1 + selector { match_labels = { app = "tasknote-db" } } + template { + metadata { labels = { app = "tasknote-db" } } + spec { + container { + image = "postgres:15.8-bookworm" + name = "postgres" + volume_mount { + name = "postgres-storage" + mount_path = "/var/lib/postgresql/data" + } + env { + name = "POSTGRES_USER" + value_from { + secret_key_ref { + name = kubernetes_secret_v1.tasknote_secrets.metadata[0].name + key = "postgres_user" + } + } + } + env { + name = "POSTGRES_PASSWORD" + value_from { + secret_key_ref { + name = kubernetes_secret_v1.tasknote_secrets.metadata[0].name + key = "postgres_password" + } + } + } + env { + name = "POSTGRES_DB" + value_from { + secret_key_ref { + name = kubernetes_secret_v1.tasknote_secrets.metadata[0].name + key = "postgres_db" + } + } + } + port { container_port = 5432 } + } + volume { + name = "postgres-storage" + persistent_volume_claim { + claim_name = kubernetes_persistent_volume_claim_v1.tasknote_db_data.metadata[0].name + } + } + } + } + } +} + +resource "kubernetes_service_v1" "tasknote_db_svc" { + metadata { + name = "tasknote-db-svc" + namespace = kubernetes_namespace_v1.tasknote.metadata[0].name + } + spec { + selector = { app = "tasknote-db" } + port { port = 5432 } + type = "ClusterIP" + } +} + +resource "kubernetes_deployment_v1" "tasknote_backend" { + metadata { + name = "tasknote-backend" + namespace = kubernetes_namespace_v1.tasknote.metadata[0].name + } + spec { + replicas = 1 + selector { match_labels = { app = "tasknote-backend" } } + template { + metadata { labels = { app = "tasknote-backend" } } + spec { + container { + image = var.backend_image + name = "backend" + env { + name = "POSTGRES_DB" + value_from { + secret_key_ref { + name = kubernetes_secret_v1.tasknote_secrets.metadata[0].name + key = "postgres_db" + } + } + } + env { + name = "POSTGRES_HOST" + value = "tasknote-db-svc" + } + env { + name = "POSTGRES_USER" + value_from { + secret_key_ref { + name = kubernetes_secret_v1.tasknote_secrets.metadata[0].name + key = "postgres_user" + } + } + } + env { + name = "POSTGRES_PASSWORD" + value_from { + secret_key_ref { + name = kubernetes_secret_v1.tasknote_secrets.metadata[0].name + key = "postgres_password" + } + } + } + env { + name = "POSTGRES_PORT" + value = "5432" + } + env { + name = "CORS_ALLOWED_ORIGINS" + value = var.cors_allowed_origins + } + env { + name = "SERVER_SERVLET_CONTEXT_PATH" + value = "/" + } + env { + name = "ROOT_LOG_LEVEL" + value = var.root_log_level + } + env { + name = "SECURITY_KEY" + value_from { + secret_key_ref { + name = kubernetes_secret_v1.tasknote_secrets.metadata[0].name + key = "security_key" + } + } + } + env { + name = "TARGET_ENV" + value = "production" + } + env { + name = "MAILGUN_APIKEY" + value_from { + secret_key_ref { + name = kubernetes_secret_v1.tasknote_secrets.metadata[0].name + key = "mailgun_apikey" + } + } + } + resources { + limits = { memory = "256Mi", cpu = "500m" } + requests = { memory = "256Mi", cpu = "250m" } + } + } + } + } + } +} + +resource "kubernetes_service_v1" "tasknote_backend_svc" { + metadata { + name = "tasknote-backend-svc" + namespace = kubernetes_namespace_v1.tasknote.metadata[0].name + } + spec { + selector = { app = "tasknote-backend" } + port { + port = 8585 + target_port = 8585 + } + } +} + +resource "kubernetes_deployment_v1" "tasknote_frontend" { + metadata { + name = "tasknote-frontend" + namespace = kubernetes_namespace_v1.tasknote.metadata[0].name + } + spec { + replicas = 1 + selector { match_labels = { app = "tasknote-app" } } + template { + metadata { labels = { app = "tasknote-app" } } + spec { + container { + image = var.frontend_image + name = "frontend" + port { container_port = 5000 } + env { + name = "VITE_BACKEND_SERVER" + value = "https://tasknoteapi.darkroasted.vps-kinghost.net" + } + } + } + } + } +} + +resource "kubernetes_service_v1" "tasknote_frontend_svc" { + metadata { + name = "tasknote-frontend-svc" + namespace = kubernetes_namespace_v1.tasknote.metadata[0].name + } + spec { + selector = { app = "tasknote-app" } + port { + port = 5000 + target_port = 5000 + } + type = "ClusterIP" + } +} + +# Unified Ingress for App and API +resource "kubernetes_ingress_v1" "tasknote_ingress" { + metadata { + name = "tasknote-ingress" + namespace = kubernetes_namespace_v1.tasknote.metadata[0].name + annotations = { + "kubernetes.io/ingress.class" = "traefik" + "cert-manager.io/cluster-issuer" = "letsencrypt-prod" + } + } + spec { + tls { + hosts = ["tasknote.darkroasted.vps-kinghost.net", "tasknoteapi.darkroasted.vps-kinghost.net"] + secret_name = "tasknote-tls-certs" + } + rule { + host = "tasknote.darkroasted.vps-kinghost.net" + http { + path { + path = "/" + path_type = "Prefix" + backend { + service { + name = kubernetes_service_v1.tasknote_frontend_svc.metadata[0].name + port { number = 5000 } + } + } + } + } + } + rule { + host = "tasknoteapi.darkroasted.vps-kinghost.net" + http { + path { + path = "/" + path_type = "Prefix" + backend { + service { + name = kubernetes_service_v1.tasknote_backend_svc.metadata[0].name + port { number = 8585 } + } + } + } + } + } + } +}