Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions docs/agent/ops.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,11 @@ If a precondition is missing, `./stack` prints exactly what to do. Don't improvi
| Command | Idempotent | Destructive | Touches |
|---|---|---|---|
| `./stack` | yes | no | interactive installer (first-run path) |
| `./stack up <id>` | yes | no | starts containers, renders `.env`, runs hooks |
| `./stack down <id>` | yes | no | stops containers; data preserved |
| `./stack up <id>...` | yes | no | starts containers, renders `.env`, runs hooks |
| `./stack down <id>...` | yes | no | stops containers; data preserved. Several ids: spaces or commas |
| `./stack down all` | yes | no | stops every running stacklet in reverse dep order |
| `./stack restart [<id>]` | yes | no | `down` + `up`; with no argument, only the stacklets running stale code |
| `./stack destroy <id>` | yes | **YES** | removes containers + `~/famstack-data/<id>/` + secrets |
| `./stack restart [<id>...]` | yes | no | `down` + `up`; several ids as for `down`; with no argument, only the stacklets running stale code |
| `./stack destroy <id>...` | yes | **YES** | removes containers + `~/famstack-data/<id>/` + secrets |
| `./stack uninstall` | yes | **YES, EVERYTHING** | destroys every stacklet, network, all data, config |
| `./stack update [<tag>]` | yes | no | moves the checkout to a release; restarts nothing, prints what to restart |
| `./stack list` | yes | no | reports state |
Expand Down
10 changes: 9 additions & 1 deletion docs/stack-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -526,11 +526,19 @@ effect.
means first run. Created after `on_install` completes. Deleted by
destroy.

### `stack down <id>`
### `stack down <id>...`

Transitions from **running** to **stopped**. Data and containers are
preserved — `stack up` brings it back without re-running setup.

Several ids stop together, separated by spaces or commas
(`stack down memory code docs` or `stack down memory,code,docs`), in
reverse dependency order. Every id is checked first, so an unknown one
stops nothing. `stack up` and `stack destroy` take ids the same way (up
in dependency order, destroy in reverse, each destroy confirmed on its
own unless `--yes`). `stack restart` stops them all, then starts them in
dependency order.

Use `stack down all` to stop every currently-running stacklet in reverse
dependency order (dependents first, deps last).

Expand Down
148 changes: 139 additions & 9 deletions lib/stack/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -346,8 +346,22 @@ def _down_all(self) -> dict:
untouched. Dependents shut down before their deps so services
making outbound calls don't error on a disappearing backend.
"""
running = docker.running_project_ids()
order = _reverse_dependency_order(self.stack.discover(), running)
return self._down_ordered(docker.running_project_ids())

def down_many(self, stacklet_ids: list[str]) -> dict:
"""Stop the named stacklets, dependents first.

Every id is checked before anything stops, so a typo in one
leaves the rest running rather than half the list stopped.
"""
unknown = self._unknown(stacklet_ids)
if unknown:
return {"ok": False, "error": f"Unknown stacklet: {', '.join(unknown)}",
"stopped": [], "errors": []}
return self._down_ordered(set(stacklet_ids))

def _down_ordered(self, include: set[str]) -> dict:
order = _reverse_dependency_order(self.stack.discover(), include)

stopped: list[str] = []
errors: list[dict] = []
Expand Down Expand Up @@ -375,7 +389,21 @@ def _up_all(self) -> dict:
s["id"] for s in self.stack.discover()
if self.stack.is_installed(s["id"])
}
order = _dependency_order(self.stack.discover(), installed)
return self._up_ordered(installed)

def up_many(self, stacklet_ids: list[str]) -> dict:
"""Bring up the named stacklets, dependencies first.

Checked the same way as `down_many`: an unknown id starts nothing.
"""
unknown = self._unknown(stacklet_ids)
if unknown:
return {"ok": False, "error": f"Unknown stacklet: {', '.join(unknown)}",
"started": [], "errors": []}
return self._up_ordered(set(stacklet_ids))

def _up_ordered(self, include: set[str]) -> dict:
order = _dependency_order(self.stack.discover(), include)

started: list[str] = []
errors: list[dict] = []
Expand All @@ -388,6 +416,10 @@ def _up_all(self) -> dict:

return {"ok": not errors, "started": started, "errors": errors}

def _unknown(self, stacklet_ids: list[str]) -> list[str]:
known = {s["id"] for s in self.stack.discover()}
return [sid for sid in stacklet_ids if sid not in known]

def destroy(self, stacklet_id: str) -> dict:
"""Destroy: Docker compose down + Stack.destroy()."""
stacklet = self.stack._find_stacklet(stacklet_id)
Expand All @@ -411,6 +443,22 @@ def destroy(self, stacklet_id: str) -> dict:
return self.stack.destroy(stacklet_id)


# ── Stacklet id arguments ─────────────────────────────────────────────────

def _stacklet_ids(values: list[str]) -> list[str]:
"""Flatten `a b`, `a,b` and any mix of the two into one id list.

