iago

package module
v0.0.0-...-c6d4ad4 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 29, 2026 License: MIT Imports: 27 Imported by: 0

README

Infrastructure as Code (Iago)

Iago is a lightweight software deployment framework. Iago scripts are written in Go and compiled into a single binary. It supports executing tasks concurrently across multiple hosts, such as uploading and downloading files, running commands, and managing services.

Basic API

Iago executes tasks on a group of hosts. Tasks are functions that describe the actions to be performed on each individual host concurrently. Use iago.NewSSHGroup to connect to remote hosts defined in an SSH config file, or iago.DialSSH to connect to a single host directly.

hosts := []string{"wrk1", "wrk2", "wrk3"}
configPath := "/path/to/ssh/config"

g, err := iago.NewSSHGroup(hosts, configPath)
if err != nil {
	// handle error
}
defer g.Close()

g.Run("Example Task", func(ctx context.Context, host iago.Host) error {
	// Executed concurrently on each host.
	log.Println(host.Name())
	return nil
})

Support for other connection methods can be added by implementing the iago.Host interface.

Error handling is configured at the group level using the ErrorHandler field. By default, errors cause a panic, but you can set a custom handler:

g.ErrorHandler = func(e error) {
	log.Printf("Task failed: %v", e)
}

SSH config files

iago.NewSSHGroup reads an OpenSSH-style config file (defaulting to ~/.ssh/config). It honours the following per-host options: Hostname, Port, User, IdentityFile, ProxyJump, ConnectTimeout, StrictHostKeyChecking, and UserKnownHostsFile. OpenSSH's first-match-wins rule applies: the first Host stanza that matches a given alias wins for each option.

Connections must be passphrase-free at connect time. Load keys into ssh-agent ahead of time (entering the passphrase once):

ssh-add

Alternatively, point IdentityFile at a passphrase-less private key.

Example config

The following config connects 15 workers through a bastion host using a single wildcard stanza:

Host *
  IdentityFile ~/.ssh/id_ed25519
  UserKnownHostsFile ~/.ssh/known_hosts

Host bastion
  User deploy
  HostName bastion.example.com

Host wrk*
  HostName %h.cluster.example.com
  User deploy
  ProxyJump bastion
  StrictHostKeyChecking no

The %h token expands to the alias, so wrk7 resolves to wrk7.cluster.example.com. No per-host stanzas are needed for the workers — the wildcard covers all of them.

Resolving host aliases with ParseHosts

iago.ParseHosts resolves a comma-separated host spec to a slice of SSH aliases suitable for passing to NewSSHGroup. Each token is handled as follows:

Form Example Best for
Literal list atlas,titan,helios A small, fixed set of irregularly named hosts
Numeric range wrk[1-15] Numerically named hosts; no config lookup needed
Glob gpu-* Irregularly named hosts that share a role prefix; config is the membership list
aliases, err := iago.ParseHosts("wrk[1-15]", configPath)
// aliases == []string{"wrk1", "wrk2", ..., "wrk15"}

g, err := iago.NewSSHGroup(aliases, configPath)

The numeric range form is usually the cleanest for regular names: it expands without consulting the config file and works even when only a wildcard stanza is present.

The glob form is most useful when host names are irregular — for example, GPU nodes named after Greek titans rather than by number. The config file becomes the source of truth for cluster membership: adding or removing a Host stanza automatically changes what the glob returns, with no code change required.

Host gpu-atlas gpu-titan gpu-helios
  HostName %h.cluster.example.com
  User deploy
  ProxyJump bastion
  StrictHostKeyChecking no
aliases, err := iago.ParseHosts("gpu-*", configPath)
// aliases == []string{"gpu-atlas", "gpu-titan", "gpu-helios"}

Multiple space-separated aliases can share one stanza; %h still expands per-alias. When a fourth GPU node is added to the config, gpu-* picks it up without touching any Go code.

