Skip to content
Open
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
21 changes: 21 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Build context: repository root. Keeps the `az acr build` upload small.
.git
.github
.agents
.vscode
docs
node_modules
**/node_modules
**/.output
**/.tanstack
**/.nitro
.env
**/.env
**/src/generated
test-results
**/test-results
.DS_Store
*.md
lighthouse.json
.cta.json
todos.json
145 changes: 145 additions & 0 deletions .github/workflows/deploy-azure.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
# Generated by GLM 5.3 NVFP4

# Builds the Docker images in ACR and redeploys the Azure Container Apps.
#
# Images are built with `docker buildx build --push` rather than
# `az acr build`: ACR Tasks (the service behind `az acr build`) is not
# permitted on Azure for Students subscriptions (TasksOperationsNotAllowed).
#
# Prerequisites (one-time):
# - terraform apply in infra/ (creates ACR, DB, apps with placeholder images,
# the migrate job, and the Cloudflare DNS/certificate setup)
# - Azure login secrets for the runner (repo or organization secrets):
# AZURE_CLIENT_ID = output ci_identity_client_id of infra/prod (the
# client ID of the id-gspotter-ci managed identity)
# AZURE_TENANT_ID = the Entra tenant ID
# AZURE_SUBSCRIPTION_ID = the subscription ID
# The identity federates to this repo's main branch via git.ntnu.no's
# Actions OIDC issuer, so no client secret exists or is needed.
# - The identity's role grants (Contributor on the resource group,
# AcrPush on the registry) are managed by the Terraform config, not
# by hand.
# - The runner needs the Docker CLI (preinstalled on ubuntu-latest).
#
# Rollback: `az containerapp revision list` + `az containerapp revision
# activate` for the previous revision, or re-run this workflow on an old
# commit (image tags are immutable, per-commit SHAs).

name: Deploy to Azure Container Apps

on:
push:
branches:
- main
workflow_dispatch:

permissions:
id-token: write # Azure OIDC login
contents: read

env:
RESOURCE_GROUP: rg-gspotter
BACKEND_APP: gspotter-backend
FRONTEND_APP: gspotter-frontend
MIGRATE_JOB: gspotter-migrate

jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 45

# Never overlap production deploys; queue instead of cancelling.
concurrency:
group: production-azure
cancel-in-progress: false

steps:
- name: Check out source
uses: actions/checkout@v4

- name: Log in to Azure
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

- name: Resolve Azure resource names
run: |
set -euo pipefail
ACR_NAME="$(az acr list -g "$RESOURCE_GROUP" --query '[0].name' -o tsv)"
if [[ -z "$ACR_NAME" || "$ACR_NAME" == "None" ]]; then
echo "No container registry found in $RESOURCE_GROUP. Run terraform apply in infra/ first." >&2
exit 1
fi
echo "ACR_NAME=$ACR_NAME" >> "$GITHUB_ENV"
echo "ACR_SERVER=$ACR_NAME.azurecr.io" >> "$GITHUB_ENV"
- name: Resolve internal backend URL
run: |
set -euo pipefail
# The backend's ingress is internal-only; this is what the frontend
# proxies /graphql to and what SSR fetches use (BACKEND_URL).
BACKEND_FQDN="$(az containerapp show -n "$BACKEND_APP" -g "$RESOURCE_GROUP" \
--query 'properties.configuration.ingress.fqdn' -o tsv)"
echo "GRAPHQL_PROXY_TARGET=http://$BACKEND_FQDN" >> "$GITHUB_ENV"
- name: Log in to the container registry
run: az acr login --name "$ACR_NAME"

- name: Build and push backend image
run: |
docker buildx build \
--platform linux/amd64 \
--provenance=false \
-f apps/backend/Dockerfile \
-t "$ACR_SERVER/gspotter-backend:$GITHUB_SHA" \
--push .
- name: Build and push frontend image
run: |
docker buildx build \
--platform linux/amd64 \
--provenance=false \
-f apps/frontend/Dockerfile \
--build-arg "APP_BASE_PATH=/" \
--build-arg "VITE_GRAPHQL_URL=/graphql" \
--build-arg "GRAPHQL_PROXY_TARGET=$GRAPHQL_PROXY_TARGET" \
-t "$ACR_SERVER/gspotter-frontend:$GITHUB_SHA" \
--push .
- name: Run database migrations
run: |
set -euo pipefail
# `job start --image` is broken: it creates an execution that drops the
# job's command and env (the container runs the image's default CMD and
# crashes without DATABASE_URL). Update the job's image first, then
# start it with no overrides so the full job template applies.
az containerapp job update -n "$MIGRATE_JOB" -g "$RESOURCE_GROUP" \
--image "$ACR_SERVER/gspotter-backend:$GITHUB_SHA"
az containerapp job start -n "$MIGRATE_JOB" -g "$RESOURCE_GROUP"
# Poll the newest execution until it finishes.
for _ in $(seq 1 60); do
STATUS="$(az containerapp job execution list -n "$MIGRATE_JOB" -g "$RESOURCE_GROUP" \
--query 'sort_by(@, &startTime)[-1].status' -o tsv)"
case "$STATUS" in
Succeeded) echo "Migrations applied."; exit 0 ;;
Failed) echo "Migration job failed. See: az containerapp job execution logs" >&2; exit 1 ;;
*) sleep 10 ;;
esac
done
echo "Timed out waiting for the migration job." >&2
exit 1
# Backend first, so the frontend never points at an older GraphQL schema
# than the one the migrations just applied.
- name: Deploy backend
run: |
az containerapp update -n "$BACKEND_APP" -g "$RESOURCE_GROUP" \
--image "$ACR_SERVER/gspotter-backend:$GITHUB_SHA"
- name: Deploy frontend
run: |
az containerapp update -n "$FRONTEND_APP" -g "$RESOURCE_GROUP" \
--image "$ACR_SERVER/gspotter-frontend:$GITHUB_SHA"
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,9 @@ __unconfig*
todos.json
**/src/generated/
test-results/

