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
89 changes: 89 additions & 0 deletions .github/workflows/BuildBatchImage.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
name: Build and Push Batch Image

on:
push:
branches: [ main ]
paths:
- 'scripts/**'
- 'app/external/**'
- 'docker/batch/**'
- 'prisma/**'
- 'package.json'
- 'pnpm-lock.yaml'
- '.github/workflows/BuildBatchImage.yml'
- '.github/workflows/DeployandRunBatchJob.yml'

workflow_dispatch:
inputs:
tag:
description: 'Custom tag for the image'
required: false
default: ''

env:
REGISTRY: ghcr.io
IMAGE_NAME: formalbridge/project_alpha/batch

jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write

concurrency:
group: build-batch
cancel-in-progress: true

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Setup PNPM
uses: pnpm/action-setup@v3

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Generate Prisma Client
run: pnpm prisma generate

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=sha-${{ github.sha }}
type=raw,value=${{ github.event.inputs.tag }},enable=${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag != '' }}

- name: Build and push Docker image
id: build
uses: docker/build-push-action@v5
with:
context: .
file: ./docker/batch/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

- name: Image digest
run: echo ${{ steps.build.outputs.digest }}
132 changes: 132 additions & 0 deletions .github/workflows/DeployAndRunBatchJob.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
name: Deploy & Run Batch Job

on:
workflow_run:
workflows: ["Build and Push Batch Image"]
types: [completed]
workflow_dispatch:
inputs:
job_name:
description: "JS entry file to run (e.g. /app/dist/scripts/spotify-backfill.js)"
required: false
default: "/app/dist/scripts/spotify-backfill.js"
job_resource_name:
description: "K8s Job resource name (metadata.name). If empty, auto-derive from job_name"
required: false
default: ""
dry_run:
description: "Set DRY_RUN=true or false"
required: false
default: "true"
limit:
description: "LIMIT for batch size"
required: false
default: "200"
image_tag:
description: "Image tag to deploy (e.g. sha-<commit>)"
required: false
default: ""
namespace:
description: "Kubernetes namespace"
required: false
default: "default"
image_pull_secret:
description: "ImagePullSecret name"
required: false
default: "ghcr-secret"
db_secret_name:
description: "DB secret name"
required: false
default: "db-url-secret"
db_secret_key:
description: "DB secret key"
required: false
default: "URL"
spotify_secret_name:
description: "Spotify secret name"
required: false
default: "spotify-secret"

jobs:
deploy:
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success') }}
runs-on: ubuntu-latest
permissions:
contents: read

env:
IMAGE_REPO: ghcr.io/formalbridge/project_alpha/batch
IMAGE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.image_tag != '' && inputs.image_tag || format('sha-{0}', github.event.workflow_run.head_sha) }}
NAMESPACE: ${{ inputs.namespace || 'default' }}
JOB_NAME_FILE: ${{ inputs.job_name || '/app/dist/scripts/spotify-backfill.js' }}
DRY_RUN: ${{ inputs.dry_run || 'true' }}
LIMIT: ${{ inputs.limit || '200' }}
IMAGE_PULL_SECRET: ${{ inputs.image_pull_secret || 'ghcr-secret' }}
DB_SECRET_NAME: ${{ inputs.db_secret_name || 'db-url-secret' }}
DB_SECRET_KEY: ${{ inputs.db_secret_key || 'URL' }}
SPOTIFY_SECRET_NAME: ${{ inputs.spotify_secret_name || 'spotify-secret' }}

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Setup kubectl
uses: azure/setup-kubectl@v3
with:
version: 'v1.28.0'

- name: Configure kubeconfig
env:
KUBECONFIG_DATA: ${{ secrets.KUBECONFIG }}
run: |
mkdir -p ~/.kube
echo "$KUBECONFIG_DATA" | base64 --decode > ~/.kube/config
chmod 600 ~/.kube/config

- name: Render Job manifest with inputs
run: |
mkdir -p .out
# Compose IMAGE_URI from repo + tag (cannot reference env.* within env block)
export IMAGE_URI="${{ env.IMAGE_REPO }}:${{ env.IMAGE_TAG }}"

# Bring through env from job-level
export NAMESPACE="${{ env.NAMESPACE }}"
export JOB_NAME_FILE="${{ env.JOB_NAME_FILE }}"
export DRY_RUN="${{ env.DRY_RUN }}"
export LIMIT="${{ env.LIMIT }}"
export IMAGE_PULL_SECRET="${{ env.IMAGE_PULL_SECRET }}"
export DB_SECRET_NAME="${{ env.DB_SECRET_NAME }}"
export DB_SECRET_KEY="${{ env.DB_SECRET_KEY }}"
export SPOTIFY_SECRET_NAME="${{ env.SPOTIFY_SECRET_NAME }}"

# Derive JOB_RESOURCE_NAME if not explicitly provided via input
JOB_RESOURCE_NAME_INPUT='${{ inputs.job_resource_name }}'
if [ -n "$JOB_RESOURCE_NAME_INPUT" ]; then
JOB_RESOURCE_NAME="$JOB_RESOURCE_NAME_INPUT"
else
base="$(basename "$JOB_NAME_FILE")"
stem="${base%.*}"
# normalize: lowercase and replace non-alnum with '-'
JOB_RESOURCE_NAME="$(echo "$stem" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]/-/g')"
fi
export JOB_RESOURCE_NAME
echo "JOB_RESOURCE_NAME=$JOB_RESOURCE_NAME" >> "$GITHUB_ENV"

