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
134 changes: 134 additions & 0 deletions cmd/flue/close.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package main

import (
"bytes"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"net/http"
"net/url"
"os"

"github.com/karnstack/flue/internal/daemon"
"github.com/karnstack/flue/internal/transport/local"
)

// errCloseUsage answers a bare `flue close`, which could mean either form and
// so gets both. A sentinel rather than a plain error because cmdClose exits 2
// on it — the code main uses for an unknown command, and the right one for
// "you have not said what to close".
var errCloseUsage = errors.New("usage: flue close <id>... | flue close --all")

// errUnknownSessions reports that at least one named id closed nothing. The
// per-id lines have already gone to stderr by the time it is returned, so
// cmdClose turns it into a bare exit 1 rather than printing it again.
var errUnknownSessions = errors.New("some sessions were not found")

// cmdClose owns the exit codes runClose cannot: 2 for a usage error and 1 for
// unknown ids, both already explained on stderr. Everything else flows back
// to main's ordinary error path.
func cmdClose(args []string) error {
err := runClose(os.Stdout, os.Stderr, args)
switch {
case errors.Is(err, errCloseUsage):
fmt.Fprintln(os.Stderr, "flue:", err)
os.Exit(2)
case errors.Is(err, errUnknownSessions):
os.Exit(1)
}
return err
}

// runClose ends sessions on the local daemon: every one under --all, the
// named ones otherwise. The writers are the seam — same pattern as statusTo —
// so the tests read both streams without capturing the process's own.
//
// A daemon that is not running is answered with a notice and success, not a
// failure: the user asked for no sessions, and no daemon means exactly that.
// Unknown ids are the one partial outcome — each is named on stderr, the rest
// are closed and counted, and errUnknownSessions carries the failure out.
func runClose(stdout, stderr io.Writer, args []string) error {
fs := flag.NewFlagSet("close", flag.ContinueOnError)
fs.SetOutput(stderr)
all := fs.Bool("all", false, "close every session, running and exited")
if err := fs.Parse(args); err != nil {
return errCloseUsage
}
ids := fs.Args()
if !*all && len(ids) == 0 {
return errCloseUsage
}

port, ok := ourDaemon()
if !ok {
fmt.Fprintln(stdout, "daemon not running; nothing to close")
return nil
}
token, err := loadToken()
if err != nil {
return fmt.Errorf("load auth token: %w", err)
}

closed, missing, err := postSessionsClose(port, token, *all, ids)
if err != nil {
return err
}
for _, id := range missing {
fmt.Fprintf(stderr, "flue: no such session: %s\n", id)
}
noun := "sessions"
if closed == 1 {
noun = "session"
}
fmt.Fprintf(stdout, " ✓ closed %d %s\n", closed, noun)
if len(missing) > 0 {
return errUnknownSessions
}
return nil
}