# Terraform
infra/**/.terraform/
infra/**/*.tfstate
infra/**/*.tfstate.*
infra/**/tfplan
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ Most recent commit hash at time of hand-in: [`054801b`](https://git.ntnu.no/IT28

Handed in TDB

Most recent commit hash at time of hand-in: [`TDB`](<>) (Will update if there is a more recent version in the future)
Most recent commit hash at time of hand-in: [`6ed8255`](https://git.ntnu.no/IT2810-H26/T09-Project-2/tree/6ed82554ed10bd7797feb405d391db1cc6071e92)

### Final hand-in of project 2

Expand All @@ -177,3 +177,13 @@ The first hand-in was only our initial plan and consited of setting up tanstack
### 2nd hand-in:

We have mostly used AI as a sparring partner in the development of this project, mainly focused on deployment and debugging. For deployment to the VM, GLM 5.3 NVFP4 running on NTNU's LLM servers was used to help generate the script setting up the postgres instance, and to create the deploy pipeline. As can be seen in the `.agents` folder, AI was also used to create more descriptive PR descriptions, but this skill was used with heavy human supervision, and often updated manually afterwards. AI was not used for generating much code, but rather used to help debug issues we were running into, and as a supplement to documentation when it proved lacking/hard to navigate (e.g. when using Pothos to get a DateTime in the GraphQL schema, we could not find any mention of the "expose" function without the expose helper (such as "exposeString" or "exposeFloat"), and thus used AI to figure out how to expose a DateTime object in the schema).

### 3rd hand-in

Essentially the same as handin 2

### 4th hand-in

While outside the scope of the course, we did deploy to Azure. The IaC (terraform code) was initially developed without help from AI, but debugged and refined with AI. The dockerfiles are AI generated, though explicitly guided to use the human-written dockerfiles from monoweb (The student association Online's website [(github.com/dotkom/monoweb)](https://github.com/dotkom/monoweb)) as referance.

cont...
30 changes: 30 additions & 0 deletions apps/backend/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# To bump Node: docker pull node:24-bookworm-slim && \
# docker inspect --format '{{index .RepoDigests 0}}' node:24-bookworm-slim
FROM node:24-bookworm-slim@sha256:01d566e8d94d8c619591d78652babfccd3c6992a3dffa9ed5ab39e11cf6c70e9

WORKDIR /app

RUN corepack enable

# Copy all workspace manifests first so the apps/* and packages/* globs in
# pnpm-workspace.yaml resolve during the (filtered) dependency install.
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY packages/db/package.json packages/db/
COPY apps/frontend/package.json apps/frontend/
COPY apps/backend/package.json apps/backend/
RUN pnpm install --frozen-lockfile --filter @t09-project-2/backend...

# prisma.config.ts calls env('DATABASE_URL') even during `prisma generate`,
# so a placeholder value is required for the client generation step.
COPY packages/db packages/db
COPY apps/backend apps/backend
RUN DATABASE_URL="postgresql://placeholder:placeholder@localhost:5432/placeholder" \
pnpm --filter @t09-project-2/db exec prisma generate

# devDependencies (prisma, tsx) are intentionally kept: the Prisma migration
# job runs `prisma migrate deploy` from this image, and `tsx` is the runtime.
ENV NODE_ENV=production

EXPOSE 3001

CMD ["pnpm", "--filter", "@t09-project-2/backend", "start"]
2 changes: 1 addition & 1 deletion apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"type": "module",
"scripts": {
"dev": "dotenv -e .env -- tsx watch src/index.ts",
"start": "dotenv -e .env -- tsx src/index.ts",
"start": "tsx src/index.ts",
"test": "dotenv -e .env -- playwright test",
"typecheck": "tsc --noEmit"
},
Expand Down
45 changes: 45 additions & 0 deletions apps/frontend/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# To bump Node: docker pull node:24-bookworm-slim && \
# docker inspect --format '{{index .RepoDigests 0}}' node:24-bookworm-slim
FROM node:24-bookworm-slim@sha256:01d566e8d94d8c619591d78652babfccd3c6992a3dffa9ed5ab39e11cf6c70e9 AS base
FROM base AS builder

WORKDIR /app

RUN corepack enable

# Copy all workspace manifests first so the apps/* and packages/* globs in
# pnpm-workspace.yaml resolve during the (filtered) dependency install.
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY packages/db/package.json packages/db/
COPY apps/frontend/package.json apps/frontend/
COPY apps/backend/package.json apps/backend/
RUN pnpm install --frozen-lockfile --filter @t09-project-2/frontend...

# APP_BASE_PATH: base path the app is served from ('/' on Azure, '/project2'
# on the VM). VITE_GRAPHQL_URL is baked into the client bundle ('/graphql'
# on Azure = same origin, proxied to the backend by Nitro's route rules).
# GRAPHQL_PROXY_TARGET is where the Nitro server forwards /graphql requests.
ARG APP_BASE_PATH=/
ARG VITE_GRAPHQL_URL=/graphql
ARG GRAPHQL_PROXY_TARGET=http://localhost:3001/

ENV APP_BASE_PATH=$APP_BASE_PATH
ENV VITE_GRAPHQL_URL=$VITE_GRAPHQL_URL
ENV GRAPHQL_PROXY_TARGET=$GRAPHQL_PROXY_TARGET

COPY packages/db packages/db
COPY apps/frontend apps/frontend
RUN pnpm --filter @t09-project-2/frontend run build

FROM base AS runner

WORKDIR /app

COPY --from=builder /app/apps/frontend/.output apps/frontend/.output

ENV NODE_ENV=production
ENV PORT=3000

EXPOSE 3000

CMD ["node", "apps/frontend/.output/server/index.mjs"]
8 changes: 8 additions & 0 deletions apps/frontend/src/components/MockLocationNotice.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ export default function MockLocationNotice() {
<a href={REPOSITORY_URL} target="_blank" rel="noreferrer">
the repository
</a>{' '}
or the
<a
href={'https://gspotter.graesberg.com'}
target="_blank"
rel="norefferer"
>
public Azure deployment
</a>{' '}
and run the website on localhost.
</p>
</aside>
Expand Down
11 changes: 10 additions & 1 deletion apps/frontend/src/graphql/client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
const GRAPHQL_URL = import.meta.env.VITE_GRAPHQL_URL ?? 'http://localhost:3001/'
// In the browser, requests go to the same origin (VITE_GRAPHQL_URL is baked as
// '/graphql' for the Azure deployment) and the frontend's Nitro server proxies
// them to the backend. During SSR, fetches go straight to the backend instead:
// BACKEND_URL is a runtime env var on the container app and falls back to the
// baked VITE_GRAPHQL_URL for local development and the VM deployment.
const GRAPHQL_URL = import.meta.env.SSR
? (process.env.BACKEND_URL ??
import.meta.env.VITE_GRAPHQL_URL ??
'http://localhost:3001/')
: (import.meta.env.VITE_GRAPHQL_URL ?? 'http://localhost:3001/')

interface GraphQLResponse<T> {
data?: T
Expand Down
27 changes: 22 additions & 5 deletions apps/frontend/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,30 +7,47 @@ import viteReact from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { nitro } from 'nitro/vite'

// The base path the app is served from, normalized without a trailing slash:
// '' when served from the domain root (Azure), '/project2' on the group VM.
// Deriving every route-rule key from this form avoids '//graphql'-style keys
// when the base is '/'.
const basePath = (process.env.APP_BASE_PATH ?? '/project2').replace(/\/+$/, '')

// Vite's `base` option must start with a slash.
const base = basePath === '' ? '/' : basePath

// Apollo standalone serves GraphQL at the root path, so the proxy target is
// the bare backend URL without a /graphql suffix. On Azure this points at the
// backend container app's internal ingress (plain HTTP inside the environment).
const graphqlProxyTarget =
process.env.GRAPHQL_PROXY_TARGET ?? 'http://localhost:3001/'

const config = defineConfig({
resolve: { tsconfigPaths: true },
plugins: [
devtools(),
nitro({
rollupConfig: { external: [/^@sentry\//] },
baseURL: '/project2/',
baseURL: `${basePath}/`,
// Serve brotli/gzip versions of the built assets. Without this the
// JavaScript, CSS and poster go over the wire at their full size.
compressPublicAssets: { gzip: true, brotli: true },
routeRules: {
'/project2/assets/**': {
// Proxy same-origin browser requests to the GraphQL backend.
[`${basePath}/graphql`]: { proxy: graphqlProxyTarget },
[`${basePath}/assets/**`]: {
headers: {
'cache-control': 'public, max-age=31536000, immutable',
},
},
// Public files are not content-hashed, but they rarely change and a
// long cache lifetime avoids a round trip on repeat visits.
'/project2/favicon.ico': {
[`${basePath}/favicon.ico`]: {
headers: {
'cache-control': 'public, max-age=2592000',
},
},
'/project2/*.svg': {
[`${basePath}/*.svg`]: {
headers: {
'cache-control': 'public, max-age=2592000',
},
Expand All @@ -41,7 +58,7 @@ const config = defineConfig({
tanstackStart(),
viteReact(),
],
base: '/project2',
base,
})

export default config
3 changes: 3 additions & 0 deletions infra/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
vars.tfvars
*.config
*.tfplan
Loading