once-add/internal/deploy/deploy.go
28allday 68c64265a6 once-add: deploy + remove Once apps at <name>.local
A small Go tool that wraps the `/etc/hosts` + `once deploy --disable-tls`
recipe needed to host a Once app on a single-label `<name>.local`. Adds
the hosts entry, runs the deploy, polls /up, and on removal cleans the
hosts line that the Once UI orphans (including the orphan-only case,
where the app is already gone).

Bubble Tea TUI for interactive use; non-interactive CLI for scripts.
Curated apps live in /etc/once-add/apps.toml; seeded with Writebook +
Campfire on first run.
2026-05-28 07:23:07 +01:00

356 lines
11 KiB
Go

// Package deploy implements the once-add recipe for adding a LAN-reachable
// Once site as a single-label <name>.local host.
//
// Once's binary is statically linked (pure-Go resolver), so its post-deploy
// HTTP verify resolves /etc/hosts + real DNS only — never mDNS. The TUI install
// also forces TLS on and can't get a cert for a private .local host. So we add
// an /etc/hosts entry (verify resolves to loopback) and deploy from the CLI with
// --disable-tls; once-mdns-sync then publishes <name>.local to the LAN.
package deploy
import (
"bufio"
"context"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"regexp"
"sort"
"strings"
"time"
)
const (
hostsPath = "/etc/hosts"
verifyTimeout = 60 * time.Second
)
var nameRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`)
// ValidateName enforces a single DNS label: lowercase letters, digits and
// hyphens, not starting or ending with a hyphen, 63 characters or fewer.
func ValidateName(name string) error {
switch {
case name == "":
return fmt.Errorf("name is required")
case strings.Contains(name, "."):
return fmt.Errorf("name must be a single label (no dots), e.g. 'book'")
case len(name) > 63:
return fmt.Errorf("name must be 63 characters or fewer")
case !nameRe.MatchString(name):
return fmt.Errorf("use lowercase letters, digits and hyphens (not leading/trailing)")
}
return nil
}
// Host returns the .local hostname for a name.
func Host(name string) string { return name + ".local" }
var hostLabelRe = regexp.MustCompile(`"host"\s*:\s*"([^"]+)"`)
// InUseHosts returns the set of *.local hosts currently served by running Once
// apps. It mirrors once-mdns-sync: read each app container's `once` label (Once
// stores ApplicationSettings as JSON there) and pull its "host" field, keeping
// only the *.local names — so image names and env values can't false-match.
func InUseHosts() (map[string]bool, error) { return dockerHosts(false) }
// PresentHosts returns *.local hosts on this box's Once containers including
// stopped ones — the right check for "does this app still exist?" before
// attempting removal.
func PresentHosts() (map[string]bool, error) { return dockerHosts(true) }
func dockerHosts(includeStopped bool) (map[string]bool, error) {
psArgs := []string{"ps", "-q"}
if includeStopped {
psArgs = []string{"ps", "-a", "-q"}
}
out, err := exec.Command("docker", psArgs...).Output()
if err != nil {
return nil, fmt.Errorf("docker ps: %w", err)
}
ids := strings.Fields(string(out))
hosts := map[string]bool{}
if len(ids) == 0 {
return hosts, nil
}
args := append([]string{"inspect", "--format", `{{ index .Config.Labels "once" }}`}, ids...)
out, err = exec.Command("docker", args...).Output()
if err != nil {
return nil, fmt.Errorf("docker inspect: %w", err)
}
return parseHosts(string(out)), nil
}
// parseHosts extracts the *.local "host" values from concatenated `once` label
// JSON blobs.
func parseHosts(blob string) map[string]bool {
hosts := map[string]bool{}
for _, m := range hostLabelRe.FindAllStringSubmatch(blob, -1) {
h := strings.ToLower(m[1])
if strings.HasSuffix(h, ".local") {
hosts[h] = true
}
}
return hosts
}
// DeployedHosts returns the *.local hosts currently served by running Once
// apps, sorted — the candidates for removal.
func DeployedHosts() ([]string, error) {
m, err := InUseHosts()
if err != nil {
return nil, err
}
hosts := make([]string, 0, len(m))
for h := range m {
hosts = append(hosts, h)
}
sort.Strings(hosts)
return hosts, nil
}
// EnsureHostsEntry adds `127.0.0.1 <host>` to /etc/hosts if no line already
// resolves host. It reports whether it added a line. Requires root.
func EnsureHostsEntry(host string) (bool, error) {
data, err := os.ReadFile(hostsPath)
if err != nil {
return false, err
}
present := regexp.MustCompile(`(?m)^[0-9.]+[ \t]+` + regexp.QuoteMeta(host) + `([ \t]|$)`)
if present.Match(data) {
return false, nil
}
f, err := os.OpenFile(hostsPath, os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
return false, err
}
defer f.Close()
var b strings.Builder
if len(data) > 0 && data[len(data)-1] != '\n' {
b.WriteByte('\n') // don't fuse onto a file that lacks a trailing newline
}
b.WriteString("127.0.0.1 " + host + "\n")
if _, err := f.WriteString(b.String()); err != nil {
return false, err
}
return true, nil
}
// RemoveHostsEntry deletes any line in /etc/hosts that resolves host. Returns
// whether it removed one. Requires root.
func RemoveHostsEntry(host string) (bool, error) {
data, err := os.ReadFile(hostsPath)
if err != nil {
return false, err
}
kept, removed := filterHostLines(string(data), host)
if !removed {
return false, nil
}
// O_TRUNC keeps the existing file mode; root owns /etc/hosts.
return true, os.WriteFile(hostsPath, []byte(kept), 0o644)
}
// filterHostLines drops every line resolving host, returning the rewritten file
// content and whether anything was removed. A line matches only when host is the
// first name right after the leading IP — so 'book.local' never matches
// 'mybook.local' or 'book.local2'.
func filterHostLines(data, host string) (string, bool) {
re := regexp.MustCompile(`^[0-9.]+[ \t]+` + regexp.QuoteMeta(host) + `([ \t]|$)`)
lines := strings.Split(data, "\n")
kept := make([]string, 0, len(lines))
removed := false
for _, ln := range lines {
if re.MatchString(ln) {
removed = true
continue
}
kept = append(kept, ln)
}
return strings.Join(kept, "\n"), removed
}
// Verify polls http://<host>/up until it returns 2xx or the timeout elapses.
// The box resolves single-label .local via nss, so this is a real end-to-end
// reachability check.
func Verify(ctx context.Context, host string, timeout time.Duration) error {
url := "http://" + host + "/up"
client := &http.Client{Timeout: 5 * time.Second}
deadline := time.Now().Add(timeout)
var lastErr error
for time.Now().Before(deadline) {
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
resp, err := client.Do(req)
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
lastErr = err
} else {
resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
lastErr = fmt.Errorf("GET %s returned %d", url, resp.StatusCode)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(2 * time.Second):
}
}
if lastErr == nil {
lastErr = fmt.Errorf("timed out")
}
return fmt.Errorf("%s not reachable within %s: %w", url, timeout, lastErr)
}
// Event is one step of progress from Run. Exactly one of the terminal flags is
// set on the final event: Err for failure, Done for success.
type Event struct {
Line string // progress or streamed command output
Err error // non-nil: terminal failure
Done bool // true: terminal success (Line holds the reachable URL note)
}
// Run executes the full recipe for name+image, emitting progress on the
// returned channel. The channel is closed after a terminal Event. Used by both
// the TUI and the non-interactive CLI path.
func Run(ctx context.Context, name, image string) <-chan Event {
ch := make(chan Event)
go func() {
defer close(ch)
emit := func(s string) { ch <- Event{Line: s} }
fail := func(err error) { ch <- Event{Err: err} }
host := Host(name)
emit("Checking " + host + " isn't already in use…")
inUse, err := InUseHosts()
if err != nil {
fail(err)
return
}
if inUse[host] {
fail(fmt.Errorf("%s is already served by a running app — pick another name", host))
return
}
added, err := EnsureHostsEntry(host)
if err != nil {
fail(fmt.Errorf("updating /etc/hosts: %w", err))
return
}
if added {
emit("Added '127.0.0.1 " + host + "' to /etc/hosts")
} else {
emit("/etc/hosts already resolves " + host)
}
emit("Deploying " + image + " as " + host + " (TLS off)…")
if err := streamCmd(ctx, "once", []string{"deploy", image, "--host", host, "--disable-tls"}, emit); err != nil {
fail(fmt.Errorf("once deploy failed: %w", err))
return
}
emit("Waiting for " + host + " to come up…")
if err := Verify(ctx, host, verifyTimeout); err != nil {
fail(err)
return
}
ch <- Event{Done: true, Line: "http://" + host}
}()
return ch
}
// RunRemove tears down the app on host: `once remove <host> [--remove-data]`
// (skipped if no container for host exists — the orphan case, where the Once
// UI already removed the app but left its /etc/hosts line behind), then clears
// that /etc/hosts line (once-mdns-sync drops the LAN publisher on its own).
// Emits progress like Run; channel closes after a terminal Event.
func RunRemove(ctx context.Context, host string, removeData bool) <-chan Event {
ch := make(chan Event)
go func() {
defer close(ch)
emit := func(s string) { ch <- Event{Line: s} }
fail := func(err error) { ch <- Event{Err: err} }
present, err := PresentHosts()
if err != nil {
fail(err)
return
}
appRemoved := false
if present[host] {
args := []string{"remove", host}
note := ""
if removeData {
args = append(args, "--remove-data")
note = " (incl. data volume)"
}
emit("Removing app " + host + note + "…")
if err := streamCmd(ctx, "once", args, emit); err != nil {
fail(fmt.Errorf("once remove failed: %w", err))
return
}
appRemoved = true
} else {
emit("No container for " + host + " — cleaning up /etc/hosts only")
}
hostsRemoved, err := RemoveHostsEntry(host)
if err != nil {
fail(fmt.Errorf("updating /etc/hosts: %w", err))
return
}
if hostsRemoved {
emit("Removed '" + host + "' from /etc/hosts")
} else {
emit("No /etc/hosts entry for " + host)
}
ch <- Event{Done: true, Line: removeDoneMsg(host, appRemoved, hostsRemoved)}
}()
return ch
}
// removeDoneMsg picks the success-line wording for the four outcomes of remove.
func removeDoneMsg(host string, appRemoved, hostsRemoved bool) string {
switch {
case appRemoved && hostsRemoved:
return "Removed " + host
case appRemoved:
return "Removed " + host + " (no /etc/hosts entry to clean)"
case hostsRemoved:
return "Cleaned orphan /etc/hosts entry for " + host
default:
return "Nothing to remove for " + host
}
}
// streamCmd runs name+args, streaming combined stdout/stderr line by line
// through emit, and returns the command's exit error.
func streamCmd(ctx context.Context, name string, args []string, emit func(string)) error {
cmd := exec.CommandContext(ctx, name, args...)
pr, pw := io.Pipe()
cmd.Stdout = pw
cmd.Stderr = pw // same writer: os/exec uses a single pipe, so lines stay ordered
if err := cmd.Start(); err != nil {
pw.Close()
return fmt.Errorf("starting %s: %w", name, err)
}
scanDone := make(chan struct{})
go func() {
sc := bufio.NewScanner(pr)
sc.Buffer(make([]byte, 0, 64*1024), 1<<20)
for sc.Scan() {
emit(sc.Text())
}
close(scanDone)
}()
werr := cmd.Wait()
pw.Close()
<-scanDone
return werr
}