diff --git a/.github/workflows/teste-infra.yml b/.github/workflows/teste-infra.yml new file mode 100644 index 0000000..f23c5be --- /dev/null +++ b/.github/workflows/teste-infra.yml @@ -0,0 +1,85 @@ +name: teste-infra + +on: + pull_request: + branches: [main] + paths: + - "apps/**" + - "argocd/**" + - "infra/aws/**" + - ".github/workflows/teste-infra.yml" + push: + branches: [main] + paths: + - "apps/**" + - "argocd/**" + - "infra/aws/**" + - ".github/workflows/teste-infra.yml" + +permissions: + contents: read + +jobs: + validar-kustomize: + runs-on: ubuntu-latest + + steps: + - name: baixar codigo + uses: actions/checkout@v4 + + - name: instalar kubectl + uses: azure/setup-kubectl@v4 + + - name: renderizar manifests + run: kubectl kustomize apps > manifests-rendered.yaml + + - name: validar schemas k8s + uses: docker://ghcr.io/yannh/kubeconform:v0.6.7 + with: + entrypoint: /kubeconform + args: -strict -summary -skip InfisicalSecret /github/workspace/manifests-rendered.yaml + + validar-terraform: + runs-on: ubuntu-latest + + steps: + - name: baixar codigo + uses: actions/checkout@v4 + + - name: instalar terraform + uses: hashicorp/setup-terraform@v4 + with: + terraform_version: 1.13.3 + + - name: verificar formatacao + run: terraform -chdir=infra/aws fmt -check + + - name: inicializar sem backend + run: terraform -chdir=infra/aws init -backend=false -input=false + + - name: validar terraform + run: terraform -chdir=infra/aws validate + + validar-argocd: + runs-on: ubuntu-latest + + steps: + - name: baixar codigo + uses: actions/checkout@v4 + + - name: validar estrutura da application + uses: mikefarah/yq@v4.52.1 + with: + cmd: >- + yq -e ' + .apiVersion == "argoproj.io/v1alpha1" and + .kind == "Application" and + .metadata.namespace == "argocd" and + .spec.source.repoURL == "https://github.com/AppActa/acta-platform.git" and + .spec.source.targetRevision == "main" and + .spec.source.path == "apps" and + .spec.destination.server == "https://kubernetes.default.svc" and + .spec.destination.namespace == "acta-prod" and + .spec.syncPolicy.automated.prune == true and + .spec.syncPolicy.automated.selfHeal == true + ' argocd/prod.yaml diff --git a/.gitignore b/.gitignore index e69de29..3a0dfe2 100644 --- a/.gitignore +++ b/.gitignore @@ -0,0 +1,7 @@ +*.env +local/*.env +testes/ +*.terraform +*.tfstate +terraform.tfvars +.aws/ \ No newline at end of file diff --git a/apps/config/pg-api-secrets.yaml b/apps/config/pg-api-secrets.yaml new file mode 100644 index 0000000..6df8781 --- /dev/null +++ b/apps/config/pg-api-secrets.yaml @@ -0,0 +1,46 @@ +apiVersion: secrets.infisical.com/v1alpha1 +kind: InfisicalSecret + +metadata: + name: acta-pg-api-secrets + +spec: + hostAPI: https://app.infisical.com/api + + syncConfig: + resyncInterval: 60s + instantUpdates: false + + authentication: + universalAuth: + credentialsRef: + secretName: infisical-universal-auth + secretNamespace: acta-prod + secretsScope: + projectId: 051dcf5e-00ed-4d46-8b36-baad0a621f5d + envSlug: prod + secretsPath: /pg-api + recursive: false + + managedKubeSecretReferences: + - secretName: acta-pg-api-env + secretNamespace: acta-prod + creationPolicy: Owner + + template: + includeAllSecrets: false + data: + DB_URL: "{{ .DB_URL.Value }}" + DB_USER: "{{ .DB_USER.Value }}" + DB_PASSWORD: "{{ .DB_PASSWORD.Value }}" + FIREBASE_PROJECT_ID: "{{ .FIREBASE_PROJECT_ID.Value }}" + CORS_ALLOWED_ORIGINS: "{{ .CORS_ALLOWED_ORIGINS.Value }}" + + - secretName: firebase-admin + secretNamespace: acta-prod + creationPolicy: Owner + + template: + includeAllSecrets: false + data: + service-account.json: "{{ .FIREBASE_SERVICE_ACCOUNT_JSON.Value }}" diff --git a/apps/kustomization.yaml b/apps/kustomization.yaml new file mode 100644 index 0000000..18309af --- /dev/null +++ b/apps/kustomization.yaml @@ -0,0 +1,8 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: acta-prod + +resources: + - pg-api.yaml + - config/pg-api-secrets.yaml \ No newline at end of file diff --git a/apps/pg-api.yaml b/apps/pg-api.yaml new file mode 100644 index 0000000..e71c2a8 --- /dev/null +++ b/apps/pg-api.yaml @@ -0,0 +1,109 @@ +apiVersion: apps/v1 +kind: Deployment + +metadata: + name: acta-pg-api + annotations: + # reinicia os pods quando o infisical atualizar os secrets + secrets.infisical.com/auto-reload: "true" + labels: + app.kubernetes.io/name: acta-pg-api + app.kubernetes.io/part-of: acta + +spec: + replicas: 3 + revisionHistoryLimit: 3 + + selector: + matchLabels: + app.kubernetes.io/name: acta-pg-api + + template: + metadata: + labels: + app.kubernetes.io/name: acta-pg-api + app.kubernetes.io/part-of: acta + + spec: + containers: + - name: acta-pg-api + image: ghcr.io/appacta/acta-pg-api@sha256:760a2c439bc64494e69342166338a0f934b72354ee0a352444a596bbd6e6427d + imagePullPolicy: IfNotPresent + + ports: + - name: http + containerPort: 8080 + protocol: TCP + + envFrom: + - secretRef: + name: acta-pg-api-env + + env: + - name: GOOGLE_APPLICATION_CREDENTIALS + value: /var/run/secrets/firebase/service-account.json + + # monta o json do firebase como arquivo somente leitura + volumeMounts: + - name: firebase-admin + mountPath: /var/run/secrets/firebase + readOnly: true + + startupProbe: + tcpSocket: + port: http + periodSeconds: 5 + failureThreshold: 60 + + readinessProbe: + httpGet: + path: /api/v1/health + port: http + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + + # verifica se o processo continua aceitando conexoes na porta + livenessProbe: + tcpSocket: + port: http + periodSeconds: 20 + timeoutSeconds: 5 + failureThreshold: 3 + + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi + + volumes: + - name: firebase-admin + secret: + secretName: firebase-admin + items: + - key: service-account.json + path: service-account.json +--- +apiVersion: v1 +kind: Service + +metadata: + name: acta-pg-api + labels: + app.kubernetes.io/name: acta-pg-api + app.kubernetes.io/part-of: acta + +spec: + type: ClusterIP + + selector: + app.kubernetes.io/name: acta-pg-api + + ports: + - name: http + port: 8080 + targetPort: http + protocol: TCP diff --git a/argocd/prod.yaml b/argocd/prod.yaml new file mode 100644 index 0000000..2fdca2c --- /dev/null +++ b/argocd/prod.yaml @@ -0,0 +1,23 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application + +metadata: + name: acta-prod + namespace: argocd + +spec: + project: default + + source: + repoURL: https://github.com/AppActa/acta-platform.git + targetRevision: main + path: apps + + destination: + server: https://kubernetes.default.svc + namespace: acta-prod + + syncPolicy: + automated: + prune: true + selfHeal: true diff --git a/infra/aws/main.tf b/infra/aws/main.tf new file mode 100644 index 0000000..3e782ef --- /dev/null +++ b/infra/aws/main.tf @@ -0,0 +1,202 @@ +# configuração do terraform +terraform { + required_version = ">= 1.5.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } +} + +# configuração da AWS +provider "aws" { + region = var.aws_region + + default_tags { + tags = { + Project = "ACTA" + ManagedBy = "Terraform" + } + } +} + +# verificação de disponibilidade da região escolhida +data "aws_availability_zones" "available" { + state = "available" +} + +# pull da imagem ubuntu +data "aws_ami" "ubuntu" { + most_recent = true + owners = ["099720109477"] # conta oficial da canonical + + filter { + name = "name" + values = ["ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*"] + } + + filter { + name = "architecture" + values = ["x86_64"] + } + + filter { + name = "virtualization-type" + values = ["hvm"] + } +} + +# rede privada do acta (vpc) +resource "aws_vpc" "acta" { + cidr_block = var.vpc_cidr + enable_dns_support = true + enable_dns_hostnames = true + + tags = { + Name = "acta-prod-vpc" + } +} + +# porta da vpc com a internet +resource "aws_internet_gateway" "acta" { + vpc_id = aws_vpc.acta.id + + tags = { + Name = "acta-prod-igw" + } +} + +# subnet na vpc +# onde a ec2 do k3s vai ficar +resource "aws_subnet" "public" { + vpc_id = aws_vpc.acta.id + cidr_block = var.public_subnet_cidr + availability_zone = data.aws_availability_zones.available.names[0] + map_public_ip_on_launch = true + + tags = { + Name = "acta-prod-public" + } +} + +# tabela de rotas +resource "aws_route_table" "public" { + vpc_id = aws_vpc.acta.id + + route { + cidr_block = "0.0.0.0/0" # qualquer ipv4 + gateway_id = aws_internet_gateway.acta.id + } + + tags = { + Name = "acta-prod-public" + } +} + +# rotas na subnet +resource "aws_route_table_association" "public" { + subnet_id = aws_subnet.public.id + route_table_id = aws_route_table.public.id +} + +# firewall da ec2 +resource "aws_security_group" "k3s" { + name = "acta-prod-k3s" + description = "Acesso restrito ao k3s" + vpc_id = aws_vpc.acta.id + + # libera porta 22 + ingress { + description = "SSH administrativo" + from_port = 22 + to_port = 22 + protocol = "tcp" + cidr_blocks = [var.admin_cidr] # apenas para esse ip + } + + # libera api do kubernetes + ingress { + description = "API Kubernetes" + from_port = 6443 + to_port = 6443 + protocol = "tcp" + cidr_blocks = [var.admin_cidr] + } + + # permite conexão de ec2 para qualquer endereço + egress { + description = "Saida necessaria para atualizacoes e imagens" + from_port = 0 + to_port = 0 + protocol = "-1" # todos os protocolos + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { + Name = "acta-prod-k3s" + } +} + +# criação da máquina virtual +resource "aws_instance" "k3s" { + ami = data.aws_ami.ubuntu.id + instance_type = var.instance_type + key_name = var.key_name + subnet_id = aws_subnet.public.id + + # instância pública da subnet + vpc_security_group_ids = [aws_security_group.k3s.id] + associate_public_ip_address = true + + # acesso a dados internos da ec2 exigindo token de segurança + metadata_options { + http_endpoint = "enabled" + http_tokens = "required" + } + + root_block_device { + volume_type = "gp3" + volume_size = var.root_volume_size + encrypted = true + delete_on_termination = true # evita discos abertos depois da exclusão + } + + # define bash como interpretador + # para script se houver erro + # usa ip público fixo, somente o proprietário lê ou altera o kubeconfig + user_data_replace_on_change = true + user_data = <<-EOT + #!/usr/bin/env bash + set -euo pipefail + + curl --proto '=https' --tlsv1.2 -sfL https://get.k3s.io \ + | INSTALL_K3S_EXEC="server --secrets-encryption --tls-san=${aws_eip.k3s.public_ip} --write-kubeconfig-mode=600" sh - + EOT + + # só criada depois da subnet na tabela de rotas + depends_on = [aws_route_table_association.public] + + tags = { + Name = "acta-prod-k3s" + } +} + +# elastic ip +# reserva ipv4 fixo (não muda quando pausa ou é reinicializado) +resource "aws_eip" "k3s" { + domain = "vpc" + + depends_on = [aws_internet_gateway.acta] + + tags = { + Name = "acta-prod-k3s" + } +} + +# elastic ip com a ec2 +resource "aws_eip_association" "k3s" { + allocation_id = aws_eip.k3s.id + instance_id = aws_instance.k3s.id +} diff --git a/infra/aws/output.tf b/infra/aws/output.tf new file mode 100644 index 0000000..9d24bf1 --- /dev/null +++ b/infra/aws/output.tf @@ -0,0 +1,23 @@ +# mostra o elastic ip da ec2 +output "public_ip" { + description = "Elastic IP público do servidor K3s." + value = aws_eip.k3s.public_ip +} + +# id da ec2 criado pela aws +output "instance_id" { + description = "ID da instância EC2 do servidor K3s." + value = aws_instance.k3s.id +} + +# qual zona de região foi criada na ec2 +output "availability_zone" { + description = "Zona de disponibilidade usada pela instância." + value = aws_instance.k3s.availability_zone +} + +# modelo de comando ssh usando o elastic ip +output "ssh_command" { + description = "Modelo de comando para acessar a instância usando a chave privada correspondente." + value = "ssh -i ubuntu@${aws_eip.k3s.public_ip}" +} diff --git a/infra/aws/variables.tf b/infra/aws/variables.tf new file mode 100644 index 0000000..20ba3e2 --- /dev/null +++ b/infra/aws/variables.tf @@ -0,0 +1,84 @@ +# região dos recursos (obrigatório) +variable "aws_region" { + description = "Região AWS onde a infraestrutura do ACTA será criada." + type = string + + validation { + # apenas valida formato + condition = can(regex("^[a-z]{2}(-gov)?-[a-z]+-[0-9]+$", var.aws_region)) + error_message = "aws_region deve ser uma região AWS válida, por exemplo sa-east-1." + } +} + +# nome de ssh cadstrada na aws (obrigatório) +variable "key_name" { + description = "Nome de um EC2 Key Pair já existente na região escolhida." + type = string + + validation { + condition = length(trimspace(var.key_name)) > 0 + error_message = "key_name não pode ser vazio." + } +} + +# ip admin (obrigatório) +# ssh na porta 22 +# api o k8s na porta 6443 +variable "admin_cidr" { + description = "IP público autorizado a acessar SSH e a API do K3s, obrigatoriamente em /32." + type = string + + validation { + condition = can(cidrhost(var.admin_cidr, 0)) && can(regex("^[0-9]{1,3}(\\.[0-9]{1,3}){3}/32$", var.admin_cidr)) + error_message = "admin_cidr deve ser um IPv4 válido com prefixo /32." + } +} + +# capacidade da vm (obrigatório) +variable "instance_type" { + description = "Tipo da instância EC2 x86_64 que executará o K3s." + type = string + + validation { + condition = length(trimspace(var.instance_type)) > 0 + error_message = "instance_type não pode ser vazio." + } +} + +# espaço total de endereços da vpc +variable "vpc_cidr" { + description = "CIDR privado da VPC." + type = string + default = "10.42.0.0/16" + + validation { + condition = can(cidrhost(var.vpc_cidr, 0)) + error_message = "vpc_cidr deve ser um bloco CIDR válido." + } +} + +# separa parte da vpc para a subnet pública +# onde fica a ec2 +variable "public_subnet_cidr" { + description = "CIDR da subnet pública. Deve estar contido em vpc_cidr." + type = string + default = "10.42.1.0/24" + + validation { + condition = can(cidrhost(var.public_subnet_cidr, 0)) + error_message = "public_subnet_cidr deve ser um bloco CIDR válido." + } +} + +# tamanho do disco principal da ec2 (em gib) +# onde fica ubuntu, k3s, imagens, logs, dados internos... +variable "root_volume_size" { + description = "Tamanho, em GiB, do volume raiz GP3 criptografado." + type = number + default = 30 + + validation { + condition = var.root_volume_size >= 20 && var.root_volume_size <= 16384 + error_message = "root_volume_size deve estar entre 20 e 16384 GiB." + } +}