Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 20 additions & 108 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ permissions:
contents: read

env:
NODE_VERSION: "22"
AWS_EKS_PROD_CLUSTER_NAME: ${{ secrets.AWS_EKS_PROD_CLUSTER_NAME }}
AWS_EKS_PROD_REGION: ${{ secrets.AWS_EKS_PROD_REGION }}
DASHBOARD_IMAGE_REGISTRY: ${{ secrets.DASHBOARD_IMAGE_REGISTRY }}
Expand All @@ -39,127 +40,38 @@ jobs:
name: Run main database migration
runs-on: ubuntu-latest
needs: build-and-publish
if: ${{ github.ref == 'refs/heads/staging' || github.event_name == 'workflow_dispatch' }}
if: ${{ github.event_name == 'release' || github.ref == 'refs/heads/staging' || github.event_name == 'workflow_dispatch' }}
permissions:
contents: read
env:
AWS_EKS_PROD_CLUSTER_NAME: ${{ secrets.AWS_EKS_PROD_CLUSTER_NAME }}
AWS_EKS_PROD_REGION: ${{ secrets.AWS_EKS_PROD_REGION }}
DASHBOARD_IMAGE_REGISTRY: ${{ secrets.DASHBOARD_IMAGE_REGISTRY }}
DASHBOARD_IMAGE_REPOSITORY: ${{ secrets.DASHBOARD_IMAGE_REPOSITORY }}
DASHBOARD_KUBE_CONTAINER: ${{ secrets.DASHBOARD_KUBE_CONTAINER }}
DASHBOARD_KUBE_DEPLOYMENT: ${{ secrets.DASHBOARD_KUBE_DEPLOYMENT }}
DASHBOARD_KUBE_NAMESPACE_PROD: ${{ secrets.DASHBOARD_KUBE_NAMESPACE_PROD }}
DASHBOARD_KUBE_NAMESPACE_STAGING: ${{ secrets.DASHBOARD_KUBE_NAMESPACE_STAGING }}
steps:
- name: Decide migration context
id: context
shell: bash
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "release" ]; then
environment="prod"
else
environment="staging"
fi
if [ "$environment" = "prod" ]; then
namespace="${DASHBOARD_KUBE_NAMESPACE_PROD}"
else
namespace="${DASHBOARD_KUBE_NAMESPACE_STAGING}"
fi
short_sha="${GITHUB_SHA::8}"
if [ "${{ github.event_name }}" = "release" ]; then
image_tag="${{ github.event.release.tag_name }}-${environment}"
else
image_tag="${environment}-${short_sha}"
fi
echo "environment=$environment" >> "$GITHUB_OUTPUT"
echo "namespace=$namespace" >> "$GITHUB_OUTPUT"
echo "image_uri=${DASHBOARD_IMAGE_REGISTRY}/${DASHBOARD_IMAGE_REPOSITORY}:${image_tag}" >> "$GITHUB_OUTPUT"

- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
- name: Checkout
uses: actions/checkout@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ env.AWS_EKS_PROD_REGION }}
persist-credentials: false

- name: Validate migration configuration
shell: bash
run: |
set -euo pipefail
for variable_name in \
AWS_EKS_PROD_CLUSTER_NAME \
AWS_EKS_PROD_REGION \
DASHBOARD_IMAGE_REGISTRY \
DASHBOARD_IMAGE_REPOSITORY \
DASHBOARD_KUBE_CONTAINER \
DASHBOARD_KUBE_DEPLOYMENT \
DASHBOARD_KUBE_NAMESPACE_STAGING
do
if [ -z "${!variable_name}" ]; then
echo "::error::Missing migration configuration: ${variable_name}"
exit 1
fi
done
- name: Enable pnpm
run: corepack enable

- name: Setup kubectl
uses: azure/setup-kubectl@v3
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm

