diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..be16664 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.git +.gitea +deploy +scripts +README.md +AGENTS.md +VERSION diff --git a/.gitea/workflows/publish.yaml b/.gitea/workflows/publish.yaml new file mode 100644 index 0000000..886f884 --- /dev/null +++ b/.gitea/workflows/publish.yaml @@ -0,0 +1,147 @@ +name: Build and publish frontend + +on: + push: + branches: + - main + tags: + - "v*" + +permissions: + contents: write + +jobs: + publish: + runs-on: large + if: ${{ !startsWith(github.ref, 'refs/heads/') || !contains(github.event.head_commit.message, '[auto-version-bump]') }} + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + token: ${{ secrets.GITEA_TOKEN }} + persist-credentials: true + + - name: Verify release version and frontend source + shell: bash + run: | + set -euo pipefail + python3 scripts/bump-version.py --check + scripts/check-frontend.sh + + - name: Ensure Helm is available + shell: bash + env: + HELM_VERSION: v3.17.3 + run: | + set -euo pipefail + if command -v helm >/dev/null 2>&1; then + helm version --short + exit 0 + fi + mkdir -p "$HOME/.local/bin" "$RUNNER_TEMP/helm" + archive="$RUNNER_TEMP/helm/helm-${HELM_VERSION}-linux-amd64.tar.gz" + curl -fsSLo "$archive" "https://get.helm.sh/helm-${HELM_VERSION}-linux-amd64.tar.gz" + curl -fsSLo "$archive.sha256sum" "https://get.helm.sh/helm-${HELM_VERSION}-linux-amd64.tar.gz.sha256sum" + (cd "$RUNNER_TEMP/helm" && sha256sum -c "$(basename "$archive").sha256sum") + tar -xzf "$archive" -C "$RUNNER_TEMP/helm" + install "$RUNNER_TEMP/helm/linux-amd64/helm" "$HOME/.local/bin/helm" + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + + - name: Lint and render chart + shell: bash + run: | + set -euo pipefail + helm lint deploy/helm/ai-spel-frontend + helm template ai-spel-frontend deploy/helm/ai-spel-frontend \ + --namespace ai-spel > "$RUNNER_TEMP/frontend-manifest.yaml" + grep -q 'kind: Deployment' "$RUNNER_TEMP/frontend-manifest.yaml" + grep -q 'kind: Service' "$RUNNER_TEMP/frontend-manifest.yaml" + + - name: Build and push commit image + if: startsWith(github.ref, 'refs/heads/') + shell: bash + env: + HARBOR_USERNAME: ${{ secrets.HARBOR_USERNAME }} + HARBOR_PASSWORD: ${{ secrets.HARBOR_PASSWORD }} + run: | + set -euo pipefail + printf '%s' "$HARBOR_PASSWORD" | docker login harbor.k3s.k0.nu \ + --username "$HARBOR_USERNAME" --password-stdin + unset HARBOR_PASSWORD + version="$(cat VERSION)" + tag="harbor.k3s.k0.nu/ai-spel/frontend:edge-${GITHUB_SHA:0:12}" + docker build --platform linux/amd64 --pull \ + --build-arg "FRONTEND_VERSION=$version" --tag "$tag" . + docker image push "$tag" + + - name: Bump patch version and create release tag + id: bump + if: startsWith(github.ref, 'refs/heads/') + shell: bash + run: | + set -euo pipefail + git fetch origin main --quiet + test "$(git rev-parse origin/main)" = "$GITHUB_SHA" || { + echo 'main advanced during this workflow; a newer push will run the release step' + exit 1 + } + version="$(python3 scripts/bump-version.py)" + git config user.name "Gitea Actions" + git config user.email "actions@users.noreply.gitea" + git add VERSION deploy/helm/ai-spel-frontend/Chart.yaml + git commit -m "Release $version [auto-version-bump]" + git tag -a "v$version" -m "v$version" + git push origin HEAD:main "refs/tags/v$version" + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Publish versioned image, chart, and manifest + if: ${{ startsWith(github.ref, 'refs/tags/') || steps.bump.outputs.version != '' }} + shell: bash + env: + AUTO_RELEASE_VERSION: ${{ steps.bump.outputs.version }} + HARBOR_USERNAME: ${{ secrets.HARBOR_USERNAME }} + HARBOR_PASSWORD: ${{ secrets.HARBOR_PASSWORD }} + ORAS_VERSION: 1.2.3 + run: | + set -euo pipefail + version="$(cat VERSION)" + test -n "$version" + if [[ "$GITHUB_REF" == refs/tags/* ]]; then + test "$GITHUB_REF_NAME" = "v$version" + else + test "$AUTO_RELEASE_VERSION" = "$version" + fi + python3 scripts/bump-version.py --check + printf '%s' "$HARBOR_PASSWORD" | docker login harbor.k3s.k0.nu \ + --username "$HARBOR_USERNAME" --password-stdin + image="harbor.k3s.k0.nu/ai-spel/frontend:$version" + docker build --platform linux/amd64 --pull \ + --build-arg "FRONTEND_VERSION=$version" --tag "$image" . + docker image push "$image" + + publish_dir="$(mktemp -d)" + trap 'rm -rf "$publish_dir"' EXIT + helm lint deploy/helm/ai-spel-frontend + helm package deploy/helm/ai-spel-frontend --destination "$publish_dir" \ + --version "$version" --app-version "$version" + helm push "$publish_dir/ai-spel-frontend-$version.tgz" \ + oci://harbor.k3s.k0.nu/ai-spel + helm template ai-spel-frontend "$publish_dir/ai-spel-frontend-$version.tgz" \ + --namespace ai-spel > "$publish_dir/frontend-manifest.yaml" + + mkdir -p "$RUNNER_TEMP/oras" + archive="$RUNNER_TEMP/oras/oras_${ORAS_VERSION}_linux_amd64.tar.gz" + curl -fsSLo "$archive" \ + "https://github.com/oras-project/oras/releases/download/v${ORAS_VERSION}/oras_${ORAS_VERSION}_linux_amd64.tar.gz" + echo 'b4efc97a91f471f323f193ea4b4d63d8ff443ca3aab514151a30751330852827 '"$archive" | sha256sum -c - + tar -xzf "$archive" -C "$RUNNER_TEMP/oras" oras + export PATH="$RUNNER_TEMP/oras:$PATH" + printf '%s' "$HARBOR_PASSWORD" | oras login harbor.k3s.k0.nu \ + --username "$HARBOR_USERNAME" --password-stdin + unset HARBOR_PASSWORD + ( + cd "$publish_dir" + oras push --artifact-type application/vnd.ai-spel.frontend-manifest.v1 \ + "harbor.k3s.k0.nu/ai-spel/frontend-manifest:$version" \ + "frontend-manifest.yaml:application/yaml" + ) diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b6994cd --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,9 @@ +# AI-spel frontend instructions + +- This repository owns only the static, accessible web client and its container/chart packaging. +- Rust remains the sole authority for game state. The frontend calls the backend through same-origin `/api/...` and `/version` paths. +- Keep frontend and backend behind `ai-spel.k3s.k0.nu` so session cookies remain same-origin. +- `VERSION` is the only release-version source in this repository. Use `scripts/bump-version.py`; do not manually edit chart versions. +- Changes are written locally and built, checked, published, and exercised by Gitea Actions on the existing `large` runner and Kubernetes. Never run apps, tests, containers, images, or binaries locally. Do not create or register runners. +- Never commit secrets, generated credentials, kubeconfigs, or environment files. +- Helm charts are published to `harbor.k3s.k0.nu/ai-spel`; Argo CD uses the existing `ai-spel` project and namespace. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ae2ee7b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +FROM nginxinc/nginx-unprivileged:1.27-alpine + +ARG FRONTEND_VERSION +RUN test -n "$FRONTEND_VERSION" \ + && sed -i "s/__FRONTEND_VERSION__/${FRONTEND_VERSION}/g" /usr/share/nginx/html/index.html + +COPY nginx/default.conf /etc/nginx/conf.d/default.conf + +EXPOSE 8080 diff --git a/README.md b/README.md index 117320b..85436a4 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,15 @@ -# ai-spel-frontend +# AI-spel frontend -Statisk webbfrontend för den AI-drivna svenska livssimulatorn \ No newline at end of file +This repository contains the static Swedish web client for the AI-driven life simulator. The intended deployment is a small Nginx pod in the `ai-spel` namespace. The existing Rust service remains authoritative for accounts, game state, and all `/api/...` routes. + +The browser and API stay on the same public origin, `https://ai-spel.k3s.k0.nu`. The ingress routes `/` to this frontend service and routes `/api`, `/version`, and `/health` to the Rust service. That keeps the current server-side session cookie same-origin and avoids browser CORS configuration. + +## Release flow + +`VERSION` is the only version source for this repository. Gitea Actions on the existing `large` runner runs the static checks, Helm lint/template, publishes a commit image, bumps the patch version through `scripts/bump-version.py`, creates the matching release commit and Git tag, and publishes the release image, Helm chart, and rendered frontend manifest to Harbor. Argo CD tracks the newest published frontend chart. The image and chart releases use the same version as the commit and tag. + +CI needs repository Action secrets named `HARBOR_USERNAME` and `HARBOR_PASSWORD`. The Harbor pull secret `harbor-pull` is already present in the Kubernetes namespace. Argo CD uses the existing `ai-spel` project and Harbor OCI credentials. + +The first Argo Application is described in `deploy/argocd/application.yaml`. The backend ingress must be switched to the frontend Service before public traffic uses this pod. That cutover is intentionally coordinated with a backend release so the API routes and login session remain on the same host. + +Builds, checks, and runtime verification happen in the pipeline and Kubernetes. Do not run the app, tests, container images, or binaries locally. diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..7ac4e5e --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.13 diff --git a/deploy/argocd/README.md b/deploy/argocd/README.md new file mode 100644 index 0000000..391883d --- /dev/null +++ b/deploy/argocd/README.md @@ -0,0 +1 @@ +Apply `application.yaml` once to bootstrap the frontend Argo CD Application. It tracks the latest published `ai-spel-frontend` chart in Harbor and auto-syncs it into namespace `ai-spel`. The chart creates only the frontend Deployment and Service; the existing backend Helm release owns the shared ingress and routes `/` to the frontend Service after the frontend pod is ready. diff --git a/deploy/argocd/application.yaml b/deploy/argocd/application.yaml new file mode 100644 index 0000000..ad5a933 --- /dev/null +++ b/deploy/argocd/application.yaml @@ -0,0 +1,22 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: ai-spel-frontend + namespace: argocd +spec: + project: ai-spel + source: + repoURL: harbor.k3s.k0.nu/ai-spel + chart: ai-spel-frontend + targetRevision: "*" + helm: + releaseName: ai-spel-frontend + destination: + server: https://kubernetes.default.svc + namespace: ai-spel + syncPolicy: + automated: + prune: false + selfHeal: true + syncOptions: + - CreateNamespace=false diff --git a/deploy/helm/ai-spel-frontend/Chart.yaml b/deploy/helm/ai-spel-frontend/Chart.yaml new file mode 100644 index 0000000..dc2a985 --- /dev/null +++ b/deploy/helm/ai-spel-frontend/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: ai-spel-frontend +description: Static web frontend for the Swedish life simulator +type: application +version: 0.1.13 +appVersion: "0.1.13" diff --git a/deploy/helm/ai-spel-frontend/templates/deployment.yaml b/deploy/helm/ai-spel-frontend/templates/deployment.yaml new file mode 100644 index 0000000..a146686 --- /dev/null +++ b/deploy/helm/ai-spel-frontend/templates/deployment.yaml @@ -0,0 +1,50 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Release.Name }} + labels: + app.kubernetes.io/name: {{ .Chart.Name }} + app.kubernetes.io/instance: {{ .Release.Name }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + app.kubernetes.io/instance: {{ .Release.Name }} + template: + metadata: + labels: + app.kubernetes.io/name: {{ .Chart.Name }} + app.kubernetes.io/instance: {{ .Release.Name }} + spec: + {{- if .Values.image.pullSecretName }} + imagePullSecrets: + - name: {{ .Values.image.pullSecretName }} + {{- end }} + securityContext: + runAsNonRoot: true + runAsUser: 101 + runAsGroup: 101 + seccompProfile: + type: RuntimeDefault + containers: + - name: frontend + image: "{{ .Values.image.repository }}:{{ .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.service.targetPort }} + protocol: TCP + readinessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 2 + periodSeconds: 5 + livenessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + resources: + {{- toYaml .Values.resources | nindent 12 }} diff --git a/deploy/helm/ai-spel-frontend/templates/service.yaml b/deploy/helm/ai-spel-frontend/templates/service.yaml new file mode 100644 index 0000000..24ffbfa --- /dev/null +++ b/deploy/helm/ai-spel-frontend/templates/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ .Release.Name }} + labels: + app.kubernetes.io/name: {{ .Chart.Name }} + app.kubernetes.io/instance: {{ .Release.Name }} +spec: + type: ClusterIP + selector: + app.kubernetes.io/instance: {{ .Release.Name }} + ports: + - name: http + port: {{ .Values.service.port }} + targetPort: http + protocol: TCP diff --git a/deploy/helm/ai-spel-frontend/values.yaml b/deploy/helm/ai-spel-frontend/values.yaml new file mode 100644 index 0000000..46fb191 --- /dev/null +++ b/deploy/helm/ai-spel-frontend/values.yaml @@ -0,0 +1,15 @@ +replicaCount: 1 +image: + repository: harbor.k3s.k0.nu/ai-spel/frontend + pullPolicy: IfNotPresent + pullSecretName: harbor-pull +service: + port: 80 + targetPort: 8080 +resources: + requests: + cpu: 10m + memory: 24Mi + limits: + cpu: 250m + memory: 96Mi diff --git a/index.html b/index.html new file mode 100644 index 0000000..2597e46 --- /dev/null +++ b/index.html @@ -0,0 +1,175 @@ + + + + + + + Svensk livssimulator + + + +
+

En värld du formar

+

Skapa en vuxen person och forma en egen vardag. AI-förslag sparas inte förrän du har granskat och godkänt dem.

+

+
+

Logga in eller skapa konto

+
+ + +
+
+
+ + + +
+ + + + diff --git a/nginx/default.conf b/nginx/default.conf new file mode 100644 index 0000000..f769b9c --- /dev/null +++ b/nginx/default.conf @@ -0,0 +1,16 @@ +server { + listen 8080; + server_name _; + root /usr/share/nginx/html; + index index.html; + + location = /healthz { + access_log off; + return 204; + } + + location / { + add_header Cache-Control "no-cache" always; + try_files $uri $uri/ /index.html; + } +} diff --git a/scripts/bump-version.py b/scripts/bump-version.py new file mode 100755 index 0000000..db410f2 --- /dev/null +++ b/scripts/bump-version.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Use VERSION as the single source for frontend release metadata.""" + +import argparse +from pathlib import Path +import re + + +VERSION_FILE = Path("VERSION") +CHART_FILE = Path("deploy/helm/ai-spel-frontend/Chart.yaml") +SEMVER = re.compile(r"^(\d+)\.(\d+)\.(\d+)$") + + +def read_version() -> str: + version = VERSION_FILE.read_text().strip() + if not SEMVER.fullmatch(version): + raise SystemExit(f"Invalid semantic version in {VERSION_FILE}: {version}") + return version + + +def update_one(contents: str, pattern: str, replacement: str, description: str) -> str: + updated, count = re.subn(pattern, replacement, contents, count=1, flags=re.MULTILINE) + if count != 1: + raise SystemExit(f"Expected one {description} in {CHART_FILE}, found {count}") + return updated + + +def check(version: str) -> str: + chart = CHART_FILE.read_text() + chart_version = re.findall(r"(?m)^version:\s*(\S+)\s*$", chart) + app_version = re.findall(r'(?m)^appVersion:\s*"(\S+)"\s*$', chart) + if chart_version != [version] or app_version != [version]: + raise SystemExit( + f"Version drift: VERSION={version}, chart version={chart_version}, " + f"appVersion={app_version}" + ) + return version + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--check", action="store_true", help="verify metadata without editing") + args = parser.parse_args() + + current = check(read_version()) + if args.check: + print(current) + return + + major, minor, patch = map(int, current.split(".")) + next_version = f"{major}.{minor}.{patch + 1}" + chart = CHART_FILE.read_text() + chart = update_one( + chart, + r"(?m)^version:\s*" + re.escape(current) + r"\s*$", + f"version: {next_version}", + "chart version", + ) + chart = update_one( + chart, + r'(?m)^appVersion:\s*"' + re.escape(current) + r'"\s*$', + f'appVersion: "{next_version}"', + "chart appVersion", + ) + VERSION_FILE.write_text(f"{next_version}\n") + CHART_FILE.write_text(chart) + print(next_version) + + +if __name__ == "__main__": + main() diff --git a/scripts/check-frontend.sh b/scripts/check-frontend.sh new file mode 100755 index 0000000..e81c171 --- /dev/null +++ b/scripts/check-frontend.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +test -s index.html +grep -q '' index.html +grep -q '

En värld du formar

' index.html +grep -q 'id="frontend-version">__FRONTEND_VERSION__' index.html +grep -q "request('/version')" index.html +grep -q 'id="api-version"' index.html +grep -Fq "credentials: 'same-origin'" index.html +grep -q 'location = /healthz' nginx/default.conf + +echo 'Frontend source checks passed.'