ProxyJump and connection sharing

When Host wrk1 has ProxyJump bastion, iago dials bastion first and tunnels the connection to wrk1 through it. NewSSHGroup dials each unique ProxyJump target once and reuses that connection for every alias that routes through it. A group of 15 workers that all share ProxyJump bastion opens exactly one TCP/SSH connection to bastion — equivalent to what OpenSSH's ControlMaster / ControlPersist achieves for the system ssh client, without requiring a background process or a Unix-domain socket.

The shared connection is owned by the Group and closed by group.Close(). Closing an individual iago.Host closes only that host's tunnel.

Example

The following example downloads a file from each remote host. The file is downloaded to a temporary directory created by the test framework and named os.<hostname>. See iago_test.go for the complete example with logging.

This example uses the iagotest package, which spawns docker containers and connects to them with SSH for testing.

func TestIago(t *testing.T) {
	dir := t.TempDir()

	// The iagotest package provides a helper function that automatically
	// builds and starts docker containers with an exposed SSH port for testing.
	g := iagotest.CreateSSHGroup(t, 4, false)

	g.Run("Download files", func(ctx context.Context, host iago.Host) error {
		src, err := iago.NewPath("/etc", "os-release")
		if err != nil {
			return err
		}
		dest, err := iago.NewPath(dir, "os")
		if err != nil {
			return err
		}
		return iago.Download{
			Src:  src,
			Dest: dest,
			Perm: iago.NewPerm(0o644),
		}.Apply(ctx, host)
	})
}

Documentation

Overview

Package iago provides a framework for running tasks on remote hosts.

Index

Constants

This section is empty.

Variables

View Source
var (
	// ErrNotAbsolute is returned when a path is relative, but was expected to be absolute.
	ErrNotAbsolute = errors.New("not an absolute path")
	// ErrNotRelative is returned when a path is absolute, but was expected to be relative.
	ErrNotRelative = errors.New("not a relative path")
)
View Source
var ConnectTimeout time.Duration

ConnectTimeout is the default timeout for establishing an SSH connection. It is used when a host does not set ConnectTimeout in the SSH config. The zero value means no timeout, which preserves the prior behavior of ssh.ClientConfig.Timeout. Callers can set this to a non-zero value to apply a default dial timeout across all hosts.

View Source
var DefaultTimeout = 30 * time.Second

DefaultTimeout is the default timeout for an action.

Functions

func CleanPath

func CleanPath(path string) string

CleanPath cleans the path and converts it to slashes.

func Expand

func Expand(h Host, s string) string

Expand expands any environment variables in the string 's' using the environment of the host 'h'.

func GetIntVar

func GetIntVar(host Host, key string) int

GetIntVar gets an integer variable from the host.

func GetStringVar

func GetStringVar(host Host, key string) string

GetStringVar gets a string variable from the host.

func Ignore

func Ignore(e error)

Ignore ignores errors.

func Panic

func Panic(e error)

Panic handles errors by panicking.

func ParseHosts

func ParseHosts(spec, configFile string) ([]string, error)

ParseHosts resolves a comma-separated host specification to a slice of SSH host aliases. Each token in the spec is handled as follows:

  • A PREFIX[lo-hi]SUFFIX token with numeric bounds is expanded to individual aliases (e.g. "bb[1-30]" → bb1, bb2, …, bb30) without consulting the SSH config.
  • A token containing *, ?, or a non-numeric [...] bracket expression is treated as a glob and matched against the non-wildcard Host entries read from configFile. Wildcard SSH stanzas (e.g. "Host bb*") are skipped because they do not enumerate specific host names.
  • Any other token is returned verbatim as a literal alias.

If configFile is empty, ~/.ssh/config is used. The config file is parsed at most once, only when a glob token is encountered.

func ParseSSHConfig

func ParseSSHConfig(configFile string) (*sshConfig, error)

