From a2ec78fdfcf99e5d3a81902def0468f09be868a4 Mon Sep 17 00:00:00 2001 From: nfebe Date: Mon, 28 Sep 2026 12:09:04 +0100 Subject: [PATCH] feat: Start the agent when a command needs it, and add sourceant update A command that reads from the agent starts one where nothing answers, on the next free port when something else holds the usual one, and writes the address it got down so later commands and the view agree on it. Setup starts what it installed. Asking whether it runs, and stopping it, start nothing. sourceant update replaces this command, the agent and the core, or the parts named, from the releases each is published in. Nothing is written until its checksum matches the release it came from, and a replacement is renamed over the old file, so an interrupted update leaves a working install. A machine already on a prerelease follows prereleases. --- README.md | 6 +- internal/agent/client.go | 56 ++++- internal/agent/client_test.go | 67 ++++++ internal/command/lifecycle.go | 123 +++++++++++ internal/command/lifecycle_test.go | 80 +++++++ internal/command/root.go | 41 +++- internal/command/root_test.go | 2 +- internal/command/setup.go | 18 +- internal/command/start.go | 28 +++ internal/command/stop.go | 2 +- internal/command/ui.go | 60 +----- internal/command/update.go | 214 +++++++++++++++++++ internal/command/update_test.go | 80 +++++++ internal/install/install.go | 58 +++++ internal/install/install_test.go | 34 +++ internal/update/update.go | 328 +++++++++++++++++++++++++++++ internal/update/update_test.go | 172 +++++++++++++++ 17 files changed, 1309 insertions(+), 60 deletions(-) create mode 100644 internal/agent/client_test.go create mode 100644 internal/command/lifecycle.go create mode 100644 internal/command/lifecycle_test.go create mode 100644 internal/command/start.go create mode 100644 internal/command/update.go create mode 100644 internal/command/update_test.go create mode 100644 internal/update/update.go create mode 100644 internal/update/update_test.go diff --git a/README.md b/README.md index fd4ac9d..ca9b120 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ curl -fsSL https://raw.githubusercontent.com/sourceant/cli/main/scripts/install. Both put the index in the same place, `$XDG_DATA_HOME/sourceant`, so it does not matter which one indexed it. The container runs as whoever installed, so what it writes there belongs to them. -`sourceant ui` starts the agent and opens the view. `sourceant stop` shuts down the agent and its core without removing the index or configuration. Stopping requires an agent with stop support. +Any command that needs the agent starts one, so nothing has to be started by hand. The usual port is 8930, and where something else holds it the agent takes the next one free and the address is written to `~/.sourceant/config.json` for every later command. `sourceant start` does it on its own, `sourceant stop` shuts the agent and its core down without touching the index or the configuration, and `sourceant status` reports on both without starting anything. | Variable | Default | Meaning | |---|---|---| @@ -93,10 +93,14 @@ Both put the index in the same place, `$XDG_DATA_HOME/sourceant`, so it does not | `sourceant graph ` | What the indexer found in one of them | | `sourceant architecture ` | Indexed components and dependencies; compare an exported baseline with `--baseline` | | `sourceant ui` | Open the graph in a browser | +| `sourceant start` | Start the agent and the indexer | +| `sourceant update [cli\|agent\|core]...` | Bring this machine up to the current release | | `sourceant version` | What this build is | `review` reads the folder you are standing in, committed or not, against the branch the repository defaults to. It exits 2 when a skill blocks the change, so a shell script can use it. `--against ` compares against something else, `--no-model` says what changed without judging it, `--no-wait` prints the link and leaves it running, and `--title` and `--skill` name the change and the skills to read it against. +`update` replaces this command, the agent and the core, or only the parts named. `--check` says what is available and changes nothing. `--to ` takes a version other than the newest, for one named part. A machine on a prerelease follows prereleases; `--prerelease` asks for that on a machine that is not. Nothing is written until its checksum matches the release it came from, and each replacement is renamed over the old file, so an interrupted update leaves what was working in place. + `--json` prints the agent's own answer, for anything that wants to read it rather than look at it. ## Building diff --git a/internal/agent/client.go b/internal/agent/client.go index 3da605a..9550a88 100644 --- a/internal/agent/client.go +++ b/internal/agent/client.go @@ -15,6 +15,7 @@ import ( "net/url" "strconv" "strings" + "sync" "syscall" "time" ) @@ -96,14 +97,67 @@ func IsConnectionRefused(err error) bool { type Client struct { baseURL string http *http.Client + revive *reviving } // New builds a client for the agent at baseURL. func New(baseURL string, timeout time.Duration) *Client { - return &Client{ + client := &Client{ baseURL: strings.TrimRight(baseURL, "/"), http: &http.Client{Timeout: timeout}, } + client.revive = &reviving{client: client} + client.http.Transport = client.revive + return client +} + +// Starter brings the agent up and answers with the address it listens on. +type Starter func(ctx context.Context) (string, error) + +// StartWith gives this client something to run when nothing answers. Without +// it, a call to an agent that is not running fails as it always did. +func StartWith(client *Client, start Starter) { client.revive.start = start } + +// reviving starts the agent once, for the first call that finds it absent, and +// sends that call again. The agent may come up somewhere else, so the retry +// follows the address it answers on. +type reviving struct { + client *Client + start Starter + once sync.Once + url string + err error +} + +func (r *reviving) RoundTrip(req *http.Request) (*http.Response, error) { + resp, err := http.DefaultTransport.RoundTrip(req) + if err == nil || r.start == nil || !errors.Is(err, syscall.ECONNREFUSED) { + return resp, err + } + r.once.Do(func() { r.url, r.err = r.start(req.Context()) }) + if r.err != nil { + return nil, err + } + retry := req.Clone(req.Context()) + if r.url != "" { + moved, parsed := url.Parse(r.url) + if parsed != nil { + return nil, err + } + retry.URL.Scheme, retry.URL.Host, retry.Host = moved.Scheme, moved.Host, "" + r.client.baseURL = strings.TrimRight(r.url, "/") + } + if req.Body != nil { + if req.GetBody == nil { + return nil, err + } + body, again := req.GetBody() + if again != nil { + return nil, err + } + retry.Body = body + } + return http.DefaultTransport.RoundTrip(retry) } // BaseURL is the agent this client talks to. diff --git a/internal/agent/client_test.go b/internal/agent/client_test.go new file mode 100644 index 0000000..e7917d5 --- /dev/null +++ b/internal/agent/client_test.go @@ -0,0 +1,67 @@ +package agent + +import ( + "context" + "net" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func dead(t *testing.T) string { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + address := listener.Addr().String() + _ = listener.Close() + return "http://" + address +} + +func TestACallThatFindsNoAgentStartsOneAndAsksAgain(t *testing.T) { + answers := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + answers++ + _, _ = w.Write([]byte(`{"version":"1.0.0","core_url":"http://127.0.0.1:1","core_up":true}`)) + })) + defer server.Close() + + client := New(dead(t), 5*time.Second) + started := 0 + StartWith(client, func(context.Context) (string, error) { + started++ + return server.URL, nil + }) + + status, err := client.Status(context.Background()) + if err != nil { + t.Fatalf("the call failed after starting the agent: %v", err) + } + if status.Version != "1.0.0" || answers != 1 || started != 1 { + t.Errorf("version %q, %d answers, %d starts", status.Version, answers, started) + } + if client.BaseURL() != server.URL { + t.Errorf("client still points at %s", client.BaseURL()) + } +} + +func TestWithoutAStarterAnAbsentAgentIsStillAnError(t *testing.T) { + client := New(dead(t), time.Second) + + if _, err := client.Status(context.Background()); !IsConnectionRefused(err) { + t.Errorf("got %v, want the refusal", err) + } +} + +func TestAnAgentThatCannotBeStartedReportsTheOriginalRefusal(t *testing.T) { + client := New(dead(t), time.Second) + StartWith(client, func(context.Context) (string, error) { + return "", context.DeadlineExceeded + }) + + if _, err := client.Status(context.Background()); !IsConnectionRefused(err) { + t.Errorf("got %v, want the refusal", err) + } +} diff --git a/internal/command/lifecycle.go b/internal/command/lifecycle.go new file mode 100644 index 0000000..6762f5f --- /dev/null +++ b/internal/command/lifecycle.go @@ -0,0 +1,123 @@ +package command + +import ( + "context" + "fmt" + "io" + "net" + "net/url" + "os" + "os/exec" + "strconv" + "syscall" + "time" + + "github.com/sourceant/cli/internal/agent" + "github.com/sourceant/cli/internal/install" +) + +// ports tried after the one asked for, when something else holds it. +const ports = 10 + +// ensureAgent answers with an agent that is running, starting one if it has to. +func ensureAgent(ctx context.Context, opts *options, out io.Writer) (string, error) { + if _, err := agent.New(opts.agentURL, opts.timeout).Status(ctx); err == nil { + return opts.agentURL, nil + } + // An address somebody named is the address they meant, so it is started + // there or not at all. Only the default moves when something holds it. + listen, err := address(opts.agentURL, !opts.agentNamed) + if err != nil { + return "", err + } + target := "http://" + listen + if err := run(listen, out); err != nil { + return "", err + } + if err := answering(ctx, target, opts.timeout); err != nil { + return "", err + } + if !opts.agentNamed { + if err := install.SaveAgentURL(target); err != nil { + _, _ = fmt.Fprintf(out, "The agent is at %s, which could not be written down: %v\n", target, err) + } + } + opts.agentURL = target + return target, nil +} + +// address is what to listen on. With move, a port something else holds is +// passed over for the next one up. +func address(from string, move bool) (string, error) { + parsed, err := url.Parse(from) + if err != nil { + return "", fmt.Errorf("%s is not an address: %w", from, err) + } + host, port := parsed.Hostname(), parsed.Port() + if host == "" { + host = "127.0.0.1" + } + first, err := strconv.Atoi(port) + if err != nil { + first = 8930 + } + last := first + if move { + last = first + ports - 1 + } + for candidate := first; candidate <= last; candidate++ { + wanted := net.JoinHostPort(host, strconv.Itoa(candidate)) + listener, err := net.Listen("tcp", wanted) + if err != nil { + continue + } + _ = listener.Close() + return wanted, nil + } + if !move { + return net.JoinHostPort(host, strconv.Itoa(first)), nil + } + return "", fmt.Errorf("nothing is free between %d and %d on %s", first, last, host) +} + +// run starts the installed agent so it outlives this process. +func run(listen string, out io.Writer) error { + path := install.AgentPath() + if _, err := os.Stat(path); err != nil { + return fmt.Errorf("no agent is running and none is installed here. Run sourceant setup") + } + logPath := install.Home() + "/agent.log" + log, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + return err + } + defer func() { _ = log.Close() }() + + started := exec.Command(path) + started.Env = append(os.Environ(), "SOURCEANT_AGENT_LISTEN="+listen) + started.Stdout, started.Stderr = log, log + started.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + if err := started.Start(); err != nil { + return fmt.Errorf("could not start the agent: %w", err) + } + _, _ = fmt.Fprintf(out, "Started the agent on %s. It logs to %s\n", listen, logPath) + return nil +} + +// answering waits for the agent to serve, which means waiting for the core it +// starts first. A first run pulls or builds one. +func answering(ctx context.Context, target string, timeout time.Duration) error { + client := agent.New(target, timeout) + deadline := time.Now().Add(90 * time.Second) + for time.Now().Before(deadline) { + if _, err := client.Status(ctx); err == nil { + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(time.Second): + } + } + return fmt.Errorf("the agent did not answer within 90s. See %s/agent.log", install.Home()) +} diff --git a/internal/command/lifecycle_test.go b/internal/command/lifecycle_test.go new file mode 100644 index 0000000..5d3f36c --- /dev/null +++ b/internal/command/lifecycle_test.go @@ -0,0 +1,80 @@ +package command + +import ( + "bytes" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestAPortSomethingElseHoldsIsPassedOver(t *testing.T) { + held, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer func() { _ = held.Close() }() + taken := "http://" + held.Addr().String() + + chosen, err := address(taken, true) + if err != nil { + t.Fatal(err) + } + if chosen == held.Addr().String() { + t.Errorf("chose %s, which something else is already listening on", chosen) + } +} + +func TestAnAddressSomebodyNamedIsNotMoved(t *testing.T) { + held, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer func() { _ = held.Close() }() + + chosen, err := address("http://"+held.Addr().String(), false) + if err != nil { + t.Fatal(err) + } + if chosen != held.Addr().String() { + t.Errorf("got %s, want the address that was named", chosen) + } +} + +func TestStartSaysItIsAlreadyRunning(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"version":"1.0.0","core_url":"http://127.0.0.1:1","core_up":true}`)) + })) + defer server.Close() + + var stdout, stderr bytes.Buffer + if code := Run([]string{"--agent", server.URL, "start"}, &stdout, &stderr); code != 0 { + t.Fatalf("exited %d: %s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), "Already running at "+server.URL) { + t.Errorf("got %q, want it to say what is already up", stdout.String()) + } +} + +func TestStopDoesNotStartAnAgent(t *testing.T) { + home := t.TempDir() + t.Setenv("SOURCEANT_INSTALL_HOME", home) + // An agent that would be started if anything tried to. + if err := os.MkdirAll(filepath.Join(home, "bin"), 0o755); err != nil { + t.Fatal(err) + } + script := filepath.Join(home, "bin", "sourceant-agent") + if err := os.WriteFile(script, []byte("#!/bin/sh\ntouch "+filepath.Join(home, "started")+"\n"), 0o755); err != nil { + t.Fatal(err) + } + + var stdout, stderr bytes.Buffer + Run([]string{"--agent", "http://127.0.0.1:1", "stop"}, &stdout, &stderr) + + if _, err := os.Stat(filepath.Join(home, "started")); err == nil { + t.Error("stop started the agent it was asked to stop") + } +} diff --git a/internal/command/root.go b/internal/command/root.go index fa1806a..08b878f 100644 --- a/internal/command/root.go +++ b/internal/command/root.go @@ -2,6 +2,7 @@ package command import ( + "context" "encoding/json" "errors" "fmt" @@ -11,6 +12,7 @@ import ( "time" "github.com/sourceant/cli/internal/agent" + "github.com/sourceant/cli/internal/install" "github.com/sourceant/cli/internal/presentation" "github.com/spf13/cobra" ) @@ -32,6 +34,10 @@ type options struct { agentURL string timeout time.Duration asJSON bool + // Whether somebody named the agent, in which case it is not this + // command's business to start one somewhere else. + agentNamed bool + out io.Writer } // Run executes the command tree and returns the process exit code. @@ -43,18 +49,23 @@ func Run(args []string, stdout, stderr io.Writer) int { SilenceUsage: true, SilenceErrors: true, } + opts.out = stdout root.SetOut(stdout) root.SetErr(stderr) root.SetArgs(args) root.PersistentFlags().StringVar(&opts.agentURL, "agent", agentDefault(), "The agent to talk to") + root.PersistentPreRun = func(cmd *cobra.Command, _ []string) { + opts.agentNamed = cmd.Flags().Changed("agent") || os.Getenv(EnvAgent) != "" + } root.PersistentFlags().DurationVar(&opts.timeout, "timeout", 30*time.Second, "How long to wait for the agent") root.PersistentFlags().BoolVar(&opts.asJSON, "json", false, "Print the agent's answer as JSON") root.AddCommand( reviewCommand(opts), + startCommand(opts), stopCommand(opts), - setupCommand(), + setupCommand(opts), statusCommand(opts), reposCommand(opts), repoCommand(opts), @@ -62,6 +73,7 @@ func Run(args []string, stdout, stderr io.Writer) int { graphCommand(opts), architectureCommand(opts), uiCommand(opts), + updateCommand(opts), versionCommand(), ) @@ -81,7 +93,7 @@ func Run(args []string, stdout, stderr io.Writer) int { func message(err error) string { var unreachable *agent.Unreachable if errors.As(err, &unreachable) { - return fmt.Sprintf("no agent answering at %s. Start it with sourceant ui", unreachable.BaseURL) + return fmt.Sprintf("no agent answering at %s. Start it with sourceant start", unreachable.BaseURL) } return err.Error() } @@ -90,20 +102,43 @@ func agentDefault() string { if value := os.Getenv(EnvAgent); value != "" { return value } + if saved := install.SavedAgentURL(); saved != "" { + return saved + } return DefaultAgent } +// client talks to the agent, starting one where nothing answers. Anything that +// needs the agent is something somebody asked for, and asking them to run a +// second command first is a step the command can take itself. func (o *options) client() *agent.Client { + client := agent.New(o.agentURL, o.timeout) + agent.StartWith(client, func(ctx context.Context) (string, error) { + return ensureAgent(ctx, o, o.writer()) + }) + return client +} + +// plainClient reports on the agent rather than needing it, so it does not start +// one: asking whether something runs is not asking for it to run. +func (o *options) plainClient() *agent.Client { return agent.New(o.agentURL, o.timeout) } +func (o *options) writer() io.Writer { + if o.out == nil { + return io.Discard + } + return o.out +} + func statusCommand(opts *options) *cobra.Command { return &cobra.Command{ Use: "status", Short: "Whether the agent and the indexer are running", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - status, err := opts.client().Status(cmd.Context()) + status, err := opts.plainClient().Status(cmd.Context()) if err != nil { return err } diff --git a/internal/command/root_test.go b/internal/command/root_test.go index 85aa006..0d45bae 100644 --- a/internal/command/root_test.go +++ b/internal/command/root_test.go @@ -202,7 +202,7 @@ func TestAnAgentThatIsNotRunningSaysHowToStartIt(t *testing.T) { if code != 1 { t.Fatalf("exited %d, want 1", code) } - if !strings.Contains(stderr.String(), "Start it with sourceant ui") { + if !strings.Contains(stderr.String(), "Start it with sourceant start") { t.Errorf("got %q, want what to do about it", stderr.String()) } } diff --git a/internal/command/setup.go b/internal/command/setup.go index af09429..b202901 100644 --- a/internal/command/setup.go +++ b/internal/command/setup.go @@ -8,7 +8,7 @@ import ( "github.com/spf13/cobra" ) -func setupCommand() *cobra.Command { +func setupCommand(opts *options) *cobra.Command { var ( coreVersion string agentVersion string @@ -17,6 +17,7 @@ func setupCommand() *cobra.Command { from string noPull bool noAgent bool + noStart bool ) command := &cobra.Command{ Use: "setup", @@ -65,7 +66,19 @@ func setupCommand() *cobra.Command { _, _ = fmt.Fprintf(out, "Agent at %s\n\n", agentPath) } - _, _ = fmt.Fprintln(out, "Run sourceant ui to start it and open the view.") + if noStart || noAgent { + _, _ = fmt.Fprintln(out, "Run sourceant start when you want it running.") + return nil + } + // Installing worked whether or not it starts, so a machine that + // cannot start one is told why and left installed. + target, err := ensureAgent(cmd.Context(), opts, out) + if err != nil { + _, _ = fmt.Fprintf(out, "Installed, but not started: %v\n", err) + return nil + } + _, _ = fmt.Fprintf(out, "Running at %s\n\n", target) + _, _ = fmt.Fprintln(out, "Review your work with sourceant review, or open the view with sourceant ui.") return nil }, } @@ -76,6 +89,7 @@ func setupCommand() *cobra.Command { command.Flags().StringVar(&from, "from", "", "What pip installs, for the python runtime. Defaults to the selected core release wheel") command.Flags().BoolVar(&noPull, "no-pull", false, "Use an image already on this machine") command.Flags().BoolVar(&noAgent, "no-agent", false, "Leave the agent alone, install only the core") + command.Flags().BoolVar(&noStart, "no-start", false, "Install without starting anything") command.MarkFlagsMutuallyExclusive("core-version", "image") command.MarkFlagsMutuallyExclusive("core-version", "from") return command diff --git a/internal/command/start.go b/internal/command/start.go new file mode 100644 index 0000000..36ccdd7 --- /dev/null +++ b/internal/command/start.go @@ -0,0 +1,28 @@ +package command + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +func startCommand(opts *options) *cobra.Command { + return &cobra.Command{ + Use: "start", + Short: "Start the agent and the indexer", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + out := cmd.OutOrStdout() + if _, err := opts.plainClient().Status(cmd.Context()); err == nil { + _, _ = fmt.Fprintf(out, "Already running at %s\n", opts.agentURL) + return nil + } + target, err := ensureAgent(cmd.Context(), opts, out) + if err != nil { + return err + } + _, _ = fmt.Fprintln(out, target) + return nil + }, + } +} diff --git a/internal/command/stop.go b/internal/command/stop.go index d928c9a..68dd2e4 100644 --- a/internal/command/stop.go +++ b/internal/command/stop.go @@ -13,7 +13,7 @@ func stopCommand(opts *options) *cobra.Command { Short: "Stop the agent and its core", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - if err := opts.client().Stop(cmd.Context()); err != nil { + if err := opts.plainClient().Stop(cmd.Context()); err != nil { if agent.IsConnectionRefused(err) { _, _ = fmt.Fprintln(cmd.OutOrStdout(), "SourceAnt is already stopped.") return nil diff --git a/internal/command/ui.go b/internal/command/ui.go index 8d9f034..9bc9cb6 100644 --- a/internal/command/ui.go +++ b/internal/command/ui.go @@ -1,15 +1,10 @@ package command import ( - "context" "fmt" - "os" "os/exec" "runtime" - "syscall" - "time" - "github.com/sourceant/cli/internal/install" "github.com/spf13/cobra" ) @@ -20,18 +15,21 @@ func uiCommand(opts *options) *cobra.Command { Short: "Open the graph in a browser", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - // Asking the agent first turns "the browser opened on an error - // page" into a line saying the agent is not running. - if _, err := opts.client().Status(cmd.Context()); err != nil { - if err := start(cmd.Context(), opts, cmd.OutOrStdout()); err != nil { + out := cmd.OutOrStdout() + // Asked first, so a browser does not open on an error page. + target := opts.agentURL + if _, err := opts.plainClient().Status(cmd.Context()); err != nil { + started, err := ensureAgent(cmd.Context(), opts, out) + if err != nil { return err } + target = started } - _, _ = fmt.Fprintln(cmd.OutOrStdout(), opts.agentURL) + _, _ = fmt.Fprintln(out, target) if stayPut { return nil } - if err := open(opts.agentURL); err != nil { + if err := open(target); err != nil { _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "Could not open a browser. Follow the address above.") } return nil @@ -41,46 +39,6 @@ func uiCommand(opts *options) *cobra.Command { return command } -// start runs the installed agent and waits for it to answer. It outlives this -// process, because the agent is the thing that stays up. -func start(ctx context.Context, opts *options, out interface{ Write([]byte) (int, error) }) error { - path := install.AgentPath() - if _, err := os.Stat(path); err != nil { - return fmt.Errorf("no agent is running and none is installed here. Run sourceant setup") - } - - logPath := install.Home() + "/agent.log" - log, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) - if err != nil { - return err - } - defer log.Close() - - agent := exec.Command(path) - agent.Stdout = log - agent.Stderr = log - agent.SysProcAttr = &syscall.SysProcAttr{Setsid: true} - if err := agent.Start(); err != nil { - return fmt.Errorf("could not start the agent: %w", err) - } - _, _ = fmt.Fprintf(out, "Started the agent. It logs to %s\n", logPath) - - // The agent has to start the core before it answers, which is the slow - // part on a first run. - deadline := time.Now().Add(90 * time.Second) - for time.Now().Before(deadline) { - if _, err := opts.client().Status(ctx); err == nil { - return nil - } - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(time.Second): - } - } - return fmt.Errorf("the agent did not answer within 90s. See %s", logPath) -} - // open hands a URL to whatever the desktop uses for one. func open(target string) error { var name string diff --git a/internal/command/update.go b/internal/command/update.go new file mode 100644 index 0000000..86f97a2 --- /dev/null +++ b/internal/command/update.go @@ -0,0 +1,214 @@ +package command + +import ( + "context" + "fmt" + "io" + "os" + "runtime" + "strings" + + "github.com/sourceant/cli/internal/install" + "github.com/sourceant/cli/internal/presentation" + "github.com/sourceant/cli/internal/update" + "github.com/spf13/cobra" +) + +func updateCommand(opts *options) *cobra.Command { + var ( + checkOnly bool + prerelease bool + to string + ) + command := &cobra.Command{ + Use: "update [cli|agent|core]...", + Short: "Bring this machine up to the current release", + Long: "Updates this command, the agent and the core. Name parts to update only those. Nothing is replaced until its checksum matches the release it came from.", + ValidArgs: []string{"cli", "agent", "core"}, + Args: cobra.OnlyValidArgs, + RunE: func(cmd *cobra.Command, args []string) error { + out := cmd.OutOrStdout() + wanted := map[string]bool{} + for _, name := range args { + wanted[name] = true + } + // Named nothing means all of it: somebody asking to update wants a + // machine that is up to date, not a choice of three. + if len(wanted) == 0 { + wanted = map[string]bool{"cli": true, "agent": true, "core": true} + } + if to != "" && len(wanted) != 1 { + return fmt.Errorf("--to names a version of one part, so name the part: sourceant update agent --to %s", to) + } + // A machine on a prerelease keeps up with prereleases, whether or + // not anybody asks: the alternative is telling somebody on beta.4 + // that beta.5 does not exist. + if strings.Contains(Version, "-") { + prerelease = true + } + + rows := make([][]string, 0, len(wanted)) + if wanted["cli"] { + rows = append(rows, part(cmd.Context(), "cli", install.CLIRepo, Version, cliArchive, replaceSelf, to, prerelease, checkOnly, out)) + } + if wanted["agent"] { + rows = append(rows, agentRow(cmd.Context(), opts, to, prerelease, checkOnly, out)) + } + if wanted["core"] { + rows = append(rows, coreRow(cmd.Context(), to, prerelease, checkOnly, out)) + } + presentation.Table(out, []string{"PART", "HAVE", "AVAILABLE", ""}, rows) + return nil + }, + } + command.Flags().BoolVar(&checkOnly, "check", false, "Say what is available and change nothing") + command.Flags().BoolVar(&prerelease, "prerelease", false, "Include prereleases") + command.Flags().StringVar(&to, "to", "", "A version other than the newest, for the part named") + return command +} + +// part is one component's row: what is installed, what is published, and what +// was done about it. +func part(ctx context.Context, name, repo, have, archive string, replace func([]byte) error, to string, prerelease, checkOnly bool, out io.Writer) []string { + release, err := published(ctx, repo, to, prerelease) + if err != nil { + return []string{name, have, "", err.Error()} + } + there := release.Version() + if have == "dev" { + return []string{name, have, there, "a build of your own is left alone"} + } + newer, err := update.Newer(there, have) + if err != nil { + return []string{name, have, there, err.Error()} + } + if !newer && to == "" { + return []string{name, have, there, "current"} + } + if checkOnly { + return []string{name, have, there, "can be updated"} + } + binary, err := update.Binary(ctx, update.Read, release, fmt.Sprintf(archive, there, runtime.GOOS, runtime.GOARCH)) + if err != nil { + return []string{name, have, there, err.Error()} + } + if err := replace(binary); err != nil { + return []string{name, have, there, err.Error()} + } + return []string{name, have, there, "updated"} +} + +const cliArchive = "sourceant-%s-%s-%s.tar.gz" + +// replaceSelf writes over the command that is running, which is safe on a +// rename: this process keeps the file it opened and the next one gets the new +// binary. +func replaceSelf(binary []byte) error { + path, err := install.Self() + if err != nil { + return err + } + return update.Replace(path, binary) +} + +const agentArchive = "sourceant-agent-%s-%s-%s.tar.gz" + +func agentRow(ctx context.Context, opts *options, to string, prerelease, checkOnly bool, out io.Writer) []string { + have := "not installed" + if status, err := opts.plainClient().Status(ctx); err == nil { + have = status.Version + } else if _, err := os.Stat(install.AgentPath()); err == nil { + have = "unknown" + } + replace := func(binary []byte) error { + // Stopped first: a running agent holds the file it was started from, + // and the new one only runs once something restarts it. + running := false + if _, err := opts.plainClient().Status(ctx); err == nil { + running = true + _ = opts.plainClient().Stop(ctx) + } + if err := update.Replace(install.AgentPath(), binary); err != nil { + return err + } + if !running { + return nil + } + _, err := ensureAgent(ctx, opts, out) + return err + } + if have == "not installed" || have == "unknown" { + release, err := published(ctx, install.AgentRepo, to, prerelease) + if err != nil { + return []string{"agent", have, "", err.Error()} + } + there := release.Version() + if checkOnly { + return []string{"agent", have, there, "can be installed"} + } + binary, err := update.Binary(ctx, update.Read, release, fmt.Sprintf(agentArchive, there, runtime.GOOS, runtime.GOARCH)) + if err != nil { + return []string{"agent", have, there, err.Error()} + } + if err := replace(binary); err != nil { + return []string{"agent", have, there, err.Error()} + } + return []string{"agent", have, there, "installed"} + } + return part(ctx, "agent", install.AgentRepo, have, agentArchive, replace, to, prerelease, checkOnly, out) +} + +// coreRow updates the core through the installer, which knows whether this +// machine runs it in a container or as a Python program. +func coreRow(ctx context.Context, to string, prerelease, checkOnly bool, out io.Writer) []string { + release, err := published(ctx, install.CoreRepo, to, prerelease) + if err != nil { + return []string{"core", "", "", err.Error()} + } + there := release.Version() + config, err := install.Load(install.ConfigPath()) + if err != nil { + return []string{"core", "", there, err.Error()} + } + have := config.Core.Describe() + if config.Core.Runtime == "" { + have = "not installed" + } + if checkOnly { + return []string{"core", have, there, installedOrUpdated(have, "can be")} + } + written, err := install.Install(install.Options{ + Runtime: config.Core.Runtime, + Image: config.Core.Image, + Pull: true, + Version: there, + Out: io.Discard, + }, install.Run) + if err != nil { + return []string{"core", have, there, err.Error()} + } + written.Agent = config.Agent + if err := install.Save(install.ConfigPath(), written); err != nil { + return []string{"core", have, there, err.Error()} + } + return []string{"core", have, there, installedOrUpdated(have, "")} +} + +func published(ctx context.Context, repo, to string, prerelease bool) (update.Release, error) { + if to != "" { + return update.Named(ctx, update.Read, repo, to) + } + return update.Latest(ctx, update.Read, repo, prerelease) +} + +// installedOrUpdated says which of the two words fits what was there before. +func installedOrUpdated(have, prefix string) string { + word := "updated" + if have == "not installed" { + word = "installed" + } + if prefix == "" { + return word + } + return prefix + " " + word +} diff --git a/internal/command/update_test.go b/internal/command/update_test.go new file mode 100644 index 0000000..b430aed --- /dev/null +++ b/internal/command/update_test.go @@ -0,0 +1,80 @@ +package command + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// forge answers as the release API does, for whichever repositories a test +// names. +func forge(t *testing.T, versions map[string]string) *httptest.Server { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + for repo, version := range versions { + if !strings.HasPrefix(r.URL.Path, "/"+repo+"/releases") { + continue + } + body := map[string]any{"tag_name": "v" + version} + if strings.Contains(r.URL.RawQuery, "per_page") { + _ = json.NewEncoder(w).Encode([]map[string]any{body}) + return + } + _ = json.NewEncoder(w).Encode(body) + return + } + w.WriteHeader(http.StatusNotFound) + })) + t.Cleanup(server.Close) + return server +} + +func TestCheckSaysWhatIsAvailableAndChangesNothing(t *testing.T) { + server := forge(t, map[string]string{ + "sourceant/cli": "9.9.9", + "sourceant/agent": "9.9.9", + "sourceant/sourceant": "9.9.9", + }) + t.Setenv("SOURCEANT_RELEASES_BASE", server.URL) + t.Setenv("SOURCEANT_INSTALL_HOME", t.TempDir()) + Version = "1.0.0" + t.Cleanup(func() { Version = "dev" }) + + var stdout, stderr bytes.Buffer + if code := Run([]string{"--agent", "http://127.0.0.1:1", "update", "--check"}, &stdout, &stderr); code != 0 { + t.Fatalf("exited %d: %s", code, stderr.String()) + } + printed := stdout.String() + for _, want := range []string{"cli", "agent", "core", "9.9.9", "can be"} { + if !strings.Contains(printed, want) { + t.Errorf("got %q, want it to name %q", printed, want) + } + } +} + +func TestABuildOfYourOwnIsLeftAlone(t *testing.T) { + server := forge(t, map[string]string{"sourceant/cli": "9.9.9"}) + t.Setenv("SOURCEANT_RELEASES_BASE", server.URL) + t.Setenv("SOURCEANT_INSTALL_HOME", t.TempDir()) + + var stdout, stderr bytes.Buffer + if code := Run([]string{"--agent", "http://127.0.0.1:1", "update", "cli"}, &stdout, &stderr); code != 0 { + t.Fatalf("exited %d: %s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), "a build of your own is left alone") { + t.Errorf("got %q", stdout.String()) + } +} + +func TestNamingAVersionNeedsAPartToNameItFor(t *testing.T) { + var stdout, stderr bytes.Buffer + if code := Run([]string{"update", "--to", "1.2.3"}, &stdout, &stderr); code == 0 { + t.Fatal("took a version without being told what it is for") + } + if !strings.Contains(stderr.String(), "name the part") { + t.Errorf("got %q", stderr.String()) + } +} diff --git a/internal/install/install.go b/internal/install/install.go index ead793d..4225019 100644 --- a/internal/install/install.go +++ b/internal/install/install.go @@ -28,6 +28,9 @@ const ( // DefaultImage is the published core. const DefaultImage = "ghcr.io/sourceant/sourceant:latest" +// CLIRepo publishes this command. +const CLIRepo = "sourceant/cli" + // CoreRepo publishes the core. const CoreRepo = "sourceant/sourceant" @@ -70,6 +73,13 @@ type Core struct { // Config is the whole file. type Config struct { Core Core `json:"core"` + // Agent is where the agent answers, written when it is not the default. + Agent Agent `json:"agent,omitempty"` +} + +// Agent is where this machine's agent listens. +type Agent struct { + URL string `json:"url,omitempty"` } // Home is where SourceAnt keeps what it installed. @@ -106,6 +116,45 @@ func DataDir() string { } // Save writes the runtime for the agent to read. +// Load reads what was written here, or an empty config where nothing was. +func Load(path string) (Config, error) { + var config Config + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return config, nil + } + return config, err + } + if err := json.Unmarshal(data, &config); err != nil { + return config, fmt.Errorf("%s is not readable as configuration: %w", path, err) + } + return config, nil +} + +// SavedAgentURL is the address written here, or empty. +func SavedAgentURL() string { + config, err := Load(ConfigPath()) + if err != nil { + return "" + } + return config.Agent.URL +} + +// SaveAgentURL records where the agent answers, leaving the rest alone. +func SaveAgentURL(url string) error { + path := ConfigPath() + config, err := Load(path) + if err != nil { + return err + } + if config.Agent.URL == url { + return nil + } + config.Agent.URL = url + return Save(path, config) +} + func Save(path string, config Config) error { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err @@ -285,3 +334,12 @@ func Chosen(run Runner) Runtime { } return Python } + +// Self is where this command is on disk, for replacing it with a newer one. +func Self() (string, error) { + path, err := os.Executable() + if err != nil { + return "", err + } + return path, nil +} diff --git a/internal/install/install_test.go b/internal/install/install_test.go index 5bfeb6b..c49a202 100644 --- a/internal/install/install_test.go +++ b/internal/install/install_test.go @@ -226,3 +226,37 @@ func TestThePythonRuntimePreparesTheDatabase(t *testing.T) { t.Errorf("a core was installed without a schema:\n%s", run.commands()) } } + +func TestSavingWhereTheAgentAnswersKeepsTheRest(t *testing.T) { + home := t.TempDir() + t.Setenv("SOURCEANT_INSTALL_HOME", home) + path := ConfigPath() + if err := Save(path, Config{Core: Core{Runtime: Docker, Image: "ghcr.io/sourceant/sourceant:latest"}}); err != nil { + t.Fatal(err) + } + + if err := SaveAgentURL("http://127.0.0.1:8931"); err != nil { + t.Fatal(err) + } + + config, err := Load(path) + if err != nil { + t.Fatal(err) + } + if config.Agent.URL != "http://127.0.0.1:8931" { + t.Errorf("agent url is %q", config.Agent.URL) + } + if config.Core.Image != "ghcr.io/sourceant/sourceant:latest" || config.Core.Runtime != Docker { + t.Errorf("the core was rewritten: %+v", config.Core) + } + if SavedAgentURL() != "http://127.0.0.1:8931" { + t.Errorf("read back %q", SavedAgentURL()) + } +} + +func TestNoConfigIsNoAgentAddress(t *testing.T) { + t.Setenv("SOURCEANT_INSTALL_HOME", t.TempDir()) + if SavedAgentURL() != "" { + t.Errorf("got %q from a machine with no configuration", SavedAgentURL()) + } +} diff --git a/internal/update/update.go b/internal/update/update.go new file mode 100644 index 0000000..75732c7 --- /dev/null +++ b/internal/update/update.go @@ -0,0 +1,328 @@ +// Package update replaces what is installed with what a release holds. +// +// Every download is checked against the release's own checksums before it is +// written anywhere, and a replacement is renamed over the old file rather than +// written through it, so an interrupted update leaves the old one working. +package update + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +// Largest thing worth fetching. An agent archive is about 10 MiB. +const most = 100 << 20 + +// Release is one published release and what it carries. +type Release struct { + Tag string `json:"tag_name"` + Draft bool `json:"draft"` + Prerelease bool `json:"prerelease"` + Assets []Asset `json:"assets"` +} + +// Version is the release's version, without the leading v. +func (r Release) Version() string { return strings.TrimPrefix(r.Tag, "v") } + +// Asset is one file on a release. +type Asset struct { + Name string `json:"name"` + URL string `json:"browser_download_url"` +} + +// Fetcher reads a URL. +type Fetcher func(ctx context.Context, url string) ([]byte, error) + +// Latest is the newest release of a repository. Prereleases are included when +// asked for, which is what a machine already running one wants. +func Latest(ctx context.Context, get Fetcher, repo string, prerelease bool) (Release, error) { + base := api(repo) + if !prerelease { + body, err := get(ctx, base+"/latest") + if err != nil { + return Release{}, err + } + var found Release + if err := json.Unmarshal(body, &found); err != nil { + return Release{}, fmt.Errorf("%s answered with something other than a release: %w", repo, err) + } + return found, nil + } + body, err := get(ctx, base+"?per_page=20") + if err != nil { + return Release{}, err + } + var found []Release + if err := json.Unmarshal(body, &found); err != nil { + return Release{}, fmt.Errorf("%s answered with something other than releases: %w", repo, err) + } + for _, release := range found { + if !release.Draft { + return release, nil + } + } + return Release{}, fmt.Errorf("%s has published no release", repo) +} + +// Named is one release by version, whether or not it is the latest. +func Named(ctx context.Context, get Fetcher, repo, version string) (Release, error) { + body, err := get(ctx, api(repo)+"/tags/v"+strings.TrimPrefix(version, "v")) + if err != nil { + return Release{}, fmt.Errorf("%s has no release %s: %w", repo, version, err) + } + var found Release + if err := json.Unmarshal(body, &found); err != nil { + return Release{}, err + } + return found, nil +} + +// Binary is the verified binary held in one of a release's archives. +func Binary(ctx context.Context, get Fetcher, release Release, archive string) ([]byte, error) { + asset, ok := find(release.Assets, archive) + if !ok { + return nil, fmt.Errorf("release %s does not carry %s", release.Version(), archive) + } + sums, ok := find(release.Assets, "checksums.txt") + if !ok { + return nil, fmt.Errorf("release %s publishes no checksums", release.Version()) + } + body, err := get(ctx, asset.URL) + if err != nil { + return nil, err + } + listed, err := get(ctx, sums.URL) + if err != nil { + return nil, err + } + if err := verify(archive, body, string(listed)); err != nil { + return nil, err + } + return first(body) +} + +// Replace puts binary where path is, by rename, keeping the old file's mode. +func Replace(path string, binary []byte) error { + if resolved, err := filepath.EvalSymlinks(path); err == nil { + path = resolved + } + mode := os.FileMode(0o755) + if info, err := os.Stat(path); err == nil { + mode = info.Mode().Perm() + } + temporary, err := os.CreateTemp(filepath.Dir(path), ".sourceant-update-*") + if err != nil { + return fmt.Errorf("could not write beside %s: %w", path, err) + } + name := temporary.Name() + defer func() { _ = os.Remove(name) }() + if _, err := temporary.Write(binary); err != nil { + _ = temporary.Close() + return err + } + if err := temporary.Chmod(mode); err != nil { + _ = temporary.Close() + return err + } + if err := temporary.Sync(); err != nil { + _ = temporary.Close() + return err + } + if err := temporary.Close(); err != nil { + return err + } + return os.Rename(name, path) +} + +// Newer says whether candidate is a later version than current. +func Newer(candidate, current string) (bool, error) { + later, err := parse(candidate) + if err != nil { + return false, err + } + now, err := parse(current) + if err != nil { + return false, err + } + for i := 0; i < 3; i++ { + if later.numbers[i] != now.numbers[i] { + return later.numbers[i] > now.numbers[i], nil + } + } + switch { + case later.pre == now.pre: + return false, nil + case later.pre == "": + return true, nil + case now.pre == "": + return false, nil + } + return compare(later.pre, now.pre) > 0, nil +} + +// Read fetches a URL, capped, with the headers the forge asks for. +func Read(ctx context.Context, url string) ([]byte, error) { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + request.Header.Set("Accept", "application/vnd.github+json") + request.Header.Set("User-Agent", "sourceant-cli") + answer, err := (&http.Client{Timeout: 2 * time.Minute}).Do(request) + if err != nil { + return nil, err + } + defer func() { _ = answer.Body.Close() }() + if answer.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%s answered %s", url, answer.Status) + } + body, err := io.ReadAll(io.LimitReader(answer.Body, most+1)) + if err != nil { + return nil, err + } + if len(body) > most { + return nil, fmt.Errorf("%s is larger than %d bytes", url, most) + } + return body, nil +} + +func api(repo string) string { + if base := os.Getenv("SOURCEANT_RELEASES_BASE"); base != "" { + return strings.TrimRight(base, "/") + "/" + repo + "/releases" + } + return "https://api.github.com/repos/" + repo + "/releases" +} + +func find(assets []Asset, name string) (Asset, bool) { + for _, asset := range assets { + if asset.Name == name { + return asset, true + } + } + return Asset{}, false +} + +func verify(name string, body []byte, checksums string) error { + want := "" + for _, line := range strings.Split(checksums, "\n") { + fields := strings.Fields(line) + if len(fields) == 2 && strings.TrimPrefix(fields[1], "*") == name { + want = fields[0] + break + } + } + if want == "" { + return fmt.Errorf("the checksums do not cover %s", name) + } + sum := sha256.Sum256(body) + if !strings.EqualFold(hex.EncodeToString(sum[:]), want) { + return fmt.Errorf("%s does not match its checksum", name) + } + return nil +} + +// first is the first regular file in a gzipped tar, which is how both archives +// carry their one binary. +func first(archive []byte) ([]byte, error) { + zipped, err := gzip.NewReader(bytes.NewReader(archive)) + if err != nil { + return nil, fmt.Errorf("the archive is not gzip: %w", err) + } + defer func() { _ = zipped.Close() }() + reader := tar.NewReader(zipped) + for { + header, err := reader.Next() + if errors.Is(err, io.EOF) { + return nil, errors.New("the archive holds no file") + } + if err != nil { + return nil, err + } + if header.Typeflag != tar.TypeReg { + continue + } + if header.Size < 0 || header.Size > most { + return nil, errors.New("the file in the archive is too large") + } + body, err := io.ReadAll(io.LimitReader(reader, header.Size)) + if err != nil { + return nil, err + } + if int64(len(body)) != header.Size { + return nil, errors.New("the file in the archive is truncated") + } + return body, nil + } +} + +type parts struct { + numbers [3]int + pre string +} + +func parse(value string) (parts, error) { + value = strings.TrimPrefix(value, "v") + value = strings.SplitN(value, "+", 2)[0] + split := strings.SplitN(value, "-", 2) + numbers := strings.Split(split[0], ".") + if len(numbers) != 3 { + return parts{}, fmt.Errorf("%q is not a version", value) + } + var found parts + for i, number := range numbers { + parsed, err := strconv.Atoi(number) + if err != nil || parsed < 0 { + return parts{}, fmt.Errorf("%q is not a version", value) + } + found.numbers[i] = parsed + } + if len(split) == 2 { + found.pre = split[1] + } + return found, nil +} + +func compare(left, right string) int { + l := strings.FieldsFunc(left, func(r rune) bool { return r == '.' || r == '-' }) + r := strings.FieldsFunc(right, func(r rune) bool { return r == '.' || r == '-' }) + for i := 0; i < len(l) && i < len(r); i++ { + ln, le := strconv.Atoi(l[i]) + rn, re := strconv.Atoi(r[i]) + if le == nil && re == nil && ln != rn { + if ln > rn { + return 1 + } + return -1 + } + if l[i] != r[i] { + if le == nil { + return -1 + } + if re == nil || l[i] > r[i] { + return 1 + } + return -1 + } + } + switch { + case len(l) > len(r): + return 1 + case len(l) < len(r): + return -1 + } + return 0 +} diff --git a/internal/update/update_test.go b/internal/update/update_test.go new file mode 100644 index 0000000..e26cd84 --- /dev/null +++ b/internal/update/update_test.go @@ -0,0 +1,172 @@ +package update + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "testing" +) + +func archive(t *testing.T, name string, body []byte) []byte { + t.Helper() + var packed bytes.Buffer + zipped := gzip.NewWriter(&packed) + writer := tar.NewWriter(zipped) + if err := writer.WriteHeader(&tar.Header{Name: name, Mode: 0o755, Size: int64(len(body)), Typeflag: tar.TypeReg}); err != nil { + t.Fatal(err) + } + if _, err := writer.Write(body); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + if err := zipped.Close(); err != nil { + t.Fatal(err) + } + return packed.Bytes() +} + +func sum(body []byte) string { + digest := sha256.Sum256(body) + return hex.EncodeToString(digest[:]) +} + +func TestAnArchiveThatMatchesItsChecksumGivesUpTheBinary(t *testing.T) { + packed := archive(t, "sourceant-1.0.0-linux-amd64", []byte("the binary")) + checksums := sum(packed) + " sourceant-1.0.0-linux-amd64.tar.gz\n" + get := func(_ context.Context, url string) ([]byte, error) { + if url == "archive" { + return packed, nil + } + return []byte(checksums), nil + } + release := Release{Tag: "v1.0.0", Assets: []Asset{ + {Name: "sourceant-1.0.0-linux-amd64.tar.gz", URL: "archive"}, + {Name: "checksums.txt", URL: "sums"}, + }} + + binary, err := Binary(context.Background(), get, release, "sourceant-1.0.0-linux-amd64.tar.gz") + if err != nil { + t.Fatal(err) + } + if string(binary) != "the binary" { + t.Errorf("got %q", binary) + } +} + +func TestAnArchiveThatDoesNotMatchIsRefused(t *testing.T) { + packed := archive(t, "sourceant-1.0.0-linux-amd64", []byte("the binary")) + get := func(_ context.Context, url string) ([]byte, error) { + if url == "archive" { + return packed, nil + } + return []byte(sum([]byte("something else")) + " sourceant-1.0.0-linux-amd64.tar.gz\n"), nil + } + release := Release{Tag: "v1.0.0", Assets: []Asset{ + {Name: "sourceant-1.0.0-linux-amd64.tar.gz", URL: "archive"}, + {Name: "checksums.txt", URL: "sums"}, + }} + + if _, err := Binary(context.Background(), get, release, "sourceant-1.0.0-linux-amd64.tar.gz"); err == nil { + t.Fatal("took an archive whose checksum did not match") + } +} + +func TestAReleaseWithoutChecksumsIsRefused(t *testing.T) { + release := Release{Tag: "v1.0.0", Assets: []Asset{{Name: "sourceant-1.0.0-linux-amd64.tar.gz", URL: "archive"}}} + get := func(context.Context, string) ([]byte, error) { return nil, nil } + + if _, err := Binary(context.Background(), get, release, "sourceant-1.0.0-linux-amd64.tar.gz"); err == nil { + t.Fatal("took a release that publishes no checksums") + } +} + +func TestReplacingKeepsTheOldFileUntilTheNewOneIsWhole(t *testing.T) { + path := filepath.Join(t.TempDir(), "sourceant") + if err := os.WriteFile(path, []byte("old"), 0o755); err != nil { + t.Fatal(err) + } + + if err := Replace(path, []byte("new")); err != nil { + t.Fatal(err) + } + + body, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(body) != "new" { + t.Errorf("got %q", body) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o755 { + t.Errorf("mode is %v", info.Mode().Perm()) + } + left, err := filepath.Glob(filepath.Join(filepath.Dir(path), ".sourceant-update-*")) + if err != nil { + t.Fatal(err) + } + if len(left) != 0 { + t.Errorf("left %v behind", left) + } +} + +func TestOneVersionIsNewerThanAnother(t *testing.T) { + for _, item := range []struct { + candidate, current string + want bool + }{ + {"1.0.1", "1.0.0", true}, + {"1.0.0", "1.0.0", false}, + {"1.0.0", "1.0.1", false}, + {"1.0.0", "1.0.0-beta.5", true}, + {"1.0.0-beta.5", "1.0.0-beta.4", true}, + {"1.0.0-beta.4", "1.0.0-beta.5", false}, + {"1.0.0-beta.10", "1.0.0-beta.9", true}, + } { + got, err := Newer(item.candidate, item.current) + if err != nil { + t.Fatalf("%s vs %s: %v", item.candidate, item.current, err) + } + if got != item.want { + t.Errorf("%s newer than %s: got %v", item.candidate, item.current, got) + } + } +} + +func TestAPrereleaseIsSkippedUnlessAskedFor(t *testing.T) { + t.Setenv("SOURCEANT_RELEASES_BASE", "https://example.invalid") + asked := "" + get := func(_ context.Context, url string) ([]byte, error) { + asked = url + if url == "https://example.invalid/sourceant/cli/releases/latest" { + return []byte(`{"tag_name":"v0.9.0"}`), nil + } + return []byte(`[{"tag_name":"v1.0.0-beta.4","prerelease":true}]`), nil + } + + stable, err := Latest(context.Background(), get, "sourceant/cli", false) + if err != nil { + t.Fatal(err) + } + if stable.Version() != "0.9.0" { + t.Errorf("got %s from %s", stable.Version(), asked) + } + + early, err := Latest(context.Background(), get, "sourceant/cli", true) + if err != nil { + t.Fatal(err) + } + if early.Version() != "1.0.0-beta.4" { + t.Errorf("got %s from %s", early.Version(), asked) + } +}