- name: Update kubeconfig
shell: bash
run: |
aws eks update-kubeconfig \
--name "${AWS_EKS_PROD_CLUSTER_NAME}" \
--region "${AWS_EKS_PROD_REGION}"
- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Run main database migration job
shell: bash
- name: Run main database migration
env:
IMAGE_URI: ${{ steps.context.outputs.image_uri }}
NAMESPACE: ${{ steps.context.outputs.namespace }}
DATABASE_URL: ${{ secrets.DATABASE_MIGRATION_URL }}
shell: bash
run: |
set -euo pipefail
job_name="knowhere-dashboard-migrate-${GITHUB_RUN_ID}"

migration_manifest="$(kubectl get deployment/"${DASHBOARD_KUBE_DEPLOYMENT}" --namespace "$NAMESPACE" -o json \
| jq --arg job_name "$job_name" --arg image_uri "$IMAGE_URI" --arg container_name "$DASHBOARD_KUBE_CONTAINER" '
if ((.spec.template.spec.containers // []) | map(select(.name == $container_name)) | length) == 0 then
error("dashboard deployment container was not found")
else
{
apiVersion: "batch/v1",
kind: "Job",
metadata: {name: $job_name, namespace: .metadata.namespace},
spec: {
backoffLimit: 0,
ttlSecondsAfterFinished: 300,
template: {
metadata: {labels: {"app": "knowhere-dashboard-migrate"}},
spec: (
.spec.template.spec
| .restartPolicy = "Never"
| .containers = [(.containers[] | select(.name == $container_name))]
| .containers[0].image = $image_uri
| .containers[0].command = ["pnpm", "db:migrate"]
| del(.containers[0].args)
)
}
}
}
end
')"

printf '%s\n' "$migration_manifest" | kubectl apply -f -

if ! kubectl wait --for=condition=complete "job/$job_name" \
--namespace "$NAMESPACE" --timeout=900s; then
kubectl describe job "$job_name" --namespace "$NAMESPACE" || true
kubectl logs "job/$job_name" --namespace "$NAMESPACE" --all-containers=true || true
if [ -z "${DATABASE_URL}" ]; then
echo "::error::Missing DATABASE_MIGRATION_URL repository secret"
exit 1
fi

kubectl delete job "$job_name" --namespace "$NAMESPACE" --ignore-not-found
pnpm db:migrate

build-and-publish:
name: Build and Publish
Expand Down
39 changes: 39 additions & 0 deletions lib/dashboard-deploy-workflow.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";

const readDeployWorkflow = (): string =>
readFileSync(join(process.cwd(), ".github/workflows/deploy.yml"), "utf8");

const EXPRESSION_START: string = "$";

describe("dashboard deploy workflow", (): void => {
it("runs main migrations with the dedicated direct migration credential", (): void => {
const deployWorkflow: string = readDeployWorkflow();

expect(deployWorkflow).toContain(
`DATABASE_URL: ${EXPRESSION_START}{{ secrets.DATABASE_MIGRATION_URL }}`
);
expect(deployWorkflow).toContain("pnpm db:migrate");
expect(deployWorkflow).not.toContain(
`knowhere-dashboard-migrate-${EXPRESSION_START}{GITHUB_RUN_ID}`
);
expect(deployWorkflow).not.toContain("ttlSecondsAfterFinished");
});

it("fails clearly before migration when the dedicated credential is missing", (): void => {
const deployWorkflow: string = readDeployWorkflow();

expect(deployWorkflow).toContain(`if [ -z "${EXPRESSION_START}{DATABASE_URL}" ]; then`);
expect(deployWorkflow).toContain("Missing DATABASE_MIGRATION_URL");
});

it("gates both staging pushes and production releases on migration success", (): void => {
const deployWorkflow: string = readDeployWorkflow();

expect(deployWorkflow).toContain(
`if: ${EXPRESSION_START}{{ github.event_name == 'release' || github.ref == 'refs/heads/staging' || github.event_name == 'workflow_dispatch' }}`
);
expect(deployWorkflow).toContain("needs: [build-and-publish, migrate]");
});
});
Loading