// Package deploy implements the once-add recipe for adding a LAN-reachable // Once site as a single-label .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 .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 ` 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:///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 [--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 }