# kuploy native stack spec — AnubisLMS (autograded coding courses)
#
# ⛔ STILL A DRAFT — but no longer for want of an image.
#
# THE IMAGE HALF OF #9 IS DONE. AnubisLMS publishes none: api/web go only to a
# private DigitalOcean registry. So we build our own from kuploy/anubis — our
# copy, already on a modern base — and publish it to GHCR. The ref below is a
# real, public, digest-pinned image, verified anonymously pullable.
#
# HOW TO CHECK THAT, because the obvious way lies — TWICE, and the second trap
# is one this comment used to walk into.
#
# TRAP 1, THE TOKEN. GHCR answers 401 to an unauthenticated request whether the
# package is public or private, so a bare
# `curl https://ghcr.io/v2/.../manifests/<ref>` returns 401 for a PUBLIC image
# and reads as "still private". The token is the variable, not the endpoint,
# and it needs no credentials — `docker pull` fetches one for you, which is why
# pulling works while curling does not.
#
# TRAP 2, THE ACCEPT HEADER, and it fails in the SAME DIRECTION. Without an
# Accept list GHCR answers **404** for a healthy, public, correctly-pinned
# image — indistinguishable from a deleted manifest behind a dangling tag.
# Measured on this file's own example tag:
#
#                              sha-894ed020997f   sha-6c528f25051a
#   no Authorization header           401                401
#   token, no Accept                  404                404      <- healthy!
#   token + Accept list               200                200
#
# The earlier version of this comment printed a command with no Accept header
# and a table claiming "anonymous token → manifests 200". Run verbatim it
# returned 404 for both — so the warning against a misleading 404 produced
# one. It cost two sessions a false "the packages are gone" alarm.
#
#   ACC="application/vnd.oci.image.index.v1+json, \
#        application/vnd.docker.distribution.manifest.list.v2+json, \
#        application/vnd.oci.image.manifest.v1+json, \
#        application/vnd.docker.distribution.manifest.v2+json"
#   T=$(curl -s "https://ghcr.io/token?scope=repository%3Akuploy%2Fanubis-api%3Apull&service=ghcr.io" | jq -r .token)
#   curl -sI -H "Authorization: Bearer $T" -H "Accept: $ACC" \
#        https://ghcr.io/v2/kuploy/anubis-api/manifests/sha-6c528f25051a
#
# `scripts/pin-images.mjs` is the source of truth for that list — it has
# carried all four types since #31, and its comment explains why the
# `manifest.list.v2` entry is not optional. Copy from there, not from here.
#
# AND USE A POSITIVE CONTROL. A probe that returns 404 cannot tell you whether
# the image is gone or your request was malformed, and both traps above answer
# in the failing direction. Probe something known-good in the same run — a
# public third-party image, or another of our tags — and only believe a 404
# when the control returns 200.
#
# Upstream's own v4.2.0 cannot be built at all any more: its Dockerfile is on
# Debian bullseye, now end-of-life, so `apt install` 404s against a mirror that
# has moved. Building our copy is the only version of this that works, and the
# only one that does not commit us to repairing someone else's base.
#
# IT HAS NOW BEEN DEPLOYED, AND IT DOES NOT WORK. Four components reached
# Running 1/1, every one green in the dashboard, ingress and TLS up, homepage
# 200 — and the app is unusable. Full account on #9. Four things stop it; 1
# and 4 are one core defect showing twice, 2 and 3 are ours:
#
#   1. The API gets no Service, so the web UI cannot reach it (kuploy#490).
#      A spec component can only get an in-cluster address by also getting a
#      public one. Through the host: 502 {"error":"api_unreachable"}.
#   2. Migrations never run. api/docker-entrypoint.sh waits for the DB then
#      execs gunicorn — no `alembic upgrade head`. 76 revisions in
#      api/migrations/versions, none ever applied, so there is no schema.
#      The chart's MIGRATE knob is not the answer, and cannot be made into
#      one. It needs THREE gates: DEBUG=1, so docker-entrypoint.sh:14 takes
#      the `python3 dev.py` loop instead of gunicorn; MINDEBUG=1; and
#      MIGRATE=1 (dev.py:29). The image pins DEBUG=0 and MINDEBUG=0
#      (api/Dockerfile:3-4), so dev.py's own default-MINDEBUG-to-1
#      (dev.py:6-8) never fires in a container, and the chart sets
#      MIGRATE "0" (_helpers.tpl:77).
#
#      But the real reason it is a dead end: MINDEBUG=1 is exactly what
#      makes env.py:41 use `sqlite:///../.data/anubis.db` instead of
#      DATABASE_URI (env.py:19 gates it). So the only path that reaches
#      db.create_all() (dev.py:31) is the path that CANNOT SEE THE MARIADB.
#      Turn every gate on and you migrate a throwaway sqlite file inside
#      the container — while MINDEBUG also nulls the cache (env.py:45),
#      overwrites the wired REDIS_HOST with 127.0.0.1 (dev.py:11) and runs
#      the Flask dev server instead of gunicorn (dev.py:32). It is a dev
#      convenience with a production-looking name, sitting two lines from
#      DISABLE_ELK in the same chart env block.
#
#      GREP WARNING: MIGRATE *does* appear in app code, at
#      api/anubis/k8s/reserve/create.py:15 — but as a WRITE, setting
#      MIGRATE="0" on a pod the API itself creates. Not a read. The claim
#      above holds; the hit will look like it doesn't.
#
#      It would also be wrong even if it worked: db.create_all() builds
#      tables from current models and leaves alembic_version unset, so the
#      76 revisions could never be applied afterwards.
#
#      The only `alembic upgrade head` in the repo is in Makefiles a human
#      runs from OUTSIDE the container (Makefile:98 via compose exec,
#      api/Makefile:41 on a venv). Upstream expects an operator to migrate
#      by hand; nothing in the container ever does.
#      And a spec has nowhere to put them: the component surface is
#      name/type/serviceType/engine/source/replicas/resources/dependsOn/
#      env/domain/port/cache (stack-spec.ts:179) — no command, no init
#      container, no pre-deploy hook, no Job. Fix belongs in kuploy/anubis'
#      own entrypoint; it is our copy, so it is ours to change.
#   3. DB credentials cannot be chosen and DATABASE_URI cannot be composed.
#      provisionDatabase derives all three from the component name
#      (stack-spec.ts:820): this component becomes database `anubis_db`,
#      user `anubis_db`, random password. No spec field overrides them.
#      DATABASE_URI would be the escape and is shut twice: mariadb's
#      connectionString emits `mysql://` not `mysql+pymysql://`
#      (stack-connection-fields.ts:174), and env refs are one-of
#      generate|fromInput|value|fromComponent (buildStaticEnv), so a literal
#      scheme cannot be concatenated with connection fields. Same fix as 2.
#   4. The API got no readiness probe, so it read 1/1 Ready and showed a
#      GREEN DOT IN THE DASHBOARD while still looping in `until mysqladmin
#      ping`, never having bound its port. Same defect as 1, not a second
#      one: resolveReadinessPort wants a ports row or a domain (index.ts
#      :140,:148) — the same two inputs the Service wants. Readiness is the
#      ONLY probe kuploy builds for anything; liveness and startup are null
#      on anubis-web too, so there is nothing else to lose. Also kuploy#490.
#
# UPDATE 2026-10-16: BLOCKERS 1 AND 4 ARE FIXED IN CORE (kuploy#495, merged
# to dev 2026-10-16, closing #490), AND 2, 3 AND 5 ARE FIXED IN kuploy/anubis
# (#5, merged). What remains is a core RELEASE carrying #495 reaching the
# cluster, which is not this repo's to make. Retest plan on #9.
#
# ⚠️ THE API'S SERVICE NOW COMES FROM A PORTS ROW, AND A TENANT CAN DELETE IT.
#
# #495 gives a domainless application component an in-cluster Service by
# writing a ports row at provision time (`stack-spec.ts`, the loop guarded on
# `!c.domain && serviceType === "application" && typeof c.port === "number"`).
# In this whole catalogue that matches exactly ONE component — `anubis-api`,
# port 5000 — checked by parsing all five specs, not taken on trust.
#
# Nothing marks that row as spec-owned, and `routers/port.ts:67` deletes it on
# request with only an authorisation check. **Deleting it silently restores
# #490**: no Service, no readiness probe, pod green in the dashboard, and
# every call from the UI answering 502 with nothing anywhere saying why.
#
# Core names this residual in its own source, where a core developer will find
# it. The person who will actually hit it is a tenant clicking around the Ports
# tab of their own deployment, and they are not reading `stack-spec.ts`. So it
# is written here too, and retest item 10 is to confirm the Ports tab SHOWS
# that row — somebody should have seen the thing they must not remove.
#
# Also expected, not a new bug: `createPort` calls `syncK8sAfterPortChange`,
# a cluster write during provisioning, before the Deployment exists, and it
# swallows its own errors. A provision that fails after that loop leaves an
# orphan ClusterIP Service with no endpoints. It is overwritten by name on the
# next deploy.
#
# 1 GATES THE REST. No more spec edits until a component can be reached
# without being published.
#
# AND TWO THINGS THIS HEADER USED TO CLAIM ARE WRONG:
#
# No Elasticsearch is needed for this install. The API never reads
# DISABLE_ELK — it appears nowhere under api/anubis/ — so the cut-down
# API + MariaDB + Redis stack does not need ELK. The flag survives as a
# vestige in the test harness (api/tests/utils.py:17,25, api/tests/test.sh
# :19) and is STILL INJECTED by upstream's chart as "0", i.e. not disabled
# (k8s/chart/templates/_helpers.tpl:79). Upstream's prod overlay does deploy
# Elasticsearch, Kibana and filebeat (k8s/prod/elastic-values.yaml,
# filebeat-values.yaml:41) — filebeat scrapes container logs out of band.
# That is log aggregation, not an API dependency. Say it that way: a flat
# "Anubis does not use Elasticsearch" is false at the chart and overlay
# level, and sends the next reader to k8s/ to find it contradicted.
#
# HOW BOTH WRONG VERSIONS OF THAT CLAIM WERE REACHED, because the next
# person will reach for the same tools. First: `gh search code` returns 0
# hits for DISABLE_ELK in that repo. Two sessions independently got that
# zero and read it as absence. Clone and grep instead. Second, and worse:
# the grep that replaced it ran with --include='*.py' --include='*.yaml',
# so it could not see _helpers.tpl or test.sh, and produced "exactly one
# file" — a search narrower than the claim it was supporting, which is the
# same error as trusting the zero, one level in. Grep unfiltered first,
# then narrow the CLAIM to what the unfiltered grep actually shows.
#
# DATABASE_URI is a supported override, not a hack. Outside MINDEBUG,
# api/anubis/env.py:25 reads it and only falls back to
# mysql+pymysql://anubis:anubis@$DB_HOST/anubis. (Under MINDEBUG, env.py:41
# hardcodes sqlite and the var is ignored — irrelevant to the deploy path,
# but the citation needs the qualifier to survive checking.) Upstream's own
# chart sets it (_helpers.tpl:118) and Anubis passes it into pods IT creates
# (api/anubis/k8s/reserve/create.py:21), so it is a first-class knob, not a
# legacy read. Anubis is willing to be told; kuploy cannot say it (see 3).
#
# WHAT IS STILL TRUE: the API dynamically launches student IDE (Theia) pods
# and autograder Jobs via the Kubernetes API (RBAC/ServiceAccount) and runs
# RQ workers + a pipeline-api. None of that is needed for the cut-down
# API + MariaDB + Redis install, which is what is attempted below.
#
# WHAT IS DONE: both images are built from kuploy/anubis by kuploy-templates'
# `anubis-image` workflow, published to GHCR, public, and pinned below by
# digest. api and web come from one commit, so they cannot drift. The web
# image proxies /api to the API and returns a real 502 when it cannot reach
# it (kuploy/anubis#4) — which is how blocker 1 was found at all.
#
# The `draft` tag is what keeps the agent from offering this (kuploy#484).
# It stays until 1 is fixed in core and 2+3 in kuploy/anubis.

