Skip to content

Latest commit

 

History

History
304 lines (235 loc) · 8.38 KB

creating-stacklets.md

File metadata and controls

304 lines (235 loc) · 8.38 KB

Creating a Stacklet

A stacklet is a self-contained service unit. It's a directory with a manifest, a Docker Compose file, and optional hooks.

Minimal stacklet

stacklets/myapp/
  stacklet.toml          # manifest — required
  docker-compose.yml     # Docker services — required for Docker stacklets

That's it. Two files and stack up myapp works.

Where it lives

stacklets/ holds the stacklets famstack ships. Everything else goes in ~/famstack-extensions/, or wherever [core] extension_dirs points:

~/famstack-extensions/myapp/
  stacklet.toml
  docker-compose.yml

Nothing creates that directory. Make it when you have something to put in it, then stack up myapp.

Discovery, ids, hooks, CLI plugins, secrets and template variables are the same code path in both places, so stack up myapp does not care which you picked. Being outside the repo means an upgrade cannot take your stacklet with it, git status never mentions it, and it can be its own git repo. Nothing famstack does backs it up, though: it is source code, so push it somewhere or include it in your own backup.

extension_dirs is a list, so you can keep a dev checkout beside the default directory. It is searched in order and the first tree to claim an id keeps it, so the repo wins a clash with an extension and an earlier directory wins a clash with a later one.

A stacklet here can ship a bot, because the bot runner mounts the first extension directory next to the repo one. It mounts only that one, so a bot in a later directory is not discovered and stack up tells you.

Saying it isn't finished

stage = "incubating"     # or "beta"

stack list marks the stacklet and every stack up warns that it can change or disappear and is not for production. Delete the line (or set "stable") when that stops being true.

The manifest: stacklet.toml

id          = "myapp"
name        = "My App"
description = "What it does in one line"
version     = "0.1.0"
category    = "productivity"      # productivity, communication, ai, etc.
port        = 42070               # primary web UI port

hints = [
    "Open {url} in your browser",
    "Log in as {admin_username} / {admin_password}",
]

Template variables

Values in {braces} are resolved from stack.toml and runtime state:

Variable Source
{language} [core].language (falls back to [ai].language, then en)
{timezone} [core].timezone
{data_dir} [core].data_dir
{domain} [core].domain
{ip} LAN IP of the host
{url} Public URL for this stacklet
{admin_username} Tech admin username (stackadmin)
{admin_email} Tech admin email (stackadmin@home.local)
{admin_password} Tech admin password (generated, in secrets)
{myapp_url} URL of any stacklet by ID
{myapp_synapse_url} Named port URL ([ports].synapse)

Environment variables

[env]
generate = ["DB_PASSWORD", "SECRET_KEY"]   # auto-generated, stored in secrets

[env.defaults]
DATA_DIR       = "{data_dir}/myapp"
TZ             = "{timezone}"
ADMIN_USER     = "{admin_email}"
ADMIN_PASSWORD = "{admin_password}"

generate keys are random 32-char strings, created once, stable across restarts. defaults are rendered from template variables every time stack up runs.

Health checks

# Simple — single URL
[health]
url = "http://localhost:42070/api/health"

# Multiple with failure hints
[[health.checks]]
url  = "http://localhost:42070/"
hint = "Web UI not responding — check 'docker logs stack-myapp'"

[[health.checks]]
url  = "http://localhost:42071/api"
hint = "API not responding"

# With auth headers (template vars supported)
[[health.checks]]
url  = "{some_url}/status"
hint = "Backend not reachable"
[health.checks.headers]
Authorization = "Bearer {some_key}"

Health checks run during stack list to determine online/degraded state.

Dependencies

requires = ["messages"]   # must be set up before this stacklet

stack up myapp will refuse if dependencies aren't set up, with a helpful error message.

Docker Compose

Follow these conventions:

name: stack-myapp                    # project name: stack-{id}