Order is kept and repeats are dropped, so `down a,b a` stops a once.
"""
ids: list[str] = []
for value in values:
for sid in value.split(","):
sid = sid.strip()
if sid and sid not in ids:
ids.append(sid)
return ids


# ── Topological helpers ───────────────────────────────────────────────────

def _dependency_order(stacklets: list[dict], include: set[str]) -> list[str]:
Expand Down Expand Up @@ -772,6 +820,19 @@ def handle_up(stck, args):
if getattr(args, "no_voice", False):
os.environ["STACK_AI_NO_VOICE"] = "1"

ids = _many_ids(args)
if ids:
result = cli.up_many(ids)
if not result.get("ok"):
print_error({"error": result.get("error") or "Some stacklets failed to start",
"problems": [e["stacklet"] for e in result.get("errors", [])]})
sys.exit(1)
for sid in result["started"]:
print(f" {GREEN}✓{RESET} {sid}: started")
if "core" not in ids:
_refresh_core(stck, ids[0])
return

if args.stacklet == "all":
print(f"\n Bringing up {TEAL}all installed stacklets{RESET}...\n",
file=sys.stderr)
Expand Down Expand Up @@ -804,9 +865,38 @@ def handle_up(stck, args):
sys.exit(1)


def _many_ids(args):
"""The ids named on the command line, or None for one id or `all`.

One id keeps the single-stacklet path and its output. `all` cannot
be combined with other ids, since it already names every one.
"""
ids = _stacklet_ids(args.stacklet)
if len(ids) == 1:
args.stacklet = ids[0]
return None
if "all" in ids:
print_error({"error": "'all' already names every stacklet; pass it alone"})
sys.exit(1)
return ids


def handle_down(stck, args):
cli = CLI(stck)

ids = _many_ids(args)
if ids:
result = cli.down_many(ids)
if not result.get("ok"):
print_error({"error": result.get("error") or "Some stacklets failed to stop",
"problems": [e["stacklet"] for e in result.get("errors", [])]})
sys.exit(1)
for sid in result["stopped"]:
print(f" {GREEN}✓{RESET} {sid}: stopped")
if "core" not in ids:
_refresh_core(stck, ids[0])
return

if args.stacklet == "all":
result = cli.down("all")
stopped = result.get("stopped", [])
Expand Down Expand Up @@ -836,6 +926,22 @@ def handle_down(stck, args):


def handle_destroy(stck, args):
ids = _many_ids(args)
if not ids:
_destroy_one(stck, args)
return

unknown = [sid for sid in ids if not stck._find_stacklet(sid)]
if unknown:
print_error({"error": f"Unknown stacklet: {', '.join(unknown)}"})
sys.exit(1)
# Dependents first, as down does. Each one keeps its own confirmation.
for sid in _reverse_dependency_order(stck.discover(), set(ids)):
args.stacklet = sid
_destroy_one(stck, args)


def _destroy_one(stck, args):
stacklet = stck._find_stacklet(args.stacklet)
if not stacklet:
print_error({"error": f"Stacklet '{args.stacklet}' not found"})
Expand Down Expand Up @@ -1101,6 +1207,24 @@ def handle_restart(stck, args):
_restart_stale(stck, cli, yes=getattr(args, "yes", False))
return

ids = _many_ids(args)
if ids:
# Stop the lot before starting any, as `all` does, so nothing comes
# back up against a dependency that is about to go down.
down_result = cli.down_many(ids)
if not down_result.get("ok"):
print_error({"error": down_result.get("error") or "Some stacklets failed to stop",
"problems": [e["stacklet"] for e in down_result.get("errors", [])]})
sys.exit(1)
up_result = cli.up_many(ids)
if not up_result.get("ok"):
print_error({"error": "Some stacklets failed to start",
"problems": [e["stacklet"] for e in up_result.get("errors", [])]})
sys.exit(1)
for sid in up_result["started"]:
print(f" {GREEN}✓{RESET} {sid}: restarted")
return

if args.stacklet == "all":
# Whole-stack restart: stop everything running, then bring up
# every installed stacklet. The two phases use their own
Expand Down Expand Up @@ -1785,15 +1909,21 @@ def main():
sub.add_parser("help")
sub.add_parser("version")

p = sub.add_parser("up"); p.add_argument("stacklet")
p = sub.add_parser("up")
p.add_argument("stacklet", nargs="+",
help="Stacklets to start, space or comma separated, or 'all'")
p.add_argument("--no-voice", action="store_true",
help="(ai) start without the voice container (TTS + Whisper); sets STACK_AI_NO_VOICE=1")
p = sub.add_parser("down"); p.add_argument("stacklet")
p = sub.add_parser("destroy"); p.add_argument("stacklet"); p.add_argument("--yes", action="store_true")
p = sub.add_parser("down")
p.add_argument("stacklet", nargs="+",
help="Stacklets to stop, space or comma separated, or 'all'")
p = sub.add_parser("destroy")
p.add_argument("stacklet", nargs="+", help="Stacklets to destroy, space or comma separated")
p.add_argument("--yes", action="store_true")
p = sub.add_parser("restart")
p.add_argument("stacklet", nargs="?", default=None,
help="Stacklet to restart, 'all', or nothing for whatever "
"is running stale code")
p.add_argument("stacklet", nargs="*", default=None,
help="Stacklets to restart (space or comma separated), "
"'all', or nothing for whatever is running stale code")
p.add_argument("--yes", action="store_true", help="Skip the confirmation")
p = sub.add_parser("update")
p.add_argument("tag", nargs="?", default=None,
Expand Down
10 changes: 2 additions & 8 deletions stacklets/messages/hooks/on_install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -135,14 +135,8 @@ config = {
],
"turn_allow_guests": False,

# Relaxed rate limits for a LAN-only family server.
"rc_login": {
"address": {"per_second": 1, "burst_count": 20},
"account": {"per_second": 1, "burst_count": 20},
"failed_attempts": {"per_second": 0.5, "burst_count": 20},
},
"rc_message": {"per_second": 5, "burst_count": 30},
"rc_admin_redaction": {"per_second": 5, "burst_count": 30},
# Rate limits are not set here: hooks/on_start.py adds them on every
# start, so a limit added later reaches servers installed before it.
}

with open(conf_path, 'w') as f:
Expand Down
54 changes: 54 additions & 0 deletions stacklets/messages/hooks/on_start.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"""Keep the rate limits famstack relaxes in Synapse's config.

