diff --git a/AGENTS.md b/AGENTS.md index 79463b2..d6749db 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -233,6 +233,8 @@ Fetch only the files relevant to the task. A typical example contains - **`costguard`** `[cost, cleanup, budgets, automation, iaas, terraform]` **costguard keeps your STACKIT cloud tidy and installs with one `terraform apply`.** It looks for things that cost money but are not used by anything, reports them in a chat channel and, once you switch deletion on, deletes them. It also watches monthly budgets and posts when one passes a threshold. It runs on a small server that logs in with the service account attached to it, so no key is stored on the server +- **`whisper-fn`** `[functions, serverless, object-storage, s3, ai-ml, scale-to-zero]` + Rebuilds an always-on VM transcription demo as a **STACKIT Function**: a container that only runs while it's handling a request and scales to zero afterwards, instead of a VM that runs (and costs) 24/7 --- diff --git a/apps/whisper-fn/.gitignore b/apps/whisper-fn/.gitignore new file mode 100644 index 0000000..69ec80f --- /dev/null +++ b/apps/whisper-fn/.gitignore @@ -0,0 +1,3 @@ +whisper-fn/.env +__pycache__/ +*.pyc diff --git a/apps/whisper-fn/DECISIONS.md b/apps/whisper-fn/DECISIONS.md new file mode 100644 index 0000000..835d2e9 --- /dev/null +++ b/apps/whisper-fn/DECISIONS.md @@ -0,0 +1,72 @@ +# Decisions + +## 2026-10-02: WHISPER_MODEL small, not large-v3 + +**Was:** Deploy whisper-fn with `WHISPER_MODEL=small`, not `large-v3` as originally requested. + +**Warum:** STACKIT Functions plans cap at `f6` (4096 MB RAM, 1 shared vCPU) -- +there is no bigger self-service tier; a larger plan requires contacting +STACKIT directly, with no guaranteed turnaround. Empirically tested all +three model sizes under memory caps matching the real plans: + +- `large-v3` (2.88 GB checkpoint): OOM-killed at 4096 MB (`f6`, the max plan), + and even failed to *load* during a local Docker build at 7.75 GB (needed + 11.67 GB to succeed there). +- `medium` (1.42 GB checkpoint): also OOM-killed at 4096 MB. +- `small` (~500 MB checkpoint): confirmed working end-to-end (real + transcription of `elephants_dream.mp4`) under caps down to 2048 MB. + +**Alternative verworfen:** Hosting `large-v3` on an always-on STACKIT Server/VM +instead of Functions would work memory-wise, but loses Scale-to-Zero and runs +(and bills) continuously. +Not pursued since the user confirmed `small` is acceptable. + +## 2026-10-02: Dockerfile custom-build, not Cloud Native Buildpacks + +**Was:** Build `whisper-fn`'s image via a hand-written `Dockerfile` +(`sfn function deploy --build=false --push=false`), not the default +`sfn functions build` (buildpacks) path. + +**Warum:** The buildpack-built image includes a zero-byte metadata layer +that this project's STACKIT Harbor registry rejects on push +(`error from registry: blob unknown to registry - +sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855` -- +the well-known empty-blob digest). Reproduced consistently across retries; +all other layers pushed fine. A plain Dockerfile build (ordinary +`RUN`/`COPY` layers) does not produce that layer, and pushed successfully. + +This was initially built for `large-v3` (to bake the model weights into +the image at build time, since buildpacks have no `apt-get` and openai-whisper +needs `ffmpeg`), then rebuilt for `small` after the model-size decision +above. The `small` Dockerfile dropped the `large-v3`-specific RAM tuning +(Docker Desktop memory bump) since `small` loads fine without it. + +**Alternative verworfen:** Pushing to a different registry (ghcr.io, Docker +Hub) instead of fixing the buildpack image -- would have avoided the bug +but moves the image outside STACKIT's own registry; not pursued per user's +choice to stay on the official STACKIT Container Registry path. + +## 2026-10-02: Plan f6, concurrency 1 + +**Was:** Deployed revision uses `plan: f6` (4096 MB) and `concurrency: 1`, +not the template defaults (`f3` / 512 MB, concurrency 50). + +**Warum:** `small` was empirically confirmed to need more than the `f3` +(512 MB) and `f4` (1024 MB) tiers -- both OOM-killed. `f5` (2048 MB) passed +a full end-to-end transcription test locally and was initially deployed; +user then asked for `f6` for extra headroom, so that's the live +configuration. Concurrency was dropped from the template's default of 50 to +1, since each `/transcribe` request is a single CPU-bound ~100-150s job on +a 1-shared-vCPU plan -- running several in parallel would contend for the +same CPU/memory budget that's already near its ceiling. + +## 2026-10-02: Image tag must be SemVer + +**Was:** Pushed image as `whisper-fn:0.1.0`, not `whisper-fn:small`. + +**Warum:** `sfn functions deploy` rejected the `:small` tag with +`Error: Error creating function revision: oci image reference invalid` -- +STACKIT Functions requires either a SemVer tag (with or without `v` prefix) +or `latest` combined with a digest. Not documented in the Functions docs +mirror at the time of this session; discovered via the deploy error message +itself. diff --git a/apps/whisper-fn/GETTING-STARTED.md b/apps/whisper-fn/GETTING-STARTED.md new file mode 100644 index 0000000..2887a2a --- /dev/null +++ b/apps/whisper-fn/GETTING-STARTED.md @@ -0,0 +1,185 @@ +# Getting Started + +Step-by-step guide to pick this project up and get `whisper-fn` running -- +locally first, then deployed to STACKIT. This is the as-built path; see +`DECISIONS.md` for why it diverges from the original plan (short version: +`large-v3` does not fit any STACKIT Functions plan, and the default +buildpack build can't be pushed to this project's Container Registry). + +## Current live deployment + +- URL: `https://.functions.onstackit.cloud` +- Project: `functions` (``) +- Model: `small` (not `large-v3` -- see `DECISIONS.md`) +- Plan: `f6` (4096 MB), concurrency 1 +- Image: `registry.onstackit.cloud//whisper-fn:0.1.0` + +## Prerequisites + +- Docker installed and running (`docker info` should succeed) +- `sfn` CLI >= 1.7.0 (`./install-sfn.sh`; older versions fail auth with + "outdated cli" against this project) +- `curl` +- A STACKIT project with Functions enabled, `Object Storage bucket`, and + `Container Registry` already set up (see below -- all three already exist + for this project as of this writing) + +## 1. Install/update the STACKIT Functions CLI (`sfn`) + +```bash +cd apps/whisper-fn # this directory +./install-sfn.sh +export PATH="$HOME/.local/bin:$PATH" # add to ~/.zshrc to persist +sfn --version # must be >= 1.7.0 +``` + +## 2. Build the function container (Dockerfile, not buildpacks) + +```bash +cd whisper-fn +docker build --platform linux/amd64 -t whisper-fn:local . +``` + +This is a hand-written `Dockerfile`, deliberately **not** +`sfn functions build` (Cloud Native Buildpacks). The buildpack output +includes a zero-byte metadata layer that this project's STACKIT Harbor +registry rejects on push (`blob unknown to registry`) -- see +`DECISIONS.md`. The Dockerfile bakes the `small` model weights in at build +time so cold starts don't re-download them, creates the required non-root +`uid 1001` user, and serves via `uvicorn asgi:app` (see `asgi.py` for the +hand-rolled ASGI lifespan wrapper that the buildpack path would normally +generate for you). + +`--platform linux/amd64` is required even on Apple Silicon -- STACKIT +Functions' Knative runtime only runs `linux/amd64` images. + +## 3. Run it locally and smoke-test + +```bash +docker run -d --platform linux/amd64 -p 8080:8080 -e PORT=8080 \ + --env-file .env --name whisper-fn-local whisper-fn:local +curl http://127.0.0.1:8080/health +# {"status": "ok", "model": "small"} +curl -X POST http://127.0.0.1:8080/transcribe +# downloads elephants_dream.mp4 from the bucket, transcribes, uploads the +# transcript back -- takes 90-150s on CPU +docker rm -f whisper-fn-local +``` + +`whisper-fn/.env` already has working credentials for the `your-bucket-name` +bucket, which already contains `elephants_dream.mp4`. If you need to +recreate it elsewhere: see `.env.example` for the fields, and `stackit +object-storage bucket create` / `credentials create` to provision a new one. + +## 4. (Optional, no Docker needed) Run the pure logic test + +```bash +cd whisper-fn +python3 test_handler_local.py +``` + +Stubs out `boto3`/`whisper`/`imageio_ffmpeg` in memory; checks routing and +the S3 upload flow without real transcription or a real S3 connection. + +## 5. Push to the STACKIT Container Registry + +The registry itself has no CLI or Terraform support -- it's created once +via the STACKIT Portal (Container Registry -> New Project -> Robot Account +with Pull+Push permissions for CI use). This project already has one: +registry project ``. + +```bash +docker login registry.onstackit.cloud \ + --username 'robot$+' + # password: the robot account's secret from the Portal + +docker tag whisper-fn:local registry.onstackit.cloud//whisper-fn: +docker push registry.onstackit.cloud//whisper-fn: +``` + +**The tag must be SemVer** (e.g. `0.1.0`, optionally with a `v` prefix), or +`latest` combined with an explicit digest. Plain tags like `small` or +`local` are rejected by `sfn functions deploy` with `oci image reference +invalid`. + +If `docker login` fails with `error storing credentials ... User +interaction is not allowed` (macOS Keychain inaccessible, e.g. from a +non-interactive shell), write the base64 auth directly into an isolated +`DOCKER_CONFIG` directory instead of your real `~/.docker/config.json`: + +```bash +mkdir -p /tmp/docker-config +python3 -c " +import base64, json +auth = base64.b64encode(b'robot\$+:').decode() +json.dump({'auths': {'registry.onstackit.cloud': {'auth': auth}}}, open('/tmp/docker-config/config.json', 'w')) +" +docker --config /tmp/docker-config push registry.onstackit.cloud//whisper-fn: +``` + +## 6. Register the runtime pull-secret (once per project) + +```bash +sfn pull-credentials create --address registry.onstackit.cloud \ + --pull-credential-name whisper-fn-registry +# prompts for username (robot$...) and password interactively -- +# --no-interactive is explicitly disallowed here for security reasons +``` + +## 7. Update `.stackit-functions/revision.yaml` + +Set `spec.image` to the pushed `//:` +reference, and `spec.limits.plan` to a plan with enough memory. For +`small`, empirically: `f3` (512 MB) and `f4` (1024 MB) OOM-kill; `f5` +(2048 MB) and `f6` (4096 MB) both work. This project runs `f6`. Test your +own plan choice locally first: + +```bash +docker run -d --platform linux/amd64 --memory=m --memory-swap=m \ + -p 8080:8080 -e PORT=8080 --env-file .env --name whisper-fn-memtest whisper-fn:local +# wait, then: +docker inspect -f 'OOMKilled={{.State.OOMKilled}}' whisper-fn-memtest +docker rm -f whisper-fn-memtest +``` + +## 8. Log in and deploy + +```bash +sfn auth login --project-id \ + --service-account-key-path ~/.stackit/sa-key.json + # or with a user account via browser login + +cd whisper-fn +sfn functions deploy --project-id --env-file .env --no-interactive -v +``` + +No `--build`/`--push` flags needed -- omitting them means "don't rebuild, +don't repush," since the image is already in the registry from step 5. +(Note: these are boolean switches, not `key=value` options -- +`--build=false` is a CLI parse error, just omit the flag entirely.) + +## 9. Verify + +```bash +sfn function describe --function-id # shows the public URL +curl https:///health +curl -X POST https:///transcribe +``` + +Then check the bucket for the uploaded transcript. + +## Known constraints + +- **No model bigger than `small` fits any STACKIT Functions plan.** Plans + cap at `f6` / 4096 MB with 1 shared vCPU; both `large-v3` and `medium` + were OOM-killed even at that ceiling. See `DECISIONS.md`. +- **No documented request-timeout limit** on STACKIT Functions, so the + 90-150s transcription time for `small` is not a gateway-timeout risk. +- Concurrency is set to 1: each `/transcribe` call is a single CPU-bound + job that would contend for the same memory/CPU budget if run in parallel. + +## If something's unclear + +`docs/history/Task.md` has the original design rationale and open questions; +`docs/history/Handout.md` has the session-by-session implementation log; +`DECISIONS.md` has the non-trivial choices made and why. diff --git a/apps/whisper-fn/MAINTAINERS.md b/apps/whisper-fn/MAINTAINERS.md new file mode 100644 index 0000000..9b4a1b2 --- /dev/null +++ b/apps/whisper-fn/MAINTAINERS.md @@ -0,0 +1,7 @@ +# Maintainers + +Created by Konstantin Kloster ([@KonstantinMKloster](https://github.com/KonstantinMKloster)). + +This app does not currently have an assigned maintainer responsible for +ongoing upkeep. If you rely on it and are interested in maintaining it, +please open an issue or reach out via a Pull Request. diff --git a/apps/whisper-fn/README.md b/apps/whisper-fn/README.md new file mode 100644 index 0000000..29cfcf7 --- /dev/null +++ b/apps/whisper-fn/README.md @@ -0,0 +1,82 @@ + + +# whisper-fn + +Rebuilds an always-on VM transcription demo as a **STACKIT Function**: a +container that only runs while it's handling a request and scales to zero +afterwards, instead of a VM that runs (and costs) 24/7. + +## What it does + +An HTTP-triggered function (`whisper-fn/`) that, on `POST /transcribe`: + +1. downloads a video from an S3 (STACKIT Object Storage) bucket, +2. transcribes it locally with [`openai-whisper`](https://github.com/openai/whisper) + (the open-weights Whisper model running inside the function — **not** a call + to OpenAI's paid API), +3. uploads the resulting transcript back to the same bucket, +4. returns a small JSON summary (bucket, keys, model, duration). + +`GET /health` returns a plain status check. + +## Why it exists + +A Terraform-provisioned-VM version of this demo works, but it runs continuously +via `cloud-init` and only ever leaves its result on local disk — nobody stops +it, so it costs money the whole time it's not transcribing anything. STACKIT +Functions offered a scale-to-zero, HTTP-triggered alternative, so this project +reimplements the same demo (same input video, same Whisper model family) as a +function instead of a VM, with the transcript written back to S3 so the result +is actually retrievable afterwards. + +This project provisions its own S3 bucket and Container Registry rather than +assuming you already have one set up — see [GETTING-STARTED.md](GETTING-STARTED.md) +for the one-time manual steps. + +## Project layout + +| Path | Purpose | +|---|---| +| `whisper-fn/` | The actual function: `handler.py`, dependencies, STACKIT Functions manifests | +| `whisper-fn/.env.example` | Template for the env vars you need (S3 bucket/credentials, model size) — copy to `whisper-fn/.env`, fill in your own values, never commit it | +| `install-sfn.sh` | Installs the `sfn` CLI (STACKIT Functions CLI) locally | +| `setup.sh` | One-command local bootstrap: installs the sfn CLI, prepares `whisper-fn/.env`. Creates no cloud resources. | +| `deploy.sh` | Convenience wrapper: build → auth → confirm → deploy, using `whisper-fn/.env` | +| `GETTING-STARTED.md` | Step-by-step setup and deploy instructions | +| `docs/history/Task.md` | Original design/planning notes (background reading, not required to run this) | +| `docs/history/Handout.md` | Session log — implementation history and the disk-space blocker that paused the first deploy attempt (background reading, not required to run this) | + +## Status + +Verified end-to-end with a real transcription of `elephants_dream.mp4` +(downloaded from the bucket, transcribed, transcript written back), running +`WHISPER_MODEL=small`, not the originally planned `large-v3` — see +`DECISIONS.md` for why (short version: no STACKIT Functions plan has enough +memory for `large-v3` or even `medium`). Built via a hand-written +`Dockerfile`, not the default buildpack path — see `DECISIONS.md` for that +too (buildpack images get rejected by this registry). + +## Quick start + +```bash +cd apps/whisper-fn +./setup.sh # installs sfn CLI, prepares whisper-fn/.env (no cloud resources created) +``` + +Then, after filling in `whisper-fn/.env` with your own S3 bucket/credentials: + +```bash +./deploy.sh +# e.g. ./deploy.sh registry.onstackit.cloud//whisper-fn:0.1.1 'robot$+' +``` + +See [`GETTING-STARTED.md`](GETTING-STARTED.md) for the full walkthrough -- +creating the Object Storage bucket and Container Registry are one-time, +manual (Portal) steps not covered by `setup.sh` or `deploy.sh`. + +## Cost note + +This project creates real STACKIT resources once deployed (S3 bucket, a running +function instance while it's warm). Check your STACKIT budget/alerting before +deploying, and tear the function down (`sfn function delete`) when you're done +with it. diff --git a/apps/whisper-fn/deploy.sh b/apps/whisper-fn/deploy.sh new file mode 100644 index 0000000..77e9a5b --- /dev/null +++ b/apps/whisper-fn/deploy.sh @@ -0,0 +1,79 @@ +#!/bin/sh + +# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Convenience wrapper around the sfn CLI steps in GETTING-STARTED.md. +# Usage: ./deploy.sh +# Example: ./deploy.sh registry.onstackit.cloud//whisper-fn:0.1.1 'robot$+' +# +# Requires: Docker running, sfn CLI installed (see install-sfn.sh), +# whisper-fn/.env filled in from whisper-fn/.env.example, and a registry +# account/robot already set up (see GETTING-STARTED.md step 5 -- the +# Container Registry itself has no CLI/Terraform support, so that part +# can't be scripted here). +# +# Note: this builds via the Dockerfile, NOT `sfn functions build` +# (Cloud Native Buildpacks) -- the buildpack output includes a zero-byte +# metadata layer that this project's STACKIT Harbor registry rejects on +# push. See DECISIONS.md. +set -e + +PROJECT_ID="$1" +IMAGE_REF="$2" +REGISTRY_USER="$3" +if [ -z "$PROJECT_ID" ] || [ -z "$IMAGE_REF" ] || [ -z "$REGISTRY_USER" ]; then + echo "Usage: $0 " + echo " registry-image-ref must use a SemVer tag, e.g. registry.onstackit.cloud//whisper-fn:0.1.1" + echo " registry-username is the robot account, e.g. 'robot\$+'" + exit 1 +fi + +command -v sfn >/dev/null 2>&1 || { echo "ERROR: sfn CLI not found or too old (need >= 1.7.0). Run ./install-sfn.sh first (and add it to PATH)."; exit 1; } +command -v docker >/dev/null 2>&1 || { echo "ERROR: docker not found. Install Docker Desktop (or a docker-socket-compatible alternative)."; exit 1; } +docker info >/dev/null 2>&1 || { echo "ERROR: docker daemon not responding. Start Docker Desktop first."; exit 1; } + +cd "$(dirname "$0")/whisper-fn" + +if [ ! -f .env ]; then + echo "ERROR: whisper-fn/.env not found. Copy .env.example to .env and fill in your S3 bucket/credentials first." + exit 1 +fi + +echo "== Step 1/4: local build (Dockerfile, linux/amd64) ==" +docker build --platform linux/amd64 -t whisper-fn:local . + +echo "== Step 2/4: push to registry ==" +docker tag whisper-fn:local "$IMAGE_REF" +echo "You will be prompted for the registry password/robot secret." +docker login registry.onstackit.cloud --username "$REGISTRY_USER" +docker push "$IMAGE_REF" + +echo "== Step 3/4: auth with STACKIT Functions ==" +sfn auth login --project-id "$PROJECT_ID" + +echo +echo "This will run 'sfn functions deploy', which creates a billable STACKIT" +echo "function instance in project $PROJECT_ID. Make sure .stackit-functions/revision.yaml's" +echo "'image' field matches $IMAGE_REF and 'plan' has enough memory for your WHISPER_MODEL" +echo "(see GETTING-STARTED.md step 7 -- small needs f5+, large-v3/medium don't fit any plan)." +printf "Continue? [y/N] " +read -r CONFIRM +case "$CONFIRM" in + y|Y|yes|YES) ;; + *) echo "Aborted. Nothing was deployed."; exit 0 ;; +esac + +echo "== Step 4/4: deploy (no --build/--push, image is already in the registry) ==" +sfn functions deploy --project-id "$PROJECT_ID" --env-file .env --no-interactive diff --git a/apps/whisper-fn/docs/history/Handout.md b/apps/whisper-fn/docs/history/Handout.md new file mode 100644 index 0000000..a152d25 --- /dev/null +++ b/apps/whisper-fn/docs/history/Handout.md @@ -0,0 +1,151 @@ +# Handout — STACKIT-Whisper-functions (laufende Session) + +## Status: ÜBERHOLT -- siehe DECISIONS.md und GETTING-STARTED.md + +**Dieses Dokument ist historisch.** Der Disk-Space-Blocker unten wurde in +einer späteren Session gelöst (freier Speicher + Docker-RAM erhöht), und das +Projekt ist seit 2026-10-02 **live deployed** -- mit `small` statt `large-v3` +und per Dockerfile statt Buildpacks (siehe `DECISIONS.md` für warum). Für den +aktuellen Stand: `GETTING-STARTED.md`, `DECISIONS.md`, Root-`README.md`. Rest +dieser Datei ist das Session-Log bis zum Blocker, nicht der aktuelle Stand. + +Fortlaufendes Log für Session-Übergabe (token-sparend). Konzept/Ausgangslage siehe `Task.md`. +Diese Datei wird nach jedem abgeschlossenen Schritt aktualisiert. + +## Entscheidung (durch Nutzer bestätigt) +- Projekt bleibt getrennt von `STACKIT-Whisper-open-ai` (Projektgrenze-Regel dort respektiert). +- **Neuer eigener S3-Bucket** wird angelegt (nicht den alten wiederverwenden). Noch nicht erstellt + — braucht explizite Bestätigung vor dem Anlegen (Kostenregel). + +## Erledigt +1. **`sfn` CLI installiert** — v1.6.5, lokal via offizielles Install-Skript nach `~/.local/bin/sfn`. + PATH-Hinweis: `export PATH="$HOME/.local/bin:$PATH"` nötig in neuen Shells. +2. **Templates-Repo installiert**: `sfn templates install` → `sfn-templates` (Quelle: + github.com/stackitcloud/sfn-templates). Enthält `py-template` (Python, HTTP-Trigger, ASGI-Stil). + → **Kein Dockerfile nötig** — Build läuft über Cloud Native Buildpacks aus `pyproject.toml`. + Das widerspricht dem ursprünglichen Plan im Task.md (dort war noch von Dockerfile die Rede). +3. **Function bootstrapped**: `sfn functions bootstrap --name whisper-fn --path . --runtime python + --trigger http` → Ordner `whisper-fn/` mit `handler.py`, `pyproject.toml`, + `.stackit-functions/` (function.yaml, project.yaml, revision.yaml), `stackit-functions.yaml`. +4. **`handler.py` geschrieben** (ersetzt den Platzhalter aus dem Template): + - Routen: `GET /health` (Status+Modellname), `POST /transcribe` (Kernlogik) + - Lifecycle: Whisper-Modell wird in `start()` einmal pro warmer Instanz geladen (nicht pro + Request) — reduziert Cold-Start-Overhead bei wiederholten Aufrufen + - Ablauf `/transcribe`: Video aus S3 laden (`S3_BUCKET`/`S3_INPUT_KEY`) → mit `whisper` + transkribieren → Text-Datei erzeugen → **zurück nach S3 hochladen** (`S3_OUTPUT_KEY`) → + JSON-Antwort mit Bucket/Keys/Modell/Dauer. Das ist der entscheidende Unterschied zur alten + VM-Lösung, die das Ergebnis nur lokal auf der VM abgelegt hat. + - Alle S3-Parameter und `WHISPER_MODEL` (Default: `small`, NICHT `large-v3` — siehe + Timeout-Risiko in Task.md) über Env-Vars konfigurierbar, kein Hardcoding. + - **ffmpeg-Problem gelöst**: `openai-whisper` ruft intern das System-Binary `ffmpeg` auf. + Cloud-Native-Buildpack-Container haben aber keinen `apt-get`-Zugriff → System-ffmpeg wäre + nicht installierbar gewesen. Lösung: `imageio-ffmpeg` (pip-Paket, bringt eine statische + ffmpeg-Binary mit) und deren Verzeichnis zur Laufzeit vorne an `$PATH` gehängt. Kein + System-Paket nötig. +5. **`pyproject.toml` ergänzt** um `boto3`, `openai-whisper`, `imageio-ffmpeg`. +6. **Lokaler Build gestartet**: `sfn functions build --image whisper-fn:local --no-interactive -v` + im Hintergrund (Docker lokal vorhanden, `docker info` bestätigt). Lädt torch (CPU-Wheel, + mehrere hundert MB) + whisper — dauert einige Minuten, rein lokal/kostenlos. + Log: `/tmp/sfn-build.log`. + **Wichtiger Hinweis für Folge-Session:** Erster Build-Versuch ohne `--image` schlug fehl + (`Can not interactively ask for image ... or configure an image name in the .stackit-functions/ + manifest file`) — Flag `--image whisper-fn:local` behebt das. + +## 🔴 BLOCKER (kritisch, Maschinen-weit) — 04.09., ca. 10:55–11:15 CEST +Lokaler Build (`sfn functions build --image whisper-fn:local`) ist inhaltlich **erfolgreich** +durchgelaufen (siehe `/tmp/sfn-build.log`: alle Deps inkl. torch-2.14.0, openai-whisper-20250625, +imageio-ffmpeg installiert, Image `whisper-fn:local` mit ID `ef3f1efcacca` gespeichert). Direkt +danach schlug das Aufräumen des Builder-Containers fehl mit Docker-`input/output error` +(containerd-Blobstore). + +**Root Cause:** Root-Disk des Macs war zwischenzeitlich **komplett voll** (0 Byte frei, +`ENOSPC: no space left on device` sogar bei winzigen Datei-Schreibversuchen). Vermutlich hat der +~2-3 GB Torch/Whisper-Download in der Docker-Desktop-VM-Disk den letzten freien Platz verbraucht +und dabei die containerd-Metadaten-DB korrumpiert. Stand 11:15: wieder ~170-200 Mi frei (Textdateien +gehen wieder), aber Docker-Daemon reagiert nicht mehr zuverlässig — `docker system df` läuft in +den 120s-Timeout, keine Ausgabe. + +**Das ist kein Projekt-Problem, sondern betrifft die gesamte Maschine.** Ich habe bewusst +**keine Dateien gelöscht**, um Platz zu schaffen — das ist eine Entscheidung, die der Nutzer +treffen muss (welche Downloads/Altlasten weg können), nicht die KI ungefragt. Ein Neustart von +Docker Desktop würde vermutlich den hängenden Daemon reparieren, wurde aber ebenfalls nicht +eigenständig ausgeführt, da das andere laufende Container/Workloads des Nutzers stören könnte. + +**Update 11:20 CEST:** Nutzer hat Docker-Neustart bestätigt. Durchgeführt: `osascript -e 'quit app +"Docker Desktop"'` (sauberer Quit, Prozess war unter "Docker Desktop" registriert, nicht "Docker" — +`killall Docker` lief ins Leere) → `open -a "Docker Desktop"`. Dabei mit-beendet: 4 langlaufende +`docker run hashicorp/terraform-mcp-server`-Container (mehrere Tage alt, von anderen MCP-Tools) — +sollten bei Bedarf automatisch neu starten. Warte aktuell auf Daemon-Bereitschaft (`docker info`). + +**Update 12:49 CEST — Neustart hat NICHT geholfen, Ursache endgültig bestätigt:** +Docker Desktop GUI-Prozess lief nach dem Neustart wieder (ab 12:05PM), aber der Daemon selbst +antwortet auch nach >5 Minuten nicht (`docker info`, `docker ps`, `docker system df` — alle drei +liefen unbegrenzt und mussten zwangsbeendet werden). Root-Disk unverändert bei **100 % Auslastung, +170 Mi frei**. Schlussfolgerung: Die Docker-interne Linux-VM (liegt als Diskimage auf derselben +fast vollen Root-Volume) kann mit so wenig Platz nicht sauber starten. **Das ist der Kern des +Blockers, nicht der Docker-Prozess selbst.** + +**Session-Fazit:** Weitere Docker-Neustartversuche sind aussichtslos und wurden gestoppt (auch aus +Kostengründen — Session lag bei ~$11.70). Fortschritt kann erst weitergehen, wenn echter +Speicherplatz frei ist (nicht nur Docker-Cache — die Root-Disk selbst ist voll, das ist ein +Maschinen-weites Problem, kein Docker-Problem). + +**Update 13:05 CEST — sichtbares Ergebnis OHNE Docker geliefert:** +Da Docker/Disk-Blocker nicht kurzfristig lösbar war, wurde `whisper-fn/test_handler_local.py` +geschrieben: ruft den echten `handler.py`-Code auf (Routing, Fehlerbehandlung, S3-Upload-Flow), +mit `boto3`/`whisper`/`imageio_ffmpeg` als In-Memory-Stubs (kein zusätzlicher Speicherplatz nötig, +kein Docker nötig). Ergebnis — alle 5 Checks bestanden: +``` +[PASS] GET /health -> 200 {"status": "ok", "model": "small"} +[PASS] GET /unknown -> 404 {"error": "not_found"} +[PASS] POST /transcribe (no S3_BUCKET) -> 500 {"error": "S3_BUCKET env var not set"} +[PASS] POST /transcribe (fake S3+whisper) -> 200 {"bucket": "fake-bucket", ...} +[PASS] transcript uploaded to fake S3: 'hello from fake whisper' +``` +Dabei einen echten kleinen Bug gefunden+behoben: `S3_BUCKET` wird in `handler.py` als +Modul-Konstante beim Import gelesen (für echten Deploy korrekt, da Env-Vars beim Container-Start +fix sind) — der Test musste daher `handler.S3_BUCKET` direkt patchen statt `os.environ` nach dem +Import zu setzen. Handler-Code selbst mmusste nicht geändert werden. +**Was das NICHT beweist:** echte Whisper-Transkription, echte S3-Verbindung, Verhalten im +Buildpack-Container. Das bleibt vom Disk-Space-Blocker abhängig. + +**Nächste Schritte für die Folge-Session, SOBALD Speicherplatz wirklich frei ist (deutlich mehr als +170 Mi, mind. einige GB empfohlen wg. weiterer Docker-Builds):** +1. Docker Desktop ggf. erneut neu starten, `docker info` prüfen. +2. `docker images | grep whisper-fn` — evtl. ist das bereits gebaute Image (`whisper-fn:local`, + ID `ef3f1efcacca`) trotz allem noch da und nutzbar. +3. Falls nicht mehr vorhanden/beschädigt: `sfn functions build --image whisper-fn:local + --no-interactive -v` erneut laufen lassen (Build-Logik war fehlerfrei, siehe oben/ + `/tmp/sfn-build.log`). +4. `sfn functions run --path .` → `GET http://127.0.0.1:8080/health` als Rauchtest. +5. Erst danach: Nutzer nach STACKIT Project-ID fragen (für `sfn auth login`), neuen S3-Bucket + anlegen (nur nach expliziter Bestätigung, Kostenregel), Deploy (nur nach explizitem OK). + +## Was bereits fertig UND verifiziert im Code ist (unabhängig vom Docker-Blocker) +- `whisper-fn/handler.py` — vollständige Transkriptions-Logik (S3 → Whisper → S3-Upload), + ffmpeg-Workaround via `imageio-ffmpeg`, Env-var-Konfiguration, Warm-Start-Model-Loading +- `whisper-fn/pyproject.toml` — alle Dependencies ergänzt, wurden im Buildpack-Log erfolgreich + installiert (Beweis: `Successfully installed ... openai-whisper-20250625 ... torch-2.14.0 ...`) +- `sfn` CLI + Templates lokal eingerichtet, Function-Skeleton bootstrapped + +## Noch offen / nächste Schritte +1. **Build-Ergebnis prüfen** (`/tmp/sfn-build.log`, `docker images | grep whisper-fn`). +2. **Lokal laufen lassen & testen**: `sfn functions run --path .` (nutzt das gebaute Image oder + baut neu) → HTTP-Server auf `127.0.0.1:8080` (Default-Range 8080–8090). Ohne echten S3-Bucket + schlägt `/transcribe` erwartungsgemäß fehl (kein `S3_BUCKET` gesetzt) — `/health` sollte aber + 200 liefern. Das reicht als lokaler Rauchtest, solange der Bucket noch nicht existiert. +3. **`sfn auth login` — braucht Nutzer-Interaktion**: Browser-Flow, benötigt `--p-id ` + oder `--project-name `. Diese Info liegt nur beim Nutzer vor (kein Cross-Projekt-Zugriff + auf andere STACKIT-Configs erlaubt). → **Frage an Nutzer, sobald es an echtes Deploy geht.** +4. **Neuer S3-Bucket anlegen** — braucht explizite Bestätigung (Kostenregel!). Erst Plan zeigen + (welcher Service, welche Region, geschätzte Kosten), dann auf "ja, anlegen" warten, genau wie + beim Terraform-Apply-Muster im alten Projekt. +5. Test-Video `elephants_dream.mp4` in neuen Bucket hochladen (kein Zugriff auf alten Bucket). +6. `sfn functions deploy` — **nur nach explizitem OK**, verursacht STACKIT-Kosten. +7. Nach erfolgreichem Deploy: Transkript in S3 verifizieren, dann Vergleich alte VM-Lösung vs. + neue Function-Lösung dokumentieren (Task.md Punkt 7). + +## Für eine neue/token-arme Session +Alles Nötige steht in `Task.md` (Konzept) + dieser `Handout.md` (Fortschritt). Reicht als Kontext, +ohne die komplette bisherige Conversation neu zu laden. Nächster sinnvoller Einstieg: Punkt 1–2 +oben (Build-Log/Ergebnis prüfen, lokal testen), danach Nutzer nach Project-ID fragen (Punkt 3). diff --git a/apps/whisper-fn/docs/history/Task.md b/apps/whisper-fn/docs/history/Task.md new file mode 100644 index 0000000..90db0ab --- /dev/null +++ b/apps/whisper-fn/docs/history/Task.md @@ -0,0 +1,81 @@ +# Task: STACKIT-Whisper-open-ai → STACKIT Functions + +## Ziel +Die bestehende, funktionierende Lösung in `~/Projects/STACKIT-Whisper-open-ai/` (Terraform-VM, +läuft dauerhaft, führt beim Boot per cloud-init eine feste Whisper-Transkription aus) als +**STACKIT Function** (Container, HTTP-getriggert, scale-to-zero) neu bauen — kein Dauer-VM mehr. + +Quelle für STACKIT Functions: https://docs.functions.onstackit.cloud/getting-started/install.html + +## Status: ÜBERHOLT -- siehe DECISIONS.md und GETTING-STARTED.md + +**Dieses Dokument ist historisch.** Alle unten offenen Punkte (S3-Credentials, +Plan-Größe, Timeout-Risiko) sind seit 2026-10-02 entschieden und das Projekt +ist **live deployed**. Aktueller Stand: `GETTING-STARTED.md` (wie es läuft), +`DECISIONS.md` (was entschieden wurde und warum), Root-`README.md` (Status + +Live-URL). Rest dieser Datei ist die ursprüngliche Planungsgrundlage, nicht +der aktuelle Stand. + +--- + +## Was die alte Lösung macht (Referenz, NICHT verändern/anfassen ohne Erlaubnis) +- `~/Projects/STACKIT-Whisper-open-ai/stackit/` — Terraform: Ubuntu-VM + S3-Bucket +- `cloud-init.sh` beim ersten Boot: + 1. apt: `ffmpeg`, `python3-pip` + 2. pip: `torch` (CPU), `openai-whisper`, `boto3` + 3. lädt `elephants_dream.mp4` aus S3-Bucket + 4. transkribiert mit `whisper --model large-v3 --language en` + 5. Ergebnis liegt nur lokal auf der VM (`/root/transcription/elephants_dream.txt`) +- **Wichtig:** Dieses alte Projekt hat eine strikte Projektgrenze-Regel (eigene CLAUDE.md): + keine Credentials/Ressourcen von dort ungefragt übernehmen. + +## Was STACKIT Functions kann (aus den Docs recherchiert) +- Container-basiert, `linux/amd64`, HTTP 1.1-Server auf Port aus `$PORT`-Env, non-root `USER` +- Scale-to-zero automatisch, kein manuelles "Herunterfahren" nötig +- Pläne: RAM konfigurierbar (Start 128 MB), **1 shared vCPU** pro Instanz +- Deploy-Flow: `sfn function bootstrap` → `sfn function build --push` → `sfn function deploy` +- Env-Vars/Secrets: fixed / env / dotenv / file (Secret-Manager-Anbindung laut Docs "coming soon") +- **Kein dokumentiertes Request-Timeout-Limit gefunden** (Risiko, s.u.) +- **Kein natives S3-Trigger-Feature** — Auslösung nur per HTTP-Call +- Auth: `sfn auth login --project-id ` (Browser) oder mit Service-Account-Key für CI/CD + +## Vereinbartes Design (mit Nutzer abgestimmt) +Neuer Ordner: `~/Projects/STACKIT-Whisper-functions/` +- `Dockerfile` — Python-Image, installiert `ffmpeg`, `openai-whisper`, `boto3`, startet HTTP-Server +- `app.py` — minimaler HTTP-Server (Flask/FastAPI). Bei Aufruf: + 1. Video aus S3-Bucket laden (wie bisher: `elephants_dream.mp4`) + 2. Mit `whisper` transkribieren + 3. **Neu ggü. VM-Version:** Transkript zurück nach S3 hochladen (nicht nur lokal ablegen) + 4. HTTP 200 zurückgeben — Function skaliert danach selbst auf 0 +- Deploy per `sfn` CLI — **`sfn function deploy` nur nach explizitem OK des Nutzers** (Kostenregel) + +## Offene Punkte / Risiken (noch zu klären) +1. **Timeout-Risiko:** Kein dokumentiertes Request-Timeout. `large-v3` auf CPU dauert mehrere + Minuten → könnte an einem Gateway-Timeout scheitern. Vorschlag: erstmal mit `base` oder + `small` starten, später ggf. hochskalieren. Noch nicht final entschieden. +2. **Plan-Größe:** `large-v3` braucht ~10 GB RAM, Standard-Plan startet bei 128 MB. Muss im + STACKIT-Portal/den Docs geprüft werden, welcher Plan groß genug ist (und was er kostet). +3. **S3-Credentials — NOCH NICHT ENTSCHIEDEN:** + - Option A: bestehenden Bucket/Keys aus `STACKIT-Whisper-open-ai` wiederverwenden + (braucht explizite Erlaubnis, da Projektgrenze-Regel im alten Projekt) + - Option B: neuer eigener S3-Bucket + neue Zugangsdaten nur für dieses Projekt + - **→ Diese Frage zuerst klären, wenn wir weitermachen.** +4. Kein natives S3-Event-Trigger vorhanden — Function wird einfach per HTTP-Request angestoßen + (reicht für den Demo-Case, ist aber kein "lade Video hoch → automatisch transkribiert"). + +## Nächste Schritte (nach Klärung von Punkt 3) +1. S3-Credentials-Entscheidung treffen +2. `sfn` CLI installieren (`~/Projects/STACKIT-Whisper-functions/` — lokal, kostenlos) +3. `sfn auth login --project-id ` (Browser-Flow, kostenlos, nur Login) +4. Dockerfile + app.py schreiben und **lokal** testen (`sfn function build` + `sfn function run` + — läuft lokal, keine Cloud-Kosten) +5. Erst nach explizitem OK: `sfn function build --push` + `sfn function deploy` (verursacht + STACKIT-Kosten laut Kostenregel — nicht eigenständig ausführen) +6. Transkriptions-Ergebnis in S3 verifizieren +7. Vergleich alte VM-Lösung vs. neue Function-Lösung dokumentieren, dann altes Projekt ggf. + `terraform destroy`-en (nur nach Bestätigung) + +## Kostenhinweis (aus globaler Regel) +Kein `sfn function deploy`, kein Anlegen von STACKIT-Ressourcen (Bucket, Function-Instanz) +ohne explizite Bestätigung. CLI-Install, lokales Bauen/Testen des Containers und `sfn auth login` +sind unkritisch. diff --git a/apps/whisper-fn/install-sfn.sh b/apps/whisper-fn/install-sfn.sh new file mode 100644 index 0000000..ccef98e --- /dev/null +++ b/apps/whisper-fn/install-sfn.sh @@ -0,0 +1,89 @@ +#!/bin/sh + +# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -e + +# Check prerequisites: +which curl >/dev/null || (echo "ERROR: curl not found; this script requires curl to run"; exit 1) + +# Defaults: +DRY_RUN="${DRY_RUN:-}" +INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/bin}" +INSTALL_VERSION="${INSTALL_VERSION:-latest}" +INSTALL_GOOS="${INSTALL_GOOS:-autodetect}" +INSTALL_GOARCH="${INSTALL_GOARCH:-autodetect}" + +# Parse arguments: # https://stackoverflow.com/a/14203146 +while [ $# -gt 0 ]; do + case "$1" in + --help) + echo "Usage: $0 [--path \$HOME/.local/bin] [--version latest] [--os autodetect] [--arch autodetect] [--dry-run]" + echo "Install the latest STACKIT Function CLI binary from GitHub releases" + exit 1 + ;; + --path) INSTALL_DIR="$2"; shift ;; + --version) INSTALL_VERSION="$2"; shift;; + --os) INSTALL_GOOS="$2"; shift;; + --arch) INSTALL_GOARCH="$2"; shift;; + --dry-run) DRY_RUN='1';; + --*) echo "ERROR: Unknown option $1" && exit 2;; + esac + shift || (echo "ERROR: Expected argument"; exit 2) +done + +# Resolve OS and architecture: +if [ "$INSTALL_GOOS" = 'autodetect' ]; then + case "$(uname -s)" in + darwin | Darwin) INSTALL_GOOS="darwin";; + linux | Linux) INSTALL_GOOS="linux";; + *) echo "ERROR: Unknown operating system: $(uname -s)" && exit 3;; + esac +fi +if [ "$INSTALL_GOARCH" = 'autodetect' ]; then + case "$(uname -m)" in + x86_64 | amd64) INSTALL_GOARCH="amd64";; + aarch64 | arm64) INSTALL_GOARCH="arm64";; + *) echo "ERROR: Unknown architecture: $(uname -m)" && exit 3;; + esac +fi + +# Resolve latest version: +if [ "$INSTALL_VERSION" = 'latest' ]; then + INSTALL_VERSION="$(curl --silent https://api.github.com/repos/stackitcloud/sfn-cli/releases/latest | sed -En 's|.+"tag_name": "v([^"]+)".+|\1|p')" +fi + +# Construct download URL: +DOWNLOAD_URL="https://github.com/stackitcloud/sfn-cli/releases/download/v${INSTALL_VERSION}/sfn_${INSTALL_VERSION}_${INSTALL_GOOS}_${INSTALL_GOARCH}.tar.gz" + +# Download and install: +echo "INFO: Downloading SFN CLI $INSTALL_VERSION from $DOWNLOAD_URL and installing to $INSTALL_DIR" +if [ -z "$DRY_RUN" ]; then + mkdir -p "$INSTALL_DIR" + curl -L "$DOWNLOAD_URL" | tar -xz -C "$INSTALL_DIR" sfn + echo "INFO: Installed SFN CLI to $INSTALL_DIR/sfn!" +else + echo "INFO: Dry run, skipping installation" +fi + +# Check whether PATH contains install dir: # https://unix.stackexchange.com/a/32054 +case ":$PATH:" in +*:$INSTALL_DIR:*);; +*) +echo "WARNING: $INSTALL_DIR is not part of your \$PATH" +echo "To use the \`sfn\` command, add export PATH=\"$INSTALL_DIR:\$PATH\" to your ~/.bashrc or ~/.zshrc (or use fish_add_path $INSTALL_DIR)." +echo "Alternatively, call the sfn CLI directly using \`$INSTALL_DIR/sfn\`" +;; +esac diff --git a/apps/whisper-fn/setup.sh b/apps/whisper-fn/setup.sh new file mode 100644 index 0000000..7ea9ada --- /dev/null +++ b/apps/whisper-fn/setup.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash + +# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -euo pipefail + +# STACKIT-Whisper-functions — First-time local setup +# Usage: ./setup.sh +# +# This script ONLY prepares your local environment: it installs the `sfn` +# CLI and copies the .env template. It does NOT deploy anything and does NOT +# create any billable STACKIT resource. Deploying (./deploy.sh) is a +# separate, explicitly-confirmed step — see GETTING-STARTED.md. + +echo "=== STACKIT-Whisper-functions Setup ===" + +cd "$(dirname "$0")" + +# Check prerequisites +command -v curl >/dev/null 2>&1 || { echo "Error: curl is required."; exit 1; } +command -v docker >/dev/null 2>&1 || echo "Warning: docker not found — needed later to build/run the function locally." + +# Install/update the sfn CLI (local, free — no cloud resources created) +echo "Installing the sfn CLI (STACKIT Functions CLI)..." +./install-sfn.sh + +# Environment +if [ ! -f whisper-fn/.env ]; then + cp whisper-fn/.env.example whisper-fn/.env + echo "Created whisper-fn/.env from whisper-fn/.env.example — edit it with your S3 bucket/credentials" +else + echo "whisper-fn/.env already exists — leaving it as is" +fi + +echo "" +echo "=== Setup complete! ===" +echo "" +echo "Next steps (manual — nothing further was created or deployed):" +echo " 1. Edit whisper-fn/.env with your S3 bucket, credentials, and endpoint" +echo " 2. Create the Object Storage bucket and Container Registry via the" +echo " STACKIT Portal (one-time, manual — see GETTING-STARTED.md)" +echo " 3. Add \$HOME/.local/bin to your PATH if the sfn CLI install warned about it" +echo " 4. Test locally without Docker/S3: python3 whisper-fn/test_handler_local.py" +echo " 5. When ready to deploy (creates billable STACKIT resources):" +echo " ./deploy.sh " +echo " 6. Using Claude Code? CLAUDE.md has all the context, including the" +echo " cost-safety rule for this project." diff --git a/apps/whisper-fn/whisper-fn/.env.example b/apps/whisper-fn/whisper-fn/.env.example new file mode 100644 index 0000000..40ee2c2 --- /dev/null +++ b/apps/whisper-fn/whisper-fn/.env.example @@ -0,0 +1,21 @@ +# Copy to .env and fill in your own values. Never commit the real .env file. +# Used by: sfn functions deploy --env-file .env (see ../GETTING-STARTED.md) + +# STACKIT Object Storage bucket that holds the input video and receives the transcript. +S3_BUCKET=your-bucket-name + +# S3 credentials for that bucket (STACKIT Object Storage access key pair). +S3_ACCESS_KEY=your-access-key +S3_SECRET_KEY=your-secret-key + +# STACKIT Object Storage endpoint for your region, e.g. https://object.storage.eu01.onstackit.cloud +S3_ENDPOINT_URL=https://object.storage..onstackit.cloud +S3_REGION=eu01 + +# Object keys inside the bucket (defaults shown match the demo video name). +S3_INPUT_KEY=elephants_dream.mp4 +S3_OUTPUT_KEY=elephants_dream.txt + +# Whisper model size. Keep "small" unless you've confirmed larger models don't +# hit STACKIT Functions' request timeout — see Task.md point 1. +WHISPER_MODEL=small diff --git a/apps/whisper-fn/whisper-fn/.funcignore b/apps/whisper-fn/whisper-fn/.funcignore new file mode 100644 index 0000000..e8e281c --- /dev/null +++ b/apps/whisper-fn/whisper-fn/.funcignore @@ -0,0 +1,5 @@ + +# Use the .funcignore file to exclude files which should not be +# tracked in the image build. To instruct the system not to track +# files in the image build, add the regex pattern or file information +# to this file. diff --git a/apps/whisper-fn/whisper-fn/.stackit-functions/function.yaml b/apps/whisper-fn/whisper-fn/.stackit-functions/function.yaml new file mode 100644 index 0000000..0acfdfd --- /dev/null +++ b/apps/whisper-fn/whisper-fn/.stackit-functions/function.yaml @@ -0,0 +1,23 @@ +# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +apiVersion: functions.stackit.cloud/v1alpha +kind: Function +metadata: + id: + name: whisper-fn + created: 2026-09-04T08:39:35.384938Z + annotations: + cli.functions.stackit.cloud/trigger: http +spec: {} diff --git a/apps/whisper-fn/whisper-fn/.stackit-functions/project.yaml b/apps/whisper-fn/whisper-fn/.stackit-functions/project.yaml new file mode 100644 index 0000000..2b93ae3 --- /dev/null +++ b/apps/whisper-fn/whisper-fn/.stackit-functions/project.yaml @@ -0,0 +1,18 @@ +# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +apiVersion: functions.stackit.cloud/v1alpha +kind: Project +metadata: + id: diff --git a/apps/whisper-fn/whisper-fn/.stackit-functions/revision.yaml b/apps/whisper-fn/whisper-fn/.stackit-functions/revision.yaml new file mode 100644 index 0000000..2ae7cc8 --- /dev/null +++ b/apps/whisper-fn/whisper-fn/.stackit-functions/revision.yaml @@ -0,0 +1,25 @@ +# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +apiVersion: functions.stackit.cloud/v1alpha +kind: Revision +metadata: + created: 2025-12-31T23:00:00Z + annotations: + cli.functions.stackit.cloud/runtime: python +spec: + image: registry.onstackit.cloud//whisper-fn:0.1.0 + limits: + plan: f6 + concurrency: 1 diff --git a/apps/whisper-fn/whisper-fn/Dockerfile b/apps/whisper-fn/whisper-fn/Dockerfile new file mode 100644 index 0000000..d370b65 --- /dev/null +++ b/apps/whisper-fn/whisper-fn/Dockerfile @@ -0,0 +1,61 @@ +# Custom-build image for whisper-fn, used instead of the default Cloud +# Native Buildpacks path because this project's STACKIT Harbor registry +# rejects the zero-byte metadata layer CNB adds to every image +# ("blob unknown to registry" on push) -- a plain Dockerfile build doesn't +# produce that layer, so pushing actually succeeds. +# +# Build (from this directory, whisper-fn/): +# docker build --platform linux/amd64 -t whisper-fn:small . +# +# Deploy (after pushing to a registry -- see ../GETTING-STARTED.md): +# sfn function deploy --build=false --push=true --image /whisper-fn:small +# +# STACKIT Functions' container interface requires: HTTP server on $PORT, +# SIGTERM handling, non-root numeric user. See +# docs.functions.onstackit.cloud/how-tos/create-custom-functions.html. + +FROM --platform=linux/amd64 python:3.11-slim + +ENV DEBIAN_FRONTEND=noninteractive \ + PYTHONUNBUFFERED=1 \ + WHISPER_MODEL=small \ + HOME=/home/appuser + +# Create the non-root user STACKIT Functions requires before anything else, +# so its home dir (and later the baked-in model cache under it) is owned by +# uid 1001 from the start. +RUN useradd --create-home --uid 1001 --shell /usr/sbin/nologin appuser + +WORKDIR /app +COPY pyproject.toml handler.py asgi.py ./ + +# CPU-only PyTorch wheel (much smaller than the default CUDA build); must +# land before "pip install ." so the whisper dependency resolves against it +# instead of pulling the CUDA build from PyPI. +RUN pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu +RUN pip install --no-cache-dir . + +# handler.py symlinks imageio_ffmpeg's static binary to "ffmpeg" inside its +# own site-packages dir the first time it's imported -- do that now, as +# root, while site-packages is still writable. The symlink check is +# idempotent (`if not os.path.exists`), so importing handler.py again at +# runtime as uid 1001 just finds it already there instead of failing with +# PermissionError. +RUN python -c "import handler" + +# Running the two pip installs as root under linux/amd64 emulation (e.g. +# Rosetta on Apple Silicon) can leave root-owned files under $HOME/.cache +# (e.g. .cache/rosetta), which would block appuser from creating +# .cache/whisper in the next step -- reclaim ownership before switching. +RUN chown -R appuser:appuser /home/appuser + +USER 1001 + +# Bake the small weights into the image as uid 1001, so handler.py's +# whisper.load_model(MODEL_SIZE) (no download_root override) finds them +# already cached under $HOME/.cache/whisper at cold start instead of +# fetching them from openaipublic.azureedge.net on every scale-up. +RUN python -c "import whisper; whisper.load_model('small')" + +EXPOSE 8080 +CMD uvicorn asgi:app --host 0.0.0.0 --port ${PORT:-8080} diff --git a/apps/whisper-fn/whisper-fn/README.md b/apps/whisper-fn/whisper-fn/README.md new file mode 100644 index 0000000..cc0c120 --- /dev/null +++ b/apps/whisper-fn/whisper-fn/README.md @@ -0,0 +1,61 @@ +# whisper-fn + +STACKIT Function: transcribes a video from S3 with `openai-whisper` and writes +the transcript back to S3. See the top-level [`../README.md`](../README.md) for +what this is and why, and [`../GETTING-STARTED.md`](../GETTING-STARTED.md) for +setup/build/deploy steps. + +## Project Structure + +- `handler.py`: main entry point — routing, S3 download/upload, Whisper call. +- `stackit-functions.yaml` / `.stackit-functions/`: STACKIT Functions manifests + (created by `sfn functions bootstrap`). +- `test_handler_local.py`: routing/logic smoke test with `boto3`/`whisper` + stubbed in memory — no Docker or heavy deps needed. + +## Routes + +- `GET /health` → `200 {"status": "ok", "model": ""}` +- `POST /transcribe` → downloads `S3_INPUT_KEY` from `S3_BUCKET`, transcribes it, + uploads the result to `S3_OUTPUT_KEY`, returns a JSON summary. +- anything else → `404` + +## Environment variables + +Copy [`.env.example`](.env.example) to `.env` and fill in your own values +(never commit `.env` — it's gitignored). `../deploy.sh` reads it via +`sfn functions deploy --env-file .env`. + +| Variable | Default | Purpose | +|---|---|---| +| `WHISPER_MODEL` | `small` | Whisper model size (kept small deliberately — see timeout risk in `../docs/history/Task.md`) | +| `S3_BUCKET` | *(required)* | Bucket to read the video from / write the transcript to | +| `S3_INPUT_KEY` | `elephants_dream.mp4` | Object key of the source video | +| `S3_OUTPUT_KEY` | `elephants_dream.txt` | Object key the transcript is written to | +| `S3_ENDPOINT_URL` | *(none)* | STACKIT Object Storage endpoint | +| `S3_REGION` | `eu01` | Object Storage region | +| `S3_ACCESS_KEY` / `S3_SECRET_KEY` | *(none)* | S3 credentials | + +## Local Development + +Built via a hand-written `Dockerfile`, **not** `sfn functions build` +(Cloud Native Buildpacks) -- the buildpack output includes a zero-byte +metadata layer that this project's STACKIT Harbor registry rejects on +push. See `../DECISIONS.md`. + +```bash +docker build --platform linux/amd64 -t whisper-fn:local . +docker run -d --platform linux/amd64 -p 8080:8080 -e PORT=8080 \ + --env-file .env --name whisper-fn-local whisper-fn:local +curl http://localhost:8080/health +docker rm -f whisper-fn-local +``` + +See `../GETTING-STARTED.md` for the full build/push/deploy walkthrough. + +## Function Deployment + +See `../GETTING-STARTED.md` steps 5-9 (push to registry, register +pull-secret, update `.stackit-functions/revision.yaml`, then +`sfn functions deploy --project-id --env-file .env`). Not a bare +`sfn function deploy` -- the image is built and pushed manually first. diff --git a/apps/whisper-fn/whisper-fn/asgi.py b/apps/whisper-fn/whisper-fn/asgi.py new file mode 100644 index 0000000..6b503c0 --- /dev/null +++ b/apps/whisper-fn/whisper-fn/asgi.py @@ -0,0 +1,49 @@ +# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""ASGI entrypoint for the custom-build (Dockerfile) deployment path. + +The buildpack path's generated entrypoint normally wraps handler.py's +Function.start()/stop()/handle() in an ASGI app with lifespan support -- +see docs.functions.onstackit.cloud/reference/generated-code.html. Since a +Dockerfile-built image skips that generated entrypoint, this file recreates +the same lifespan wiring so `uvicorn asgi:app` boots and shuts down +correctly (Function.start() loads the Whisper model once per warm +instance; Function.stop() runs on SIGTERM via uvicorn's graceful shutdown). + +Used instead of the buildpack build because the project's STACKIT Harbor +registry rejects the zero-byte metadata layer Cloud Native Buildpacks adds +("blob unknown to registry") -- a plain Dockerfile build doesn't produce +that layer. +""" + +from handler import new + +_fn = new() + + +async def app(scope, receive, send): + if scope["type"] == "lifespan": + while True: + message = await receive() + if message["type"] == "lifespan.startup": + await _fn.start() + await send({"type": "lifespan.startup.complete"}) + elif message["type"] == "lifespan.shutdown": + await _fn.stop() + await send({"type": "lifespan.shutdown.complete"}) + return + return + + await _fn.handle(scope, receive, send) diff --git a/apps/whisper-fn/whisper-fn/handler.py b/apps/whisper-fn/whisper-fn/handler.py new file mode 100644 index 0000000..a12f044 --- /dev/null +++ b/apps/whisper-fn/whisper-fn/handler.py @@ -0,0 +1,135 @@ +# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import asyncio +import json +import os +import tempfile +import time + +import boto3 +import imageio_ffmpeg +import whisper + +# openai-whisper shells out to a literal "ffmpeg" binary on PATH. STACKIT +# Functions containers are built via Cloud Native Buildpacks (no apt-get +# access), so we can't install ffmpeg as a system package. imageio-ffmpeg +# ships a static ffmpeg binary via pip instead, but under a versioned +# filename (e.g. "ffmpeg-linux-aarch64-v7.0.2"), not "ffmpeg" -- putting its +# directory on PATH alone isn't enough, so symlink it to the expected name. +_ffmpeg_exe = imageio_ffmpeg.get_ffmpeg_exe() +_ffmpeg_dir = os.path.dirname(_ffmpeg_exe) +_ffmpeg_link = os.path.join(_ffmpeg_dir, "ffmpeg") +if not os.path.exists(_ffmpeg_link): + os.symlink(_ffmpeg_exe, _ffmpeg_link) +os.environ["PATH"] = _ffmpeg_dir + os.pathsep + os.environ.get("PATH", "") + +MODEL_SIZE = os.environ.get("WHISPER_MODEL", "small") +S3_BUCKET = os.environ.get("S3_BUCKET") +S3_INPUT_KEY = os.environ.get("S3_INPUT_KEY", "elephants_dream.mp4") +S3_OUTPUT_KEY = os.environ.get("S3_OUTPUT_KEY", "elephants_dream.txt") +S3_ENDPOINT_URL = os.environ.get("S3_ENDPOINT_URL") # STACKIT Object Storage endpoint +S3_REGION = os.environ.get("S3_REGION", "eu01") + +_model = None # loaded once per warm instance, see Function.start() + + +def new(): + return Function() + + +def _s3_client(): + return boto3.client( + "s3", + endpoint_url=S3_ENDPOINT_URL, + region_name=S3_REGION, + aws_access_key_id=os.environ.get("S3_ACCESS_KEY"), + aws_secret_access_key=os.environ.get("S3_SECRET_KEY"), + ) + + +class Function: + async def start(self): + # Load the whisper model once at warm-up instead of per request -- + # model loading dominates cold-start time otherwise. + global _model + _model = whisper.load_model(MODEL_SIZE) + + async def stop(self): + return + + async def handle(self, scope, receive, send): + assert scope["type"] == "http" + method = scope.get("method", "GET") + path = scope.get("path", "/") + + if method == "GET" and path == "/health": + return await self._respond(send, 200, {"status": "ok", "model": MODEL_SIZE}) + + if method == "POST" and path == "/transcribe": + try: + result = await self._transcribe() + return await self._respond(send, 200, result) + except Exception as exc: # surface transcription/S3 errors to the caller + return await self._respond(send, 500, {"error": str(exc)}) + + return await self._respond(send, 404, {"error": "not_found"}) + + async def _transcribe(self): + # whisper.transcribe() is CPU-bound and blocking -- run it off the + # event loop so the ASGI server can still serve /health concurrently. + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, self._transcribe_sync) + + def _transcribe_sync(self): + if not S3_BUCKET: + raise RuntimeError("S3_BUCKET env var not set") + + s3 = _s3_client() + with tempfile.TemporaryDirectory() as tmp: + video_path = os.path.join(tmp, "input.mp4") + s3.download_file(S3_BUCKET, S3_INPUT_KEY, video_path) + + model = _model or whisper.load_model(MODEL_SIZE) + started = time.time() + result = model.transcribe(video_path) + duration = time.time() - started + + text_path = os.path.join(tmp, "output.txt") + with open(text_path, "w", encoding="utf-8") as f: + f.write(result["text"]) + + # Upload the transcript back to S3 -- unlike the old VM version, + # which only left the result on local disk. + s3.upload_file(text_path, S3_BUCKET, S3_OUTPUT_KEY) + + return { + "bucket": S3_BUCKET, + "input_key": S3_INPUT_KEY, + "output_key": S3_OUTPUT_KEY, + "model": MODEL_SIZE, + "seconds": round(duration, 1), + } + + @staticmethod + async def _respond(send, status, payload): + body = json.dumps(payload).encode("utf-8") + await send( + { + "type": "http.response.start", + "status": status, + "headers": [(b"content-type", b"application/json")], + } + ) + await send({"type": "http.response.body", "body": body}) diff --git a/apps/whisper-fn/whisper-fn/pyproject.toml b/apps/whisper-fn/whisper-fn/pyproject.toml new file mode 100644 index 0000000..8c5c245 --- /dev/null +++ b/apps/whisper-fn/whisper-fn/pyproject.toml @@ -0,0 +1,23 @@ +[project] +name = "whisper-fn" +description = "STACKIT Function that transcribes a video from S3 with openai-whisper and writes the transcript back to S3." +version = "0.1.0" +requires-python = ">=3.9" +readme = "README.md" +license = "MIT" +dependencies = [ + "httpx", + "pytest", + "pytest-asyncio", + "boto3", + "openai-whisper", + "imageio-ffmpeg", + "uvicorn" +] + +[tool.pytest.ini_options] +asyncio_mode = "strict" +asyncio_default_fixture_loop_scope = "fn" + +[tool.setuptools] +py-modules = ["handler"] diff --git a/apps/whisper-fn/whisper-fn/stackit-functions.yaml b/apps/whisper-fn/whisper-fn/stackit-functions.yaml new file mode 100644 index 0000000..868a740 --- /dev/null +++ b/apps/whisper-fn/whisper-fn/stackit-functions.yaml @@ -0,0 +1,23 @@ +# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +function: + name: py-template + runtime: python + trigger: http + created: 2026-01-01T00:00:00.000000+01:00 + limits: + plan: f3 + concurrency: 50 +# This file has been automatically converted to new configuration in ../.stackit-functions diff --git a/apps/whisper-fn/whisper-fn/test_handler_local.py b/apps/whisper-fn/whisper-fn/test_handler_local.py new file mode 100644 index 0000000..491b3f4 --- /dev/null +++ b/apps/whisper-fn/whisper-fn/test_handler_local.py @@ -0,0 +1,136 @@ +# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Lightweight local smoke test for handler.py that needs NO Docker, NO Buildpacks, +and NO heavy pip installs (torch/whisper/boto3 are stubbed via sys.modules). + +Why this exists: it lets you verify the ASGI routing/response logic in +handler.py is correct without Docker, heavy pip installs, or real S3/STACKIT +credentials -- useful any time those aren't available or convenient (it was +originally written to work around a disk-space outage blocking the local +Docker daemon; see docs/history/Handout.md for that history). It does NOT +exercise real Whisper transcription or real S3 calls -- those still require +the full container build (see GETTING-STARTED.md). + +Run: python3 test_handler_local.py +""" +import asyncio +import sys +import tempfile +import types + + +def _install_stub_modules(): + # --- stub boto3 --- + boto3_stub = types.ModuleType("boto3") + + class _FakeS3Client: + def download_file(self, bucket, key, path): + with open(path, "wb") as f: + f.write(b"fake-video-bytes") + + def upload_file(self, path, bucket, key): + with open(path, "r", encoding="utf-8") as f: + _FakeS3Client.last_uploaded_text = f.read() + + def _client(service, **kwargs): + assert service == "s3" + return _FakeS3Client() + + boto3_stub.client = _client + sys.modules["boto3"] = boto3_stub + + # --- stub imageio_ffmpeg --- + # handler.py symlinks this path to a sibling "ffmpeg" file at import time, + # so the fake path needs to (a) actually exist and (b) not already be + # named "ffmpeg", mirroring imageio_ffmpeg's real versioned filenames. + fake_ffmpeg_dir = tempfile.mkdtemp(prefix="fake-ffmpeg-") + fake_ffmpeg_exe = f"{fake_ffmpeg_dir}/ffmpeg-fake-v0" + open(fake_ffmpeg_exe, "w").close() + ffmpeg_stub = types.ModuleType("imageio_ffmpeg") + ffmpeg_stub.get_ffmpeg_exe = lambda: fake_ffmpeg_exe + sys.modules["imageio_ffmpeg"] = ffmpeg_stub + + # --- stub whisper --- + whisper_stub = types.ModuleType("whisper") + + class _FakeModel: + def transcribe(self, path): + return {"text": "hello from fake whisper"} + + whisper_stub.load_model = lambda size: _FakeModel() + sys.modules["whisper"] = whisper_stub + + return _FakeS3Client + + +async def _call(fn, scope): + sent = [] + + async def send(message): + sent.append(message) + + async def receive(): + return {"type": "http.disconnect"} + + await fn.handle(scope, receive, send) + status = sent[0]["status"] + body = sent[1]["body"].decode("utf-8") + return status, body + + +async def main(): + fake_s3_cls = _install_stub_modules() + import importlib + + handler = importlib.import_module("handler") + + fn = handler.new() + await fn.start() + + # GET /health -> 200 + status, body = await _call(fn, {"type": "http", "method": "GET", "path": "/health"}) + assert status == 200, f"expected 200, got {status}: {body}" + print(f"[PASS] GET /health -> {status} {body}") + + # GET /unknown -> 404 + status, body = await _call(fn, {"type": "http", "method": "GET", "path": "/unknown"}) + assert status == 404, f"expected 404, got {status}: {body}" + print(f"[PASS] GET /unknown -> {status} {body}") + + # POST /transcribe without S3_BUCKET set -> 500 with a clear error message + status, body = await _call(fn, {"type": "http", "method": "POST", "path": "/transcribe"}) + assert status == 500, f"expected 500, got {status}: {body}" + assert "S3_BUCKET" in body + print(f"[PASS] POST /transcribe (no S3_BUCKET) -> {status} {body}") + + # POST /transcribe with S3_BUCKET set -> full fake pipeline runs end-to-end + # NOTE: handler.py reads S3_BUCKET as a module-level constant at import time + # (correct for real deployments, where env vars are fixed at container start). + # Setting os.environ here wouldn't retroactively change it, so we patch the + # already-imported module's constant directly instead. + handler.S3_BUCKET = "fake-bucket" + status, body = await _call(fn, {"type": "http", "method": "POST", "path": "/transcribe"}) + assert status == 200, f"expected 200, got {status}: {body}" + assert "fake-bucket" in body + print(f"[PASS] POST /transcribe (fake S3+whisper) -> {status} {body}") + print(f"[PASS] transcript uploaded to fake S3: {fake_s3_cls.last_uploaded_text!r}") + + await fn.stop() + print("\nAll local routing/logic checks passed (Docker/whisper/S3 stubbed).") + + +if __name__ == "__main__": + asyncio.run(main())