name: anubis
description: AnubisLMS — autograding + in-browser IDEs for coding courses (DRAFT)
inputs:
  - key: domain
    type: domain
    appliesTo: anubis-web
    label: Address for your Anubis
    help: >-
      Leave blank for a free .kuploy.app address. If you use your own name,
      point it at kuploy first — HTTPS switches on once it resolves here.

components:
  - name: anubis-api
    type: web
    serviceType: application
    # NO `domain: true` ANY MORE. It had one, which meant the template
    # published a public address for a backend that can only answer JSON — a
    # buyer told "your coding school is at X" visits X and sees nothing they
    # recognise, and cannot tell headless from broken. Since kuploy#477 the
    # agent reads every address it created back to the person, so that URL
    # stopped being a wart and became something they are pointed at.
    #
    # The web component below is the address now. The API is reached from the
    # browser over the same origin, which is how upstream's own compose and
    # Helm chart arrange it.
    port: 5000 # gunicorn -b 0.0.0.0:5000 (verified, api/docker-entrypoint.sh)
    source:
      type: docker
      image: ghcr.io/kuploy/anubis-api:sha-6c528f25051a@sha256:daa7ee152603d2d46e9706382eff0aed8a47f0c8e31bc477906a3c12ea4ce8ad
    dependsOn:
      - anubis-db
      - anubis-redis
    env:
      # Anubis reads DB_HOST (a hostname) and builds mysql+pymysql://anubis:anubis
      # @${DB_HOST}/anubis by default — so the kuploy mariadb must use anubis/
      # anubis/anubis creds, OR override DATABASE_URI. Note kuploy's connectionString
      # emits `mysql://` (not the `mysql+pymysql://` SQLAlchemy driver scheme Anubis
      # wants) — another reason this needs reconciliation before deploy.
      - key: DB_HOST
        fromComponent: anubis-db
        field: host
      - key: REDIS_HOST
        fromComponent: anubis-redis
        field: host
      - key: REDIS_PASS
        fromComponent: anubis-redis
        field: password

  - name: anubis-web
    type: web
    serviceType: application
    domain: true
    port: 3000 # `serve -s build -l 3000` (verified, web/Dockerfile CMD)
    source:
      type: docker
      # Serves the SPA and proxies /api to anubis-api (kuploy/anubis#4). The
      # previous image used `serve -s build`, which answered /api/* with
      # index.html and HTTP 200 — so the UI loaded, every call resolved, and
      # `.json()` threw on markup while pods, ingress and TLS all read green.
      image: ghcr.io/kuploy/anubis-web:sha-6c528f25051a@sha256:4d7fff889ac1ca47f23ac49dafb61945563be5277a1aa15a99fa8195b98da3d8
    dependsOn:
      - anubis-api

  - name: anubis-db
    type: database
    serviceType: mariadb # verified: Anubis uses mariadb (compose.yml: mariadb:10.6.14)
    engine: mariadb

  - name: anubis-redis
    type: database
    serviceType: redis
    engine: redis
