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
2 changes: 1 addition & 1 deletion .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 9 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand All @@ -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 .
```

Expand All @@ -55,6 +55,8 @@ Each module needs a `.devops/commands.yaml` file. The structure is:

`<container>` is either a Docker Compose service name or `host` (runs directly on the machine without Docker).

All scripts listed under the same `<container>` 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

```
Expand Down Expand Up @@ -115,6 +117,7 @@ devops all <command> Run command across all modules (explicit)
devops <module> exec [cmd...] Open interactive shell in module's container
devops <module> shell Alias for exec
devops help Show this help
devops reinstall Download and install the latest release
```

### Examples
Expand Down
Binary file added devops-cli
Binary file not shown.
22 changes: 19 additions & 3 deletions exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
102 changes: 102 additions & 0 deletions exec_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
module github.com/convidera/devops-parallel-runner
module github.com/convidera/devops-cli

go 1.25.0

Expand Down
3 changes: 3 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ func main() {
os.Exit(1)
}
os.Exit(runAllCommand(args[1], args[2:]))
case "reinstall":
os.Exit(runReinstall())
}

modules, err := discoverModules()
Expand Down Expand Up @@ -157,6 +159,7 @@ func showHelp() {
fmt.Println(" devops <module> exec [cmd...] Open interactive shell in module's container")
fmt.Println(" devops <module> 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 {
Expand Down
94 changes: 94 additions & 0 deletions reinstall.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package main

import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
)
Comment on lines +3 to +10

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 {
Comment on lines +14 to +18
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
}