diff --git a/.golangci.yml b/.golangci.yml index c5f5e39..9dafcf0 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -14,7 +14,7 @@ formatters: settings: goimports: local-prefixes: - - github.com/convidera/devops-parallel-runner + - github.com/convidera/devops-cli issues: max-issues-per-linter: 0 diff --git a/README.md b/README.md index 609e2d4..79c21b8 100644 --- a/README.md +++ b/README.md @@ -14,15 +14,15 @@ Grab the latest binary for your platform from the [releases page](../../releases ```bash # macOS (Apple Silicon) -curl -L https://github.com/convidera/devops-parallel-runner/releases/latest/download/devops-darwin-arm64 -o /usr/local/bin/devops +curl -L https://github.com/convidera/devops-cli/releases/latest/download/devops-darwin-arm64 -o /usr/local/bin/devops chmod +x /usr/local/bin/devops # macOS (Intel) -curl -L https://github.com/convidera/devops-parallel-runner/releases/latest/download/devops-darwin-amd64 -o /usr/local/bin/devops +curl -L https://github.com/convidera/devops-cli/releases/latest/download/devops-darwin-amd64 -o /usr/local/bin/devops chmod +x /usr/local/bin/devops # Linux (amd64) -curl -L https://github.com/convidera/devops-parallel-runner/releases/latest/download/devops-linux-amd64 -o /usr/local/bin/devops +curl -L https://github.com/convidera/devops-cli/releases/latest/download/devops-linux-amd64 -o /usr/local/bin/devops chmod +x /usr/local/bin/devops ``` @@ -31,14 +31,14 @@ chmod +x /usr/local/bin/devops Requires Go 1.22+. ```bash -go install github.com/convidera/devops-parallel-runner@latest +go install github.com/convidera/devops-cli@latest ``` Or clone and build: ```bash -git clone https://github.com/convidera/devops-parallel-runner -cd devops-parallel-runner +git clone https://github.com/convidera/devops-cli +cd devops-cli go build -o devops . ``` @@ -55,6 +55,8 @@ Each module needs a `.devops/commands.yaml` file. The structure is: `` is either a Docker Compose service name or `host` (runs directly on the machine without Docker). +All scripts listed under the same `` run in a single shell process, in order, so `cd`, `export`, and other shell state carry over from one line to the next (a failing line aborts the rest, like `set -e`). Different containers — and different modules — still run as separate processes. + ### Example ``` @@ -115,6 +117,7 @@ devops all Run command across all modules (explicit) devops exec [cmd...] Open interactive shell in module's container devops shell Alias for exec devops help Show this help +devops reinstall Download and install the latest release ``` ### Examples diff --git a/devops-cli b/devops-cli new file mode 100755 index 0000000..0f224f9 Binary files /dev/null and b/devops-cli differ diff --git a/exec.go b/exec.go index b798f93..df92aa2 100644 --- a/exec.go +++ b/exec.go @@ -4,11 +4,14 @@ import ( "fmt" "os" "os/exec" + "strings" "golang.org/x/term" ) // runModuleSequential runs all containers/entries for a module's command in order. +// Entries for the same container share a single shell invocation, so state like +// `cd` and exported variables carries over from one entry to the next. func runModuleSequential(m *Module, command string, extraArgs []string) error { containers, ok := m.Config[command] if !ok { @@ -18,14 +21,27 @@ func runModuleSequential(m *Module, command string, extraArgs []string) error { fmt.Printf("Running commands for: %s\n", container) for _, entry := range entries { fmt.Printf("Executing: %s\n", entry.Script) - if err := runScript(container, entry.Script, extraArgs); err != nil { - return fmt.Errorf("[%s/%s] command failed: %w", m.Name, container, err) - } + } + if err := runScript(container, joinEntries(entries), extraArgs); err != nil { + return fmt.Errorf("[%s/%s] command failed: %w", m.Name, container, err) } } return nil } +// joinEntries combines a container's entries into a single shell script so +// they run in one process, letting `cd`/`export`/etc. persist across lines. +// `set -e` makes the script stop at the first failing entry, matching the +// previous per-entry fail-fast behavior. +func joinEntries(entries []Entry) string { + lines := make([]string, 0, len(entries)+1) + lines = append(lines, "set -e") + for _, e := range entries { + lines = append(lines, e.Script) + } + return strings.Join(lines, "\n") +} + // runScript executes a single script in the given container (or "host"). // extraArgs are passed as positional parameters so $@ expands correctly inside the script. func runScript(container, script string, extraArgs []string) error { diff --git a/exec_test.go b/exec_test.go new file mode 100644 index 0000000..2bf3f64 --- /dev/null +++ b/exec_test.go @@ -0,0 +1,102 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestJoinEntries_prependsSetE(t *testing.T) { + got := joinEntries([]Entry{{Script: "echo one"}, {Script: "echo two"}}) + want := "set -e\necho one\necho two" + if got != want { + t.Errorf("joinEntries() = %q, want %q", got, want) + } +} + +func TestJoinEntries_empty(t *testing.T) { + got := joinEntries(nil) + if got != "set -e" { + t.Errorf("joinEntries(nil) = %q, want %q", got, "set -e") + } +} + +// TestRunModuleSequential_stateCarriesAcrossEntries verifies that `cd` and +// `export` in one entry affect subsequent entries for the same container, +// which requires the whole container's entries to run as one shell process. +func TestRunModuleSequential_stateCarriesAcrossEntries(t *testing.T) { + dir := t.TempDir() + sub := filepath.Join(dir, "sub") + if err := os.Mkdir(sub, 0o755); err != nil { + t.Fatal(err) + } + marker := filepath.Join(dir, "marker.txt") + + m := &Module{ + Name: "test", + Config: CommandConfig{ + "go": { + "host": []Entry{ + {Script: "cd " + sub}, + {Script: "export FOO=bar"}, + {Script: `pwd > "` + marker + `"; echo "$FOO" >> "` + marker + `"`}, + }, + }, + }, + } + + if err := runModuleSequential(m, "go", nil); err != nil { + t.Fatalf("runModuleSequential() error = %v", err) + } + + data, err := os.ReadFile(marker) + if err != nil { + t.Fatalf("reading marker file: %v", err) + } + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) != 2 { + t.Fatalf("expected 2 lines, got %q", string(data)) + } + wantSub, err := filepath.EvalSymlinks(sub) + if err != nil { + t.Fatal(err) + } + gotSub, err := filepath.EvalSymlinks(lines[0]) + if err != nil { + t.Fatalf("resolving pwd output %q: %v", lines[0], err) + } + if gotSub != wantSub { + t.Errorf("cd did not carry over: pwd = %q, want %q", gotSub, wantSub) + } + if lines[1] != "bar" { + t.Errorf("export did not carry over: FOO = %q, want %q", lines[1], "bar") + } +} + +// TestRunModuleSequential_stopsOnFailure verifies that `set -e` still makes a +// failing entry abort the remaining entries for that container. +func TestRunModuleSequential_stopsOnFailure(t *testing.T) { + dir := t.TempDir() + marker := filepath.Join(dir, "marker.txt") + + m := &Module{ + Name: "test", + Config: CommandConfig{ + "go": { + "host": []Entry{ + {Script: "false"}, + {Script: `echo should-not-run > "` + marker + `"`}, + }, + }, + }, + } + + if err := runModuleSequential(m, "go", nil); err == nil { + t.Fatal("expected error from failing entry, got nil") + } + + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Errorf("marker file should not exist, later entry ran despite earlier failure") + } +} diff --git a/go.mod b/go.mod index ba971b6..3baa5f2 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/convidera/devops-parallel-runner +module github.com/convidera/devops-cli go 1.25.0 diff --git a/main.go b/main.go index 78afdde..f9701c3 100644 --- a/main.go +++ b/main.go @@ -26,6 +26,8 @@ func main() { os.Exit(1) } os.Exit(runAllCommand(args[1], args[2:])) + case "reinstall": + os.Exit(runReinstall()) } modules, err := discoverModules() @@ -157,6 +159,7 @@ func showHelp() { fmt.Println(" devops exec [cmd...] Open interactive shell in module's container") fmt.Println(" devops shell Alias for exec") fmt.Println(" devops help Show this help") + fmt.Println(" devops reinstall Download and install the latest release") fmt.Println() if len(modules) == 0 { diff --git a/reinstall.go b/reinstall.go new file mode 100644 index 0000000..cdc9375 --- /dev/null +++ b/reinstall.go @@ -0,0 +1,94 @@ +package main + +import ( + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" +) + +const releaseBaseURL = "https://github.com/convidera/devops-cli/releases/latest/download" + +// runReinstall downloads the latest release binary for the current platform +// and replaces the currently running executable with it. +func runReinstall() int { + asset, ok := releaseAsset(runtime.GOOS, runtime.GOARCH) + if !ok { + fmt.Fprintf(os.Stderr, "error: no release binary available for %s/%s\n", runtime.GOOS, runtime.GOARCH) + return 1 + } + + target := selfPath() + url := releaseBaseURL + "/" + asset + + fmt.Printf("Downloading %s...\n", url) + data, err := downloadBinary(url) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + return 1 + } + + tmp, err := os.CreateTemp(filepath.Dir(target), ".devops-reinstall-*") + if err != nil { + fmt.Fprintf(os.Stderr, "error: creating temp file: %v\n", err) + return 1 + } + defer func() { _ = os.Remove(tmp.Name()) }() + + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + fmt.Fprintf(os.Stderr, "error: writing temp file: %v\n", err) + return 1 + } + if err := tmp.Close(); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + return 1 + } + if err := os.Chmod(tmp.Name(), 0o755); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + return 1 + } + + if err := os.Rename(tmp.Name(), target); err != nil { + fmt.Fprintf(os.Stderr, "error: replacing %s: %v (do you need to run with sudo?)\n", target, err) + return 1 + } + + fmt.Printf("Reinstalled devops to %s\n", target) + return 0 +} + +// releaseAsset maps a GOOS/GOARCH pair to the release artifact name built by +// .github/workflows/release.yml. +func releaseAsset(goos, goarch string) (string, bool) { + switch goos + "/" + goarch { + case "linux/amd64": + return "devops-linux-amd64", true + case "darwin/amd64": + return "devops-darwin-amd64", true + case "darwin/arm64": + return "devops-darwin-arm64", true + default: + return "", false + } +} + +func downloadBinary(url string) ([]byte, error) { + resp, err := http.Get(url) + if err != nil { + return nil, fmt.Errorf("downloading release: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("downloading release: unexpected status %s", resp.Status) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading release: %w", err) + } + return data, nil +}