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.
356 lines
11 KiB
Go
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
|
|
}
|