ParseSSHConfig returns a ssh configuration object that can be used to create a ssh.ClientConfig for a given host alias.

Types

type CmdRunner

type CmdRunner interface {
	Run(cmd string) error
	RunContext(ctx context.Context, cmd string) error
	Start(cmd string) error
	Wait() error

	StdinPipe() (io.WriteCloser, error)
	StdoutPipe() (io.ReadCloser, error)
	StderrPipe() (io.ReadCloser, error)
}

CmdRunner defines an interface for running commands on remote hosts. This interface is based on the "exec.Cmd" struct.

type Download

type Download struct {
	Src  Path
	Dest Path
	Perm Perm
}

Download downloads a file or directory from a remote host.

func (Download) Apply

func (d Download) Apply(ctx context.Context, host Host) error

Apply performs the download.

type ErrorHandler

type ErrorHandler func(error)

ErrorHandler is a function that handles errors from actions.

type Group

type Group struct {
	Hosts        []Host
	ErrorHandler ErrorHandler
	Timeout      time.Duration

	// DialErrors holds per-alias dial failures from [NewSSHGroup] when
	// [FailFast] is not set. A nil map means all aliases connected successfully.
	DialErrors map[string]error
	// contains filtered or unexported fields
}

Group is a group of hosts.

func NewGroup

func NewGroup(hosts []Host) Group

NewGroup returns a new Group consisting of the given hosts.

func NewSSHGroup

func NewSSHGroup(hostAliases []string, sshConfigFile string, opts ...GroupOption) (group Group, err error)

NewSSHGroup returns a new ssh group from the given host aliases. The sshConfigFile argument specifies the ssh config file to use. If sshConfigFile is empty, the default configuration files will be used: ~/.ssh/config.

The host aliases should be defined in the ssh config file, and the config file should contain the necessary information to connect to the hosts without a passphrase. This usually means setting up the ssh-agent with the necessary keys beforehand (and entering the passphrase), or specifying the passphrase-less key to use with the IdentityFile option. Moreover, the config file should specify whether or not to use strict host key checking using the StrictHostKeyChecking option. If strict host key checking is enabled, the ssh server's host keys should be present in the known_hosts files specified by UserKnownHostsFile (the default known_hosts files will be used if this option is not specified).

The specified hosts must all contain an authorized_keys file containing the public key of the user running this program.

When several aliases share the same ProxyJump spec, a single TCP/SSH connection to the jump host is dialed once and reused for every target tunnelled through it. This mirrors what OpenSSH's ControlMaster provides for the system ssh client and avoids opening one proxy connection per target alias. The shared jump clients are owned by the returned Group and closed by Group.Close.

By default, dial failures are collected in [Group.DialErrors] instead of aborting the call. If no hosts connect successfully, an error is returned. Pass FailFast to return an error if any target fails. Pass DialConcurrency to dial target hosts concurrently; jump connections are always established sequentially first so at most one TCP connection is made to each jump host.

func (Group) Close

func (g Group) Close() (err error)

Close closes any connections to hosts and any group-owned shared resources (such as ProxyJump connections shared across hosts in this group).

func (Group) Run

func (g Group) Run(name string, f func(context.Context, Host) error)

Run runs the task on all hosts in the group concurrently.

type GroupOption

type GroupOption func(*groupConfig)

GroupOption configures how NewSSHGroup dials hosts.

func DialConcurrency

func DialConcurrency(n int) GroupOption

DialConcurrency returns a GroupOption that sets the maximum number of target hosts dialed concurrently inside NewSSHGroup. Values less than 2 leave dialing sequential (the default). Jump connections are always established sequentially before concurrent target dialing begins, so there is at most one TCP connection to each jump host regardless of n.

func FailFast

func FailFast() GroupOption

FailFast returns a GroupOption that makes NewSSHGroup stop and return an error if any dial fails. Targets that have not yet been dialed when the first failure is observed are skipped; combined with DialConcurrency this is best-effort, as dials already in flight still run to completion. Without this option, NewSSHGroup collects all dial errors in [Group.DialErrors] and returns only the successfully connected hosts.

