diff --git a/config/skills/bazarr.md b/config/skills/bazarr.md new file mode 100644 index 0000000..35092ce --- /dev/null +++ b/config/skills/bazarr.md @@ -0,0 +1,30 @@ +# Bazarr — VM103 (odczyt 2026-09-16) + +Usługa: `bazarr`. Obraz: `linuxserver/bazarr`, PUID/PGID 1001. Port 6767, domena `bazarr.mariuszpacek.pl`. Healthcheck: brak. +Dane: `/config` ← bind `/srv/appdata/bazarr/config` (ext4, SQLite); `/movies`, `/tv` ← volume NFS. +Integracje: Sonarr, Radarr (Bazarr jest klientem). Konsumenci: brak. + +## Źródła +| README obrazu | https://github.com/linuxserver/docker-bazarr | +|---|---| +| Wydania obrazu | https://github.com/linuxserver/docker-bazarr/releases | +| Release notes upstream | https://github.com/morpheus65535/bazarr/releases | +| Zgłoszenia | https://github.com/linuxserver/docker-bazarr/issues · https://github.com/morpheus65535/bazarr/issues | +| Przewodnik upgrade | brak | + +## Wersje +Tagi: `image_tags.sh linuxserver/bazarr --pattern '^v[0-9.]+-ls[0-9]+$' --current `. Auto: `patch`. +Tylko tagi `v-ls`; tagi `development-*` pomijaj. +Migracja bazy przy zmianie wersji: nieustalone — sprawdź release notes przy każdym kroku (baza SQLite w `/config`). + +## Przed zmianą +brak + +## Odbiór (limit 3 min) +`verify.sh` running + image + HTTPS 200. Bez wyszukiwania napisów. + +## STOP +Release notes tego kroku wprost mówią o migracji schematu/breaking change/ręcznym kroku; utrata połączenia z Sonarr/Radarr. + +## Powrót +Sam rebuild LinuxServer: `rollback.sh bazarr --from --service bazarr`. Po migracji bazy: restore `/config` z nocnej kopii VM103. diff --git a/config/skills/deluge.md b/config/skills/deluge.md new file mode 100644 index 0000000..60dcc00 --- /dev/null +++ b/config/skills/deluge.md @@ -0,0 +1,33 @@ +# Deluge — VM103 (odczyt 2026-09-16) + +Usługa: `deluge`. Obraz: `linuxserver/deluge`, PUID/PGID 1001. Web 8112, domena `deluge.mariuszpacek.pl`. Healthcheck: brak. +Porty hosta: 18112→8112, 15846→58846 (daemon), 15946→58946. +Dane: `/config` ← bind `/srv/appdata/deluge/config`; `/downloads` ← bind `/srv/appdata/deluge/data` (ext4). +Konsumenci: Sonarr, Radarr (klient pobrań); Jackett montuje `/downloads`. + +## Źródła +| README obrazu | https://github.com/linuxserver/docker-deluge | +|---|---| +| Wydania obrazu | https://github.com/linuxserver/docker-deluge/releases | +| Upstream | https://github.com/deluge-torrent/deluge | +| Zgłoszenia | https://github.com/linuxserver/docker-deluge/issues | +| Przewodnik upgrade | brak | + +## Wersje +Tagi: `image_tags.sh linuxserver/deluge --pattern '^[0-9][0-9.]*-ls[0-9]+$' --current `. Auto: `patch`. +Tylko tagi `-ls`. Tagi `libtorrentv1-*` to inna gałąź libtorrent — pomijaj. +Obecny tag `2.2.0-r1-ls361` (pakiet Alpine); nowsze `2.2.0-ls` (PIP). Ta sama wersja Deluge 2.2.0. + +## Przed zmianą +brak + +## Odbiór (limit 3 min) +1. `verify.sh` running + image + HTTPS 200. +2. `bash scripts/video-upgrade-check.sh sonarr check` → `passed:true` (klient pobrań widoczny w Sonarr). +Bez dodawania torrentów. + +## STOP +Zmiana wersji Deluge (major/minor); Sonarr traci klienta pobrań. + +## Powrót +`rollback.sh deluge --from --service deluge`. diff --git a/config/skills/dockge.md b/config/skills/dockge.md new file mode 100644 index 0000000..06b3307 --- /dev/null +++ b/config/skills/dockge.md @@ -0,0 +1,28 @@ +# Dockge — VM103 (odczyt 2026-09-17) + +Usługa: `dockge`. Obraz: `louislam/dockge`, **bez tagu** — tylko digest w compose. +Domena `docker.mariuszpacek.pl`, port 5001. Healthcheck: tak (obecnie `healthy`). +Dane: `/app/data` ← bind `/srv/appdata/dockge/data`; `/var/run/docker.sock` (zarządza wszystkimi stackami compose); `/opt/homelab/compose` ← bind (RW, widzi manifesty innych projektów). +Obecna wersja działająca: `1.5.0` (odczyt `docker exec dockge-dockge-1 cat /app/package.json` — brak etykiety OCI wersji, `image_version.sh` zwraca `no_version_label`). + +## Źródła +| README obrazu | https://github.com/louislam/dockge | +| Wydania | https://github.com/louislam/dockge/releases | +| Zgłoszenia | https://github.com/louislam/dockge/issues | +| Przewodnik upgrade | brak | + +## Wersje +Tagi: `image_tags.sh louislam/dockge --pattern '^[0-9.]+$' --current 1.5.0`. Auto: `patch`. +Obraz bez tagu w compose → przed jakąkolwiek automatyczną akcją repin/update wymaga jawnego wpisania tagu (nie `latest`). + +## Przed zmianą +brak — restart dockge nie zatrzymuje innych stacków (tylko traci socket na czas restartu). + +## Odbiór (limit 5 min) +`verify.sh` running + image + HTTPS 200. Lista stacków w UI widoczna i zgodna ze stanem sprzed restartu. + +## STOP +`no_version_label` przy `image_version.sh` — wersję potwierdź ręcznie (`package.json`) przed repinem, nie zgaduj z tagu. + +## Powrót +`rollback.sh dockge --from --service dockge`. diff --git a/config/skills/gitea.md b/config/skills/gitea.md new file mode 100644 index 0000000..8515c1d --- /dev/null +++ b/config/skills/gitea.md @@ -0,0 +1,28 @@ +# Gitea — VM103 (odczyt 2026-09-17) + +Usługi: `gitea` (obraz `gitea/gitea`, USER_UID/GID 1000), `db` (`postgres:14`, bez zmian przy update gitea). +Domena `git.mariuszpacek.pl` (web 3000→13080, SSH 22→12224, whitelist IP w Traefik). Healthcheck: brak. +Dane: `/data` ← bind `/srv/appdata/gitea/data` (ext4, 980M); DB ← bind `/srv/appdata/gitea/db`. +Konsumenci: brak zewnętrznych. + +## Źródła +| README obrazu | https://github.com/go-gitea/gitea | +| Wydania | https://github.com/go-gitea/gitea/releases | +| Zgłoszenia | https://github.com/go-gitea/gitea/issues | +| Przewodnik upgrade | https://docs.gitea.com/installation/upgrade-from-gitea | + +## Wersje +Tagi: `image_tags.sh gitea/gitea --pattern '^[0-9.]+$' --current `. Auto: `patch`. +Bez `-rc`, `-dev`, `nightly`. Gitea wspiera tylko upgrade z bezpośrednio poprzedniej wersji minor — nie pomijaj minorów. + +## Przed zmianą +Świeży `pg_dump` (backup.sh robi kopię wolumenu, nie logiczny dump) — sprawdź przed majorem/minorem. + +## Odbiór (limit 5 min) +`verify.sh` running + image + HTTPS 200 (`/user/login` lub strona główna po whitelist). Sprawdź logi `gitea` bez `migration failed`. + +## STOP +Skok więcej niż jeden minor naraz; błąd migracji DB w logach. + +## Powrót +`rollback.sh gitea --from --service gitea`. Migracja DB nieudana → restore wolumenu z backup_dir albo nocny vzdump. diff --git a/config/skills/grafana.md b/config/skills/grafana.md new file mode 100644 index 0000000..2417174 --- /dev/null +++ b/config/skills/grafana.md @@ -0,0 +1,29 @@ +# Grafana — VM103 (odczyt 2026-09-17) + +Usługi: `grafana` (obraz `grafana/grafana`, **bez tagu** — tylko digest w compose), `influxdb` (`influxdb:1.8.4`, osobny cykl, nie ruszać przy update grafany). +Domena `grafana.mariuszpacek.pl`, port 3000. Healthcheck: brak. +Dane: `/var/lib/grafana` ← bind `/srv/appdata/grafana/data`; `/etc/grafana` ← bind `/srv/appdata/grafana/etc`; influxdb dane ← `/srv/appdata/grafana/influxdb`. +Obecna wersja działająca: `13.1.3` (odczyt `docker exec grafana-grafana-1 grafana-server -v` — brak etykiety OCI wersji na obrazie, `image_version.sh` zwraca `no_version_label`). + +## Źródła +| README obrazu | https://github.com/grafana/grafana/blob/main/packaging/docker/README.md | +| Wydania | https://github.com/grafana/grafana/releases | +| Release notes | https://grafana.com/docs/grafana/latest/whatsnew/ | +| Zgłoszenia | https://github.com/grafana/grafana/issues | +| Przewodnik upgrade | https://grafana.com/docs/grafana/latest/upgrade-guide/ | + +## Wersje +Tagi: `image_tags.sh grafana/grafana --pattern '^[0-9.]+$' --current 13.1.3`. Auto: `patch`. +Obraz bez tagu w compose → przed jakąkolwiek automatyczną akcją repin/update wymaga jawnego wpisania tagu (nie `latest`). + +## Przed zmianą +brak + +## Odbiór (limit 5 min) +`verify.sh` running + image + HTTPS 302 (root przekierowuje na `/login`, curl bez `-L` — to nie błąd). Dashboardy się ładują, źródło danych InfluxDB odpowiada (panel z danymi, nie błąd datasource). + +## STOP +`no_version_label` przy `image_version.sh` — wersję potwierdź ręcznie (`grafana-server -v`) przed repinem, nie zgaduj z tagu. + +## Powrót +`rollback.sh grafana --from --service grafana`. diff --git a/config/skills/homeassistant.md b/config/skills/homeassistant.md new file mode 100644 index 0000000..bb48090 --- /dev/null +++ b/config/skills/homeassistant.md @@ -0,0 +1,36 @@ +# Home Assistant — VM103 (odczyt 2026-09-17) + +Usługa: `homeassistant` (network_mode: host, brak `ports`, healthcheck: brak). Obraz: `homeassistant/home-assistant`. +Dane: `/config` ← bind `/srv/appdata/homeassistant/config` (ext4). Recorder: domyślny SQLite, `home-assistant_v2.db` ~514 MB. +Domena: `home.mariuszpacek.pl` (Traefik, TLS). Konsumenci: brak (frontend, dostęp bezpośredni). +`custom_components` (stan 2026-09-17, odśwież `ls`): `hacs`, `ble_monitor`, `gree`, `moonraker`. `bermuda` usunięta 2026-09-17 (nieużywana, łamała się na 2025.11.3, `BaseScannerEntity` ImportError). + +## Źródła +| README obrazu | https://hub.docker.com/r/homeassistant/home-assistant | +|---|---| +| Release notes (breaking changes per wydanie) | https://www.home-assistant.io/blog/categories/release-notes/ | +| Wydania (changelog skrócony) | https://github.com/home-assistant/core/releases | +| Zgłoszenia | https://github.com/home-assistant/core/issues | +| Zgodność HACS | https://hacs.xyz/docs/faq/known_issues/ · https://www.hacs.xyz | + +## Wersje +Tagi: `image_tags.sh homeassistant/home-assistant --pattern '^[0-9]{4}\.[0-9]{1,2}\.[0-9]+$' --current `. +Auto: `minor` (CalVer `RRRR.MM.patch`, miesięczne). Zmiana `RRRR` = `major` → STOP wg pętli. Wprost na `candidate`, +chyba że release notes pomijanego miesiąca wymagają migracji pośredniej. + +## Przed zmianą +`ls /config/custom_components` (lista żywa, nie z tego pliku). Release notes każdego pomijanego miesiąca, sekcja +„Breaking changes” — wzmianka o którejkolwiek z tej listy, recorder/SQLite lub frontend → STOP. +## Odbiór (limit 10 min) +1. `verify.sh` running + image + HTTPS 200 na `home.mariuszpacek.pl`. +2. Liczba encji w `.storage/core.entity_registry` (`data.entities`) nie mniejsza niż ostatnia wartość w `docs/status.md`. +3. `ls /config/custom_components` → lista; log (5 min) bez `ERROR`/`CRITICAL` z `custom_components.` lub `for ` z tej listy (nie z tego pliku — mogły dojść nowe przez HACS). +Nie loguj się do UI, nie klikaj integracji ręcznie jako testu. + +## STOP +Breaking change dot. HACS lub dowolnego custom_component z żywego `ls`. Migracja recorder/SQLite bez downgrade +(zawsze tak dla HA) → reguła 3 pętli: powrót tylko przez rollback + backup, nie przez downgrade obrazu. + +## Powrót +`rollback.sh homeassistant --from --service homeassistant`. DB migrowana przy starcie — rollback obrazu +bez przywrócenia `home-assistant_v2.db` z backupu może nie wystarczyć; backup.sh musi objąć `/config` całe. diff --git a/config/skills/homepage.md b/config/skills/homepage.md new file mode 100644 index 0000000..fb8cc37 --- /dev/null +++ b/config/skills/homepage.md @@ -0,0 +1,34 @@ +# Homepage — VM103 (odczyt 2026-09-17) + +Usługa: `homepage` (obraz `ghcr.io/gethomepage/homepage`). Domena `dash.mariuszpacek.pl` (port 3000), +router ograniczony middleware `homepage-ipwhitelist` (LAN 192.168.0.0/24 + proxy 172.16.0.0/24 + +docker_gwbridge 172.18.0.0/16) — celowo nie publiczny, pokazuje spis wszystkich usług i status. +Healthcheck: tak (wbudowany w obraz, `health` w status.sh). Dane: config YAML ← bind +`/srv/appdata/homepage/config` (`settings.yaml`, `docker.yaml`, `services.yaml`, bez sekretów). +Docker socket: ro bind `/var/run/docker.sock` — status kontenerów czytany bezpośrednio z API, bez pingów sieciowych. +Konsumenci: brak. Serwisy w `services.yaml` referencjonowane po nazwie kontenera (`container: --1`) — +zmiana nazwy kontenera w innym projekcie (np. przy przepisaniu compose) wymaga aktualizacji tego pliku ręcznie. + +## Źródła +| README obrazu | https://github.com/gethomepage/homepage | +| Wydania | https://github.com/gethomepage/homepage/releases | +| Zgłoszenia | https://github.com/gethomepage/homepage/issues | +| Przewodnik upgrade | https://gethomepage.dev/latest/installation/upgrading/ | + +## Wersje +Tagi: `image_tags.sh ghcr.io/gethomepage/homepage --pattern '^v[0-9]+\.[0-9]+\.[0-9]+$' --current `. Auto: `minor`. +Bez `-beta`/`-rc`. Projekt aktywny, częste minory z nowymi widgetami — sprawdź release notes pod kątem +zmian formatu `services.yaml`/`settings.yaml` (bywały breaking changes configu między minorami). + +## Przed zmianą +Brak (bezstanowe, config to zwykłe pliki YAML w bind mouncie — kopiowane razem z backup.sh). + +## Odbiór (limit 3 min) +`verify.sh` running + healthy + image + HTTPS 200 na `/`. Wizualnie: kafelki usług się ładują, status +kontenerów (kropka) się zgadza z `docker ps`. + +## STOP +Zmiana formatu configu w release notes bez jasnej ścieżki migracji. + +## Powrót +`rollback.sh homepage --from --service homepage`. diff --git a/config/skills/jackett.md b/config/skills/jackett.md new file mode 100644 index 0000000..fefcbf8 --- /dev/null +++ b/config/skills/jackett.md @@ -0,0 +1,32 @@ +# Jackett — VM103 (odczyt 2026-09-16) + +Usługa: `jackett`. Obraz: `ghcr.io/linuxserver/jackett`. Port 9117, domena `jackett.mariuszpacek.pl`. +Dane: `/config` ← bind `/srv/appdata/jackett/config` (ext4); `/downloads` ← bind `/srv/appdata/deluge/data`. +Konsumenci: Sonarr, Radarr przez `http://jackett:9117/`. + +## Źródła +| README obrazu | https://github.com/linuxserver/docker-jackett | +|---|---| +| Wydania obrazu | https://github.com/linuxserver/docker-jackett/releases | +| Release notes upstream | https://github.com/Jackett/Jackett/releases | +| Zgłoszenia | https://github.com/linuxserver/docker-jackett/issues · https://github.com/Jackett/Jackett/issues | +| Przewodnik upgrade | brak (sama podmiana obrazu) | + +## Wersje +Tagi: `image_tags.sh ghcr.io/linuxserver/jackett --pattern '^v[0-9.]+-ls[0-9]+$' --current `. Auto: `patch`. +Skok wprost na najnowszy tag LinuxServer. Zmiany to zwykle definicje trackerów. + +## Przed zmianą +`AUTO_UPDATE=false` w Env kontenera. Włączone lub nieustalone → STOP. + +## Odbiór (limit 3 min, ponawiaj do limitu) +1. `bash scripts/video-upgrade-check.sh jackett check` → `passed:true` (baza: `plans/video-baseline-jackett.json`). +2. `verify.sh` running + image + HTTPS 301. +Nie uruchamiaj wyszukiwań ani pobrań. Nie drukuj URL indeksatorów. + +## STOP +Zmieniona liczba/zawartość plików indeksatorów; Sonarr/Radarr nie osiągają usługi. +Błąd pojedynczego trackera (np. Cloudflare) i `Unknown indexer` w logach to nie bloker. + +## Powrót +`rollback.sh jackett --from --service jackett`. diff --git a/config/skills/jellyfin.md b/config/skills/jellyfin.md new file mode 100644 index 0000000..3d02599 --- /dev/null +++ b/config/skills/jellyfin.md @@ -0,0 +1,29 @@ +# Jellyfin — VM103 (odczyt 2026-09-17) + +Usługa: `jellyfin`. Obraz: `linuxserver/jellyfin`, PUID/PGID 1001. `DOCKER_MODS=linuxserver/mods:jellyfin-amd` (transkodowanie sprzętowe). +Domena `video.mariuszpacek.pl`, port 8096 (+8920). Healthcheck: brak. +Dane: `/config` ← bind `/srv/appdata/jellyfin/config` (ext4, 4.1G); `/data/movies`, `/data/tv` ← volume NFS (RO biblioteka). +Konsumenci: brak. + +## Źródła +| README obrazu | https://github.com/linuxserver/docker-jellyfin | +| Wydania obrazu | https://github.com/linuxserver/docker-jellyfin/releases | +| Release notes upstream | https://github.com/jellyfin/jellyfin/releases | +| Zgłoszenia | https://github.com/linuxserver/docker-jellyfin/issues · https://github.com/jellyfin/jellyfin/issues | +| Przewodnik upgrade | brak | + +## Wersje +Tagi: `image_tags.sh linuxserver/jellyfin --pattern '^[0-9.]+ubu[0-9]+-ls[0-9]+$' --current `. Auto: `patch`. +Obecny tag `10.11.11ubu2604-ls44` (wersja Jellyfin + kodowa nazwa Ubuntu + build LinuxServer). + +## Przed zmianą +brak + +## Odbiór (limit 5 min) +`verify.sh` running + image + HTTPS 302 (root przekierowuje na `/web/`, curl bez `-L` — to nie błąd). Sprawdź, że transkodowanie sprzętowe (`/dev/dri`) nadal zamontowane po `up`. + +## STOP +Zniknięcie `/dev/dri` z kontenera; błąd DOCKER_MODS przy starcie. + +## Powrót +`rollback.sh jellyfin --from --service jellyfin`. diff --git a/config/skills/keycloak.md b/config/skills/keycloak.md new file mode 100644 index 0000000..72bb409 --- /dev/null +++ b/config/skills/keycloak.md @@ -0,0 +1,35 @@ +# Keycloak — VM103 (odczyt 2026-09-17) + +Usługi: `keycloak` (obraz `quay.io/keycloak/keycloak`, tryb `start` produkcyjny, nie `start-dev`), +`db` (`postgres:18.6`, dedykowany, bez współdzielenia z innymi appkami). +Domena `login.mariuszpacek.pl` (port 8080). Dwa routery Traefik: publiczny (login, `/realms/*`) i +`PathPrefix(/admin)` z middleware `keycloak-admin-ipwhitelist` (LAN 192.168.0.0/24 + proxy 172.16.0.0/24 + +docker_gwbridge 172.18.0.0/16). Healthcheck: brak (KC_HEALTH_ENABLED=true, ale bez compose healthcheck). +Dane: DB ← bind `/srv/appdata/keycloak/db` (właściciel uid:gid 999 — obraz postgres 18+ wymaga zapisu +przez usera postgres na całym punkcie montowania, nie tylko na podkatalogu wersji). +Konsumenci: brak (realm/clienci do odtworzenia ręcznie przez właściciela). + +## Źródła +| README obrazu | https://quay.io/repository/keycloak/keycloak | +| Wydania | https://github.com/keycloak/keycloak/releases | +| Zgłoszenia | https://github.com/keycloak/keycloak/issues | +| Przewodnik upgrade | https://www.keycloak.org/docs/latest/upgrading/ | + +## Wersje +Tagi: `image_tags.sh quay.io/keycloak/keycloak --pattern '^[0-9]+\.[0-9]+\.[0-9]+$' --current `. Auto: `patch`. +Bez `-rc`. Keycloak wspiera upgrade tylko z bezpośrednio poprzedniej wersji major — nie pomijaj majorów. +Postgres: `image_tags.sh postgres --pattern '^1[4-9]\.[0-9]+$' --current ` — major osobno, sprawdź release notes obrazu +(zmiana układu katalogu danych między majorami, jak przy 17→18). + +## Przed zmianą +Major KC: sprawdź changelog schematu DB (Liquibase) w release notes. Downgrade niewspierany — powrót tylko przez rollback.sh. + +## Odbiór (limit 5 min) +`verify.sh` running (keycloak, db) + image + HTTPS 200 na `/realms/master/.well-known/openid-configuration`. +Logi `keycloak-keycloak-1` bez `ERROR: Failed to start server` ani błędów Liquibase po migracji. + +## STOP +Skok więcej niż jeden major KC naraz; błąd migracji schematu w logach; zmiana układu katalogu danych Postgres (jak 17→18). + +## Powrót +`rollback.sh keycloak --from --service keycloak` (i analogicznie `db` przy migracji bazy). diff --git a/config/skills/nextcloud.md b/config/skills/nextcloud.md new file mode 100644 index 0000000..212155d --- /dev/null +++ b/config/skills/nextcloud.md @@ -0,0 +1,36 @@ +# Nextcloud — VM103 (odczyt 2026-09-14) + +Usługi: `app`, `cron` (ten sam obraz — aktualizuj razem), `db` (MariaDB 10.6), `redis`. +Obraz: `nextcloud:-apache`. Bindy: `/srv/appdata/nextcloud/{db,redis,html,config,custom_apps}`. +Dane użytkowników: volume `next31_nfs-data` (NFS TrueNAS). + +## Źródła +| README obrazu | https://github.com/nextcloud/docker | +|---|---| +| Release notes | https://nextcloud.com/changelog/ · https://github.com/nextcloud/server/releases | +| Wsparcie wersji | https://github.com/nextcloud/server/wiki/Maintenance-and-Release-Schedule | +| Zgłoszenia | https://github.com/nextcloud/server/issues | +| Przewodnik upgrade | https://docs.nextcloud.com/server/stable/admin_manual/maintenance/upgrade.html | + +## Wersje +Tagi: `image_tags.sh nextcloud --pattern '^[0-9.]+-apache$' --current `. Auto: `patch`. +Najpierw najnowszy patch majora, potem jeden major na krok. Brak downgrade'u. +Przed majorem: zgodność aplikacji zewnętrznych, zakończone migracje w tle. Teraz: 31.0.12 → 31.0.14. + +## Przed zmianą +Maintenance mode on → stop `app` i `cron` → zmiana obu. `db`, `redis` bez zmian. + +## Odbiór (limit 10 min) +```bash +C=/opt/homelab/compose/nextcloud/compose.yaml +docker compose -f $C exec -T --user www-data app php occ status --output=json # installed, bez maintenance, bez needsDbUpgrade +docker compose -f $C exec -T --user www-data app php occ config:app:get core lastcron # świeży +docker compose -f $C exec -T cron sh -c 'pidof crond' +``` +Cztery kontenery działają, HTTPS odpowiada. Bez skanów i masowych zadań. + +## STOP +Nieoczekiwana migracja/repair; oczekujący upgrade DB; stary `lastcron`; major bez świeżego `mysqldump`. + +## Powrót +Po migracji schematu `rollback.sh` nie wystarcza: restore zrzutu MariaDB albo `qmrestore` nocnej kopii VM103. diff --git a/config/skills/ombi.md b/config/skills/ombi.md new file mode 100644 index 0000000..c02cf17 --- /dev/null +++ b/config/skills/ombi.md @@ -0,0 +1,30 @@ +# Ombi — VM103 (odczyt 2026-09-16) + +Usługa: `ombi`. Obraz: `linuxserver/ombi`, PUID/PGID 1001. Port 3579, domena `ombi.mariuszpacek.pl`. Healthcheck: brak. +Dane: `/config` ← bind `/srv/appdata/ombi/config` (ext4, SQLite, 64 MB). +Integracje: nieustalone. Konsumenci: brak. + +## Źródła +| README obrazu | https://github.com/linuxserver/docker-ombi | +|---|---| +| Wydania obrazu | https://github.com/linuxserver/docker-ombi/releases | +| Release notes upstream | https://github.com/Ombi-app/Ombi/releases | +| Zgłoszenia | https://github.com/linuxserver/docker-ombi/issues · https://github.com/Ombi-app/Ombi/issues | +| Przewodnik upgrade | brak | + +## Wersje +Tagi: `image_tags.sh linuxserver/ombi --pattern '^v[0-9.]+-ls[0-9]+$' --current `. Auto: `patch`. +Tylko tagi `v-ls`; tagi `development-*` pomijaj. +Migracja bazy przy zmianie wersji: nieustalone — sprawdź release notes przy każdym kroku (EF Core, baza w `/config`). + +## Przed zmianą +brak + +## Odbiór (limit 3 min) +`verify.sh` running + image + HTTPS 200. Bez składania żądań. + +## STOP +Release notes tego kroku wprost mówią o migracji schematu/breaking change/ręcznym kroku. + +## Powrót +Sam rebuild LinuxServer: `rollback.sh ombi --from --service ombi`. Po migracji bazy: restore `/config` z nocnej kopii VM103. diff --git a/config/skills/orcaslicer.md b/config/skills/orcaslicer.md new file mode 100644 index 0000000..621cebe --- /dev/null +++ b/config/skills/orcaslicer.md @@ -0,0 +1,30 @@ +# OrcaSlicer — VM103 (odczyt 2026-09-16) + +Usługa: `orcaslicer`. Obraz: `linuxserver/orcaslicer` (KasmVNC/Selkies), PUID/PGID 1000. Port 3000, domena `orca.mariuszpacek.pl`. +Healthcheck: brak. Dostęp chroniony (`CUSTOM_USER`/`PASSWORD`) → HTTPS 401 bez logowania. +Dane: `/config` ← bind `/srv/appdata/orcaslicer/config`; `/prints` ← bind `/srv/appdata/orcaslicer/prints` (ext4, 1.8 GB). +Konsumenci: brak. + +## Źródła +| README obrazu | https://github.com/linuxserver/docker-orcaslicer | +|---|---| +| Wydania obrazu | https://github.com/linuxserver/docker-orcaslicer/releases | +| Release notes upstream | https://github.com/SoftFever/OrcaSlicer/releases | +| Zgłoszenia | https://github.com/linuxserver/docker-orcaslicer/issues · https://github.com/SoftFever/OrcaSlicer/issues | +| Przewodnik upgrade | brak | + +## Wersje +Tagi: `image_tags.sh linuxserver/orcaslicer --pattern '^v[0-9.]+-ls[0-9]+$' --current `. Auto: `minor`. +Skok wprost na najnowszy tag `v-ls`. Profile drukarek w `/config`. + +## Przed zmianą +brak + +## Odbiór (limit 3 min) +`verify.sh` running + image + HTTPS 401. + +## STOP +Zmiana zmiennych `SELKIES_*` lub auth wymagana przez release notes. + +## Powrót +`rollback.sh orcaslicer --from --service orcaslicer`. diff --git a/config/skills/radarr.md b/config/skills/radarr.md new file mode 100644 index 0000000..ed8ccc5 --- /dev/null +++ b/config/skills/radarr.md @@ -0,0 +1,26 @@ +# Radarr — VM103 (odczyt 2026-09-16) + +Usługa: `radarr`. Obraz: LinuxServer amd64, PUID/PGID 1001. Port 7878, domena `radarr.mariuszpacek.pl`. Bez healthchecku. +Dane: `/config` ← `/srv/appdata/radarr/config` (ext4, SQLite); `/downloads` ← `/srv/appdata/deluge/data`; `/movies` ← volume NFS. +Integracje: Jackett `http://jackett:9117`, Deluge. + +## Źródła +| README obrazu | https://github.com/linuxserver/docker-radarr | +|---|---| +| Release notes | https://github.com/Radarr/Radarr/releases · https://github.com/linuxserver/docker-radarr/releases | +| Zgłoszenia | https://github.com/linuxserver/docker-radarr/issues · https://github.com/Radarr/Radarr/issues | +| Przewodnik upgrade | https://wiki.servarr.com/radarr/faq | + +## Wersje +Tagi: `image_tags.sh ghcr.io/linuxserver/radarr --pattern '^[0-9.]+-ls[0-9]+$' --current `. Auto: `patch`. +Gałąź v6. SQLite, schemat w tabeli `VersionInfo` — porównaj przed/po jako dowód, nie zakładaj z góry. + +## Odbiór (limit 5 min) +`bash scripts/video-upgrade-check.sh radarr check` → `passed:true`; `verify.sh` running + image + HTTPS 302. +Bez wyszukiwań, importów, pobrań. + +## STOP +ffprobe nie działa; błąd integralności SQLite; utrata biblioteki lub integracji. + +## Powrót +Schemat bez zmian: `rollback.sh radarr --from --service radarr`. Zmieniony: restore danych. diff --git a/config/skills/sonarr.md b/config/skills/sonarr.md new file mode 100644 index 0000000..cc70a69 --- /dev/null +++ b/config/skills/sonarr.md @@ -0,0 +1,26 @@ +# Sonarr — VM103 (odczyt 2026-09-16) + +Usługa: `sonarr`. Obraz: LinuxServer amd64, PUID/PGID 1001. Port 8989, domena `sonarr.mariuszpacek.pl`. Bez healthchecku. +Dane: `/config` ← `/srv/appdata/sonarr/config` (ext4, SQLite); `/downloads` ← `/srv/appdata/deluge/data`; `/tv` ← volume NFS. +Integracje: Jackett `http://jackett:9117`, Deluge. + +## Źródła +| README obrazu | https://github.com/linuxserver/docker-sonarr | +|---|---| +| Release notes | https://github.com/Sonarr/Sonarr/releases · https://github.com/linuxserver/docker-sonarr/releases | +| Zgłoszenia | https://github.com/linuxserver/docker-sonarr/issues · https://github.com/Sonarr/Sonarr/issues | +| Przewodnik upgrade | https://wiki.servarr.com/sonarr/faq | + +## Wersje +Tagi: `image_tags.sh ghcr.io/linuxserver/sonarr --pattern '^[0-9.]+-ls[0-9]+$' --current `. Auto: `patch`. +Gałąź v4. SQLite, schemat w tabeli `VersionInfo` — porównaj przed/po jako dowód, nie zakładaj z góry. + +## Odbiór (limit 5 min) +`bash scripts/video-upgrade-check.sh sonarr check` → `passed:true`; `verify.sh` running + image + HTTPS 302. +Bez wyszukiwań, importów, pobrań. + +## STOP +ffprobe nie działa (LS #335); błąd integralności SQLite; utrata biblioteki lub integracji. + +## Powrót +Schemat bez zmian: `rollback.sh sonarr --from --service sonarr`. Zmieniony: restore danych. diff --git a/src/domain/ports.ts b/src/domain/ports.ts index db98800..a672681 100644 --- a/src/domain/ports.ts +++ b/src/domain/ports.ts @@ -11,6 +11,7 @@ import type { } from './executor-api.ts'; import type { StepRecord } from './outcome.ts'; import type { PreflightReport } from './preflight.ts'; +import type { Skill } from './skill.ts'; import type { AppConfig, Executor, JsonValue, Run, RunSummary, RunType, Step, StepSpec, StepStatus } from './steps.ts'; // Ports: the seams for tests and swappable adapters. Everything else is a plain function. @@ -64,6 +65,13 @@ export interface AppConfigStore { upsert(config: AppConfig): void; } +/** The skills of the apps, one per compose project. */ +export interface SkillStore { + get(project: string): Skill | null; + list(): Skill[]; + upsert(skill: Skill): void; +} + export interface CommandResult { readonly exitCode: number; readonly stdout: string; diff --git a/src/domain/skill.ts b/src/domain/skill.ts new file mode 100644 index 0000000..ffd7e3f --- /dev/null +++ b/src/domain/skill.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; + +// A skill: facts about one app (compose project) for the model: where its release notes are, what an upgrade needs, what +// is a STOP, how to check it and how to go back. Only the skill of the project the model works on is loaded. +// Mechanical parameters (image, tag pattern, auto, URL) live in app_config, not here. + +export const MAX_SKILL_LENGTH = 50_000; + +export const SkillContent = z.string().max(MAX_SKILL_LENGTH, `at most ${String(MAX_SKILL_LENGTH)} characters`); + +export interface Skill { + readonly project: string; + readonly content: string; + readonly updatedAt: string; +} diff --git a/src/http/contract.ts b/src/http/contract.ts index 0a70479..7a23169 100644 --- a/src/http/contract.ts +++ b/src/http/contract.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { SkillContent } from '../domain/skill.ts'; import { ResolutionRequest } from '../domain/resolution.ts'; import { AutoLevel, Executor, Json, ReasonCode, RunType, StepName, StepStatus } from '../domain/steps.ts'; @@ -78,6 +79,11 @@ export type AppConfigView = z.infer; export { AppConfigPatch } from '../domain/app-config.ts'; +/** The skill of one compose project; empty content and null updatedAt when there is none yet. */ +export const SkillResponse = z.object({ project: z.string(), content: z.string(), updatedAt: z.string().nullable() }); +export type SkillView = z.infer; +export const SkillSave = z.strictObject({ content: SkillContent }); + export const RunsResponse = z.array(RunSummarySchema); export const RunDetailResponse = z.object({ run: RunSummarySchema, steps: z.array(StepSchema) }); export const StepsResponse = z.array(StepSchema); diff --git a/src/http/server.test.ts b/src/http/server.test.ts index f677a6e..a6844d9 100644 --- a/src/http/server.test.ts +++ b/src/http/server.test.ts @@ -25,11 +25,11 @@ afterEach(async () => { }); async function fixture(overrides: Partial = {}, options: { night?: boolean } = {}) { - const { store, appConfig } = createTestDatabase(); + const { store, appConfig, skills } = createTestDatabase(); seedDemoConfig(appConfig); const clock = new FakeClock(); const hub = createChangeHub(); - const app = await buildServer({ store, appConfig, clock, hub, pollChangesMs: 0, ...overrides }); + const app = await buildServer({ store, appConfig, skills, clock, hub, pollChangesMs: 0, ...overrides }); cleanup.push(() => app.close()); if (options.night === true) { @@ -331,3 +331,21 @@ describe('live updates (SSE)', () => { expect(hub.revision).toBe(1); }); }); + +describe('skills', () => { + it('reads an empty skill for a configured project, saves it, and reads it back', async () => { + const { app } = await fixture(); + expect((await app.inject('/api/projects/grafana/skill')).json()).toEqual({ project: 'grafana', content: '', updatedAt: null }); + const saved = await app.inject({ method: 'POST', url: '/api/projects/grafana/skill', payload: { content: '# Grafana\nInfluxDB 1.8 only' } }); + expect(saved.statusCode).toBe(200); + expect((await app.inject('/api/projects/grafana/skill')).json()).toMatchObject({ project: 'grafana', content: '# Grafana\nInfluxDB 1.8 only' }); + }); + + it('refuses an unknown project, a path and an oversized skill', async () => { + const { app } = await fixture(); + expect((await app.inject('/api/projects/ghost/skill')).statusCode).toBe(404); + expect((await app.inject('/api/projects/..%2Fetc/skill')).statusCode).toBe(400); + expect((await app.inject({ method: 'POST', url: '/api/projects/grafana/skill', payload: { content: 'x'.repeat(50_001) } })).statusCode).toBe(400); + expect((await app.inject({ method: 'POST', url: '/api/projects/grafana/skill', payload: { content: 'x', extra: 1 } })).statusCode).toBe(400); + }); +}); diff --git a/src/http/server.ts b/src/http/server.ts index 1c9a7da..bdcde2e 100644 --- a/src/http/server.ts +++ b/src/http/server.ts @@ -6,17 +6,19 @@ import type { FastifyInstance, FastifyReply } from 'fastify'; import { z } from 'zod'; import { nextComponentStep } from '../app/groups.ts'; import { resolveStep, startManualRun, updateAppConfig } from '../app/use-cases.ts'; -import type { AppConfigStore, Clock, StepStore } from '../domain/ports.ts'; +import type { AppConfigStore, Clock, SkillStore, StepStore } from '../domain/ports.ts'; import { buildAppOverview } from '../domain/overview.ts'; import { StepStatus } from '../domain/steps.ts'; import { watchChanges } from './change-hub.ts'; import type { ChangeHub } from './change-hub.ts'; -import { AppConfigPatch, EVENTS_PATH, ResolutionRequest } from './contract.ts'; +import { AppConfigPatch, EVENTS_PATH, ResolutionRequest, SkillSave } from './contract.ts'; import type { BuildInfo } from './contract.ts'; export interface ServerDeps { readonly store: StepStore; readonly appConfig: AppConfigStore; + /** Skills of the apps (one per compose project); without it the skill routes answer 404. */ + readonly skills?: SkillStore; readonly clock: Clock; readonly hub: ChangeHub; /** Directory with the built frontend (`dist/web`). Without it the server is API-only. */ @@ -40,6 +42,7 @@ const StepsQuery = z.object({ app: z.string().min(1).optional(), roundId: z.stri const IdParams = z.object({ id: z.coerce.number().int().positive() }); const RoundParams = z.object({ roundId: z.string().min(1) }); const AppParams = z.object({ app: z.string().min(1) }); +const ProjectParams = z.object({ project: z.string().regex(/^[a-z0-9][a-z0-9_-]*$/) }); const StartRunBody = z.object({ type: z.literal('manual') }); const CONTENT_SECURITY_POLICY = "default-src 'self'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"; @@ -155,6 +158,30 @@ export async function buildServer(deps: ServerDeps): Promise { return reply.send(result.config); }); + /** A skill belongs to a configured project; the owner edits it in the app. */ + const knownProject = (project: string): boolean => appConfig.list().some((config) => config.project === project); + + app.get('/api/projects/:project/skill', (request, reply) => { + const params = ProjectParams.safeParse(request.params); + if (!params.success) return fail(reply, 400, 'invalid_request', issues(params.error)); + const { project } = params.data; + if (deps.skills === undefined || !knownProject(project)) return fail(reply, 404, 'not_found', 'no such project'); + return reply.send(deps.skills.get(project) ?? { project, content: '', updatedAt: null }); + }); + + app.post('/api/projects/:project/skill', (request, reply) => { + const params = ProjectParams.safeParse(request.params); + if (!params.success) return fail(reply, 400, 'invalid_request', issues(params.error)); + const body = SkillSave.safeParse(request.body); + if (!body.success) return fail(reply, 400, 'invalid_request', issues(body.error)); + const { project } = params.data; + if (deps.skills === undefined || !knownProject(project)) return fail(reply, 404, 'not_found', 'no such project'); + const skill = { project, content: body.data.content, updatedAt: clock.now().toISOString() }; + deps.skills.upsert(skill); + hub.notify(); + return reply.send(skill); + }); + app.post('/api/steps/:id/resolve', (request, reply) => { const params = IdParams.safeParse(request.params); if (!params.success) return fail(reply, 400, 'invalid_request', issues(params.error)); diff --git a/src/infra/sqlite/migrations.test.ts b/src/infra/sqlite/migrations.test.ts index 60af025..c14a576 100644 --- a/src/infra/sqlite/migrations.test.ts +++ b/src/infra/sqlite/migrations.test.ts @@ -24,6 +24,12 @@ describe('migrate', () => { ]); }); + it('v4 adds the skills table', () => { + const db = new DatabaseSync(':memory:'); + migrate(db); + expect(columns(db, 'skills')).toEqual(['project', 'content', 'updated_at']); + }); + it('v3 adds app_config.project: every app configured so far is its own project', () => { const db = new DatabaseSync(':memory:'); migrate(db, MIGRATIONS.slice(0, 2)); diff --git a/src/infra/sqlite/migrations.ts b/src/infra/sqlite/migrations.ts index ac75a56..18eedc0 100644 --- a/src/infra/sqlite/migrations.ts +++ b/src/infra/sqlite/migrations.ts @@ -76,6 +76,17 @@ export const MIGRATIONS: readonly Migration[] = [ UPDATE app_config SET project = app WHERE project = ''; `, }, + { + // Skills: facts about each app for the model (one per compose project), edited in the app. + version: 4, + sql: ` + CREATE TABLE skills ( + project TEXT PRIMARY KEY, + content TEXT NOT NULL, + updated_at TEXT NOT NULL + ) STRICT; + `, + }, ]; function schemaVersion(db: DatabaseSync): number { diff --git a/src/infra/sqlite/skill-store.test.ts b/src/infra/sqlite/skill-store.test.ts new file mode 100644 index 0000000..96f63f1 --- /dev/null +++ b/src/infra/sqlite/skill-store.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; +import { createTestDatabase } from '../../testing/test-db.ts'; + +describe('skill store', () => { + it('keeps one skill per project and replaces it on save', () => { + const { skills } = createTestDatabase(); + expect(skills.get('nextcloud')).toBeNull(); + skills.upsert({ project: 'nextcloud', content: '# Nextcloud\nMariaDB 10.6', updatedAt: '2026-09-19T20:00:00.000Z' }); + skills.upsert({ project: 'grafana', content: '# Grafana', updatedAt: '2026-09-19T20:00:00.000Z' }); + skills.upsert({ project: 'nextcloud', content: '# Nextcloud\nMariaDB 10.11', updatedAt: '2026-09-19T21:00:00.000Z' }); + expect(skills.get('nextcloud')).toEqual({ project: 'nextcloud', content: '# Nextcloud\nMariaDB 10.11', updatedAt: '2026-09-19T21:00:00.000Z' }); + expect(skills.list().map((skill) => skill.project)).toEqual(['grafana', 'nextcloud']); + }); +}); diff --git a/src/infra/sqlite/skill-store.ts b/src/infra/sqlite/skill-store.ts new file mode 100644 index 0000000..5ed42e7 --- /dev/null +++ b/src/infra/sqlite/skill-store.ts @@ -0,0 +1,28 @@ +import type { DatabaseSync } from 'node:sqlite'; +import { z } from 'zod'; +import type { SkillStore } from '../../domain/ports.ts'; +import type { Skill } from '../../domain/skill.ts'; + +const SkillRow = z + .object({ project: z.string(), content: z.string(), updated_at: z.string() }) + .transform((row): Skill => ({ project: row.project, content: row.content, updatedAt: row.updated_at })); + +export function createSqliteSkillStore(db: DatabaseSync): SkillStore { + return { + get(project) { + const row = db.prepare('SELECT * FROM skills WHERE project = :project').get({ project }); + return row === undefined ? null : SkillRow.parse(row); + }, + + list() { + return db.prepare('SELECT * FROM skills ORDER BY project').all().map((row) => SkillRow.parse(row)); + }, + + upsert(skill) { + db.prepare( + `INSERT INTO skills (project, content, updated_at) VALUES (:project, :content, :updatedAt) + ON CONFLICT (project) DO UPDATE SET content = excluded.content, updated_at = excluded.updated_at`, + ).run({ project: skill.project, content: skill.content, updatedAt: skill.updatedAt }); + }, + }; +} diff --git a/src/main/config.test.ts b/src/main/config.test.ts index bcdd3dc..334b6f7 100644 --- a/src/main/config.test.ts +++ b/src/main/config.test.ts @@ -11,6 +11,7 @@ describe('loadConfig', () => { driverIdleMs: 60_000, demo: false, appsFile: 'config/apps.json', + skillsDir: 'config/skills', build: { branch: null, number: null, revision: null }, executorUrl: null, hostBackupFile: '/host/vzdump.json', @@ -29,6 +30,7 @@ describe('loadConfig', () => { driverIdleMs: 1500, demo: true, appsFile: '/etc/apps.json', + skillsDir: 'config/skills', build: { branch: 'foundations', number: 6, revision: 'de5ee92' }, executorUrl: 'http://executor:8081', hostBackupFile: '/h/v.json', diff --git a/src/main/config.ts b/src/main/config.ts index 2f6f87c..b4f5f78 100644 --- a/src/main/config.ts +++ b/src/main/config.ts @@ -12,6 +12,8 @@ const Env = z.object({ DEMO: z.enum(['0', '1']).default('0'), /** The apps to load into app_config when it is empty (real mode). */ APPS_FILE: z.string().min(1).default('config/apps.json'), + /** Skills shipped with the image (config/skills/.md), added to the database when missing. */ + SKILLS_DIR: z.string().min(1).default('config/skills'), /** Build facts baked into the image by CI (Dockerfile build args); empty or missing = unknown. */ BUILD_BRANCH: z.string().optional(), BUILD_NUMBER: z.union([z.literal(''), z.coerce.number().int().positive()]).optional(), @@ -36,6 +38,7 @@ export interface Config { readonly driverIdleMs: number; readonly demo: boolean; readonly appsFile: string; + readonly skillsDir: string; readonly build: { readonly branch: string | null; readonly number: number | null; readonly revision: string | null }; readonly executorUrl: string | null; readonly hostBackupFile: string; @@ -57,6 +60,7 @@ export function loadConfig(env: Readonly>): C driverIdleMs: values.DRIVER_IDLE_MS, demo: values.DEMO === '1', appsFile: values.APPS_FILE, + skillsDir: values.SKILLS_DIR, build: { branch: orNull(values.BUILD_BRANCH), number: orNull(values.BUILD_NUMBER), revision: orNull(values.BUILD_REVISION) }, executorUrl: values.EXECUTOR_URL ?? null, hostBackupFile: values.HOST_BACKUP_FILE, diff --git a/src/main/driver.ts b/src/main/driver.ts index 2f15596..66311c9 100644 --- a/src/main/driver.ts +++ b/src/main/driver.ts @@ -12,10 +12,12 @@ import { createHttpRegistryClient } from '../infra/http-registry.ts'; import { createProcessRunner } from '../infra/process-runner.ts'; import { openDatabase } from '../infra/sqlite/open.ts'; import { createSqliteAppConfigStore } from '../infra/sqlite/app-config-store.ts'; +import { createSqliteSkillStore } from '../infra/sqlite/skill-store.ts'; import { createSqliteStepStore } from '../infra/sqlite/step-store.ts'; import { systemClock } from '../infra/system-clock.ts'; import { hasDemoData, missingApps, parseAppsFile } from './apps-file.ts'; import { loadConfig, secretValues } from './config.ts'; +import { missingSkills, readSkillFiles } from './skills-file.ts'; const logger = createConsoleLogger(); @@ -43,6 +45,10 @@ if (config.demo) { const added = missingApps(parseAppsFile(JSON.parse(readFileSync(config.appsFile, 'utf8')) as unknown), appConfig.list()); for (const app of added) appConfig.upsert(app); if (added.length > 0) logger.info('apps.added', { apps: added.map((app) => app.app) }); + const skills = createSqliteSkillStore(db); + const newSkills = missingSkills(readSkillFiles(config.skillsDir), skills.list(), systemClock.now().toISOString()); + for (const skill of newSkills) skills.upsert(skill); + if (newSkills.length > 0) logger.info('skills.added', { projects: newSkills.map((skill) => skill.project) }); } // Real mode: the read-only chain (status finds containers by their compose labels, reads no project file) and, with diff --git a/src/main/server.ts b/src/main/server.ts index 454b221..e274987 100644 --- a/src/main/server.ts +++ b/src/main/server.ts @@ -5,6 +5,7 @@ import { createChangeHub } from '../http/change-hub.ts'; import { buildServer } from '../http/server.ts'; import { openDatabase } from '../infra/sqlite/open.ts'; import { createSqliteAppConfigStore } from '../infra/sqlite/app-config-store.ts'; +import { createSqliteSkillStore } from '../infra/sqlite/skill-store.ts'; import { createSqliteStepStore } from '../infra/sqlite/step-store.ts'; import { systemClock } from '../infra/system-clock.ts'; import { loadConfig } from './config.ts'; @@ -23,6 +24,7 @@ const db = openDatabase(config.dbPath); const app = await buildServer({ store: createSqliteStepStore(db), appConfig: createSqliteAppConfigStore(db), + skills: createSqliteSkillStore(db), clock: systemClock, hub: createChangeHub(), staticDir: config.webDir, diff --git a/src/main/skills-file.test.ts b/src/main/skills-file.test.ts new file mode 100644 index 0000000..87a883e --- /dev/null +++ b/src/main/skills-file.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { missingSkills, readSkillFiles } from './skills-file.ts'; + +describe('skill files', () => { + it('the shipped skills are one per project and none is empty', () => { + const files = readSkillFiles(new URL('../../config/skills', import.meta.url).pathname); + expect(files.map((file) => file.project)).toContain('nextcloud'); + expect(files.length).toBeGreaterThanOrEqual(15); + for (const file of files) expect(file.content.trim(), file.project).not.toBe(''); + }); + + it('adds only the skills the database lacks', () => { + const files = [ + { project: 'grafana', content: '# new' }, + { project: 'nextcloud', content: '# shipped' }, + ]; + const existing = [{ project: 'nextcloud', content: '# edited by the owner', updatedAt: 'x' }]; + expect(missingSkills(files, existing, '2026-09-19T20:00:00.000Z')).toEqual([{ project: 'grafana', content: '# new', updatedAt: '2026-09-19T20:00:00.000Z' }]); + }); +}); diff --git a/src/main/skills-file.ts b/src/main/skills-file.ts new file mode 100644 index 0000000..70e7b02 --- /dev/null +++ b/src/main/skills-file.ts @@ -0,0 +1,26 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { parseProjectName } from '../domain/compose.ts'; +import type { Skill } from '../domain/skill.ts'; +import { MAX_SKILL_LENGTH } from '../domain/skill.ts'; + +// The skills we ship (config/skills/.md, imported once from the old system's SKILL.md files). The driver adds the +// ones the database lacks; a skill already in the database belongs to the owner (edited in the app) and is left as it is. + +export function readSkillFiles(dir: string): { project: string; content: string }[] { + return readdirSync(dir) + .filter((name) => name.endsWith('.md')) + .sort() + .flatMap((name) => { + const project = parseProjectName(name.slice(0, -'.md'.length)); + if (project === null) return []; + const content = readFileSync(join(dir, name), 'utf8'); + if (content.length > MAX_SKILL_LENGTH) throw new Error(`skill ${name} is longer than ${String(MAX_SKILL_LENGTH)} characters`); + return [{ project, content }]; + }); +} + +export function missingSkills(files: readonly { project: string; content: string }[], existing: readonly Skill[], now: string): Skill[] { + const known = new Set(existing.map((skill) => skill.project)); + return files.filter((file) => !known.has(file.project)).map((file) => ({ ...file, updatedAt: now })); +} diff --git a/src/testing/test-db.ts b/src/testing/test-db.ts index 4471acc..0ded474 100644 --- a/src/testing/test-db.ts +++ b/src/testing/test-db.ts @@ -1,17 +1,19 @@ import type { DatabaseSync } from 'node:sqlite'; -import type { AppConfigStore, StepStore } from '../domain/ports.ts'; +import type { AppConfigStore, SkillStore, StepStore } from '../domain/ports.ts'; import { createSqliteAppConfigStore } from '../infra/sqlite/app-config-store.ts'; import { openDatabase } from '../infra/sqlite/open.ts'; +import { createSqliteSkillStore } from '../infra/sqlite/skill-store.ts'; import { createSqliteStepStore } from '../infra/sqlite/step-store.ts'; export interface TestDatabase { readonly db: DatabaseSync; readonly store: StepStore; readonly appConfig: AppConfigStore; + readonly skills: SkillStore; } /** A fresh in-memory database with the real schema and the real stores. */ export function createTestDatabase(): TestDatabase { const db = openDatabase(':memory:'); - return { db, store: createSqliteStepStore(db), appConfig: createSqliteAppConfigStore(db) }; + return { db, store: createSqliteStepStore(db), appConfig: createSqliteAppConfigStore(db), skills: createSqliteSkillStore(db) }; } diff --git a/src/web/api.ts b/src/web/api.ts index 548d108..2c964d6 100644 --- a/src/web/api.ts +++ b/src/web/api.ts @@ -1,5 +1,5 @@ import type { z } from 'zod'; -import { AppConfigResponse, AppsResponse, BuildResponse, ErrorResponse, ResolveResponse, RunDetailResponse, RunsResponse, StartRunResponse, StepsResponse } from '../http/contract.ts'; +import { AppConfigResponse, AppsResponse, BuildResponse, ErrorResponse, ResolveResponse, RunDetailResponse, RunsResponse, SkillResponse, StartRunResponse, StepsResponse } from '../http/contract.ts'; import type { AppConfigPatch, ResolutionRequest } from '../http/contract.ts'; export class ApiError extends Error { @@ -32,6 +32,8 @@ export const api = { build: () => call(BuildResponse, '/api/build'), appConfig: (app: string) => call(AppConfigResponse, `/api/apps/${encodeURIComponent(app)}/config`), saveAppConfig: (app: string, patch: AppConfigPatch) => call(AppConfigResponse, `/api/apps/${encodeURIComponent(app)}/config`, patch), + skill: (project: string) => call(SkillResponse, `/api/projects/${encodeURIComponent(project)}/skill`), + saveSkill: (project: string, content: string) => call(SkillResponse, `/api/projects/${encodeURIComponent(project)}/skill`, { content }), appSteps: (app: string) => call(StepsResponse, `/api/steps?app=${encodeURIComponent(app)}&limit=100`), queue: () => call(StepsResponse, '/api/queue'), startRun: () => call(StartRunResponse, '/api/runs', { type: 'manual' }), diff --git a/src/web/app.test.tsx b/src/web/app.test.tsx index fa8dab5..2650493 100644 --- a/src/web/app.test.tsx +++ b/src/web/app.test.tsx @@ -52,7 +52,7 @@ beforeEach(async () => { seedDemoConfig(appConfig); const clock = new FakeClock(); hub = createChangeHub(); - server = await buildServer({ store, appConfig, clock, hub, pollChangesMs: 0, build: { branch: 'foundations', number: 6, revision: 'de5ee92a1b2c3d4e5f60718293a4b5c6d7e8f901' } }); + server = await buildServer({ store, appConfig, skills: database.skills, clock, hub, pollChangesMs: 0, build: { branch: 'foundations', number: 6, revision: 'de5ee92a1b2c3d4e5f60718293a4b5c6d7e8f901' } }); const driver = createDriver({ store, clock, notifier: new RecordingNotifier(), handlers: createDemoHandlers({ appConfig, world: createDemoWorld() }) }); startManualRun(store, clock); await driver.drain(new AbortController().signal); @@ -264,6 +264,25 @@ describe('app configuration', () => { }); }); +describe('skill', () => { + it('edits the skill of the app\'s project on the app page', async () => { + window.location.hash = '#/apps/bazarr'; + const form = await eventually(() => { + const found = container.querySelector('form.skill'); + if (found === null) throw new Error('no skill form yet'); + return found; + }); + expect(form.textContent).toContain('No skill yet'); + const area = form.querySelector('textarea')!; + area.value = '# Bazarr\nNo database.'; + area.dispatchEvent(new Event('input', { bubbles: true })); + await nextTask(); + button(form, 'Save skill').click(); + await eventually(() => expect(form.textContent).toContain('Saved')); + expect((await server.inject('/api/projects/bazarr/skill')).json()).toMatchObject({ content: '# Bazarr\nNo database.' }); + }); +}); + describe('other views', () => { it('lists runs with counts and opens a run with its steps', async () => { window.location.hash = '#/runs'; diff --git a/src/web/styles.css b/src/web/styles.css index 10dedc1..5021cf0 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -403,6 +403,12 @@ h2 { gap: 8px; } +.skill textarea { + width: 100%; + font-family: ui-monospace, monospace; + font-size: 13px; +} + .config input[type='text'] { width: 100%; } diff --git a/src/web/views/apps.tsx b/src/web/views/apps.tsx index 47cabc9..2bfe979 100644 --- a/src/web/views/apps.tsx +++ b/src/web/views/apps.tsx @@ -4,6 +4,7 @@ import { formatInstant } from '../format.ts'; import { useResource } from '../hooks.ts'; import { hrefFor } from '../route.ts'; import { AppConfigPanel } from './app-config.tsx'; +import { SkillPanel } from './skill.tsx'; export function AppsView() { const { data, error } = useResource(() => api.apps(), []); @@ -63,6 +64,7 @@ export function AppsView() { export function AppDetailView({ app }: { app: string }) { const { data, error } = useResource(() => api.appSteps(app), [app]); + const config = useResource(() => api.appConfig(app), [app]); return (

