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
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|---|---|---|
Expand All @@ -93,10 +93,14 @@ Both put the index in the same place, `$XDG_DATA_HOME/sourceant`, so it does not
| `sourceant graph <repository>` | What the indexer found in one of them |
| `sourceant architecture <repository>` | 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 <ref>` 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 <version>` 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
Expand Down
56 changes: 55 additions & 1 deletion internal/agent/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"net/url"
"strconv"
"strings"
"sync"
"syscall"
"time"
)
Expand Down Expand Up @@ -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.
Expand Down
67 changes: 67 additions & 0 deletions internal/agent/client_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
123 changes: 123 additions & 0 deletions internal/command/lifecycle.go
Original file line number Diff line number Diff line change
@@ -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())
}
80 changes: 80 additions & 0 deletions internal/command/lifecycle_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading
Loading