skills: one per compose project, imported from the old SKILL.md files, edited in the app
Facts about each app for the model (release notes, requirements such as database versions, STOP cases, checks, the way back): table skills (migration v4), config/skills/<project>.md imported once from the old system (frontmatter dropped, no secrets), added to the database at driver start when missing and never overwritten, GET/POST /api/projects/:project/skill and a Skill panel on the app page. Only the skill of the project the model works on will be loaded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3eb1285287
commit
aab8d4704c
36 files changed
+756
-8
No files matched your search
@@ -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 <obecny tag>`. Auto: `patch`.
|
||||
Tylko tagi `v<wersja>-ls<n>`; 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 <backup_dir> --service bazarr`. Po migracji bazy: restore `/config` z nocnej kopii VM103.
|
||||
@@ -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 <obecny tag>`. Auto: `patch`.
|
||||
Tylko tagi `<wersja>-ls<n>`. Tagi `libtorrentv1-*` to inna gałąź libtorrent — pomijaj.
|
||||
Obecny tag `2.2.0-r1-ls361` (pakiet Alpine); nowsze `2.2.0-ls<n>` (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 <backup_dir> --service deluge`.
|
||||
@@ -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 <backup_dir> --service dockge`.
|
||||
@@ -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 <obecny tag>`. 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 <backup_dir> --service gitea`. Migracja DB nieudana → restore wolumenu z backup_dir albo nocny vzdump.
|
||||
@@ -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 <backup_dir> --service grafana`.
|
||||
@@ -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 <obecny tag>`.
|
||||
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 <nazwa>` 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 <backup_dir> --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.
|
||||
@@ -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: <project>-<service>-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 <obecny tag>`. 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 <backup_dir> --service homepage`.
|
||||
@@ -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 <obecny tag>`. 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 <backup_dir> --service jackett`.
|
||||
@@ -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 <obecny tag>`. 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 <backup_dir> --service jellyfin`.
|
||||
@@ -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 <obecny tag>`. 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 <tag>` — 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 <backup_dir> --service keycloak` (i analogicznie `db` przy migracji bazy).
|
||||
@@ -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:<wersja>-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 <obecny tag>`. 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.
|
||||
@@ -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 <obecny tag>`. Auto: `patch`.
|
||||
Tylko tagi `v<wersja>-ls<n>`; 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 <backup_dir> --service ombi`. Po migracji bazy: restore `/config` z nocnej kopii VM103.
|
||||
@@ -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 <obecny tag>`. Auto: `minor`.
|
||||
Skok wprost na najnowszy tag `v<wersja>-ls<n>`. 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 <backup_dir> --service orcaslicer`.
|
||||
@@ -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 <obecny tag>`. 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 <backup_dir> --service radarr`. Zmieniony: restore danych.
|
||||
@@ -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 <obecny tag>`. 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 <backup_dir> --service sonarr`. Zmieniony: restore danych.
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<typeof AppConfigResponse>;
|
||||
|
||||
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<typeof SkillResponse>;
|
||||
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);
|
||||
|
||||
+20
-2
@@ -25,11 +25,11 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
async function fixture(overrides: Partial<ServerDeps> = {}, 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);
|
||||
});
|
||||
});
|
||||
+29
-2
@@ -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<FastifyInstance> {
|
||||
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));
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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/<project>.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<Record<string, string | undefined>>): 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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' }]);
|
||||
});
|
||||
});
|
||||
@@ -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/<project>.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 }));
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
+3
-1
@@ -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' }),
|
||||
|
||||
+20
-1
@@ -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<HTMLFormElement>('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<HTMLTextAreaElement>('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';
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<section>
|
||||
<p class="crumbs">
|
||||
@@ -71,6 +73,8 @@ export function AppDetailView({ app }: { app: string }) {
|
||||
<h1>{app}</h1>
|
||||
<h2>Configuration</h2>
|
||||
<AppConfigPanel app={app} />
|
||||
<h2>Skill</h2>
|
||||
{config.data === null ? null : <SkillPanel project={config.data.project} />}
|
||||
<h2>Steps</h2>
|
||||
<p class="muted">Step history, newest first.</p>
|
||||
{error === null ? null : <ErrorNote message={error} />}
|
||||
|
||||
@@ -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 (
|
||||
<form class="skill card" onSubmit={(event) => void save(event)}>
|
||||
<p class="muted">
|
||||
Facts about <code>{saved.project}</code> 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}
|
||||
</p>
|
||||
<textarea
|
||||
name="skill"
|
||||
rows={16}
|
||||
value={text}
|
||||
disabled={pending}
|
||||
onInput={(event) => {
|
||||
setText(event.currentTarget.value);
|
||||
setMessage(null);
|
||||
}}
|
||||
/>
|
||||
<div class="actions">
|
||||
<button type="submit" class="primary" disabled={pending || text === saved.content}>
|
||||
Save skill
|
||||
</button>
|
||||
</div>
|
||||
{message === null ? null : message.kind === 'ok' ? <p class="saved">{message.text}</p> : <ErrorNote message={message.text} />}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkillPanel({ project }: { project: string }) {
|
||||
const { data, error } = useResource(() => api.skill(project), [project]);
|
||||
if (data === null) return error === null ? <Loading /> : <ErrorNote message={error} />;
|
||||
return <SkillForm key={project} initial={data} />;
|
||||
}
|
||||
Reference in new issue
Block a user