@@ -71,6 +73,8 @@ export function AppDetailView({ app }: { app: string }) {

{app}

Configuration

+

Skill

+ {config.data === null ? null : }

Steps

Step history, newest first.

{error === null ? null : } diff --git a/src/web/views/skill.tsx b/src/web/views/skill.tsx new file mode 100644 index 0000000..934aee4 --- /dev/null +++ b/src/web/views/skill.tsx @@ -0,0 +1,60 @@ +import { useState } from 'preact/hooks'; +import { api } from '../api.ts'; +import { ErrorNote, Loading } from '../components.tsx'; +import type { SkillView } from '../../http/contract.ts'; +import { useResource } from '../hooks.ts'; + +// The skill of the app's project: facts for the model when it repairs this app (only this project's skill is loaded). + +function SkillForm({ initial }: { initial: SkillView }) { + const [saved, setSaved] = useState(initial); + const [text, setText] = useState(initial.content); + const [pending, setPending] = useState(false); + const [message, setMessage] = useState<{ kind: 'ok' | 'error'; text: string } | null>(null); + + async function save(event: Event) { + event.preventDefault(); + setPending(true); + try { + const next = await api.saveSkill(saved.project, text); + setSaved(next); + setText(next.content); + setMessage({ kind: 'ok', text: 'Saved.' }); + } catch (cause) { + setMessage({ kind: 'error', text: cause instanceof Error ? cause.message : 'request failed' }); + } finally { + setPending(false); + } + } + + return ( +
void save(event)}> +

+ Facts about {saved.project} for the model when it repairs this app: release notes, requirements (e.g. database versions), + what must stop an upgrade, how to check it and how to go back. {saved.updatedAt === null ? 'No skill yet.' : null} +

+