skaldsong: scaffold compose stack + deploy playbook for ana-docker
Registry-pull pattern matching Worldtree: CI on vh/skaldsong builds and
pushes gitea.phasefinal.com/vh/skaldsong:<sha>, this playbook pulls +
recreates. SHA-pin only per current preference; no :latest moving-tag
advance yet (revisit once /health exercises Worldtree + Kokoro reach).
Host port 8300 (host) → 8000 (container). Persistent state under
/opt/docker/conf/skaldsong/{db,runs}.
Bifrost endpoint URL 10.250.50.70:8300 will need a paired
BIFROST_CLIENT_ALLOWED_HOSTS update on corviduo-dev Worldtree at first
deploy.
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
# Deploy skaldsong (https://gitea.phasefinal.com/vh/skaldsong) to a
|
||||
# Docker host following the PFI /opt/docker/ convention (ana-docker by
|
||||
# default, but the playbook works against any host with Docker in place).
|
||||
#
|
||||
# Registry-pull pattern, NOT build-on-host: vh/skaldsong's CI builds and
|
||||
# pushes `gitea.phasefinal.com/vh/skaldsong:<sha>` (+ :latest) to the
|
||||
# gitea registry; this playbook just pulls + recreates the container.
|
||||
# Differs from althing-chamber / asset-engine playbooks which build the
|
||||
# image on-host from a git clone — those are local-build stacks.
|
||||
#
|
||||
# Idempotent: rerunning is safe. Creates-gates skip work that's already
|
||||
# done; `docker compose up -d` is itself idempotent (no restart unless
|
||||
# compose content, env, or image SHA changed).
|
||||
#
|
||||
# Usage:
|
||||
# # CI passes the triggering commit SHA via --var ref=${{ github.sha }}:
|
||||
# scripts/elway ana-docker --playbook playbooks/deploy-skaldsong.yaml \
|
||||
# --var ref=<sha>
|
||||
#
|
||||
# # Manual runs can pass a branch or tag (will resolve to the registry
|
||||
# # tag of that name — CI publishes :main and :v0.1.0 tags alongside
|
||||
# # the SHA-pin):
|
||||
# scripts/elway ana-docker --playbook playbooks/deploy-skaldsong.yaml \
|
||||
# --var ref=main
|
||||
#
|
||||
# Prereqs on the target host:
|
||||
# - Docker + docker compose plugin
|
||||
# - Target user (lkraven) is in the `docker` group
|
||||
# - `docker login gitea.phasefinal.com` has been done at least once
|
||||
# (credentials persist in /home/lkraven/.docker/config.json). CI's
|
||||
# workflow runs its own `docker login` step from REGISTRY_USER /
|
||||
# REGISTRY_TOKEN secrets; manual runs assume the host's auth is
|
||||
# already established.
|
||||
|
||||
vars:
|
||||
registry_image: gitea.phasefinal.com/vh/skaldsong
|
||||
ref: latest
|
||||
compose_dir: /opt/docker/compose/skaldsong
|
||||
db_dir: /opt/docker/conf/skaldsong/db
|
||||
runs_dir: /opt/docker/conf/skaldsong/runs
|
||||
host_port: "8300"
|
||||
|
||||
steps:
|
||||
# ── compose + state dirs ────────────────────────────────────────────
|
||||
- name: Ensure compose dir exists
|
||||
shell: mkdir -p {{ compose_dir }}
|
||||
creates: "{{ compose_dir }}"
|
||||
|
||||
- name: Ensure DB dir exists
|
||||
# Created as lkraven (uid 1000 on these hosts), matching the
|
||||
# container's app user — no chown dance needed.
|
||||
shell: mkdir -p {{ db_dir }}
|
||||
creates: "{{ db_dir }}"
|
||||
|
||||
- name: Ensure runs dir exists
|
||||
# Separate from db_dir so runs/ can later move to a bigger volume
|
||||
# without touching DB state.
|
||||
shell: mkdir -p {{ runs_dir }}
|
||||
creates: "{{ runs_dir }}"
|
||||
|
||||
# ── deploy compose files ────────────────────────────────────────────
|
||||
- name: Upload compose.yaml
|
||||
upload:
|
||||
src: stacks/skaldsong/compose.yaml
|
||||
dest: "{{ compose_dir }}/compose.yaml"
|
||||
mode: "0644"
|
||||
|
||||
- name: Seed .env from template (only if absent)
|
||||
upload:
|
||||
src: stacks/skaldsong/.env.example
|
||||
dest: "{{ compose_dir }}/.env"
|
||||
mode: "0644"
|
||||
when: "[ ! -f {{ compose_dir }}/.env ]"
|
||||
|
||||
# ── pin SKALDSONG_IMAGE to the requested ref ────────────────────────
|
||||
- name: Set SKALDSONG_IMAGE pin to {{ ref }}
|
||||
# Idempotent: strip any existing SKALDSONG_IMAGE= line and append
|
||||
# the new one. CI passes --var ref=<sha>; manual runs pass branch /
|
||||
# tag names (CI publishes those as registry tags too).
|
||||
shell: |
|
||||
sed -i '/^SKALDSONG_IMAGE=/d' {{ compose_dir }}/.env
|
||||
echo "SKALDSONG_IMAGE={{ registry_image }}:{{ ref }}" >> {{ compose_dir }}/.env
|
||||
|
||||
# ── pull + bring up ─────────────────────────────────────────────────
|
||||
- name: Pull image {{ registry_image }}:{{ ref }}
|
||||
# Explicit pull step (rather than relying on compose up's pull) so
|
||||
# any auth failure surfaces here, not buried in compose output.
|
||||
shell: docker pull {{ registry_image }}:{{ ref }}
|
||||
|
||||
- name: docker compose up -d --force-recreate
|
||||
# --force-recreate picks up env changes even if the image SHA is
|
||||
# identical to what's running (rare but happens on env-only deploys).
|
||||
# --pull never: we just pulled explicitly above, so no need for
|
||||
# compose to re-check.
|
||||
shell: cd {{ compose_dir }} && docker compose up -d --force-recreate --pull never
|
||||
|
||||
- name: Wait for skaldsong /health to respond
|
||||
# Container's healthcheck is internal; this host-side poll confirms
|
||||
# the published port is reachable + the FastAPI app finished booting.
|
||||
# Generous retry budget — first-deploy bootstrapping (DB migrations,
|
||||
# SPA asset indexing) can take 30s+.
|
||||
shell: |
|
||||
for i in $(seq 1 30); do
|
||||
curl -sf -o /dev/null http://localhost:{{ host_port }}/health && exit 0
|
||||
sleep 2
|
||||
done
|
||||
exit 1
|
||||
changed_when: "false"
|
||||
|
||||
verify:
|
||||
- name: skaldsong /health returns 200
|
||||
shell: curl -sf -o /dev/null http://localhost:{{ host_port }}/health
|
||||
changed_when: "false"
|
||||
|
||||
- name: skaldsong container running + healthy
|
||||
shell: docker ps --filter name=^/skaldsong$ --format '{{.Status}}' | grep -q 'healthy'
|
||||
changed_when: "false"
|
||||
@@ -0,0 +1,53 @@
|
||||
# skaldsong stack tunables. Copy to `.env` on ana-docker before deploying.
|
||||
#
|
||||
# The deploy playbook seeds `.env` from this template on first run only —
|
||||
# it won't clobber an existing `.env`.
|
||||
|
||||
# Image tag. CI's deploy.yaml pushes this SHA-pinned per commit:
|
||||
# gitea.phasefinal.com/vh/skaldsong:<sha>
|
||||
# Workflow passes the SHA via `scripts/elway ... --var ref=<sha>`; the
|
||||
# playbook substitutes that into this var before `docker compose up -d`.
|
||||
# For manual runs, set to a known good SHA (e.g. after a green CI run):
|
||||
SKALDSONG_IMAGE=gitea.phasefinal.com/vh/skaldsong:latest
|
||||
|
||||
# Host port exposing the wizard UI + Bifrost endpoint. Container always
|
||||
# listens on 8000 internally. 8300 is the canonical slot — adjacent to
|
||||
# the 8xxx web-app block (asset-engine:8200, sillytavern:8100, beszel:8090).
|
||||
SKALDSONG_PORT=8300
|
||||
|
||||
# Bind address for the host port. 0.0.0.0 = LAN-reachable.
|
||||
SKALDSONG_BIND=0.0.0.0
|
||||
|
||||
# Host paths for persistent state. db/ holds skaldsong-ui.db (SQLite,
|
||||
# wizard state + story rows); runs/ holds per-story generation pipeline
|
||||
# artifacts (spec, state, output, logs, manuscripts). Created with uid
|
||||
# 1000 (matches container's app user — no chown dance needed).
|
||||
SKALDSONG_DB_DIR=/opt/docker/conf/skaldsong/db
|
||||
SKALDSONG_RUNS_DIR_HOST=/opt/docker/conf/skaldsong/runs
|
||||
|
||||
# Worldtree integration ─────────────────────────────────────────────────
|
||||
|
||||
# Shared-secret Bearer for Worldtree's HS256 Bifrost JWT path. Same
|
||||
# value as Worldtree's WORLDTREE_SKALDSONG_USER_KEY env. Get from
|
||||
# worldtree-dev when standing up a fresh deploy; rotate via worldtree-dev's
|
||||
# /heimdall:rotate flow if compromise suspected.
|
||||
SKALDSONG_BIFROST_JWT_KEY=
|
||||
|
||||
# URL Worldtree uses to call BACK to skaldsong's Bifrost endpoint at
|
||||
# tool-dispatch time. Must match Worldtree's BIFROST_CLIENT_ALLOWED_HOSTS
|
||||
# entry. From ana-docker LAN: http://10.250.50.70:8300 (this port).
|
||||
# Coordinated update with worldtree-dev required if host:port changes.
|
||||
SKALDSONG_HOST_BIFROST_ENDPOINT_URL=http://10.250.50.70:8300
|
||||
|
||||
# URL skaldsong uses to call Worldtree's conversation API. From
|
||||
# ana-docker, corviduo-dev's IP:port LAN-direct.
|
||||
SKALDSONG_WORLDTREE_API_URL=http://10.250.50.152:8080
|
||||
|
||||
# CORS — comma-separated origins. Include the SPA's public hostname AND
|
||||
# any dev origins still in rotation.
|
||||
SKALDSONG_HOST_CORS_ORIGINS=http://10.250.50.70:8300
|
||||
|
||||
# TTS — Kokoro on irv-ml1, reached over WireGuard via ana-wg. Same URL
|
||||
# works fleet-wide; the WG tunnel handles the cross-site routing.
|
||||
SKALDSONG_TTS_ENGINE=kokoro
|
||||
SKALDSONG_TTS_BASE_URL=http://10.100.79.3:8193
|
||||
@@ -0,0 +1,122 @@
|
||||
# skaldsong
|
||||
|
||||
Wizard + reader surface for multi-agent storytelling. Single FastAPI
|
||||
process serves both the SvelteKit-built SPA (static, baked into the
|
||||
image) and the wizard backend. Talks to Worldtree as a Bifrost
|
||||
consumer — Worldtree issues per-dispatch JWTs (post-v0.3) that
|
||||
skaldsong's `/bifrost` endpoint verifies against the shared HS256
|
||||
secret.
|
||||
|
||||
**Server:** ana-docker
|
||||
**URL:** `http://10.250.50.70:8300` (configurable via `.env`)
|
||||
**Upstream repo:** [vh/skaldsong](https://gitea.phasefinal.com/vh/skaldsong)
|
||||
**Image:** `gitea.phasefinal.com/vh/skaldsong:<sha>` — built + pushed
|
||||
by vh/skaldsong's CI on every push to `main` and on tag.
|
||||
|
||||
## Path layout (on ana-docker)
|
||||
|
||||
| Host path | Container path | Purpose | Restic? |
|
||||
|---|---|---|---|
|
||||
| `/opt/docker/compose/skaldsong/` | — | compose.yaml + .env | included (via `/opt/docker`) |
|
||||
| `/opt/docker/conf/skaldsong/db/` | `/app/state/db` | SQLite (`skaldsong-ui.db` + WAL/SHM) — wizard state, story rows | **included** |
|
||||
| `/opt/docker/conf/skaldsong/runs/` | `/app/state/runs` | Per-story generation pipeline artifacts (spec, state, output, logs, manuscripts) | **included** |
|
||||
|
||||
## Network model
|
||||
|
||||
Internal tooling, LAN-only. Container's :8000 published on host
|
||||
:8300 (configurable via `SKALDSONG_BIND` / `SKALDSONG_PORT`); access
|
||||
direct at `http://10.250.50.70:8300`. No Traefik, no TLS terminator,
|
||||
no public hostname.
|
||||
|
||||
Skaldsong talks to two services on the LAN:
|
||||
|
||||
- **Worldtree** at `http://10.250.50.152:8080` (corviduo-dev,
|
||||
LAN-direct from ana-docker — same subnet).
|
||||
- **Kokoro TTS** at `http://10.100.79.3:8193` (irv-ml1, reached via
|
||||
the WireGuard tunnel on ana-wg — same path asset-engine uses).
|
||||
|
||||
Worldtree calls back to skaldsong's `/bifrost` endpoint at
|
||||
`http://10.250.50.70:8300` for tool-dispatch — that URL must be in
|
||||
Worldtree's `BIFROST_CLIENT_ALLOWED_HOSTS`. Host:port changes
|
||||
require a coordinated allowlist update on Worldtree-side (see
|
||||
[corviduo-dev README](../../servers/corviduo-dev/README.md) for
|
||||
the docker-as-root edit pattern).
|
||||
|
||||
## Deploy
|
||||
|
||||
Two paths — automated (preferred) and manual (escape hatch).
|
||||
|
||||
### Automated (Gitea Actions, push-to-main)
|
||||
|
||||
vh/skaldsong ships `.gitea/workflows/deploy.yaml`. Push to main +
|
||||
manual `workflow_dispatch` triggers:
|
||||
|
||||
1. Build + push `gitea.phasefinal.com/vh/skaldsong:${{ github.sha }}`
|
||||
and `:latest` to the gitea registry.
|
||||
2. Checkout this management repo using `MGMT_REPO_TOKEN`.
|
||||
3. Run `scripts/elway ana-docker --playbook
|
||||
playbooks/deploy-skaldsong.yaml --var ref=${{ github.sha }}` via
|
||||
`DEPLOY_SSH_KEY`.
|
||||
|
||||
Three secrets required on vh/skaldsong's Actions settings:
|
||||
- `DEPLOY_SSH_KEY` — private key authorized on ana-docker for lkraven.
|
||||
- `MGMT_REPO_TOKEN` — Gitea PAT with `read:repository` on this repo.
|
||||
- `REGISTRY_USER` + `REGISTRY_TOKEN` — for the docker login step
|
||||
(token needs `write:package` scope).
|
||||
|
||||
### Manual (elway from a workstation)
|
||||
|
||||
```bash
|
||||
# Deploy a specific SHA (CI just pushed it):
|
||||
scripts/elway ana-docker --playbook playbooks/deploy-skaldsong.yaml \
|
||||
--var ref=<sha>
|
||||
|
||||
# Or pin to a release tag:
|
||||
scripts/elway ana-docker --playbook playbooks/deploy-skaldsong.yaml \
|
||||
--var ref=v0.1.0
|
||||
```
|
||||
|
||||
## Env-var contract
|
||||
|
||||
| Var | In compose? | In `.env`? | Notes |
|
||||
|---|---|---|---|
|
||||
| `SKALDSONG_IMAGE` | ✓ (image:) | ✓ | Full image URI; CI passes SHA-pin via elway `--var ref=<sha>` → playbook → this var. |
|
||||
| `SKALDSONG_PORT` | ✓ | ✓ | Host-side port (8300 default). Internal always 8000. |
|
||||
| `SKALDSONG_BIND` | ✓ | ✓ | Host bind address (0.0.0.0 default). |
|
||||
| `SKALDSONG_DB_DIR` | ✓ (mount) | ✓ | Host path for SQLite. |
|
||||
| `SKALDSONG_RUNS_DIR_HOST` | ✓ (mount) | ✓ | Host path for generation artifacts. |
|
||||
| `SKALDSONG_BIFROST_JWT_KEY` | ✓ (anchor) | ✓ | Shared HS256 secret w/ Worldtree's `WORLDTREE_SKALDSONG_USER_KEY`. |
|
||||
| `SKALDSONG_HOST_BIFROST_ENDPOINT_URL` | ✓ (anchor) | ✓ | URL Worldtree calls back to (must match allowlist). |
|
||||
| `SKALDSONG_WORLDTREE_API_URL` | ✓ (anchor) | ✓ | URL skaldsong calls Worldtree at. |
|
||||
| `SKALDSONG_HOST_CORS_ORIGINS` | ✓ (anchor) | ✓ | Comma-separated CORS origins. |
|
||||
| `SKALDSONG_TTS_ENGINE` | ✓ (anchor) | ✓ | `kokoro` (only engine in v1). |
|
||||
| `SKALDSONG_TTS_BASE_URL` | ✓ (anchor) | ✓ | Kokoro URL (WG-routed). |
|
||||
|
||||
`&skaldsong-env` anchor declares all `SKALDSONG_*` vars via `${VAR:-}`
|
||||
substitution. Same env-anchor pattern as Worldtree's `&worldtree-env`
|
||||
(see [Worldtree #175 heuristic](https://gitea.phasefinal.com/vh/worldtree/issues/175));
|
||||
new var additions must land in BOTH the `.env` AND the anchor.
|
||||
|
||||
## Restart semantics
|
||||
|
||||
Per skaldsong's `INV-006` (host CLAUDE.md): process restart marks any
|
||||
`running` generation rows `partial` and never auto-resumes. Deploy
|
||||
mid-run is safe — the SPA's reader treats partials like a power
|
||||
outage. Cancellation of in-flight runs on deploy is acceptable for
|
||||
v1.
|
||||
|
||||
`docker compose up -d --force-recreate --pull always` is the
|
||||
playbook's apply step (or `--pull never` when called with a specific
|
||||
`--var ref=<sha>` where the image was pre-pulled). Same kill-signal +
|
||||
graceful-shutdown pattern as Worldtree.
|
||||
|
||||
## Worldtree allowlist coordination
|
||||
|
||||
The `SKALDSONG_HOST_BIFROST_ENDPOINT_URL` value MUST be in Worldtree's
|
||||
`BIFROST_CLIENT_ALLOWED_HOSTS` env var on corviduo-dev. As of
|
||||
deploy-time the value is `10.250.50.70:8300`.
|
||||
|
||||
If you change `SKALDSONG_PORT` or move skaldsong off ana-docker, the
|
||||
allowlist needs a paired update on corviduo-dev. Reference: the
|
||||
edit-via-docker-as-root pattern in
|
||||
[servers/corviduo-dev/README.md](../../servers/corviduo-dev/README.md#permissions-model--the-docker-as-root-pattern).
|
||||
@@ -0,0 +1,92 @@
|
||||
# skaldsong stack — single FastAPI + SPA container.
|
||||
#
|
||||
# Skaldsong is the wizard + reader surface that talks to Worldtree as a
|
||||
# Bifrost consumer. One process serves both the SvelteKit-style SPA
|
||||
# (static, baked into the image) and the wizard backend (FastAPI),
|
||||
# plus the Bifrost endpoint Worldtree calls back to.
|
||||
#
|
||||
# Image is built + pushed by vh/skaldsong's `.gitea/workflows/deploy.yaml`
|
||||
# to gitea.phasefinal.com/vh/skaldsong, then pulled here. Workflow
|
||||
# passes the triggering commit SHA via `--var ref=...` to elway, which
|
||||
# substitutes it into ${SKALDSONG_IMAGE} for this compose. SHA-pinned
|
||||
# only by current preference — no :latest moving-tag advance (re-evaluate
|
||||
# once the /health endpoint exercises Worldtree + Kokoro reachability).
|
||||
#
|
||||
# Internal tooling — accessed directly on host:port over the LAN, does
|
||||
# NOT traverse Traefik. State persists under /opt/docker/conf/skaldsong/
|
||||
# {db,runs} on the host; db/ holds skaldsong-ui.db (SQLite, wizard state
|
||||
# + story rows), runs/ holds per-story generation pipeline artifacts
|
||||
# (spec, state, output, logs, manuscripts).
|
||||
#
|
||||
# Per skaldsong's INV-006: process restart marks any 'running' generation
|
||||
# rows 'partial' and never auto-resumes — deploy mid-run is safe; in-flight
|
||||
# work looks to the SPA like a power outage.
|
||||
#
|
||||
# All tunables live in .env — edit that, not this file.
|
||||
|
||||
services:
|
||||
skaldsong-host:
|
||||
image: ${SKALDSONG_IMAGE}
|
||||
container_name: skaldsong
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${SKALDSONG_BIND:-0.0.0.0}:${SKALDSONG_PORT}:8000"
|
||||
environment: &skaldsong-env
|
||||
# App always listens on 8000 internally; host port is the only knob.
|
||||
|
||||
# Worldtree integration — JWT key is the shared-secret Bearer per
|
||||
# Worldtree's HS256 path (same value as Worldtree's own
|
||||
# WORLDTREE_SKALDSONG_USER_KEY). NEVER commit; .env on the server
|
||||
# holds the real value.
|
||||
SKALDSONG_BIFROST_JWT_KEY: "${SKALDSONG_BIFROST_JWT_KEY:-}"
|
||||
|
||||
# URL Worldtree uses to call BACK to skaldsong's Bifrost endpoint
|
||||
# at tool-dispatch time. This is skaldsong's host-side reachable
|
||||
# URL from corviduo-dev's perspective — must match Worldtree's
|
||||
# BIFROST_CLIENT_ALLOWED_HOSTS entry. Coordinated allowlist update
|
||||
# required if host:port changes.
|
||||
SKALDSONG_HOST_BIFROST_ENDPOINT_URL: "${SKALDSONG_HOST_BIFROST_ENDPOINT_URL:-}"
|
||||
|
||||
# URL skaldsong uses to call Worldtree's conversation API. From
|
||||
# ana-docker, this is corviduo-dev's IP:port LAN-direct.
|
||||
SKALDSONG_WORLDTREE_API_URL: "${SKALDSONG_WORLDTREE_API_URL:-http://10.250.50.152:8080}"
|
||||
|
||||
# CORS for the SPA. Comma-separated origins; include both the
|
||||
# public hostname and any dev origins still in rotation.
|
||||
SKALDSONG_HOST_CORS_ORIGINS: "${SKALDSONG_HOST_CORS_ORIGINS:-}"
|
||||
|
||||
# TTS — Kokoro on irv-ml1, reached over WireGuard via ana-wg from
|
||||
# ana-docker. Same URL works fleet-wide.
|
||||
SKALDSONG_TTS_ENGINE: "${SKALDSONG_TTS_ENGINE:-kokoro}"
|
||||
SKALDSONG_TTS_BASE_URL: "${SKALDSONG_TTS_BASE_URL:-http://10.100.79.3:8193}"
|
||||
|
||||
# SPA static assets — baked into the image at /app/web/dist by the
|
||||
# Dockerfile's web-builder stage.
|
||||
SKALDSONG_HOST_STATIC_ASSETS_PATH: /app/web/dist
|
||||
|
||||
# Persistent state paths inside the container. The db/ parent
|
||||
# dir is the bind-mount target (not the file itself) so SQLite
|
||||
# can write its WAL + SHM siblings.
|
||||
SKALDSONG_DB_PATH: /app/state/db/skaldsong-ui.db
|
||||
SKALDSONG_RUNS_DIR: /app/state/runs
|
||||
volumes:
|
||||
# db/ and runs/ are separate bind-mounts so runs/ can later move to
|
||||
# a bigger volume without touching DB state.
|
||||
- ${SKALDSONG_DB_DIR}:/app/state/db
|
||||
- ${SKALDSONG_RUNS_DIR_HOST}:/app/state/runs
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "python -c 'import urllib.request,sys; r=urllib.request.urlopen(\"http://127.0.0.1:8000/health\",timeout=3); sys.exit(0 if r.status==200 else 1)' || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
labels:
|
||||
- homepage.group=AI Systems
|
||||
- homepage.name=Skaldsong
|
||||
- homepage.icon=mdi-book-edit-outline
|
||||
- homepage.description=Wizard + reader surface for multi-agent storytelling (Bifrost consumer of Worldtree)
|
||||
- homepage.href=http://10.250.50.70:${SKALDSONG_PORT}
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: skaldsong_default
|
||||
Reference in New Issue
Block a user