diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0f978eb --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +frontend/node_modules +frontend/build +frontend/test-results +frontend/playwright-report +.svelte-kit +backend/target +data/ +*.log +*.db +node_modules diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/ci.yml similarity index 85% rename from .gitea/workflows/test.yml rename to .gitea/workflows/ci.yml index 18cc54a..6a803eb 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/ci.yml @@ -1,8 +1,11 @@ -name: Test +name: CI on: push: - branches: [main] + branches-ignore: + - main + tags-ignore: + - '**' pull_request: jobs: @@ -13,7 +16,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '22' - uses: pnpm/action-setup@v4 with: @@ -78,3 +81,12 @@ jobs: frontend/test-results/ frontend/playwright-report/ retention-days: 7 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Docker build (no push) + uses: docker/build-push-action@v6 + with: + push: false + tags: tutortool:ci diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..53ff184 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,95 @@ +name: Release + +on: + push: + tags: + - 'v*.*.*' + +env: + IMAGE: registry.itsh.dev/s0wlz/tutortool + NAMESPACE: tenant-5 + RELEASE_NAME: tutortool + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + + - uses: pnpm/action-setup@v4 + with: + version: latest + + - uses: dtolnay/rust-toolchain@master + with: + toolchain: '1.95.0' + + - name: Cache Cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + backend/target + key: cargo-${{ hashFiles('backend/Cargo.lock') }} + restore-keys: cargo- + + - name: Cache pnpm store + uses: actions/cache@v4 + with: + path: ~/.local/share/pnpm/store + key: pnpm-${{ hashFiles('frontend/pnpm-lock.yaml') }} + restore-keys: pnpm- + + - name: Install frontend deps + run: pnpm --dir frontend install --frozen-lockfile + + - name: Type check (frontend) + run: pnpm --dir frontend exec tsgo --version && pnpm --dir frontend check + + - name: Unit tests (backend) + run: cargo test --manifest-path backend/Cargo.toml + + - name: Build frontend + run: pnpm --dir frontend build + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to registry + uses: docker/login-action@v3 + with: + registry: registry.itsh.dev + username: ${{ secrets.REGISTRY_USER }} + password: ${{ secrets.REGISTRY_TOKEN }} + + - name: Build and push image + uses: docker/build-push-action@v6 + with: + push: true + tags: | + ${{ env.IMAGE }}:${{ github.ref_name }} + ${{ env.IMAGE }}:latest + + - name: Configure kubectl + run: | + mkdir -p ~/.kube + echo "${{ secrets.K8S_CONFIG }}" | base64 -d > ~/.kube/config + chmod 600 ~/.kube/config + + - name: Set up Helm + uses: azure/setup-helm@v4 + with: + version: v3.16.2 + + - name: Deploy via Helm + run: | + helm upgrade --install ${{ env.RELEASE_NAME }} ./deploy \ + -f ./deploy/values_override.yaml \ + --set image.tag=${{ github.ref_name }} \ + -n ${{ env.NAMESPACE }} \ + --wait --timeout 5m diff --git a/CLAUDE.md b/CLAUDE.md index 11e6c21..1506e31 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -85,10 +85,11 @@ See `docs/testing.md` for the full guide including seed data, MCP-driven verific ### Container / K8s / CI -- `Dockerfile`: 3-stage build (Node 20 frontend → Rust 1.95 backend → Debian slim runtime) -- `k8s/`: Deployment, Service, PVC for SQLite, CronJob for nightly vacuum + backup rotation -- Live at `tutor.puchstein.dev` (tenant-5, ITSH Cloud) -- CI: Gitea Actions at `.gitea/workflows/test.yml` — runs `cargo check`, `pnpm check`, `cargo test`, `pnpm build`, and `make test-e2e` on every push to `main` and on PRs +- `Dockerfile`: 3-stage build (Node 22/pnpm frontend → Rust 1.95 backend → Debian slim runtime, non-root) +- `deploy/`: Helm chart — Deployment, Service, HTTPRoute (Gateway API), PVC, CronJob for nightly vacuum + backup rotation +- Live at `tutor.puchstein.dev` (tenant-5, ITSH Cloud); image at `registry.itsh.dev/s0wlz/tutortool` +- CI: Gitea Actions at `.gitea/workflows/ci.yml` — runs `cargo check`, `pnpm check` (tsgo + svelte-check), `cargo test`, `pnpm build`, `make test-e2e`, and a no-push Docker build on every non-main push and PR +- Release: `.gitea/workflows/release.yml` — triggered by `v*.*.*` tags; builds + pushes image, then `helm upgrade` ## Conventions diff --git a/Dockerfile b/Dockerfile index 85f806b..1f5e72a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,31 +1,36 @@ # --- Frontend Build --- -FROM node:20-alpine as frontend-builder +FROM node:22-alpine AS frontend-builder WORKDIR /app/frontend -COPY frontend/package*.json ./ -RUN npm install --force +RUN corepack enable && corepack prepare pnpm --activate +COPY frontend/package.json frontend/pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile COPY frontend/ ./ -RUN npm run build +RUN pnpm run check +RUN pnpm run build # --- Backend Build --- -FROM rust:1.95-slim as backend-builder +FROM rust:1.95-slim AS backend-builder WORKDIR /app/backend -# Pre-build dependencies for caching COPY backend/Cargo.toml backend/Cargo.lock ./ RUN mkdir src && echo "fn main() {}" > src/main.rs && cargo build --release && rm -rf src COPY backend/src ./src COPY backend/migrations ./migrations -# Ensure we actually rebuild the binary with the real source +COPY backend/demo ./demo RUN touch src/main.rs && cargo build --release -# --- Final Image --- +# --- Runtime --- FROM debian:bookworm-slim -RUN apt-get update && apt-get install -y sqlite3 ca-certificates && rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y ca-certificates curl && rm -rf /var/lib/apt/lists/* +RUN useradd -u 1000 -m app WORKDIR /app COPY --from=backend-builder /app/backend/target/release/attendance ./server -COPY --from=frontend-builder /app/frontend/build ./frontend/build +COPY --from=backend-builder /app/backend/demo ./backend/demo +COPY --from=frontend-builder /app/frontend/build ./frontend/build ENV STATIC_DIR=/app/frontend/build ENV DATABASE_URL=sqlite:/data/attendance.db -ENV JWT_SECRET=change-me-at-runtime EXPOSE 3000 +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \ + CMD curl -fs http://localhost:3000/health || exit 1 +USER app CMD ["./server"] diff --git a/backend/src/db.rs b/backend/src/db.rs index 8923563..9cabaec 100644 --- a/backend/src/db.rs +++ b/backend/src/db.rs @@ -1,15 +1,14 @@ -use sqlx::{sqlite::SqlitePoolOptions, SqlitePool}; +use std::str::FromStr; +use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; +use sqlx::SqlitePool; pub async fn init() -> Result { let url = std::env::var("DATABASE_URL") .unwrap_or_else(|_| "sqlite:/data/attendance.db".into()); - let pool = SqlitePoolOptions::new() - .after_connect(|conn, _| Box::pin(async move { - sqlx::query("PRAGMA foreign_keys = ON") - .execute(conn).await?; - Ok(()) - })) - .connect(&url).await?; + let opts = SqliteConnectOptions::from_str(&url)? + .create_if_missing(true) + .foreign_keys(true); + let pool = SqlitePoolOptions::new().connect_with(opts).await?; sqlx::migrate!("./migrations").run(&pool).await?; Ok(pool) } diff --git a/deploy/Chart.yaml b/deploy/Chart.yaml new file mode 100644 index 0000000..d88a32f --- /dev/null +++ b/deploy/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: tutortool +description: TutorTool attendance tracker (Rust + SvelteKit + SQLite) +type: application +version: 0.1.0 +appVersion: "0.1.0" diff --git a/deploy/templates/NOTES.txt b/deploy/templates/NOTES.txt new file mode 100644 index 0000000..2d1c2e7 --- /dev/null +++ b/deploy/templates/NOTES.txt @@ -0,0 +1,17 @@ +TutorTool has been deployed! + +Application URL: https://{{ index .Values.httpRoute.hostnames 0 }} + +To verify the deployment: + kubectl -n {{ .Release.Namespace }} rollout status deploy/{{ include "tutortool.fullname" . }} + +Health check: + kubectl -n {{ .Release.Namespace }} exec deploy/{{ include "tutortool.fullname" . }} -- curl -s http://localhost:3000/health + +The JWT_SECRET is read from the K8s Secret named "{{ .Values.jwtSecretName }}". +Provision it before deploying if it doesn't already exist: + kubectl -n {{ .Release.Namespace }} create secret generic {{ .Values.jwtSecretName }} \ + --from-literal=JWT_SECRET= + +SQLite data persists on PVC: {{ include "tutortool.fullname" . }}-data +Nightly VACUUM backups run at 03:00 UTC, retained for 7 days. diff --git a/deploy/templates/_helpers.tpl b/deploy/templates/_helpers.tpl new file mode 100644 index 0000000..f998ddb --- /dev/null +++ b/deploy/templates/_helpers.tpl @@ -0,0 +1,50 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "tutortool.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +*/}} +{{- define "tutortool.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "tutortool.labels" -}} +helm.sh/chart: {{ include "tutortool.name" . }}-{{ .Chart.Version }} +{{ include "tutortool.selectorLabels" . }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "tutortool.selectorLabels" -}} +app.kubernetes.io/name: {{ include "tutortool.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Service account name +*/}} +{{- define "tutortool.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "tutortool.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/deploy/templates/cronjob-backup.yaml b/deploy/templates/cronjob-backup.yaml new file mode 100644 index 0000000..d339a83 --- /dev/null +++ b/deploy/templates/cronjob-backup.yaml @@ -0,0 +1,31 @@ +apiVersion: batch/v1 +kind: CronJob +metadata: + name: {{ include "tutortool.fullname" . }}-backup + namespace: {{ .Release.Namespace }} + labels: + {{- include "tutortool.labels" . | nindent 4 }} +spec: + schedule: "0 3 * * *" + jobTemplate: + spec: + template: + spec: + restartPolicy: OnFailure + containers: + - name: backup + image: alpine:latest + command: + - /bin/sh + - -c + - | + apk add --no-cache sqlite + sqlite3 /data/attendance.db "VACUUM INTO '/data/backup-$(date +%F).sqlite'" + find /data -name "backup-*.sqlite" -mtime +7 -delete + volumeMounts: + - name: data + mountPath: /data + volumes: + - name: data + persistentVolumeClaim: + claimName: {{ include "tutortool.fullname" . }}-data diff --git a/deploy/templates/deployment.yaml b/deploy/templates/deployment.yaml new file mode 100644 index 0000000..8f9a1b5 --- /dev/null +++ b/deploy/templates/deployment.yaml @@ -0,0 +1,60 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "tutortool.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "tutortool.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + {{- include "tutortool.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "tutortool.selectorLabels" . | nindent 8 }} + spec: + serviceAccountName: {{ include "tutortool.serviceAccountName" . }} + securityContext: + fsGroup: 1000 + containers: + - name: app + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - containerPort: {{ .Values.containerPort }} + env: + - name: DATABASE_URL + value: {{ .Values.env.DATABASE_URL | quote }} + - name: STATIC_DIR + value: {{ .Values.env.STATIC_DIR | quote }} + - name: JWT_SECRET + valueFrom: + secretKeyRef: + name: {{ .Values.jwtSecretName }} + key: JWT_SECRET + volumeMounts: + - name: data + mountPath: /data + livenessProbe: + httpGet: + path: /health + port: {{ .Values.containerPort }} + initialDelaySeconds: 10 + periodSeconds: 30 + readinessProbe: + httpGet: + path: /health + port: {{ .Values.containerPort }} + initialDelaySeconds: 5 + periodSeconds: 10 + securityContext: + readOnlyRootFilesystem: false + allowPrivilegeEscalation: false + resources: + {{- toYaml .Values.resources | nindent 12 }} + volumes: + - name: data + persistentVolumeClaim: + claimName: {{ include "tutortool.fullname" . }}-data diff --git a/deploy/templates/httproute.yaml b/deploy/templates/httproute.yaml new file mode 100644 index 0000000..a2c0e53 --- /dev/null +++ b/deploy/templates/httproute.yaml @@ -0,0 +1,45 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: {{ include "tutortool.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "tutortool.labels" . | nindent 4 }} +spec: + parentRefs: + - name: itsh-gateway + sectionName: {{ .Values.httpRoute.sectionName }} + hostnames: + {{- range .Values.httpRoute.hostnames }} + - {{ . | quote }} + {{- end }} + rules: + - matches: + - path: + type: PathPrefix + value: / + backendRefs: + - name: {{ include "tutortool.fullname" . }} + port: {{ .Values.service.port }} +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: {{ include "tutortool.fullname" . }}-http-redirect + namespace: {{ .Release.Namespace }} + labels: + {{- include "tutortool.labels" . | nindent 4 }} +spec: + parentRefs: + - name: itsh-gateway + sectionName: http-tutor-puchstein-dev + hostnames: + {{- range .Values.httpRoute.hostnames }} + - {{ . | quote }} + {{- end }} + rules: + - filters: + - type: RequestRedirect + requestRedirect: + scheme: https + statusCode: 301 diff --git a/deploy/templates/pvc.yaml b/deploy/templates/pvc.yaml new file mode 100644 index 0000000..a5a1e1b --- /dev/null +++ b/deploy/templates/pvc.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "tutortool.fullname" . }}-data + namespace: {{ .Release.Namespace }} + labels: + {{- include "tutortool.labels" . | nindent 4 }} +spec: + accessModes: + - ReadWriteOnce + storageClassName: {{ .Values.pvc.storageClassName }} + resources: + requests: + storage: {{ .Values.pvc.storage }} diff --git a/deploy/templates/service.yaml b/deploy/templates/service.yaml new file mode 100644 index 0000000..8fd15cf --- /dev/null +++ b/deploy/templates/service.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "tutortool.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "tutortool.labels" . | nindent 4 }} +spec: + selector: + {{- include "tutortool.selectorLabels" . | nindent 4 }} + ports: + - port: {{ .Values.service.port }} + targetPort: {{ .Values.service.targetPort }} diff --git a/deploy/templates/serviceaccount.yaml b/deploy/templates/serviceaccount.yaml new file mode 100644 index 0000000..69befb8 --- /dev/null +++ b/deploy/templates/serviceaccount.yaml @@ -0,0 +1,9 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "tutortool.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "tutortool.labels" . | nindent 4 }} +{{- end }} diff --git a/deploy/templates/vpa.yaml b/deploy/templates/vpa.yaml new file mode 100644 index 0000000..1a9b5cf --- /dev/null +++ b/deploy/templates/vpa.yaml @@ -0,0 +1,29 @@ +{{- if .Values.vpa.enabled }} +apiVersion: autoscaling.k8s.io/v1 +kind: VerticalPodAutoscaler +metadata: + name: {{ include "tutortool.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "tutortool.labels" . | nindent 4 }} +spec: + targetRef: + apiVersion: "apps/v1" + kind: Deployment + name: {{ include "tutortool.fullname" . }} + updatePolicy: + updateMode: {{ .Values.vpa.updateMode | default "Off" | quote }} + resourcePolicy: + containerPolicies: + {{- range .Values.vpa.containerPolicies }} + - containerName: {{ .containerName }} + minAllowed: + {{- toYaml .minAllowed | nindent 10 }} + maxAllowed: + {{- toYaml .maxAllowed | nindent 10 }} + controlledResources: + - cpu + - memory + controlledValues: RequestsAndLimits + {{- end }} +{{- end }} diff --git a/deploy/values.yaml b/deploy/values.yaml new file mode 100644 index 0000000..1f20a10 --- /dev/null +++ b/deploy/values.yaml @@ -0,0 +1,53 @@ +replicaCount: 1 + +image: + repository: registry.itsh.dev/s0wlz/tutortool + pullPolicy: IfNotPresent + tag: latest + +serviceAccount: + create: true + name: "" + +service: + port: 80 + targetPort: 3000 + +containerPort: 3000 + +resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 500m + memory: 256Mi + +pvc: + storageClassName: hcloud-volumes + storage: 1Gi + +httpRoute: + hostnames: + - tutor.puchstein.dev + sectionName: https-tutor-puchstein-dev + +# JWT_SECRET provisioned as a pre-existing K8s Secret named here. +# Do not set jwtSecretValue in committed values — provision via kubectl manually. +jwtSecretName: tutortool-jwt + +env: + DATABASE_URL: sqlite:/data/attendance.db + STATIC_DIR: /app/frontend/build + +vpa: + enabled: false + updateMode: "Off" + containerPolicies: + - containerName: app + minAllowed: + cpu: 10m + memory: 32Mi + maxAllowed: + cpu: 1000m + memory: 512Mi diff --git a/deploy/values_override.yaml b/deploy/values_override.yaml new file mode 100644 index 0000000..0e715e9 --- /dev/null +++ b/deploy/values_override.yaml @@ -0,0 +1,6 @@ +httpRoute: + hostnames: + - tutor.puchstein.dev + +image: + tag: latest diff --git a/k8s/cronjob.yaml b/k8s/cronjob.yaml deleted file mode 100644 index 3597653..0000000 --- a/k8s/cronjob.yaml +++ /dev/null @@ -1,28 +0,0 @@ -apiVersion: batch/v1 -kind: CronJob -metadata: - name: tutortool-backup -spec: - schedule: "0 3 * * *" - jobTemplate: - spec: - template: - spec: - containers: - - name: backup - image: alpine:latest - command: - - /bin/sh - - -c - - | - apk add --no-cache sqlite - sqlite3 /data/attendance.db "VACUUM INTO '/data/backup-$(date +%F).sqlite'" - find /data -name "backup-*.sqlite" -mtime +7 -delete - volumeMounts: - - name: db-storage - mountPath: /data - restartPolicy: OnFailure - volumes: - - name: db-storage - persistentVolumeClaim: - claimName: tutortool-pvc diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml deleted file mode 100644 index dbbf9be..0000000 --- a/k8s/deployment.yaml +++ /dev/null @@ -1,59 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: tutortool -spec: - replicas: 1 - selector: - matchLabels: - app: tutortool - template: - metadata: - labels: - app: tutortool - spec: - containers: - - name: app - image: tutor-registry:latest - ports: - - containerPort: 3000 - env: - - name: DATABASE_URL - value: "sqlite:/data/attendance.db" - - name: STATIC_DIR - value: "/app/frontend/build" - volumeMounts: - - name: db-storage - mountPath: /data - volumes: - - name: db-storage - persistentVolumeClaim: - claimName: tutortool-pvc ---- -apiVersion: v1 -kind: Service -metadata: - name: tutortool -spec: - selector: - app: tutortool - ports: - - port: 80 - targetPort: 3000 ---- -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: tutortool-ingress -spec: - rules: - - host: tutor.puchstein.dev - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: tutortool - port: - number: 80 diff --git a/k8s/ingress.yaml b/k8s/ingress.yaml deleted file mode 100644 index b476da8..0000000 --- a/k8s/ingress.yaml +++ /dev/null @@ -1,25 +0,0 @@ -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: tutortool - namespace: tutortool - annotations: - cert-manager.io/cluster-issuer: "letsencrypt-prod" - nginx.ingress.kubernetes.io/ssl-redirect: "true" -spec: - ingressClassName: nginx - tls: - - hosts: - - tutor.puchstein.dev - secretName: tutortool-tls - rules: - - host: tutor.puchstein.dev - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: tutortool - port: - number: 80 diff --git a/k8s/pvc.yaml b/k8s/pvc.yaml deleted file mode 100644 index 12af263..0000000 --- a/k8s/pvc.yaml +++ /dev/null @@ -1,10 +0,0 @@ -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: tutortool-pvc -spec: - accessModes: - - ReadWriteOnce - resources: - requests: - storage: 1Gi diff --git a/k8s/service.yaml b/k8s/service.yaml deleted file mode 100644 index 2c421c8..0000000 --- a/k8s/service.yaml +++ /dev/null @@ -1,13 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: tutortool - namespace: tutortool -spec: - selector: - app: tutortool - ports: - - protocol: TCP - port: 80 - targetPort: 3000 - type: ClusterIP