From 1badb192b5302c539b1bb469b0311e1f4a83ab49 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 13 Jun 2025 18:18:34 +0000 Subject: [PATCH 1/2] feat: Implement K3s deployment with GitHub Actions CI/CD This commit introduces a complete setup for deploying the Phoenix application to a K3s Kubernetes cluster, along with a GitHub Actions workflow for automated builds and deployments. Key changes include: - **Kubernetes Manifests (`k8s/`)**: - Namespace (`aprs-app`) for isolating application resources. - PostgreSQL setup: PersistentVolumeClaim, Deployment, and Service. - Application setup: Deployment (with init container for migrations) and Service (NodePort). - An example secrets file (`k8s/secrets.yaml.example`) for configuration. - `.gitignore` updated to exclude `k8s/secrets.yaml`. - **GitHub Actions Workflow (`.github/workflows/deploy-k3s.yaml`)**: - Builds the Docker image on pushes to the main branch. - Pushes the image to GitHub Container Registry (GHCR). - Deploys the application to K3s by applying the manifests. - Dynamically updates the application deployment with the correct image tag. - Requires `KUBE_CONFIG_DATA` GitHub secret for K3s authentication. - **Application Configuration (`config/runtime.exs`)**: - Commented out `libcluster` configuration specific to Fly.io to prevent issues in a standard K3s environment. - **Documentation (`README.md`)**: - Added a new "Kubernetes (K3s) Deployment" section detailing: - Prerequisites and initial setup (GitHub & K8s secrets). - Workflow overview. - Application configuration notes (health checks, migrations). - Instructions for accessing the application. This setup provides a robust foundation for CI/CD and running the application in a self-hosted K3s environment. --- .github/workflows/deploy-k3s.yaml | 140 ++++++++++++++++++++++++++++++ .gitignore | 2 + README.md | 93 ++++++++++++++++++++ config/runtime.exs | 24 ++--- k8s/app-deployment.yaml | 53 +++++++++++ k8s/app-service.yaml | 13 +++ k8s/namespace.yaml | 4 + k8s/postgres-deployment.yaml | 42 +++++++++ k8s/postgres-pvc.yaml | 11 +++ k8s/postgres-service.yaml | 13 +++ k8s/secrets.yaml.example | 15 ++++ 11 files changed, 398 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/deploy-k3s.yaml create mode 100644 k8s/app-deployment.yaml create mode 100644 k8s/app-service.yaml create mode 100644 k8s/namespace.yaml create mode 100644 k8s/postgres-deployment.yaml create mode 100644 k8s/postgres-pvc.yaml create mode 100644 k8s/postgres-service.yaml create mode 100644 k8s/secrets.yaml.example diff --git a/.github/workflows/deploy-k3s.yaml b/.github/workflows/deploy-k3s.yaml new file mode 100644 index 0000000..3d6d08a --- /dev/null +++ b/.github/workflows/deploy-k3s.yaml @@ -0,0 +1,140 @@ +name: Deploy to K3s + +on: + push: + branches: + - main # Or your default branch + +env: + # Set your image name, adjust if your GitHub username/org is different from the repo owner + # Or if your repo name is not what you want for the image name. + IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }} + K8S_NAMESPACE: aprs-app + +jobs: + build-and-push-image: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write # Needed to push to GHCR + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.IMAGE_NAME }} + tags: | + type=sha,prefix= + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + deploy-to-k3s: + runs-on: ubuntu-latest + needs: build-and-push-image + if: github.ref == 'refs/heads/main' # Only deploy from main branch + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Kubeconfig + uses: azure/setup-kubectl@v3 # A popular action for kubectl + # Alternatively, can do this manually: + # run: | + # mkdir -p $HOME/.kube + # echo "${{ secrets.KUBE_CONFIG_DATA }}" | base64 -d > $HOME/.kube/config + # chmod 600 $HOME/.kube/config + + - name: Install Kustomize (optional, but good for managing overlays) + run: |- + curl -s "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh" | bash + sudo mv kustomize /usr/local/bin/ + # If not using kustomize, this step can be removed. + # For now, we'll use kubectl apply -f directly for simplicity. + + - name: Substitute Image Tag in Kubernetes Manifest + run: | + # The meta step outputs tags like: ghcr.io/owner/repo:sha-xxxxxxx,ghcr.io/owner/repo:latest + # We need to pick the SHA-based tag. + # github.sha gives the commit SHA, which is perfect for a specific tag. + ACTUAL_IMAGE_NAME_WITH_TAG="${{ env.IMAGE_NAME }}:${{ github.sha }}" + echo "Using image: $ACTUAL_IMAGE_NAME_WITH_TAG" + + # Create a temporary deployment file for substitution + cp k8s/app-deployment.yaml k8s/app-deployment-processed.yaml + + # Replace placeholder image in the processed file + # Using a different delimiter for sed due to slashes in image name + sed -i "s|your-ghcr-username/aprs-app:latest|$ACTUAL_IMAGE_NAME_WITH_TAG|g" k8s/app-deployment-processed.yaml + # Ensure initContainer is also updated (duplicate sed command is fine, ensures both are hit if structure changes) + sed -i "s|your-ghcr-username/aprs-app:latest|$ACTUAL_IMAGE_NAME_WITH_TAG|g" k8s/app-deployment-processed.yaml + + + - name: Apply Kubernetes manifests + env: + KUBE_CONFIG_DATA: ${{ secrets.KUBE_CONFIG_DATA }} # Pass secret to env for kubectl if not using azure/setup-kubectl + run: | + # Ensure Kubeconfig is set up if not using an action + if [ ! -f "$HOME/.kube/config" ] && [ -n "$KUBE_CONFIG_DATA" ]; then + mkdir -p $HOME/.kube + echo "$KUBE_CONFIG_DATA" | base64 -d > $HOME/.kube/config + chmod 600 $HOME/.kube/config + echo "Kubeconfig set up manually." + elif [ ! -f "$HOME/.kube/config" ] && [ -z "$KUBE_CONFIG_DATA" ] && [ -z "$(kubectl config current-context 2>/dev/null)" ]; then + echo "Kubeconfig not found. KUBE_CONFIG_DATA secret is not set or empty, and no context is set by azure/setup-kubectl." + exit 1 + fi + + # Apply namespace first + kubectl apply -f k8s/namespace.yaml + + # Apply secrets if a real one exists (checking for secrets.yaml, not secrets.yaml.example) + # This assumes you will create the actual secrets.yaml in a secure way or it's handled by another process. + # For this automated workflow, we'll only apply if the example isn't the only one. + # A better way is to manage actual secrets outside the repo & this flow. + # For now, we expect secrets to be pre-applied or managed via a more secure mechanism. + # So, we will NOT apply secrets.yaml.example here. The user must ensure secrets are present. + echo "Ensuring secrets are present in namespace ${{ env.K8S_NAMESPACE }}. This workflow does not apply them directly from a .example file." + + # Apply other resources + kubectl apply -f k8s/postgres-pvc.yaml --namespace=${{ env.K8S_NAMESPACE }} + # It's good practice to check if a resource exists before applying or use --overwrite, but apply usually handles this. + # For sensitive or stateful things like Postgres, ensure your strategy for updates is clear. + kubectl apply -f k8s/postgres-deployment.yaml --namespace=${{ env.K8S_NAMESPACE }} + kubectl apply -f k8s/postgres-service.yaml --namespace=${{ env.K8S_NAMESPACE }} + + # Apply the processed app deployment + kubectl apply -f k8s/app-deployment-processed.yaml --namespace=${{ env.K8S_NAMESPACE }} + kubectl apply -f k8s/app-service.yaml --namespace=${{ env.K8S_NAMESPACE }} + + echo "Deployment to K3s initiated." + + # Optional: Wait for rollout to complete + # kubectl rollout status deployment/aprs-app-deployment --namespace=${{ env.K8S_NAMESPACE }} --timeout=120s diff --git a/.gitignore b/.gitignore index f91da05..ba374de 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,5 @@ npm-debug.log /.lexical /badpackets.txt +# Ignore Kubernetes secrets file +k8s/secrets.yaml diff --git a/README.md b/README.md index a6053e9..21299ac 100644 --- a/README.md +++ b/README.md @@ -17,3 +17,96 @@ Ready to run in production? Please [check our deployment guides](https://hexdocs * Docs: https://hexdocs.pm/phoenix * Forum: https://elixirforum.com/c/phoenix-forum * Source: https://github.com/phoenixframework/phoenix + +## Kubernetes (K3s) Deployment + +This application can be automatically deployed to a K3s Kubernetes cluster using GitHub Actions. + +### Prerequisites + +1. **K3s Cluster:** You need a running K3s cluster. +2. **`kubectl` access:** Ensure you have `kubectl` configured to interact with your K3s cluster. +3. **Container Registry:** This setup uses GitHub Container Registry (GHCR) to store the application's Docker image. + +### Initial Setup + +#### 1. GitHub Actions Secrets + +The GitHub Actions workflow requires the following secret to be configured in your repository's settings (Settings -> Secrets and variables -> Actions -> Repository secrets): + +* `KUBE_CONFIG_DATA`: The base64 encoded content of your K3s kubeconfig file. This file grants GitHub Actions permission to deploy to your cluster. + * To get this value, you can typically run: + ```bash + cat ~/.kube/config | base64 -w 0 + ``` + (If you're on macOS, you might need `cat ~/.kube/config | base64`). + Or, directly from the K3s server node: + ```bash + sudo cat /etc/rancher/k3s/k3s.yaml | base64 -w 0 + ``` + * Ensure the user in this kubeconfig has sufficient permissions in your K3s cluster to create namespaces, deployments, services, PVCs, and secrets in the target namespace (`aprs-app`). + +#### 2. Kubernetes Secrets for the Application + +The application requires secrets for database connection, Phoenix secret key base, etc. These are managed by a Kubernetes Secret object named `aprs-secrets` in the `aprs-app` namespace. + +1. **Copy the example secrets file:** + ```bash + cp k8s/secrets.yaml.example k8s/secrets.yaml + ``` +2. **Edit `k8s/secrets.yaml`:** + Open `k8s/secrets.yaml` and replace the placeholder values with your actual credentials and configuration: + * `POSTGRES_USER`: Your desired PostgreSQL username. + * `POSTGRES_PASSWORD`: Your desired PostgreSQL password. + * `SECRET_KEY_BASE`: A strong secret key for Phoenix. You can generate one using `mix phx.gen.secret`. + * `APRS_CALLSIGN`, `APRS_PASSCODE`: Your APRS credentials, if applicable. + * `PHX_HOST`: The domain name or IP where your application will be accessible. + +3. **Apply the secrets to your K3s cluster:** + Make sure you are targeting your K3s cluster with `kubectl`. + ```bash + kubectl apply -f k8s/namespace.yaml # Ensure namespace exists + kubectl apply -f k8s/secrets.yaml -n aprs-app + ``` + **Important:** `k8s/secrets.yaml` is included in `.gitignore` and should **not** be committed to the repository with your actual secrets. + +### GitHub Actions CI/CD Workflow + +The workflow is defined in `.github/workflows/deploy-k3s.yaml`. It will: +1. Trigger on pushes to the `main` branch. +2. Build the application's Docker image using the provided `Dockerfile`. +3. Push the image to GitHub Container Registry (GHCR), tagged with the Git SHA and `latest`. The image will be named `ghcr.io//`. +4. Deploy the application and its PostgreSQL database to the K3s cluster using the manifests in the `k8s/` directory. + * It automatically updates `k8s/app-deployment.yaml` to use the newly built image tag. + +### Application Configuration Notes + +* **Database Migrations:** The Kubernetes deployment for the application includes an init container that automatically runs database migrations (`/app/bin/migrate`) before the main application container starts. +* **Health Check Endpoint:** The application deployment (`k8s/app-deployment.yaml`) is configured with readiness and liveness probes that expect a health check endpoint at `/health` (returning HTTP 200). You may need to add this to your Phoenix application's router and controller: + * In `lib/aprs_web/router.ex`: + ```elixir + scope "/", AprsWeb do + pipe_through :browser + # ... other routes + get "/health", PageController, :health + end + ``` + * In `lib/aprs_web/controllers/page_controller.ex` (or another suitable controller): + ```elixir + def health(conn, _params) do + # You can add more sophisticated checks here if needed + json(conn, %{status: "ok", version: Application.spec(:aprs, :vsn)}) + end + ``` +* **Image Name:** The application deployment manifest (`k8s/app-deployment.yaml`) uses a placeholder image name `your-ghcr-username/aprs-app:latest`. The CI/CD pipeline automatically replaces this with the correct image name from GHCR (e.g., `ghcr.io/your-github-owner/your-repo-name:`). + +### Accessing the Application + +The application service (`k8s/app-service.yaml`) is configured with `type: NodePort`. +1. Find the NodePort assigned by Kubernetes: + ```bash + kubectl get svc aprs-app-service -n aprs-app -o=jsonpath='{.spec.ports[0].nodePort}' + ``` +2. Access your application at `http://:`. + +If your K3s cluster is configured with an external load balancer and Ingress, you might want to change the service type to `ClusterIP` and create an Ingress resource for more sophisticated routing and SSL termination. diff --git a/config/runtime.exs b/config/runtime.exs index 184fff6..93fe43a 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -74,18 +74,18 @@ if config_env() == :prod do aprs_is_login_id: System.get_env("APRS_CALLSIGN"), aprs_is_password: System.get_env("APRS_PASSCODE") - config :libcluster, - debug: true, - topologies: [ - fly6pn: [ - strategy: Cluster.Strategy.DNSPoll, - config: [ - polling_interval: 5_000, - query: "#{app_name}.internal", - node_basename: app_name - ] - ] - ] + # config :libcluster, + # debug: true, + # topologies: [ + # fly6pn: [ + # strategy: Cluster.Strategy.DNSPoll, + # config: [ + # polling_interval: 5_000, + # query: "#{app_name}.internal", + # node_basename: app_name + # ] + # ] + # ] # ## SSL Support # diff --git a/k8s/app-deployment.yaml b/k8s/app-deployment.yaml new file mode 100644 index 0000000..e27cdb8 --- /dev/null +++ b/k8s/app-deployment.yaml @@ -0,0 +1,53 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: aprs-app-deployment + namespace: aprs-app + labels: + app: aprs-app +spec: + replicas: 1 # Start with 1, can be scaled later + selector: + matchLabels: + app: aprs-app + template: + metadata: + labels: + app: aprs-app + spec: + initContainers: + - name: db-migrate + image: your-ghcr-username/aprs-app:latest # Placeholder, will be set by CI + command: ["/app/bin/migrate"] + envFrom: + - secretRef: + name: aprs-secrets + containers: + - name: aprs-app + image: your-ghcr-username/aprs-app:latest # Placeholder, will be set by CI + ports: + - containerPort: 4000 # Default Phoenix port + envFrom: + - secretRef: + name: aprs-secrets + env: # Add non-secret env vars here if any + - name: PHX_SERVER + value: "true" + - name: PORT + value: "4000" + # The PHX_HOST is in secrets.yaml.example, it can be moved here if not sensitive + readinessProbe: + httpGet: + path: /health # Assuming you'll add a health check endpoint + port: 4000 + initialDelaySeconds: 20 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /health # Assuming you'll add a health check endpoint + port: 4000 + initialDelaySeconds: 30 + periodSeconds: 15 +# Note: For the health endpoint, you might need to create a simple one in your Phoenix router +# e.g., scope "/", AprsWeb do; get "/health", PageController, :health; end +# and a `def health(conn, _params), do: json(conn, %{status: "ok"})` in PageController. diff --git a/k8s/app-service.yaml b/k8s/app-service.yaml new file mode 100644 index 0000000..f1628dc --- /dev/null +++ b/k8s/app-service.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Service +metadata: + name: aprs-app-service + namespace: aprs-app +spec: + selector: + app: aprs-app + ports: + - protocol: TCP + port: 80 # External port + targetPort: 4000 # Container port + type: NodePort # Or LoadBalancer if your K3s is configured for it diff --git a/k8s/namespace.yaml b/k8s/namespace.yaml new file mode 100644 index 0000000..03120d0 --- /dev/null +++ b/k8s/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: aprs-app diff --git a/k8s/postgres-deployment.yaml b/k8s/postgres-deployment.yaml new file mode 100644 index 0000000..ffe3f6b --- /dev/null +++ b/k8s/postgres-deployment.yaml @@ -0,0 +1,42 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: postgres-deployment + namespace: aprs-app + labels: + app: postgres +spec: + replicas: 1 + selector: + matchLabels: + app: postgres + template: + metadata: + labels: + app: postgres + spec: + containers: + - name: postgres + image: postgres:15 # Using a recent version + ports: + - containerPort: 5432 + env: + - name: POSTGRES_DB + value: aprs_prod + - name: POSTGRES_USER + valueFrom: + secretKeyRef: + name: aprs-secrets + key: POSTGRES_USER + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: aprs-secrets + key: POSTGRES_PASSWORD + volumeMounts: + - name: postgres-storage + mountPath: /var/lib/postgresql/data + volumes: + - name: postgres-storage + persistentVolumeClaim: + claimName: postgres-pvc diff --git a/k8s/postgres-pvc.yaml b/k8s/postgres-pvc.yaml new file mode 100644 index 0000000..edfafd3 --- /dev/null +++ b/k8s/postgres-pvc.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: postgres-pvc + namespace: aprs-app +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 5Gi # Default size, can be adjusted diff --git a/k8s/postgres-service.yaml b/k8s/postgres-service.yaml new file mode 100644 index 0000000..d91d3e9 --- /dev/null +++ b/k8s/postgres-service.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Service +metadata: + name: postgres-service + namespace: aprs-app +spec: + selector: + app: postgres + ports: + - protocol: TCP + port: 5432 + targetPort: 5432 + type: ClusterIP diff --git a/k8s/secrets.yaml.example b/k8s/secrets.yaml.example new file mode 100644 index 0000000..8b07c5a --- /dev/null +++ b/k8s/secrets.yaml.example @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Secret +metadata: + name: aprs-secrets + namespace: aprs-app +type: Opaque +stringData: # Using stringData for readability, actual secrets should be base64 encoded if using `data` + DATABASE_URL: "ecto://postgres-service.aprs-app.svc.cluster.local/aprs_prod" # User/pass removed + POSTGRES_USER: "your_db_user" + POSTGRES_PASSWORD: "your_db_password" + SECRET_KEY_BASE: "your_super_secret_key_base_here" + # Add other secrets like APRS_CALLSIGN, APRS_PASSCODE if needed + APRS_CALLSIGN: "NOCALL" + APRS_PASSCODE: "NOPASS" + PHX_HOST: "your_app_domain.com" # Replace with your actual host From 4af05a642217a6a48076372db62ef87176c4a5db Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Fri, 13 Jun 2025 13:22:52 -0500 Subject: [PATCH 2/2] tweaks --- k8s/app-deployment.yaml | 4 ++-- k8s/postgres-deployment.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/k8s/app-deployment.yaml b/k8s/app-deployment.yaml index e27cdb8..1982478 100644 --- a/k8s/app-deployment.yaml +++ b/k8s/app-deployment.yaml @@ -17,14 +17,14 @@ spec: spec: initContainers: - name: db-migrate - image: your-ghcr-username/aprs-app:latest # Placeholder, will be set by CI + image: aprs/aprs-app:latest # Placeholder, will be set by CI command: ["/app/bin/migrate"] envFrom: - secretRef: name: aprs-secrets containers: - name: aprs-app - image: your-ghcr-username/aprs-app:latest # Placeholder, will be set by CI + image: aprs/aprs-app:latest # Placeholder, will be set by CI ports: - containerPort: 4000 # Default Phoenix port envFrom: diff --git a/k8s/postgres-deployment.yaml b/k8s/postgres-deployment.yaml index ffe3f6b..653037f 100644 --- a/k8s/postgres-deployment.yaml +++ b/k8s/postgres-deployment.yaml @@ -17,7 +17,7 @@ spec: spec: containers: - name: postgres - image: postgres:15 # Using a recent version + image: postgres:16 ports: - containerPort: 5432 env: