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
8 changes: 5 additions & 3 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ jobs:
strategy:
matrix:
os: [ubuntu-latest]
go: ['1.20']
go: ['1.26.3']

steps:
- name: Checkout
Expand All @@ -21,11 +21,13 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.20'
go-version: '1.26.3'
cache: false

- name: golangci-lint
uses: golangci/golangci-lint-action@v3
uses: golangci/golangci-lint-action@v9
with:
version: v2.12.2

- name: Get dependencies
run: go get -v -t -d ./...
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.20'
go-version: '1.26.3'
cache: false

- name: Create release notes
Expand Down
81 changes: 81 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

Sake is a task runner for local and remote hosts written in Go. Users define servers and tasks in `sake.yaml` files and execute tasks on servers via SSH or locally.

## Common Commands

```bash
# Build
make build # Build binary to dist/sake
make build-all # Cross-platform builds (requires goreleaser)

# Test
make test # Run all tests (requires docker-compose for mock SSH servers)
make test-unit # Run unit tests only
make test-integration # Run integration tests only (requires mock-ssh running)
make mock-ssh # Start mock SSH servers for integration tests
make update-golden-files # Update golden test files

# Code quality
make lint # Run golangci-lint and deadcode checker
make gofmt # Format Go code

# Development
go run ../main.go run ping -a # Quick debug from examples directory
```

## Architecture

### Entry Points
- `main.go` → calls `cmd.Execute()`
- `cmd/root.go` → Cobra CLI setup and command registration

### Core Packages

**cmd/** - CLI command handlers using Cobra framework. Each command in its own file (run.go, exec.go, ssh.go, list.go, etc.)

**core/dao/** - Data Access Objects for config parsing:
- `config.go` - Main Config struct, YAML parsing, imports
- `server.go` - Server definitions with SSH/local connection details
- `task.go` - Task commands and subtasks (TaskCmd, TaskRef)
- `spec.go` - Execution specs (strategy, batch, forks, output format)
- `target.go` - Server filtering (by name, tags, regex, limits)
- `theme.go` - Output formatting themes

**core/run/** - Task execution engine:
- `exec.go` - Main orchestrator, handles execution strategies
- `ssh.go` - SSH client wrapper (key auth, password auth, agent)
- `localhost.go` - Local execution via exec.Cmd
- `client.go` - Client interface definition

**core/print/** - Output formatting (table, text, JSON, CSV, HTML, Markdown)

### Data Flow
```
CLI Input → cmd/root.go → core/dao/config.go (parse YAML)
→ Create Config (Servers, Tasks, Specs, Targets, Themes)
→ core/run/exec.go (create SSH/Local clients, execute with strategy)
→ core/print/ (format output)
```

### Key Patterns

**YAML Struct Conversion**: Separate `*YAML` structs for unmarshaling that convert to domain structs after validation (e.g., `ServerYAML` → `Server`)

**Execution Strategies**: linear (sequential), host_pinned (serial per host), free (concurrent)

**Platform-Specific Code**: `unix.go` and `windows.go` for OS-specific handling

## Testing

Integration tests require mock SSH servers running via Docker:
```bash
make mock-ssh # Terminal 1: start mock servers
make test-integration # Terminal 2: run tests
```

Golden files in test/integration/ validate output. Update with `make update-golden-files`.
8 changes: 4 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,9 @@ test:
go test -v ./core/...

# Integration tests
cd ./test && docker-compose up -d
cd ./test && docker compose up -d
go test -v ./test/integration/... -count=5 -clean
cd ./test && docker-compose down
cd ./test && docker compose down

unit-test:
go test -v ./core/...
Expand All @@ -46,10 +46,10 @@ update-golden-files:
go test ./test/integration/... -update

mock-ssh:
cd ./test && docker-compose up
cd ./test && docker compose up

mock-performance-ssh:
cd ./test && docker-compose -f docker-compose-performance.yaml up
cd ./test && docker compose -f docker-compose-performance.yaml up

build:
CGO_ENABLED=0 go build \
Expand Down
6 changes: 1 addition & 5 deletions cmd/list_servers.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,11 +88,7 @@ func listServers(config *dao.Config, args []string, listFlags *core.ListFlags, s
theme, err := config.GetTheme(listFlags.Theme)
core.CheckIfError(err)

allServers := false
if len(serverArgs) == 0 &&
len(serverFlags.Tags) == 0 {
allServers = true
}
allServers := len(serverArgs) == 0 && len(serverFlags.Tags) == 0

err = config.ParseInventory(userArgs)
core.CheckIfError(err)
Expand Down
2 changes: 1 addition & 1 deletion core/dao/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -556,7 +556,7 @@ tasks:
return []Server{}, err
}

f.Close()
_ = f.Close()

fmt.Println("\nInitialized sake in", configDir)
fmt.Println("- Created sake.yaml")
Expand Down
30 changes: 18 additions & 12 deletions core/dao/task.go
Original file line number Diff line number Diff line change
Expand Up @@ -364,24 +364,30 @@ func ParseTaskEnv(cmdEnv []string, userEnv []string, parentEnv []string, configE
func (c *Config) GetTaskServers(task *Task, runFlags *core.RunFlags, setRunFlags *core.SetRunFlags) ([]Server, error) {
var servers []Server
var err error
// If any runtime target flags are used, disregard config specified task targets
if len(runFlags.Servers) > 0 || len(runFlags.Tags) > 0 || runFlags.Regex != "" || setRunFlags.All || setRunFlags.Invert {
// If any runtime selector flags are used, disregard config specified task targets.
// --invert is a modifier, not a selector, so it does not trigger this on its own.
if len(runFlags.Servers) > 0 || len(runFlags.Tags) > 0 || runFlags.Regex != "" || setRunFlags.All {
servers, err = c.FilterServers(runFlags.All, runFlags.Servers, runFlags.Tags, runFlags.Regex, runFlags.Invert)
if err != nil {
return []Server{}, err
}
} else if runFlags.Target != "" {
target, err := c.GetTarget(runFlags.Target)
if err != nil {
return []Server{}, err
} else {
// A target named on the CLI overrides the task's config target
if runFlags.Target != "" {
target, err := c.GetTarget(runFlags.Target)
if err != nil {
return []Server{}, err
}
task.Target = *target
}
task.Target = *target
servers, err = c.FilterServers(task.Target.All, task.Target.Servers, task.Target.Tags, task.Target.Regex, runFlags.Invert)
if err != nil {
return []Server{}, err

// Use the target's configured invert, but let an explicit --invert flag override it
invert := task.Target.Invert
if setRunFlags.Invert {
invert = runFlags.Invert
}
} else {
servers, err = c.FilterServers(task.Target.All, task.Target.Servers, task.Target.Tags, task.Target.Regex, runFlags.Invert)

servers, err = c.FilterServers(task.Target.All, task.Target.Servers, task.Target.Tags, task.Target.Regex, invert)
if err != nil {
return []Server{}, err
}
Expand Down
4 changes: 2 additions & 2 deletions core/dao/theme.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ var StyleBoxLight = table.BoxStyle{
BottomLeft: "└",
BottomRight: "┘",
BottomSeparator: "┴",
EmptySeparator: text.RepeatAndTrim(" ", text.RuneCount("┼")),
EmptySeparator: text.RepeatAndTrim(" ", text.RuneWidthWithoutEscSequences("┼")),
Left: "│",
LeftSeparator: "├",
MiddleHorizontal: "─",
Expand All @@ -137,7 +137,7 @@ var StyleBoxASCII = table.BoxStyle{
BottomLeft: "+",
BottomRight: "+",
BottomSeparator: "+",
EmptySeparator: text.RepeatAndTrim(" ", text.RuneCount("+")),
EmptySeparator: text.RepeatAndTrim(" ", text.RuneWidthWithoutEscSequences("+")),
Left: "|",
LeftSeparator: "+",
MiddleHorizontal: "-",
Expand Down
39 changes: 2 additions & 37 deletions core/run/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (
"fmt"
"math"
"os"
"os/signal"
"path/filepath"
"strconv"
"strings"
Expand Down Expand Up @@ -422,46 +421,12 @@ func (run *Run) SetClients(

func (run *Run) CleanupClients() {
clients := run.RemoteClients
var wg sync.WaitGroup

trap := make(chan os.Signal, 1)
signal.Notify(trap, os.Interrupt)
go func() {
for {
sig, ok := <-trap
if !ok {
return
}
for _, c := range clients {
switch c := c.(type) {
case *SSHClient:
for i := range c.Sessions {
err := c.Signal(i, sig)
if err != nil {
fmt.Fprintf(os.Stderr, "%v", err)
}
}
case *LocalhostClient:
for i := range c.Sessions {
err := c.Signal(i, sig)
if err != nil {
fmt.Fprintf(os.Stderr, "%v", err)
}
}
}
}
}
}()
wg.Wait()

signal.Stop(trap)
close(trap)

// Close remote connections
for _, c := range clients {
if remote, ok := c.(*SSHClient); ok {
for i := range c.(*SSHClient).Sessions {
remote.Close(i)
_ = remote.Close(i)
}
}
}
Expand Down Expand Up @@ -648,7 +613,7 @@ func ParseServers(
errConnects = append(errConnects, *errConnect)
continue
} else {
*(*servers)[i].PubFile = pubFile
(*servers)[i].PubFile = &pubFile
}
}

Expand Down
2 changes: 1 addition & 1 deletion core/run/localhost.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ func (c *LocalhostClient) Run(i int, env []string, workDir string, shell string,

var cmdString string
if workDir != "" {
cmdString = fmt.Sprintf("cd %s; %s", workDir, cmdStr)
cmdString = fmt.Sprintf("cd %s && %s", workDir, cmdStr)
} else {
cmdString = cmdStr
}
Expand Down
23 changes: 16 additions & 7 deletions core/run/ssh.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ func (c *SSHClient) Run(i int, env []string, workDir string, shell string, cmdSt

var cmdString string
if workDir != "" {
cmdString = fmt.Sprintf("cd %s; %s", workDir, exportedEnv)
cmdString = fmt.Sprintf("cd %s && %s", workDir, exportedEnv)
} else {
cmdString = exportedEnv
}
Expand Down Expand Up @@ -184,7 +184,7 @@ func (c *SSHClient) Wait(i int) error {
}

err := c.Sessions[i].sess.Wait()
c.Sessions[i].sess.Close()
_ = c.Sessions[i].sess.Close()
c.Sessions[i].running = false
c.Sessions[i].sessOpened = false

Expand All @@ -194,7 +194,7 @@ func (c *SSHClient) Wait(i int) error {
// Close closes the underlying SSH connection and session.
func (c *SSHClient) Close(i int) error {
if c.Sessions[i].sessOpened {
c.Sessions[i].sess.Close()
_ = c.Sessions[i].sess.Close()
c.Sessions[i].sessOpened = false
}
if !c.connOpened {
Expand Down Expand Up @@ -339,7 +339,7 @@ func AddKnownHost(host string, key ssh.PublicKey, knownFile string) (err error)
return err
}

defer f.Close()
defer func() { _ = f.Close() }()

line := Line(host, key)
_, err = f.WriteString(line + "\n")
Expand Down Expand Up @@ -388,18 +388,27 @@ func serialize(k ssh.PublicKey) string {

// Process all ENVs into a string of form
// Example output:
// export FOO="bar"; export BAR="baz";
// export FOO='bar'; export BAR='baz';
func AsExport(env []string) string {
exports := ``

for _, v := range env {
kv := strings.Split(v, "=")
exports += `export ` + kv[0] + `="` + kv[1] + `";`
kv := strings.SplitN(v, "=", 2)
if len(kv) != 2 {
continue
}
exports += `export ` + kv[0] + `=` + shellQuote(kv[1]) + `;`
}

return exports
}

// shellQuote wraps a value in single quotes for safe use in a POSIX shell,
// escaping any embedded single quotes as '\''.
func shellQuote(s string) string {
return `'` + strings.ReplaceAll(s, `'`, `'\''`) + `'`
}

func GetSSHAgentSigners() ([]ssh.Signer, error) {
// Load keys from SSH Agent if it's running
sockPath, found := os.LookupEnv("SSH_AUTH_SOCK")
Expand Down
13 changes: 8 additions & 5 deletions core/run/unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,16 @@ func SSHToServer(server dao.Server, disableVerifyHost bool, knownHostFile string
}

sshConnStr := fmt.Sprintf("%s@%s", server.User, server.Host)
portStr := fmt.Sprintf("-p %d", server.Port)

args := []string{"ssh", "-t", sshConnStr, portStr}
args := []string{"ssh", "-t", sshConnStr, "-p", fmt.Sprintf("%d", server.Port)}
if disableVerifyHost {
args = append(args, "-o StrictHostKeyChecking=no")
args = append(args, "-o", "StrictHostKeyChecking=no")
} else {
args = append(args, fmt.Sprintf("-o UserKnownHostsFile=%s", knownHostFile))
args = append(args, "-o", fmt.Sprintf("UserKnownHostsFile=%s", knownHostFile))
}

if server.IdentityFile != nil && *server.IdentityFile != "" {
args = append(args, "-i", *server.IdentityFile)
}

// TODO:
Expand All @@ -37,7 +40,7 @@ func SSHToServer(server dao.Server, disableVerifyHost bool, knownHostFile string
jumphosts = append(jumphosts, fmt.Sprintf("%s@%s:%d", bastion.User, bastion.Host, bastion.Port))
}

args = append(args, fmt.Sprintf("-J %s", strings.Join(jumphosts, ",")))
args = append(args, "-J", strings.Join(jumphosts, ","))
}

err = unix.Exec(sshBin, args, os.Environ())
Expand Down
Loading
Loading