# Render template
envsubst < k8s/jobs/batch-job-template.yml > .out/job.yaml
echo "--- Rendered manifest ---"
cat .out/job.yaml

- name: Apply Job
run: |
kubectl -n "$NAMESPACE" delete job "$JOB_RESOURCE_NAME" --ignore-not-found=true
kubectl -n "$NAMESPACE" apply -f .out/job.yaml
kubectl -n "$NAMESPACE" get jobs,pods -o wide

- name: Wait for Job completion
run: |
kubectl -n "$NAMESPACE" wait --for=condition=complete --timeout=1800s job/"$JOB_RESOURCE_NAME"

- name: Stream Job logs
run: |
kubectl -n "$NAMESPACE" logs -f job/"$JOB_RESOURCE_NAME" --all-containers=true --tail=1000
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,5 @@ k8s/*secret.yaml
.wrangler

.DS_Store

dist/
2 changes: 1 addition & 1 deletion app/external/music/SpotifyOEmbed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export async function getSpotifyEmbed(trackUrl: string): Promise<SpotifyEmbedDat
throw new Error(`Spotify oEmbed API 요청 실패: ${response.status} ${response.statusText}`);
}

const data = await response.json();
const data = (await response.json()) as SpotifyEmbedData;
return {
html: data.html,
width: data.width,
Expand Down
37 changes: 37 additions & 0 deletions docker/batch/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Base image - Node.js LTS
FROM node:20-alpine AS base
WORKDIR /app

# -----------------------------
# deps: install node modules and generate prisma client
# -----------------------------
FROM base AS deps
COPY package.json pnpm-lock.yaml ./
COPY prisma ./prisma
RUN corepack enable pnpm && \
pnpm install --frozen-lockfile --prod=false && \
pnpm prisma generate

# -----------------------------
# builder: build TS scripts with tsup -> dist/
# -----------------------------
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Use the same bundling command as in package.json (outputs to /app/dist)
RUN corepack enable pnpm && pnpm run build:scripts

# -----------------------------
# runner: minimal runtime image
# -----------------------------
FROM base AS runner
ENV NODE_ENV=default
# copy runtime deps and prisma artifacts
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/prisma ./prisma
# copy bundled output (dist/) produced by tsup, includes scripts/* and app/external/music/*
COPY --from=builder /app/dist ./dist

# default command: run job-runner; JOB_NAME must be provided as env
# e.g. JOB_NAME=dist/scripts/spotify-backfill.js
CMD ["node", "dist/scripts/job-runner.js"]
43 changes: 43 additions & 0 deletions k8s/jobs/batch-job-template.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
apiVersion: batch/v1
kind: Job
metadata:
name: ${JOB_RESOURCE_NAME}
namespace: ${NAMESPACE}
spec:
backoffLimit: 0
ttlSecondsAfterFinished: 600
template:
spec:
restartPolicy: Never
imagePullSecrets:
- name: ${IMAGE_PULL_SECRET}
containers:
- name: backfill
image: ${IMAGE_URI}
imagePullPolicy: Always
command: ["node","/app/dist/scripts/job-runner.js"]
env:
- name: JOB_NAME
value: "${JOB_NAME_FILE}"
- name: DRY_RUN
value: "${DRY_RUN}"
- name: LIMIT
value: "${LIMIT}"
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: ${DB_SECRET_NAME}
key: ${DB_SECRET_KEY}
- name: SPOTIFY_CLIENT_ID
valueFrom:
secretKeyRef:
name: ${SPOTIFY_SECRET_NAME}
key: SPOTIFY_CLIENT_ID
- name: SPOTIFY_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: ${SPOTIFY_SECRET_NAME}
key: SPOTIFY_CLIENT_SECRET
resources:
requests: { cpu: "100m", memory: "256Mi" }
limits: { cpu: "500m", memory: "512Mi" }
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"docker:logs": "docker-compose logs -f",
"studio:dev": "npx prisma studio",
"studio:prod": "kubectl port-forward service/prisma-studio-service 5555:5555",
"db:reset": "docker-compose down -v && docker-compose up -d postgres && npx prisma migrate dev --name reset"
"build:scripts": "tsup \"scripts/**/*.ts\" \"app/external/music/**/*.ts\" --format esm --out-dir dist --minify --clean --splitting=false --platform=node --target=node20 --treeshake --external @prisma/client,pg,pg-native,pg-query-stream"
},
"dependencies": {
"@blocknote/core": "^0.35.0",
Expand All @@ -37,6 +37,7 @@
"compression": "^1.8.0",
"cookie": "^1.0.2",
"dayjs": "^1.11.13",
"dotenv": "^17.2.3",
"es-toolkit": "^1.34.1",
"express": "^5.1.0",
"github-markdown-css": "^5.8.1",
Expand Down Expand Up @@ -64,6 +65,7 @@
"@types/amqplib": "^0.10.8",
"@types/axios": "^0.14.4",
"@types/cookie": "^1.0.0",
"@types/node": "^24.7.2",
"@types/nprogress": "^0.2.3",
"@types/react": "^19.0.7",
"@types/react-dom": "^19.0.3",
Expand All @@ -78,6 +80,8 @@
"prettier": "^3.4.2",
"prisma": "^6.2.1",
"sass": "^1.83.4",
"tsup": "^8.5.0",
"tsx": "^4.20.6",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0",
"vite": "^6.0.7",
Expand Down
Loading