Synapse's defaults are sized for a public homeserver with strangers on
it. On a family server the ones that bite are the per-account limits:
an account may receive five invites in a burst and then one every 333
seconds, so a bot that sets up a sixth room for the same person waits
five and a half minutes before the invite goes through.

`homeserver.yaml` is generated once, at install, and never again, so a
limit added to the installer would reach new servers only. This hook
runs on every start and adds whichever of these keys the file lacks.
A key already present is the admin's and is left exactly as it is.
"""

from __future__ import annotations

import json
from pathlib import Path

RATE_LIMITS = {
"rc_login": {
"address": {"per_second": 1, "burst_count": 20},
"account": {"per_second": 1, "burst_count": 20},
"failed_attempts": {"per_second": 0.5, "burst_count": 20},
},
"rc_message": {"per_second": 5, "burst_count": 30},
"rc_admin_redaction": {"per_second": 5, "burst_count": 30},
# per_room keeps Synapse's default; it guards one room, not one person.
"rc_invites": {
"per_user": {"per_second": 1, "burst_count": 20},
"per_issuer": {"per_second": 1, "burst_count": 20},
},
}


def run(ctx):
conf = Path(ctx.stack.data) / "messages" / "synapse" / "homeserver.yaml"
if not conf.exists():
return {"ok": True}

# The installer writes JSON, which Synapse reads as YAML. A file an
# admin rewrote as YAML cannot be parsed without a YAML library, and
# it is theirs to maintain, so it is left as it is.
try:
config = json.loads(conf.read_text())
except ValueError:
return {"ok": True, "message": f"{conf} is not JSON; rate limits left as they are"}

missing = {k: v for k, v in RATE_LIMITS.items() if k not in config}
if not missing:
return {"ok": True}

conf.write_text(json.dumps({**config, **missing}, indent=2))
return {"ok": True, "message": f"added Synapse rate limits: {', '.join(missing)}"}
Loading
Loading