// postSessionsClose asks the daemon to close sessions and relays its answer.
// The shape mirrors fetchSessions — token in a header, status checked before
// the body is decoded, the body bounded — because it is talking to the same
// daemon under the same rules.
func postSessionsClose(port int, token string, all bool, ids []string) (closed int, missing []string, err error) {
body, err := json.Marshal(map[string]any{"all": all, "ids": ids})
if err != nil {
return 0, nil, err
}
u := &url.URL{
Scheme: "http",
Host: fmt.Sprintf("127.0.0.1:%d", port),
Path: daemon.SessionsClosePath,
}
req, err := http.NewRequest(http.MethodPost, u.String(), bytes.NewReader(body))
if err != nil {
return 0, nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set(local.HeaderName, token)
resp, err := probeClient.Do(req)
if err != nil {
return 0, nil, err
}
defer resp.Body.Close()

switch resp.StatusCode {
case http.StatusOK:
case http.StatusUnauthorized:
return 0, nil, errTokenRejected
default:
return 0, nil, fmt.Errorf("daemon on 127.0.0.1:%d answered %s", port, resp.Status)
}

var out struct {
Closed int `json:"closed"`
Missing []string `json:"missing"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, maxListingBytes)).Decode(&out); err != nil {
return 0, nil, fmt.Errorf("decode close answer: %w", err)
}
return out.Closed, out.Missing, nil
}
156 changes: 156 additions & 0 deletions cmd/flue/close_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
package main

import (
"bytes"
"errors"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"time"

"github.com/karnstack/flue/internal/config"
"github.com/karnstack/flue/internal/daemon"
"github.com/karnstack/flue/internal/session"
"github.com/karnstack/flue/internal/transport/local"
)

// newCloseTestDaemon is newTestDaemon with the registry exposed, because these
// tests need to spawn the sessions the command is asked to close and to see
// afterwards whether they went. It also writes the runtime record, which is
// how runClose finds the daemon at all.
func newCloseTestDaemon(t *testing.T) *session.Registry {
t.Helper()
t.Setenv("XDG_CONFIG_HOME", t.TempDir())

token, err := config.LoadOrCreateToken()
if err != nil {
t.Fatalf("LoadOrCreateToken: %v", err)
}
reg := session.NewRegistry(time.Now)
srv := daemon.New(reg, local.NewAuth(token, 0), uiHandler(), version, daemon.Identity{})
ts := httptest.NewServer(srv.Handler())
t.Cleanup(ts.Close)
t.Cleanup(srv.Shutdown)

u, err := url.Parse(ts.URL)
if err != nil {
t.Fatalf("parse test server URL %q: %v", ts.URL, err)
}
port, err := strconv.Atoi(u.Port())
if err != nil {
t.Fatalf("parse port from %q: %v", ts.URL, err)
}
srv.SetAuth(local.NewAuth(token, port))
if err := daemon.WriteRuntime(port); err != nil {
t.Fatalf("WriteRuntime: %v", err)
}
return reg
}

func spawnSleeper(t *testing.T, reg *session.Registry) session.Handle {
t.Helper()
h, err := reg.Spawn(session.SpawnOpts{Cmd: []string{"sleep", "5"}, Cols: 80, Rows: 24})
if err != nil {
t.Fatalf("Spawn: %v", err)
}
t.Cleanup(func() { _ = h.Close() })
return h
}

func TestRunCloseAllClosesEverySession(t *testing.T) {
reg := newCloseTestDaemon(t)
spawnSleeper(t, reg)
spawnSleeper(t, reg)

var out, errOut bytes.Buffer
if err := runClose(&out, &errOut, []string{"--all"}); err != nil {
t.Fatalf("runClose: %v", err)
}
if !strings.Contains(out.String(), "✓ closed 2 sessions") {
t.Errorf("output %q does not report the two closed sessions", out.String())
}
if left := reg.List(); len(left) != 0 {
t.Errorf("the registry still holds %d sessions", len(left))
}
}

// TestRunCloseByIDClosesOnlyTheNamedOne also pins the singular: one session
// closed is "1 session", not "1 sessions".
func TestRunCloseByIDClosesOnlyTheNamedOne(t *testing.T) {
reg := newCloseTestDaemon(t)
going := spawnSleeper(t, reg)
staying := spawnSleeper(t, reg)

var out, errOut bytes.Buffer
if err := runClose(&out, &errOut, []string{going.ID()}); err != nil {
t.Fatalf("runClose: %v", err)
}
if !strings.Contains(out.String(), "✓ closed 1 session\n") {
t.Errorf("output %q, want the singular closed line", out.String())
}
if _, ok := reg.Get(going.ID()); ok {
t.Error("the named session is still in the registry")
}
if _, ok := reg.Get(staying.ID()); !ok {
t.Error("the unnamed session went with it")
}
}

// TestRunCloseReportsUnknownIDs: each id that named nothing is reported on
// stderr by name, the ones that exist are closed anyway, and the command
// fails — that is the errUnknownSessions cmdClose turns into exit 1.
func TestRunCloseReportsUnknownIDs(t *testing.T) {
reg := newCloseTestDaemon(t)
real := spawnSleeper(t, reg)

var out, errOut bytes.Buffer
err := runClose(&out, &errOut, []string{real.ID(), "feedfeed00000000"})
if !errors.Is(err, errUnknownSessions) {
t.Fatalf("runClose = %v, want errUnknownSessions", err)
}
if !strings.Contains(errOut.String(), "no such session: feedfeed00000000") {
t.Errorf("stderr %q does not name the unknown id", errOut.String())
}
if !strings.Contains(out.String(), "✓ closed 1 session\n") {
t.Errorf("output %q, want the real session still closed and counted", out.String())
}
if _, ok := reg.Get(real.ID()); ok {
t.Error("the real session is still in the registry")
}
}

// TestRunCloseWithNoArgumentsIsAUsageError: bare `flue close` could mean
// either form, so it gets the usage line naming both — errCloseUsage, which
// cmdClose turns into exit 2 — and never talks to the daemon at all.
func TestRunCloseWithNoArgumentsIsAUsageError(t *testing.T) {
var out, errOut bytes.Buffer
err := runClose(&out, &errOut, nil)
if !errors.Is(err, errCloseUsage) {
t.Fatalf("runClose = %v, want errCloseUsage", err)
}
for _, form := range []string{"--all", "<id>"} {
if !strings.Contains(err.Error(), form) {
t.Errorf("usage error %q does not show the %s form", err, form)
}
}
}

func TestRunCloseSaysDaemonNotRunning(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir()) // no runtime record, no daemon

var out, errOut bytes.Buffer
if err := runClose(&out, &errOut, []string{"--all"}); err != nil {
t.Fatalf("runClose = %v, want nil: nothing to close is not a failure", err)
}
if !strings.Contains(out.String(), "daemon not running; nothing to close") {
t.Errorf("output %q, want the not-running notice", out.String())
}
}

func TestUsageMentionsClose(t *testing.T) {
if !strings.Contains(usageText, "flue close") {
t.Fatalf("usage text does not mention %q:\n%s", "flue close", usageText)
}
}
3 changes: 3 additions & 0 deletions cmd/flue/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ func main() {
err = cmdServe(os.Args[2:])
case "open":
err = cmdOpen(os.Args[2:])
case "close":
err = cmdClose(os.Args[2:])
case "enable":
err = cmdEnable()
case "disable":
Expand Down Expand Up @@ -124,6 +126,7 @@ const usageText = `flue — your terminal, as a browser tab
flue relay leave take this machine off its relay; the Worker stays deployed
flue relay reset empty the relay's fleet directory; the fleet republishes
flue open [path] spawn a session in path and open it in the browser
flue close <id>... close the named sessions; --all closes every one
flue serve [--port N] [--open] run the daemon in the foreground
flue update download the newest release, swap this binary, restart the daemon
flue version print the version (also --version, -v)
Expand Down
Loading