Files
llmgw/internal/cli/wipe.go
T
Ilker ManapandClaude Opus 5.5 32c3942040 The firewall turns off and on with the cluster's settings; llmgw wipe
firewall.enabled (default true) is a cluster setting, applied live: the
leader sends a Firewall message to every agent when it registers and when
the setting or firewall_networks changes, and the agent (root) takes llmgw's
nft table out or installs it. A control plane writes the state to
/var/lib/llmgw/firewall.json, which llmgw-firewall-sync.path applies as root
with `llmgw firewall sync`; server init and server add install the units,
`llmgw firewall sync --install` on one set up before. node add and server
add put no firewall on a machine while the setting is off.

`llmgw wipe` removes llmgw from a machine after listing what goes and asking
for its name: units, engines' processes, the nft table, /etc/llmgw,
/var/lib/llmgw, the agent's state, the sudo grant, the CA from the trust
store, keepalived's configuration when llmgw wrote it, and the users.
--models, --store (Patroni member, etcd, or the local database) and --all
take more. The console has it under Do.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 08:00:06 +02:00

278 lines
9.2 KiB
Go

package cli
import (
"bufio"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"github.com/spf13/cobra"
)
// wipeUnits are the systemd units llmgw installs, stopped first so nothing
// writes while the rest goes.
var wipeUnits = []string{
"llmgw-server.service", "llmgw-agent.service", "llmgw-mirror.service",
firewallSyncPathUnit, "llmgw-firewall-sync.service", "llmgw-firewall.service",
"llmgw-backup.timer", "llmgw-backup.service",
}
// wiper removes llmgw from a machine. root prefixes every path, for tests;
// run runs a command.
type wiper struct {
root string
run func(name string, args ...string) error
store bool
models bool
all bool
}
// wipeStep is one thing to remove, said before it is done.
type wipeStep struct {
what string
do func() error
}
func (w *wiper) path(p string) string { return filepath.Join(w.root, p) }
func (w *wiper) exists(p string) bool {
_, err := os.Lstat(w.path(p))
return err == nil
}
func (w *wiper) glob(pattern string) []string {
m, _ := filepath.Glob(w.path(pattern))
out := make([]string, len(m))
for i, p := range m {
out[i] = strings.TrimPrefix(p, w.root)
}
return out
}
// mentions is whether a file is there and says llmgw: a file of a name
// another tool also uses (keepalived's, etcd's) is removed only when llmgw
// wrote it.
func (w *wiper) mentions(p string) bool {
b, err := os.ReadFile(w.path(p))
return err == nil && strings.Contains(string(b), "llmgw")
}
func (w *wiper) remove(paths ...string) func() error {
return func() error {
var errs []error
for _, p := range paths {
if err := os.RemoveAll(w.path(p)); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
}
// plan is every step, in order, for what is on this machine.
func (w *wiper) plan() []wipeStep {
var steps []wipeStep
add := func(what string, do func() error) { steps = append(steps, wipeStep{what, do}) }
files := func(what string, paths ...string) {
var there []string
for _, p := range paths {
if w.exists(p) {
there = append(there, p)
}
}
if len(there) > 0 {
add(what+": "+strings.Join(there, ", "), w.remove(there...))
}
}
// The services, stopped and gone.
var units []string
for _, u := range wipeUnits {
if w.exists("/etc/systemd/system/" + u) {
units = append(units, u)
}
}
if len(units) > 0 {
add("stop and remove "+strings.Join(units, ", "), func() error {
for _, u := range units {
_ = w.run("systemctl", "disable", "--now", u)
if err := os.Remove(w.path("/etc/systemd/system/" + u)); err != nil && !os.IsNotExist(err) {
return err
}
}
return w.run("systemctl", "daemon-reload")
})
}
// Engines run as their own user, kept alive across the agent's restarts:
// stopped by user, or they would keep their GPUs.
if w.userExists("llmgw-engine") {
add("stop the engines (every process of llmgw-engine)", func() error {
_ = w.run("pkill", "-KILL", "-u", "llmgw-engine")
return nil
})
}
add("take llmgw's table out of the firewall (nft inet llmgw), if loaded", func() error {
_ = w.run("nft", "delete", "table", "inet", "llmgw")
return nil
})
if w.mentions("/etc/keepalived/keepalived.conf") {
add("stop keepalived and remove llmgw's floating address (/etc/keepalived/keepalived.conf)", func() error {
_ = w.run("systemctl", "disable", "--now", "keepalived")
return w.remove("/etc/keepalived/keepalived.conf")()
})
}
files("configuration, certificates, the authority and secrets", "/etc/llmgw")
files("state: the admin token, a SQLite store, what the agent kept",
"/var/lib/llmgw", "/var/lib/llmgw-agent")
files("the sudo grant setup left", "/etc/sudoers.d/llmgw-setup")
if w.exists("/usr/local/share/ca-certificates/llmgw-ca.crt") {
add("the cluster's authority from the system's trust (llmgw-ca.crt)", func() error {
if err := w.remove("/usr/local/share/ca-certificates/llmgw-ca.crt")(); err != nil {
return err
}
_ = w.run("update-ca-certificates", "--fresh")
return nil
})
}
if w.exists("/etc/pki/ca-trust/source/anchors/llmgw-ca.pem") {
add("the cluster's authority from the system's trust (llmgw-ca.pem)", func() error {
if err := w.remove("/etc/pki/ca-trust/source/anchors/llmgw-ca.pem")(); err != nil {
return err
}
_ = w.run("update-ca-trust")
return nil
})
}
if w.store || w.all {
w.planStore(add, files)
}
if w.models || w.all {
files("model weights and the mirror", "/srv/models", "/var/lib/llmgw-weights", "/var/lib/llmgw-models")
files("installed engines", "/opt/llmgw")
}
if w.all {
files("backups", DefaultBackupDir)
}
for _, u := range []string{"llmgw-engine", "llmgw"} {
if w.userExists(u) {
add("the user "+u, func() error { return w.run("userdel", u) })
}
}
if w.all {
files("the llmgw binary", "/usr/local/bin/llmgw", "/usr/local/bin/llmgw.prev")
}
return steps
}
// planStore is the store, when it is on this machine: a member of the
// replicated one (Patroni, etcd), or llmgw's database in a local PostgreSQL.
func (w *wiper) planStore(add func(string, func() error), files func(string, ...string)) {
patroni := w.mentions("/etc/patroni/config.yml")
if patroni || w.mentions("/etc/default/etcd") {
add("stop Patroni and etcd", func() error {
_ = w.run("systemctl", "disable", "--now", "patroni")
_ = w.run("systemctl", "disable", "--now", "etcd")
return nil
})
}
if patroni {
data := append(w.glob("/var/lib/postgresql/*/llmgw"), w.glob("/var/lib/pgsql/*/llmgw")...)
files("the store's database on this member, and Patroni's configuration",
append([]string{"/etc/patroni", "/var/lib/postgresql/.pgpass_patroni", "/root/llmgw-pg"}, data...)...)
} else if w.exists("/usr/bin/psql") {
add("drop the llmgw database and role from the local PostgreSQL, if there", func() error {
_ = w.run("runuser", "-u", "postgres", "--", "psql", "-q",
"-c", "DROP DATABASE IF EXISTS llmgw", "-c", "DROP ROLE IF EXISTS llmgw")
return nil
})
}
if w.mentions("/etc/default/etcd") {
files("etcd's data and configuration", "/var/lib/etcd/default", "/etc/etcd/tls", "/etc/default/etcd")
}
}
func (w *wiper) userExists(name string) bool {
b, err := os.ReadFile(w.path("/etc/passwd"))
return err == nil && strings.Contains("\n"+string(b), "\n"+name+":")
}
func newWipeCmd() *cobra.Command {
var w wiper
var yes bool
c := &cobra.Command{
Use: "wipe",
Short: "Remove llmgw from this machine: its services, configuration and state",
Long: "Stops and removes llmgw's services, takes its firewall table out, and\n" +
"deletes its configuration, certificates, the authority, secrets, state\n" +
"and users — what server init, node add, server add and mirror init left —\n" +
"so the machine can be set up again from nothing. It says everything it\n" +
"will remove first, and asks for the machine's name.\n\n" +
"Kept unless asked: model weights, the mirror and engines (--models), the\n" +
"store's database when it is on this machine (--store), backups and the\n" +
"binary (--all, which is all of these). Packages llmgw installed —\n" +
"PostgreSQL, Patroni, etcd, nftables, the node exporter — stay.\n\n" +
"A node wiped is then forgotten on the control plane with\n" +
"'llmgw node remove <name>'.",
Example: " sudo llmgw wipe\n" +
" sudo llmgw wipe --models\n" +
" sudo llmgw wipe --all --yes",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
if runtime.GOOS != "linux" {
return fmt.Errorf("wipe removes llmgw from a Linux machine; this is %s", runtime.GOOS)
}
if os.Geteuid() != 0 {
return errors.New("wipe removes services, users and files under /etc: run it with sudo")
}
out := cmd.OutOrStdout()
w.run = func(name string, args ...string) error {
return exec.CommandContext(cmd.Context(), name, args...).Run() //nolint:gosec // fixed commands
}
steps := w.plan()
host, _ := os.Hostname()
fmt.Fprintf(out, "On %s, this removes:\n", host)
for _, s := range steps {
fmt.Fprintf(out, " - %s\n", s.what)
}
if !yes {
if !isTerminal(out) {
return errors.New("nothing removed: give --yes to wipe without being asked")
}
fmt.Fprintf(out, "\nType this machine's name (%s) to remove all of it: ", host)
answer, _ := bufio.NewReader(cmd.InOrStdin()).ReadString('\n')
if strings.TrimSpace(answer) != host {
return errors.New("nothing removed")
}
}
var failed []string
for _, s := range steps {
if err := s.do(); err != nil {
failed = append(failed, s.what+": "+err.Error())
fmt.Fprintf(out, " !! %s: %v\n", s.what, err)
continue
}
fmt.Fprintf(out, " ok %s\n", s.what)
}
if len(failed) > 0 {
return fmt.Errorf("%d step(s) failed; running wipe again finishes them", len(failed))
}
fmt.Fprintln(out, "\nllmgw is gone from this machine. If it was a node, forget it on the control plane: "+
"llmgw node remove "+host)
return nil
},
}
f := c.Flags()
f.BoolVar(&yes, "yes", false, "do not ask")
f.BoolVar(&w.models, "models", false, "also remove model weights, the mirror's files and installed engines")
f.BoolVar(&w.store, "store", false, "also remove the store's database here: a Patroni member's data, etcd's, or llmgw's database in a local PostgreSQL")
f.BoolVar(&w.all, "all", false, "everything: --models, --store, backups and the binary")
return c
}