diff --git a/.github/workflows/install.yml b/.github/workflows/install.yml new file mode 100644 index 0000000..4ffba01 --- /dev/null +++ b/.github/workflows/install.yml @@ -0,0 +1,42 @@ +name: Install script + +on: + push: + branches: [main] + pull_request: + release: + types: [published] + schedule: + # Whatever is published can rot without anybody touching this repository: + # a moved asset name, a deleted release, a GitHub change. + - cron: '0 6 * * 1' + workflow_dispatch: + +jobs: + # The script is exercised on a machine with nothing on it, because that is + # the machine somebody runs it on. + container: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: It installs a release into a bare Ubuntu + run: sh scripts/test-install.sh + + # This one reads the script from main and installs whatever is released, so + # it says nothing about a change under review. It runs once something is + # published, and weekly after that. + published: + if: github.event_name != 'pull_request' && github.event_name != 'push' + runs-on: ubuntu-latest + container: ubuntu:24.04 + steps: + - name: Tools a bare machine has + run: | + apt-get update -qq + apt-get install -y -qq curl ca-certificates + + - name: Install the published CLI + run: | + curl -fsSL https://raw.githubusercontent.com/sourceant/cli/main/scripts/install.sh | sh + sourceant version diff --git a/CHANGELOG.md b/CHANGELOG.md index e25317e..3ff759f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,9 +13,10 @@ First release, versioned alongside the core it reads. ### Added -- `sourceant install` puts a core on this machine and writes down which one, so - the agent knows what to start. As a container today, or as a Python package - once the core is published as one +- `sourceant setup` puts the agent and a core on this machine and writes down + which one, so the agent knows what to start. As a container today, or as a + Python package once the core is published as one +- An install script, so the command is the only thing anybody fetches by hand - `sourceant status` says whether the agent and the indexer are running - `sourceant repos` lists what is indexed here, and `sourceant graph` says what the indexer found in one of them diff --git a/README.md b/README.md index 9fdba93..2e71f53 100644 --- a/README.md +++ b/README.md @@ -36,17 +36,17 @@ The CLI never reaches past the agent. The agent is the process that is always up ## Installing ```bash -make build -./sourceant install +curl -fsSL https://raw.githubusercontent.com/sourceant/cli/main/scripts/install.sh | sh +sourceant setup ``` -`install` puts a core on this machine and writes down which one, so the agent knows what to start. +`setup` puts the agent and a core on this machine and writes down which one, so the agent knows what to start. Two ways to have it. `--runtime docker` pulls the published image, and is what works today. `--runtime python` builds a virtual environment and pip installs the core, for when the core is published as a package; until then it says so rather than recording something that will not start. 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. -Then start the agent. See [sourceant/agent](https://github.com/sourceant/agent). +`sourceant ui` starts the agent and opens the view. | Variable | Default | Meaning | |---|---|---| @@ -58,7 +58,7 @@ Then start the agent. See [sourceant/agent](https://github.com/sourceant/agent). | Command | What it does | |---|---| -| `sourceant install` | Put a core on this machine | +| `sourceant setup` | Put the agent and a core on this machine | | `sourceant status` | Whether the agent and the indexer are running | | `sourceant repos` | Repositories indexed on this machine | | `sourceant graph ` | What the indexer found in one of them | diff --git a/internal/command/root.go b/internal/command/root.go index 708abb4..fd32991 100644 --- a/internal/command/root.go +++ b/internal/command/root.go @@ -52,7 +52,7 @@ func Run(args []string, stdout, stderr io.Writer) int { root.PersistentFlags().BoolVar(&opts.asJSON, "json", false, "Print the agent's answer as JSON") root.AddCommand( - installCommand(), + setupCommand(), statusCommand(opts), reposCommand(opts), graphCommand(opts), diff --git a/internal/command/root_test.go b/internal/command/root_test.go index 5c46f11..ea579e6 100644 --- a/internal/command/root_test.go +++ b/internal/command/root_test.go @@ -175,7 +175,10 @@ func TestUIPrintsTheAddressWithoutOpeningAnything(t *testing.T) { } } -func TestUISaysTheAgentIsDownRatherThanOpeningAnErrorPage(t *testing.T) { +func TestUISaysWhatToInstallRatherThanOpeningAnErrorPage(t *testing.T) { + // A home with no agent in it, so the test cannot start one that happens to + // be installed on the machine running it. + t.Setenv("SOURCEANT_INSTALL_HOME", t.TempDir()) var stdout, stderr bytes.Buffer code := Run([]string{"--agent", "http://127.0.0.1:1", "ui", "--no-open"}, &stdout, &stderr) @@ -183,7 +186,7 @@ func TestUISaysTheAgentIsDownRatherThanOpeningAnErrorPage(t *testing.T) { if code != 1 { t.Fatalf("exited %d, want 1", code) } - if !strings.Contains(stderr.String(), "Start it with sourceant-agent") { + if !strings.Contains(stderr.String(), "sourceant setup") { t.Errorf("got %q, want what to do about it", stderr.String()) } } diff --git a/internal/command/install.go b/internal/command/setup.go similarity index 51% rename from internal/command/install.go rename to internal/command/setup.go index feaa57e..e6b31ca 100644 --- a/internal/command/install.go +++ b/internal/command/setup.go @@ -8,25 +8,34 @@ import ( "github.com/spf13/cobra" ) -func installCommand() *cobra.Command { +func setupCommand() *cobra.Command { var ( runtime string image string from string noPull bool + noAgent bool ) command := &cobra.Command{ - Use: "install", - Short: "Put a SourceAnt core on this machine", - Long: "Two ways to have the core. As a container, which is what exists today. " + - "Or as a Python program, for when the core is published as a package.", + Use: "setup", + Short: "Set this machine up to run SourceAnt", + Long: "Installs the agent and a core, so the only thing anybody had to " + + "fetch by hand is this command. Two ways to have the core. As a " + + "container where there is Docker, and as a Python program where " + + "there is not. Name a runtime to decide it yourself.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { + // Nobody should have to know which runtime they have. Asked for + // neither, take the container where Docker answers. + if !cmd.Flags().Changed("runtime") { + runtime = string(install.Chosen(install.Run)) + } config, err := install.Install(install.Options{ Runtime: install.Runtime(runtime), Image: image, From: from, Pull: !noPull, + Version: Version, Out: cmd.OutOrStdout(), }, install.Run) if err != nil { @@ -45,13 +54,23 @@ func installCommand() *cobra.Command { _, _ = fmt.Fprintf(out, "\nInstalled %s\n", config.Core.Describe()) _, _ = fmt.Fprintf(out, "Index at %s\n", config.Core.DataDir) _, _ = fmt.Fprintf(out, "Written to %s\n\n", path) - _, _ = fmt.Fprintln(out, "Start it with sourceant-agent, then sourceant ui.") + + if !noAgent { + agentPath, err := install.InstallAgent(Version, install.Get, out) + if err != nil { + return fmt.Errorf("the core is installed, but the agent is not: %w", err) + } + _, _ = fmt.Fprintf(out, "Agent at %s\n\n", agentPath) + } + + _, _ = fmt.Fprintln(out, "Run sourceant ui to start it and open the view.") return nil }, } - command.Flags().StringVar(&runtime, "runtime", string(install.Docker), "docker or python") + command.Flags().StringVar(&runtime, "runtime", string(install.Docker), "docker or python. Chosen for you when not named") command.Flags().StringVar(&image, "image", install.DefaultImage, "The container to use, for the docker runtime") - command.Flags().StringVar(&from, "from", install.DefaultPackage, "What pip installs, for the python runtime") + command.Flags().StringVar(&from, "from", "", "What pip installs, for the python runtime. Defaults to the wheel this version published") 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") return command } diff --git a/internal/command/ui.go b/internal/command/ui.go index 59c9f1f..8d9f034 100644 --- a/internal/command/ui.go +++ b/internal/command/ui.go @@ -1,10 +1,15 @@ package command import ( + "context" "fmt" + "os" "os/exec" "runtime" + "syscall" + "time" + "github.com/sourceant/cli/internal/install" "github.com/spf13/cobra" ) @@ -18,7 +23,9 @@ func uiCommand(opts *options) *cobra.Command { // 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 { - return err + if err := start(cmd.Context(), opts, cmd.OutOrStdout()); err != nil { + return err + } } _, _ = fmt.Fprintln(cmd.OutOrStdout(), opts.agentURL) if stayPut { @@ -34,6 +41,46 @@ 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/install/agent.go b/internal/install/agent.go new file mode 100644 index 0000000..53c19e9 --- /dev/null +++ b/internal/install/agent.go @@ -0,0 +1,168 @@ +package install + +import ( + "archive/tar" + "compress/gzip" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "time" +) + +// AgentRepo publishes the agent binary. +const AgentRepo = "sourceant/agent" + +// downloadBase is where release assets are served from, and apiBase is what is +// asked which version is latest. Both are overridable so an install can be +// exercised without reaching GitHub. +func downloadBase() string { + if base := os.Getenv("SOURCEANT_DOWNLOAD_BASE"); base != "" { + return base + } + return "https://github.com/" + AgentRepo + "/releases/download" +} + +func apiBase() string { + if base := os.Getenv("SOURCEANT_API_BASE"); base != "" { + return base + } + return "https://api.github.com/repos/" + AgentRepo + "/releases" +} + +// AgentName is the binary this installs. +const AgentName = "sourceant-agent" + +// BinDir is where the agent binary is kept. +func BinDir() string { return filepath.Join(Home(), "bin") } + +// AgentPath is the agent this machine runs. +func AgentPath() string { return filepath.Join(BinDir(), AgentName) } + +// Platform is the os-arch pair release assets are named for. +func Platform() (string, error) { + arch := runtime.GOARCH + switch arch { + case "amd64", "arm64": + default: + return "", fmt.Errorf("no agent is published for %s", arch) + } + switch runtime.GOOS { + case "linux", "darwin": + default: + return "", fmt.Errorf("no agent is published for %s", runtime.GOOS) + } + return runtime.GOOS + "-" + arch, nil +} + +// Fetcher gets a URL. Swapped in tests, because an install that really reaches +// GitHub is not something to run on every change. +type Fetcher func(url string) (io.ReadCloser, error) + +// Get fetches over HTTP. +func Get(url string) (io.ReadCloser, error) { + client := &http.Client{Timeout: 5 * time.Minute} + response, err := client.Get(url) + if err != nil { + return nil, err + } + if response.StatusCode != http.StatusOK { + response.Body.Close() + return nil, fmt.Errorf("%s answered %s", url, response.Status) + } + return response.Body, nil +} + +// LatestAgent asks which version to install when none was named. +func LatestAgent(get Fetcher) (string, error) { + body, err := get(apiBase() + "/latest") + if err != nil { + return "", err + } + defer body.Close() + var release struct { + Tag string `json:"tag_name"` + } + if err := json.NewDecoder(body).Decode(&release); err != nil { + return "", err + } + tag := strings.TrimPrefix(release.Tag, "v") + if tag == "" { + return "", fmt.Errorf("%s names no latest release", AgentRepo) + } + return tag, nil +} + +// AgentURL is where one version's asset lives. +func AgentURL(version, platform string) string { + asset := fmt.Sprintf("%s-%s-%s.tar.gz", AgentName, version, platform) + return fmt.Sprintf("%s/v%s/%s", downloadBase(), version, asset) +} + +// InstallAgent puts the agent beside the CLI and returns where it went. The +// archive holds one file named for the version and platform, so what comes out +// is renamed to something a person can type. +func InstallAgent(version string, get Fetcher, out io.Writer) (string, error) { + platform, err := Platform() + if err != nil { + return "", err + } + if version == "" || version == "dev" || version == "latest" { + version, err = LatestAgent(get) + if err != nil { + return "", fmt.Errorf("could not tell which agent to install: %w", err) + } + } + + say(out, "Fetching %s %s for %s.\n", AgentName, version, platform) + body, err := get(AgentURL(version, platform)) + if err != nil { + return "", fmt.Errorf("could not fetch the agent: %w", err) + } + defer body.Close() + + if err := os.MkdirAll(BinDir(), 0o755); err != nil { + return "", err + } + path := AgentPath() + if err := extractOne(body, path); err != nil { + return "", err + } + return path, nil +} + +// extractOne writes the first regular file in a gzipped tar to path. +func extractOne(archive io.Reader, path string) error { + zipped, err := gzip.NewReader(archive) + if err != nil { + return fmt.Errorf("the agent archive is not gzip: %w", err) + } + defer zipped.Close() + + reader := tar.NewReader(zipped) + for { + header, err := reader.Next() + if err == io.EOF { + return fmt.Errorf("the agent archive held no file") + } + if err != nil { + return err + } + if header.Typeflag != tar.TypeReg { + continue + } + file, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o755) + if err != nil { + return err + } + defer file.Close() + if _, err := io.Copy(file, reader); err != nil { + return err + } + return file.Chmod(0o755) + } +} diff --git a/internal/install/agent_test.go b/internal/install/agent_test.go new file mode 100644 index 0000000..c259d80 --- /dev/null +++ b/internal/install/agent_test.go @@ -0,0 +1,112 @@ +package install + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "fmt" + "io" + "os" + "strings" + "testing" +) + +// archive builds what a release asset actually is: one file named for the +// version and platform, gzipped inside a tar. +func archive(t *testing.T, name, body string) []byte { + t.Helper() + var buffer bytes.Buffer + zipped := gzip.NewWriter(&buffer) + writer := tar.NewWriter(zipped) + header := &tar.Header{Name: name, Mode: 0o755, Size: int64(len(body)), Typeflag: tar.TypeReg} + if err := writer.WriteHeader(header); err != nil { + t.Fatal(err) + } + if _, err := writer.Write([]byte(body)); err != nil { + t.Fatal(err) + } + writer.Close() + zipped.Close() + return buffer.Bytes() +} + +func TestItInstallsTheAgentNamedSomethingAPersonCanType(t *testing.T) { + t.Setenv("SOURCEANT_INSTALL_HOME", t.TempDir()) + platform, err := Platform() + if err != nil { + t.Skip(err) + } + asset := fmt.Sprintf("sourceant-agent-1.2.3-%s.tar.gz", platform) + var asked string + get := func(url string) (io.ReadCloser, error) { + asked = url + return io.NopCloser(bytes.NewReader(archive(t, strings.TrimSuffix(asset, ".tar.gz"), "binary"))), nil + } + + path, err := InstallAgent("1.2.3", get, io.Discard) + if err != nil { + t.Fatal(err) + } + + if !strings.HasSuffix(asked, asset) { + t.Errorf("fetched %q, want it to end in %q", asked, asset) + } + if got := path; !strings.HasSuffix(got, "/bin/sourceant-agent") { + t.Errorf("installed to %q, want a typeable name", got) + } + body, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(body) != "binary" { + t.Errorf("wrote %q, want the archived file", body) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm()&0o111 == 0 { + t.Errorf("mode %v, want it executable", info.Mode().Perm()) + } +} + +func TestItAsksWhichVersionWhenTheBuildDoesNotKnow(t *testing.T) { + t.Setenv("SOURCEANT_INSTALL_HOME", t.TempDir()) + platform, err := Platform() + if err != nil { + t.Skip(err) + } + get := func(url string) (io.ReadCloser, error) { + if strings.Contains(url, "api.github.com") { + return io.NopCloser(strings.NewReader(`{"tag_name":"v9.9.9"}`)), nil + } + if !strings.Contains(url, "v9.9.9") { + t.Errorf("fetched %q, want the version the API named", url) + } + name := fmt.Sprintf("sourceant-agent-9.9.9-%s", platform) + return io.NopCloser(bytes.NewReader(archive(t, name, "binary"))), nil + } + + if _, err := InstallAgent("dev", get, io.Discard); err != nil { + t.Fatal(err) + } +} + +func TestItSaysSoWhenTheAgentCannotBeFetched(t *testing.T) { + t.Setenv("SOURCEANT_INSTALL_HOME", t.TempDir()) + if _, err := Platform(); err != nil { + t.Skip(err) + } + get := func(string) (io.ReadCloser, error) { + return nil, fmt.Errorf("404 Not Found") + } + + _, err := InstallAgent("1.2.3", get, io.Discard) + + if err == nil { + t.Fatal("installed something from a fetch that failed") + } + if !strings.Contains(err.Error(), "could not fetch the agent") { + t.Errorf("got %q, want it to say what failed", err) + } +} diff --git a/internal/install/install.go b/internal/install/install.go index 633ea43..d32558d 100644 --- a/internal/install/install.go +++ b/internal/install/install.go @@ -14,6 +14,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" ) // Runtime is how the core is installed. @@ -27,8 +28,34 @@ const ( // DefaultImage is the published core. const DefaultImage = "ghcr.io/sourceant/sourceant:latest" -// DefaultPackage is the core on PyPI. -const DefaultPackage = "sourceant" +// CoreRepo publishes the core. +const CoreRepo = "sourceant/sourceant" + +// CoreWheel is the wheel one version publishes. Pip normalises the version, so +// 1.0.0-beta.2 is 1.0.0b2 in the file name. +func CoreWheel(version string) string { + return fmt.Sprintf("%s/v%s/sourceant-%s-py3-none-any.whl", + coreDownloadBase(), version, wheelVersion(version)) +} + +func coreDownloadBase() string { + if base := os.Getenv("SOURCEANT_CORE_DOWNLOAD_BASE"); base != "" { + return base + } + return "https://github.com/" + CoreRepo + "/releases/download" +} + +// wheelVersion is the version as pip writes it into a file name. +func wheelVersion(version string) string { + for _, pre := range []struct{ tag, short string }{ + {"-beta.", "b"}, {"-alpha.", "a"}, {"-rc.", "rc"}, + } { + if base, suffix, found := strings.Cut(version, pre.tag); found { + return base + pre.short + suffix + } + } + return version +} // Core is what was installed. type Core struct { @@ -100,6 +127,8 @@ type Options struct { From string // Pull says whether to fetch the image before writing anything down. Pull bool + // Version is the release to install, when nothing else names one. + Version string // Out receives progress. Out io.Writer } @@ -168,7 +197,7 @@ func installDocker(opts Options, run Runner) (Config, error) { func installPython(opts Options, run Runner) (Config, error) { from := opts.From if from == "" { - from = DefaultPackage + from = CoreWheel(opts.Version) } python, err := exec.LookPath("python3") if err != nil { @@ -190,8 +219,15 @@ func installPython(opts Options, run Runner) (Config, error) { command := filepath.Join(venv, "bin", "sourceant") if _, err := os.Stat(command); err != nil { return Config{}, fmt.Errorf( - "%s installed without a sourceant command, so there is nothing to start. "+ - "The core is not packaged for PyPI yet; use --runtime docker", from) + "%s installed without a sourceant command, so there is nothing to start", from) + } + + // The container migrates on every start. Nothing does that for a program + // installed here, so an unmigrated database would serve a core that answers + // every read with a missing table. + say(opts.Out, "Preparing the database\n") + if output, err := run(command, "db", "upgrade", "head"); err != nil { + return Config{}, fmt.Errorf("could not prepare the database: %s", trim(output)) } return Config{Core: Core{ @@ -229,3 +265,12 @@ func trim(output []byte) string { } return "…" + text[len(text)-keep:] } + +// Chosen is the runtime to use when nobody named one. Docker carries the core +// somebody else already built, so it wins wherever it answers. +func Chosen(run Runner) Runtime { + if _, err := run("docker", "version", "--format", "{{.Server.Version}}"); err == nil { + return Docker + } + return Python +} diff --git a/internal/install/install_test.go b/internal/install/install_test.go index 173fe57..5bfeb6b 100644 --- a/internal/install/install_test.go +++ b/internal/install/install_test.go @@ -148,10 +148,10 @@ func TestAnImageThatIsNotHereAndWasNotPulledIsRefused(t *testing.T) { } } -/* The core has no packaging metadata and nothing is on PyPI, so this path - * cannot work yet. What matters is that it says so rather than recording a - * runtime the agent would fail to start. */ -func TestThePythonRuntimeSaysWhyItCannotWorkYet(t *testing.T) { +/* A pip install can succeed and still leave nothing to run. What matters is + * that it says so rather than recording a runtime the agent would fail to + * start. */ +func TestThePythonRuntimeRefusesAnInstallThatLeftNoCommand(t *testing.T) { t.Setenv("SOURCEANT_INSTALL_HOME", t.TempDir()) run := &recorder{} @@ -160,11 +160,32 @@ func TestThePythonRuntimeSaysWhyItCannotWorkYet(t *testing.T) { if err == nil { t.Fatal("recorded a python runtime with no command behind it") } - if !strings.Contains(err.Error(), "not packaged for PyPI yet") { - t.Errorf("got %q, want why it cannot work", err) + if !strings.Contains(err.Error(), "without a sourceant command") { + t.Errorf("got %q, want what was wrong with it", err) } - if !strings.Contains(err.Error(), "--runtime docker") { - t.Errorf("got %q, want the way that does work", err) + if !strings.Contains(err.Error(), "sourceant") { + t.Errorf("got %q, want what it tried to install", err) + } +} + +func TestThePythonRuntimeTakesTheWheelThisVersionPublished(t *testing.T) { + t.Setenv("SOURCEANT_INSTALL_HOME", t.TempDir()) + run := &recorder{} + + _, _ = Install(Options{Runtime: Python, Version: "1.0.0-beta.2"}, run.run) + + var installed string + for _, call := range run.ran { + for _, arg := range call { + if strings.HasSuffix(arg, ".whl") { + installed = arg + } + } + } + // Pip normalises the version, so the tag and the file name differ. + want := "/v1.0.0-beta.2/sourceant-1.0.0b2-py3-none-any.whl" + if !strings.HasSuffix(installed, want) { + t.Errorf("installed %q, want it to end in %q", installed, want) } } @@ -173,3 +194,35 @@ func TestARuntimeThatIsNeitherIsRefused(t *testing.T) { t.Fatal("accepted a runtime that is neither") } } + +func TestTheRuntimeIsChosenByWhatIsHere(t *testing.T) { + if got := Chosen((&recorder{}).run); got != Docker { + t.Errorf("chose %q where docker answers, want docker", got) + } + + noDocker := &recorder{fail: map[string]error{"docker version": errors.New("not found")}} + if got := Chosen(noDocker.run); got != Python { + t.Errorf("chose %q where docker is absent, want python", got) + } +} + +func TestThePythonRuntimePreparesTheDatabase(t *testing.T) { + home := t.TempDir() + t.Setenv("SOURCEANT_INSTALL_HOME", home) + // pip is stubbed here, so the command it would have written is put there by + // hand. Without it the install stops before any schema is prepared. + bin := filepath.Join(home, "runtime", "bin") + if err := os.MkdirAll(bin, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(bin, "sourceant"), nil, 0o755); err != nil { + t.Fatal(err) + } + run := &recorder{} + + _, _ = Install(Options{Runtime: Python, From: "sourceant"}, run.run) + + if !strings.Contains(run.commands(), "db upgrade head") { + t.Errorf("a core was installed without a schema:\n%s", run.commands()) + } +} diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..bd5ea72 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,81 @@ +#!/bin/sh +# Installs the SourceAnt CLI. It is the only thing anybody fetches by hand: +# `sourceant setup` brings down the agent and the core after this. +set -eu + +REPO="sourceant/cli" +BIN="sourceant" +VERSION="${SOURCEANT_VERSION:-latest}" +INSTALL_DIR="${SOURCEANT_INSTALL_DIR:-/usr/local/bin}" +# Where releases are served from. Overridden so this can be tested without +# reaching GitHub. +DOWNLOAD_BASE="${SOURCEANT_DOWNLOAD_BASE:-https://github.com/$REPO/releases/download}" +API_BASE="${SOURCEANT_API_BASE:-https://api.github.com/repos/$REPO/releases}" + +log() { printf '%s\n' "$*" >&2; } +die() { printf 'error: %s\n' "$*" >&2; exit 1; } + +need() { command -v "$1" >/dev/null 2>&1 || die "$1 is required"; } +need curl +need tar + +platform() { + os=$(uname -s | tr '[:upper:]' '[:lower:]') + case "$os" in + linux | darwin) ;; + *) die "no build is published for $os" ;; + esac + arch=$(uname -m) + case "$arch" in + x86_64 | amd64) arch=amd64 ;; + aarch64 | arm64) arch=arm64 ;; + *) die "no build is published for $arch" ;; + esac + printf '%s-%s' "$os" "$arch" +} + +resolve() { + if [ "$VERSION" != "latest" ]; then + printf '%s' "${VERSION#v}" + return + fi + # The tag, read without jq so this works on a machine with nothing on it. + tag=$(curl -fsSL "$API_BASE/latest" | + sed -n 's/.*"tag_name" *: *"\([^"]*\)".*/\1/p' | head -1) + [ -n "$tag" ] || die "could not tell which version is latest" + printf '%s' "${tag#v}" +} + +main() { + plat=$(platform) + version=$(resolve) + asset="$BIN-$version-$plat.tar.gz" + url="$DOWNLOAD_BASE/v$version/$asset" + + log "Installing $BIN $version for $plat" + + tmp=$(mktemp -d) + trap 'rm -rf "$tmp"' EXIT + + curl -fsSL "$url" -o "$tmp/$asset" || die "could not download $url" + tar -xzf "$tmp/$asset" -C "$tmp" + # The archive holds one file named for the version and platform. + [ -f "$tmp/$BIN-$version-$plat" ] || die "the archive did not hold $BIN" + chmod +x "$tmp/$BIN-$version-$plat" + + if [ -w "$INSTALL_DIR" ]; then + mv "$tmp/$BIN-$version-$plat" "$INSTALL_DIR/$BIN" + elif command -v sudo >/dev/null 2>&1; then + sudo mv "$tmp/$BIN-$version-$plat" "$INSTALL_DIR/$BIN" + else + die "$INSTALL_DIR is not writable and sudo is not here. Set SOURCEANT_INSTALL_DIR" + fi + + log "" + log "Installed $INSTALL_DIR/$BIN" + log "" + log "Next: sourceant setup # brings down the agent and the core" + log " sourceant ui # starts it and opens the view" +} + +main "$@" diff --git a/scripts/test-install.sh b/scripts/test-install.sh new file mode 100755 index 0000000..0553ad8 --- /dev/null +++ b/scripts/test-install.sh @@ -0,0 +1,43 @@ +#!/bin/sh +# Runs install.sh against a stub release inside a bare Ubuntu container, so the +# script is exercised on a machine with nothing on it rather than on a laptop +# that already has everything. +set -eu + +IMAGE="${IMAGE:-ubuntu:24.04}" +VERSION="9.9.9" + +root=$(cd "$(dirname "$0")/.." && pwd) +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT + +# A release as the Go action builds one: a binary named for its version and +# platform, alone in a gzipped tar. +for plat in linux-amd64 linux-arm64; do + mkdir -p "$work/serve/v$VERSION" + printf '#!/bin/sh\necho "sourceant %s"\n' "$VERSION" > "$work/sourceant-$VERSION-$plat" + chmod +x "$work/sourceant-$VERSION-$plat" + tar -czf "$work/serve/v$VERSION/sourceant-$VERSION-$plat.tar.gz" \ + -C "$work" "sourceant-$VERSION-$plat" +done + +cp "$root/scripts/install.sh" "$work/install.sh" + +docker run --rm \ + -v "$work:/w:ro" \ + -e SOURCEANT_VERSION="$VERSION" \ + -e SOURCEANT_DOWNLOAD_BASE="file:///w/serve" \ + "$IMAGE" sh -c ' + set -eu + apt-get update -qq >/dev/null 2>&1 + apt-get install -y -qq curl >/dev/null 2>&1 + sh /w/install.sh + command -v sourceant >/dev/null || { echo "sourceant is not on PATH"; exit 1; } + [ -x /usr/local/bin/sourceant ] || { echo "not executable"; exit 1; } + out=$(sourceant) + case "$out" in + *9.9.9*) ;; + *) echo "ran but said: $out"; exit 1 ;; + esac + echo "PASS: installed, on PATH, executable, runs" + ' diff --git a/scripts/test-setup.sh b/scripts/test-setup.sh new file mode 100755 index 0000000..1ca715c --- /dev/null +++ b/scripts/test-setup.sh @@ -0,0 +1,110 @@ +#!/bin/sh +# Drives both install paths the way somebody would: set the machine up, start +# it, and ask the API a question. A path that installs but never answers is not +# installed. +# +# The core comes from its real release. The agent comes from a release too, +# unless AGENT_BINARY names a build to serve instead, which is what makes this +# runnable before the agent has one. +set -eu + +VERSION="${VERSION:-$(cat VERSION)}" +AGENT_BINARY="${AGENT_BINARY:-}" +RUNTIMES="${RUNTIMES:-python docker}" +# Extra flags for setup, so a run can name a local image or wheel instead of +# reaching for what a release published. +SETUP_ARGS="${SETUP_ARGS:-}" + +root=$(cd "$(dirname "$0")/.." && pwd) +work=$(mktemp -d) +served="" +agent_pid="" + +cleanup() { + [ -n "$agent_pid" ] && kill "$agent_pid" 2>/dev/null || true + [ -n "$served" ] && kill "$served" 2>/dev/null || true + docker rm -f "$(docker ps -aqf name=sourceant-core- 2>/dev/null)" 2>/dev/null || true + rm -rf "$work" +} +trap cleanup EXIT + +log() { printf '\n== %s\n' "$*"; } +die() { printf 'FAIL: %s\n' "$*" >&2; exit 1; } + +# An agent release, or a local build dressed as one. +serve_agent() { + plat="linux-$(uname -m | sed 's/x86_64/amd64/; s/aarch64/arm64/')" + mkdir -p "$work/serve/v$VERSION" + cp "$AGENT_BINARY" "$work/sourceant-agent-$VERSION-$plat" + chmod +x "$work/sourceant-agent-$VERSION-$plat" + tar -czf "$work/serve/v$VERSION/sourceant-agent-$VERSION-$plat.tar.gz" \ + -C "$work" "sourceant-agent-$VERSION-$plat" + (cd "$work/serve" && python3 -m http.server 8971 --bind 127.0.0.1 >/dev/null 2>&1) & + served=$! + export SOURCEANT_DOWNLOAD_BASE="http://127.0.0.1:8971" + # The port has to answer before an install asks it for anything. + for _ in $(seq 1 20); do + curl -fsS -o /dev/null "http://127.0.0.1:8971/" 2>/dev/null && return 0 + sleep 0.5 + done + die "the stub agent release never came up" +} + +answers() { + for _ in $(seq 1 "$2"); do + curl -fsS -o /dev/null "$1" 2>/dev/null && return 0 + sleep 1 + done + return 1 +} + +check() { + runtime=$1 + home="$work/home-$runtime" + log "$runtime: setting up" + SOURCEANT_INSTALL_HOME="$home" "$root/sourceant" setup --runtime "$runtime" $SETUP_ARGS + + [ -f "$home/config.json" ] || die "$runtime: nothing was written down" + grep -q "\"runtime\": \"$runtime\"" "$home/config.json" || + die "$runtime: config.json names another runtime" + [ -x "$home/bin/sourceant-agent" ] || die "$runtime: no agent was installed" + + log "$runtime: starting the agent" + SOURCEANT_INSTALL_HOME="$home" "$home/bin/sourceant-agent" >"$work/agent-$runtime.log" 2>&1 & + agent_pid=$! + + answers "http://127.0.0.1:8930/health" 120 || + die "$runtime: the agent never answered. $(tail -5 "$work/agent-$runtime.log")" + + # The agent answers before the core does. Waiting only for the agent asks + # about a core that has not finished starting and calls it down. + log "$runtime: waiting for the core" + health="" + for _ in $(seq 1 180); do + health=$(curl -fsS "http://127.0.0.1:8930/health" 2>/dev/null || true) + case "$health" in *'"core_up":true'*) break ;; esac + sleep 1 + done + printf ' %s\n' "$health" + case "$health" in + *'"core_up":true'*) ;; + *) die "$runtime: the core never came up. $(tail -8 "$work/agent-$runtime.log")" ;; + esac + + log "$runtime: asking the API" + curl -fsS "http://127.0.0.1:8930/api/repositories" >/dev/null || + die "$runtime: the API did not answer for repositories" + + kill "$agent_pid" 2>/dev/null || true + agent_pid="" + printf ' PASS: %s installs, starts, and answers\n' "$runtime" +} + +[ -x "$root/sourceant" ] || die "build the CLI first: make build" +[ -n "$AGENT_BINARY" ] && serve_agent + +for runtime in $RUNTIMES; do + check "$runtime" +done + +printf '\nPASS: %s\n' "$RUNTIMES"