type Host

type Host interface {
	// Name returns the name of this host.
	Name() string

	// Address returns the address of the host.
	Address() string

	// GetEnv retrieves the value of the environment variable named by the key.
	// It returns the value, which will be empty if the variable is not present.
	GetEnv(key string) string

	// GetFS returns the file system of the host.
	GetFS() fs.FS

	// NewCommand returns a new command runner.
	NewCommand() (CmdRunner, error)

	// Close closes the connection to the host.
	Close() error

	// SetVar sets a host variable with the given key and value
	SetVar(key string, val any)

	// GetVar gets the host variable with the given key.
	// Returns (val, true) if the variable exists, (nil, false) otherwise.
	GetVar(key string) (val any, ok bool)
}

Host is a connection to a remote host.

func DialSSH

func DialSSH(name, addr string, cfg *ssh.ClientConfig) (Host, error)

DialSSH connects to a remote host using ssh.

type Path

type Path struct {
	// contains filtered or unexported fields
}

Path is a path to a file or directory, relative to the prefix.

func NewPath

func NewPath(prefix, path string) (p Path, err error)

NewPath returns a new Path struct. prefix must be an absolute path, and path must be relative to the prefix.

func NewPathFromAbs

func NewPathFromAbs(path string) (p Path, err error)

NewPathFromAbs returns a new Path struct from an absolute path.

func (Path) String

func (p Path) String() string

type Perm

type Perm struct {
	// contains filtered or unexported fields
}

Perm describes the permissions that should be used when creating files or directories. Perm can use different permissions for files and directories. By default, it uses 644 for files and 755 for directories. If a file permission is specified by using NewPerm(), the WithDirPerm() method may be called to modify the directory permissions.

func NewPerm

func NewPerm(perm fs.FileMode) Perm

NewPerm returns a Perm with the requested file permission. Note that this will also set the directory permission. If a different directory permission is desired, you must call WithDirPerm on the returned Perm also.

func (Perm) GetDirPerm

func (p Perm) GetDirPerm() fs.FileMode

GetDirPerm returns the current directory permission, or the current file permission, or 755 if no permissions were set.

func (Perm) GetFilePerm

func (p Perm) GetFilePerm() fs.FileMode

GetFilePerm returns the current file permission, or 644 if no file permission was set.

func (*Perm) WithDirPerm

func (p *Perm) WithDirPerm(dirPerm fs.FileMode) Perm

WithDirPerm sets the directory permission of the Perm. It both mutates the original perm and returns a copy of it.

type Shell

type Shell struct {
	Command string
	Stdin   io.Reader
	Stdout  io.Writer
	Stderr  io.Writer
}

Shell runs a shell command.

func (Shell) Apply

func (sa Shell) Apply(ctx context.Context, host Host) (err error)

Apply runs the shell command on the host.

type TaskError

type TaskError struct {
	TaskName string
	HostName string
	Err      error
}

TaskError is the error type returned when an error occurs while running a task.

func (TaskError) Error

func (err TaskError) Error() string

func (TaskError) Unwrap

func (err TaskError) Unwrap() error

Unwrap returns the cause of the task error.

type Upload

type Upload struct {
	Src  Path
	Dest Path
	Perm Perm
}

Upload uploads a file or directory to a remote host.

func (Upload) Apply

func (u Upload) Apply(ctx context.Context, host Host) error

Apply performs the upload.

Directories

Path Synopsis
Package iagotest provides utilities for external libraries to test using the iago package.
Package iagotest provides utilities for external libraries to test using the iago package.
Package sftpfs provides a filesystem interface to an SFTP server using the github.com/pkg/sftp package.
Package sftpfs provides a filesystem interface to an SFTP server using the github.com/pkg/sftp package.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL