diff --git a/.github/workflows/create-and-publish-docker-images.yml b/.github/workflows/create-and-publish-docker-images.yml new file mode 100644 index 00000000..d58cffc7 --- /dev/null +++ b/.github/workflows/create-and-publish-docker-images.yml @@ -0,0 +1,69 @@ +# source: https://docs.github.com/en/actions/tutorials/publish-packages/publish-docker-images#publishing-images-to-docker-hub-and-github-packages +name: Create and publish a Docker image + +# manually trigger while testing +on: + workflow_dispatch + +# Defines two custom environment variables for the workflow. These are used for the Container registry domain, and a name for the Docker image that this workflow builds. +env: + REGISTRY: ghcr.io + +jobs: + build-and-push-image: + runs-on: ubuntu-latest + # Sets the permissions granted to the `PACKAGE_TOKEN` for the actions in this job. + permissions: + contents: read + packages: write + attestations: write + id-token: write + steps: + - name: Checkout repository + uses: actions/checkout@v5 + # Uses the `docker/login-action` action to log in to the Container registry registry using the account and password that will publish the packages. Once published, the packages are scoped to the account defined here. + - name: Log in to the Container registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.PACKAGE_TOKEN }} + # This step uses [docker/metadata-action](https://github.com/docker/metadata-action#about) to extract tags and labels that will be applied to the specified image. The `id` "meta" allows the output of this step to be referenced in a subsequent step. The `images` value provides the base name for the tags and labels. + - name: Set lowercase image name + run: | + IMAGE_NAME=$(echo "${GITHUB_REPOSITORY}" | tr '[:upper:]' '[:lower:]') + echo "IMAGE_NAME=${IMAGE_NAME}" >> $GITHUB_ENV + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + - name: Format repo slug + uses: actions/github-script@v8 + id: repo_slug + with: + result-encoding: string + script: | + return `ghcr.io/${process.env.GITHUB_REPOSITORY.toLowerCase()}` + # This step uses the `docker/build-push-action` action to build the image, based on your repository's `Dockerfile`. If the build succeeds, it pushes the image to GitHub Packages. + # It uses the `context` parameter to define the build's context as the set of files located in the specified path. For more information, see [Usage](https://github.com/docker/build-push-action#usage) in the README of the `docker/build-push-action` repository. + # It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step. + + - name: Build and push Docker image + id: push + uses: docker/build-push-action@v6 + with: + context: ./TEKDB + file: ./TEKDB/Dockerfile + push: true + tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}/web:latest,${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}/web:${{ github.sha }} + labels: ${{ steps.meta.outputs.labels }} + + # This step generates an artifact attestation for the image, which is an unforgeable statement about where and how it was built. It increases supply chain security for people who consume the image. For more information, see [Using artifact attestations to establish provenance for builds](/actions/security-guides/using-artifact-attestations-to-establish-provenance-for-builds). + - name: Generate artifact attestation + uses: actions/attest-build-provenance@v3 + with: + subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}} + subject-digest: ${{ steps.push.outputs.digest }} + push-to-registry: true + diff --git a/.gitignore b/.gitignore index c5ba5117..a3f79ad1 100644 --- a/.gitignore +++ b/.gitignore @@ -86,6 +86,7 @@ celerybeat-schedule # dotenv .env +.env.prod # virtualenv venv/ @@ -98,4 +99,10 @@ ENV/ .ropeproject #vscode settings -.vscode/ \ No newline at end of file +.vscode/ + +# OpenTofu/Terraform +.terraform/ +terraform.tfstate +terraform.tfstate.backup +*.tfvars \ No newline at end of file diff --git a/TEKDB/Dockerfile b/TEKDB/Dockerfile index 1ba7ac1f..78fb2bd3 100644 --- a/TEKDB/Dockerfile +++ b/TEKDB/Dockerfile @@ -42,5 +42,3 @@ ENV DJANGO_SETTINGS_MODULE=TEKDB.settings ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] -# use development server by default -CMD ["dev"] diff --git a/TEKDB/TEKDB/settings.py b/TEKDB/TEKDB/settings.py index 49af92a8..21028c6b 100644 --- a/TEKDB/TEKDB/settings.py +++ b/TEKDB/TEKDB/settings.py @@ -127,7 +127,7 @@ "USER": os.environ.get("SQL_USER", "postgres"), "PASSWORD": os.environ.get("SQL_PASSWORD", None), "HOST": os.environ.get("SQL_HOST", "db"), - "PORT": os.environ.get("SQL_PORT", None), + "PORT": os.environ.get("SQL_PORT", 5432), } } diff --git a/TEKDB/entrypoint.sh b/TEKDB/entrypoint.sh index fac43bbe..a0644cd0 100644 --- a/TEKDB/entrypoint.sh +++ b/TEKDB/entrypoint.sh @@ -32,8 +32,11 @@ if [ "$(python manage.py shell -c 'from TEKDB.models import LookupPlanningUnit; fi if [ "$1" = "prod" ]; then - echo "Starting uWSGI (HTTP) on :8000" - uwsgi --http :8000 --master --enable-threads --module TEKDB.wsgi + echo "Starting uWSGI (socket) on :8000" + uwsgi --socket :8000 --master --enable-threads --module TEKDB.wsgi +elif [ "$1" = "prod-local" ]; then + echo "Starting uWSGI (http) on :8000 with local settings" + uwsgi --http :8000 --master --enable-threads --module TEKDB.wsgi elif [ "$1" = "dev" ]; then echo "Starting python development server on :8000" python manage.py runserver 0.0.0.0:8000 diff --git a/docker/docker-compose.yml b/docker/common.yaml similarity index 63% rename from docker/docker-compose.yml rename to docker/common.yaml index f865e83a..2d58c8e7 100644 --- a/docker/docker-compose.yml +++ b/docker/common.yaml @@ -16,7 +16,7 @@ services: interval: 10s timeout: 5s retries: 5 - + web: build: context: ../TEKDB/ @@ -26,20 +26,5 @@ services: - db env_file: - .env.dev - environment: - ALLOWED_HOSTS: ${ALLOWED_HOSTS} - DEBUG: ${DEBUG} - SQL_ENGINE: ${SQL_ENGINE} - SQL_HOST: ${SQL_HOST} - SQL_PORT: ${SQL_PORT} - SQL_DATABASE: ${SQL_DATABASE} - SQL_USER: ${SQL_USER} - SQL_PASSWORD: ${SQL_PASSWORD} - SECRET_KEY: ${SECRET_KEY} ports: - - "8000:8000" - volumes: - - ../TEKDB:/usr/src/app - -volumes: - tekdb_db_data: + - "8000:8000" \ No newline at end of file diff --git a/docker/docker-compose.prod.local.yaml b/docker/docker-compose.prod.local.yaml new file mode 100644 index 00000000..421e3d93 --- /dev/null +++ b/docker/docker-compose.prod.local.yaml @@ -0,0 +1,34 @@ +services: + db: + extends: + file: common.yaml + service: db + + web: + extends: + file: common.yaml + service: web + command: ["prod-local"] + env_file: + - .env.dev + ports: [] + volumes: + - static_volume:/usr/src/app/static + - media_volume:/usr/src/app/media + proxy: + build: + context: ../proxy/ + dockerfile: ../proxy/Dockerfile + restart: unless-stopped + depends_on: + - web + ports: + - "8080:8080" + volumes: + - static_volume:/vol/static/static:ro + - media_volume:/vol/static/media:ro + +volumes: + tekdb_db_data: + static_volume: + media_volume: diff --git a/docker/docker-compose.prod.yaml b/docker/docker-compose.prod.yaml index 872e7c53..2105577a 100644 --- a/docker/docker-compose.prod.yaml +++ b/docker/docker-compose.prod.yaml @@ -1,46 +1,33 @@ services: db: - image: postgis/postgis:15-3.4 - restart: always - platform: linux/amd64 - environment: - POSTGRES_DB: ${SQL_DATABASE} - POSTGRES_USER: ${SQL_USER} - POSTGRES_PASSWORD: ${SQL_PASSWORD} - volumes: - - tekdb_db_data:/var/lib/postgresql/data - ports: - - "5432:5432" - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${SQL_USER} -d ${SQL_DATABASE} -h localhost -p ${SQL_PORT}"] - interval: 10s - timeout: 5s - retries: 5 + extends: + file: common.yaml + service: db web: - build: - context: ../TEKDB/ - dockerfile: ../TEKDB/Dockerfile + extends: + file: common.yaml + service: web + image: ${ITKDB_ECR_PATH}:latest command: ["prod"] + env_file: + - .env.prod + ports: [] + volumes: + - static_volume:/usr/src/app/static + - media_volume:/usr/src/app/media + proxy: + image: ${ITKDB_PROXY_ECR_PATH}:latest restart: unless-stopped depends_on: - - db - env_file: - - .env.dev - environment: - ALLOWED_HOSTS: ${ALLOWED_HOSTS} - DEBUG: ${DEBUG} - SQL_ENGINE: ${SQL_ENGINE} - SQL_HOST: ${SQL_HOST} - SQL_PORT: ${SQL_PORT} - SQL_DATABASE: ${SQL_DATABASE} - SQL_USER: ${SQL_USER} - SQL_PASSWORD: ${SQL_PASSWORD} - SECRET_KEY: ${SECRET_KEY} + - web ports: - - "8000:8000" + - "80:8080" volumes: - - ../TEKDB:/usr/src/app + - static_volume:/vol/static/static:ro + - media_volume:/vol/static/media:ro volumes: tekdb_db_data: + static_volume: + media_volume: diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml new file mode 100644 index 00000000..742ef053 --- /dev/null +++ b/docker/docker-compose.yaml @@ -0,0 +1,26 @@ +services: + db: + extends: + file: common.yaml + service: db + + web: + extends: + file: common.yaml + service: web + command: ["dev"] + volumes: + - ../TEKDB:/usr/src/app + environment: + ALLOWED_HOSTS: ${ALLOWED_HOSTS} + DEBUG: ${DEBUG} + SQL_ENGINE: ${SQL_ENGINE} + SQL_HOST: ${SQL_HOST} + SQL_PORT: ${SQL_PORT} + SQL_DATABASE: ${SQL_DATABASE} + SQL_USER: ${SQL_USER} + SQL_PASSWORD: ${SQL_PASSWORD} + SECRET_KEY: ${SECRET_KEY} + +volumes: + tekdb_db_data: diff --git a/infra/.terraform.lock.hcl b/infra/.terraform.lock.hcl new file mode 100644 index 00000000..900ce952 --- /dev/null +++ b/infra/.terraform.lock.hcl @@ -0,0 +1,20 @@ +# This file is maintained automatically by "tofu init". +# Manual edits may be lost in future updates. + +provider "registry.opentofu.org/hashicorp/aws" { + version = "5.100.0" + constraints = "~> 5.92" + hashes = [ + "h1:BrNG7eFOdRrRRbHdvrTjMJ8X8Oh/tiegURiKf7J2db8=", + "zh:1a41f3ee26720fee7a9a0a361890632a1701b5dc1cf5355dc651ddbe115682ff", + "zh:30457f36690c19307921885cc5e72b9dbeba369445815903acd5c39ac0e41e7a", + "zh:42c22674d5f23f6309eaf3ac3a4f1f8b66b566c1efe1dcb0dd2fb30c17ce1f78", + "zh:4cc271c795ff8ce6479ec2d11a8ba65a0a9ed6331def6693f4b9dccb6e662838", + "zh:60932aa376bb8c87cd1971240063d9d38ba6a55502c867fdbb9f5361dc93d003", + "zh:864e42784bde77b18393ebfcc0104cea9123da5f4392e8a059789e296952eefa", + "zh:9750423138bb01ecaa5cec1a6691664f7783d301fb1628d3b64a231b6b564e0e", + "zh:e5d30c4dec271ef9d6fe09f48237ec6cfea1036848f835b4e47f274b48bda5a7", + "zh:e62bd314ae97b43d782e0841b13e68a3f8ec85cc762004f973ce5ce7b6cdbfd0", + "zh:ea851a3c072528a4445ac6236ba2ce58ffc99ec466019b0bd0e4adde63a248e4", + ] +} diff --git a/infra/ec2.tf b/infra/ec2.tf new file mode 100644 index 00000000..5242a645 --- /dev/null +++ b/infra/ec2.tf @@ -0,0 +1,55 @@ +resource "aws_key_pair" "itkdb" { + key_name = "${var.project_name}-key" + public_key = var.ssh_public_key + + tags = { + Project = "${var.project_name}-staging" + } +} + +resource "aws_instance" "itkdb" { + ami = var.ec2_ami + instance_type = var.ec2_instance_type + key_name = aws_key_pair.itkdb.key_name + vpc_security_group_ids = [aws_security_group.itkdb.id] + iam_instance_profile = aws_iam_instance_profile.ec2_profile.name + subnet_id = tolist(data.aws_subnets.default.ids)[0] + user_data_replace_on_change = true + + # TODO: fix this! currently does not work + # Install Docker and AWS CLI v2 on first boot + user_data = <<-EOF + #!/bin/bash + set -e + sudo apt update + sudo apt install -y docker + systemctl start docker + systemctl enable docker + usermod -aG docker ubuntu + sudo snap install aws-cli --classic + unzip awscliv2.zip + sudo ./aws/install + EOF + + root_block_device { + volume_size = 20 + volume_type = "gp3" + encrypted = true + } + + tags = { + Name = "${var.project_name}-staging-server" + Project = var.project_name + } +} + +# Elastic IP so the address never changes across stop/start +resource "aws_eip" "itkdb" { + instance = aws_instance.itkdb.id + domain = "vpc" + + tags = { + Name = "${var.project_name}-staging-eip" + Project = var.project_name + } +} \ No newline at end of file diff --git a/infra/ecr.tf b/infra/ecr.tf new file mode 100644 index 00000000..9a5c9826 --- /dev/null +++ b/infra/ecr.tf @@ -0,0 +1,17 @@ +resource "aws_ecr_repository" "web" { + name = "ecotrust/${var.project_name}" + image_tag_mutability = "MUTABLE" + + tags = { + Project = var.project_name + } +} + +resource "aws_ecr_repository" "proxy" { + name = "ecotrust/${var.project_name}-proxy" + image_tag_mutability = "MUTABLE" + + tags = { + Project = var.project_name + } +} diff --git a/infra/iam.tf b/infra/iam.tf new file mode 100644 index 00000000..fcf46649 --- /dev/null +++ b/infra/iam.tf @@ -0,0 +1,28 @@ +# IAM role that allows the EC2 instance to pull images from ECR +resource "aws_iam_role" "ec2_ecr_role" { + name = "${var.project_name}-ec2-ecr-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { Service = "ec2.amazonaws.com" } + }] + }) + + tags = { + Project = var.project_name + } +} + +resource "aws_iam_role_policy_attachment" "ecr_read" { + role = aws_iam_role.ec2_ecr_role.name + policy_arn = "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly" +} + +# Instance profile wraps the role so EC2 can use it +resource "aws_iam_instance_profile" "ec2_profile" { + name = "${var.project_name}-ec2-profile" + role = aws_iam_role.ec2_ecr_role.name +} \ No newline at end of file diff --git a/infra/main.tf b/infra/main.tf new file mode 100644 index 00000000..57ebd1bd --- /dev/null +++ b/infra/main.tf @@ -0,0 +1,52 @@ +provider "aws" { + region = var.aws_region +} + +variable "aws_region" { + description = "AWS region to deploy into" + type = string + default = "us-west-2" +} + +variable "project_name" { + description = "Project name used to prefix all resources" + type = string + default = "itkdb" +} + +variable "aws_profile" { + description = "AWS profile to use" + type = string + default = "default" +} + +variable "bucket_name" { + description = "S3 bucket name for Terraform state (must be globally unique)" + type = string + default = "itkdb-tf-state" +} + +variable "ec2_instance_type" { + description = "EC2 instance type" + type = string + default = "t3.small" +} + +variable "ec2_ami" { + description = "Ubuntu 24.04 LTS AMI ID (region-specific — update if changing region)" + type = string + # Ubuntu 24.04 LTS us-west-1 — check https://cloud-images.ubuntu.com/locator/ec2/ for your region + default = "ami-06b527a1e4cb6f265" +} + +variable "ssh_public_key" { + description = "SSH public key to install on the EC2 instance (contents of your .pub file)" + type = string + sensitive = true +} + +variable "allowed_ssh_cidr" { + description = "CIDR block allowed to SSH into the EC2 instance (use your IP: x.x.x.x/32)" + type = string + default = "0.0.0.0/0" # Restrict this to your IP in production! +} \ No newline at end of file diff --git a/infra/networking.tf b/infra/networking.tf new file mode 100644 index 00000000..fee43725 --- /dev/null +++ b/infra/networking.tf @@ -0,0 +1,53 @@ +data "aws_vpc" "default" { + default = true +} + +data "aws_subnets" "default" { + filter { + name = "vpc-id" + values = [data.aws_vpc.default.id] + } +} + +resource "aws_security_group" "itkdb" { + name = "${var.project_name}-sg" + description = "Security group for ITKDB Staging EC2 instance" + vpc_id = data.aws_vpc.default.id + + # HTTP + ingress { + from_port = 80 + to_port = 80 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + # HTTPS (for when you add SSL via certbot/nginx later) + ingress { + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + # SSH — restrict to your IP in production! + ingress { + from_port = 22 + to_port = 22 + protocol = "tcp" + cidr_blocks = [var.allowed_ssh_cidr] + } + + # Allow all outbound (needed for ECR pulls, apt, etc.) + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { + Name = "${var.project_name}-sg" + Project = var.project_name + } +} \ No newline at end of file diff --git a/infra/outputs.tf b/infra/outputs.tf new file mode 100644 index 00000000..960acf91 --- /dev/null +++ b/infra/outputs.tf @@ -0,0 +1,16 @@ +output "ec2_public_ip" { + description = "Elastic IP of the EC2 instance — use this for DNS and GitHub secrets" + value = aws_eip.itkdb.public_ip +} + +output "ecr_web_url" { + description = "ECR URL for the web image" + value = aws_ecr_repository.web.repository_url +} + +output "ecr_proxy_url" { + description = "ECR URL for the proxy image" + value = aws_ecr_repository.proxy.repository_url +} + +data "aws_caller_identity" "current" {} \ No newline at end of file diff --git a/infra/terraform.tf b/infra/terraform.tf new file mode 100644 index 00000000..11c04bca --- /dev/null +++ b/infra/terraform.tf @@ -0,0 +1,17 @@ +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.92" + } + } + + required_version = ">= 1.2" + + backend "s3" { + bucket = "itkdb-tf-state" + key = "staging/terraform.tfstate" + region = var.aws_region + profile = "default" + } +} \ No newline at end of file diff --git a/proxy/Dockerfile b/proxy/Dockerfile index b7fd7b32..978935c0 100644 --- a/proxy/Dockerfile +++ b/proxy/Dockerfile @@ -1,11 +1,12 @@ FROM nginxinc/nginx-unprivileged:1-alpine +USER root + COPY ./default.conf /etc/nginx/conf.d/default.conf COPY ./uwsgi_params /etc/nginx/uwsgi_params -USER root - -RUN mkdir -p /vol/static -RUN chmod 755 /vol/static +RUN chmod 644 /etc/nginx/conf.d/default.conf /etc/nginx/uwsgi_params && \ + mkdir -p /vol/static && \ + chmod 755 /vol/static USER nginx diff --git a/proxy/default.conf b/proxy/default.conf index 99e1e714..8ef2d0a5 100644 --- a/proxy/default.conf +++ b/proxy/default.conf @@ -8,7 +8,7 @@ server { alias /vol/static/media; } location / { - uwsgi_pass app:8000; + uwsgi_pass web:8000; include /etc/nginx/uwsgi_params; } }