services:
  stack-myapp:                       # container name: stack-{id}
    container_name: stack-myapp
    image: someimage:1.4             # minor line, else the exact version
    labels:
      - "com.centurylinklabs.watchtower.enable=${WATCHTOWER_ENABLE:-true}"
    networks:
      - stack                     # shared network
    ports:
      - "${PORT_BIND_IP:-0.0.0.0}:42070:8080"   # use PORT_BIND_IP
    volumes:
      - ${DATA_DIR}:/data            # use env vars from [env.defaults]
    env_file: .env                   # framework writes this
    restart: unless-stopped

networks:
  stack:
    external: true

Key points:

  • Project name: stack-{id} — framework uses this to track container state
  • Network: stack (external) — all stacklets share it, can talk to each other
  • Ports: Use PORT_BIND_IP so port binding works in both dev and production
  • env_file: .env — the framework renders this from your [env.defaults]
  • Volumes: Use env vars, not hardcoded paths

Hooks

Optional lifecycle scripts in a hooks/ directory:

stacklets/myapp/
  hooks/
    on_configure.py       # first-run config (interactive prompts)
    on_install.sh         # first-run setup (install dependencies)
    on_start.py           # every stack up, before compose (config validation)
    on_install_success.py # after first successful health check
    on_start_ready.py     # every stack up, after health checks (seed data, sync accounts)
    on_stop.sh            # every stack down
    on_destroy.sh         # full teardown

Python hooks (preferred) get a ctx object:

def run(ctx):
    ctx.step("Creating admin account")      # report progress
    ctx.shell("some-command")               # run shell command
    ctx.secret("API_KEY")                   # read a secret
    ctx.secret("API_KEY", "value")          # write a secret
    ctx.stack.run_cli_command("ai", "hello") # call other stacklets

    env = ctx.env                           # rendered env vars
    stack = ctx.stack                       # full Stack instance

Shell hooks (.sh fallback) get env vars and stream output directly.

For interactive prompts in hooks, use the shared TUI primitives:

from stack.prompt import section, out, nl, ask, confirm, done

def run(ctx):
    section("My App", "Configure your widget")
    name = ask("Widget name")
    if not confirm(f"Use '{name}'?"):
        raise RuntimeError("Aborted")
    done(f"Widget configured: {name}")

CLI plugins

Add commands under cli/:

stacklets/myapp/
  cli/
    status.py     # stack myapp status
    migrate.py    # stack myapp migrate

Each plugin defines a run(args, stacklet, config) function:

HELP = "Show widget status"

def run(args, stacklet, config):
    data_dir = config["data_dir"]
    # ... do work
    return {"status": "ok"}

Port allocation

Famstack uses the 42000-42099 range:

Port Stacklet
42010 photos
42020 docs
42030-42031 messages (element, synapse)
42040 code
42050 chatai
42060-42063 ai (omlx, lmstudio, whisper, speech)

Pick an unused port in this range.

Example: the Code stacklet

The simplest real stacklet — Forgejo with SQLite, no hooks, no secrets:

stacklets/code/stacklet.toml

id          = "code"
name        = "Code"
description = "Self-hosted Git server (Forgejo)"
version     = "0.1.0"
category    = "productivity"
port        = 42040

hints = [
    "Open {url} and register your admin account",
    "SSH clone: ssh://git@{ip}:222/user/repo.git",
]

[env.defaults]
CODE_DATA_DIR = "{data_dir}/code"
TZ            = "{timezone}"

[health]
url = "http://localhost:42040/api/healthz"

stacklets/code/docker-compose.yml

name: stack-code

services:
  stack-code:
    container_name: stack-code
    image: codeberg.org/forgejo/forgejo:14.0
    networks:
      - stack
    ports:
      - "${PORT_BIND_IP:-0.0.0.0}:42040:3000"
      - "${PORT_BIND_IP:-0.0.0.0}:222:22"
    volumes:
      - ${CODE_DATA_DIR}:/data
      - /etc/localtime:/etc/localtime:ro
    env_file: .env
    restart: unless-stopped

networks:
  stack:
    external: true

That's 20 lines of TOML + 17 lines of YAML. Run stack up code and you have a Git server.