99404133d4
Run agents on any Linux box reachable over SSH: own VPS, homelab, EC2, shared dev box. One SSH connection per (host, user, identity) shared across all sessions on that host; each session gets its own remote agentctl process, remote port, and SSH-tunneled local port. Mirrors the local ~/.kandev/tasks/<task-dir>/<repo>/ layout on the remote so multi-session tasks share a worktree and per-session runtime state lives under the task dir. Implements ExecutorBackend + RemoteSessionResumer + RemoteStatusProvider + Closeable. Host-key trust is explicit: the test endpoint observes a fingerprint without pinning; the user trusts it in settings; subsequent connects reject any mismatch. Auth via ssh-agent or IdentityFile only (passphrase-protected keys must be loaded into ssh-agent). Parses ~/.ssh/config for HostName/Port/User/IdentityFile/ProxyJump inheritance. agentctl is content-hash-cached on the remote (~/.kandev/bin/agentctl plus a .sha256 sidecar) and uploaded only when local and remote differ. Linux/amd64 only in v1, matching Docker's current state; arm64 is called out as a cross-cutting follow-up. See docs/specs/ssh-executor/spec.md.
494 lines
18 KiB
Go
494 lines
18 KiB
Go
package lifecycle
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
"golang.org/x/crypto/ssh"
|
|
|
|
"github.com/kandev/kandev/internal/agent/executor"
|
|
agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl"
|
|
"github.com/kandev/kandev/internal/agentctl/server/process"
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
"github.com/kandev/kandev/internal/secrets"
|
|
)
|
|
|
|
const (
|
|
sshAgentctlHealthTimeout = 20 * time.Second
|
|
|
|
sshStatusRunning = "running"
|
|
sshStatusUnknown = "unknown"
|
|
sshStatusDisconnected = "disconnected"
|
|
sshStatusAgentctlDown = "agentctl-down"
|
|
)
|
|
|
|
// sshSessionState tracks per-session resources we need to clean up later:
|
|
// the SSH target (so we can release the pooled connection) and the local
|
|
// port forwarder.
|
|
type sshSessionState struct {
|
|
target *SSHTarget
|
|
forwarder *SSHPortForwarder
|
|
pid int
|
|
remoteDir string
|
|
}
|
|
|
|
// SSHExecutor implements ExecutorBackend for SSH-reachable Linux hosts.
|
|
//
|
|
// One SSH connection is shared per (host, user, identity) tuple across all
|
|
// sessions on that host. Each session gets its own agentctl process + remote
|
|
// port + local-forwarded port. See docs/specs/ssh-executor/spec.md for the
|
|
// full design.
|
|
type SSHExecutor struct {
|
|
pool *SSHConnPool
|
|
agentctlResolver *AgentctlResolver
|
|
secretStore secrets.SecretStore
|
|
agentList RemoteAuthAgentLister
|
|
logger *logger.Logger
|
|
|
|
mu sync.Mutex
|
|
sessions map[string]*sshSessionState // keyed by ExecutorInstance.InstanceID
|
|
}
|
|
|
|
// NewSSHExecutor wires up an SSH executor with shared infrastructure.
|
|
// agentList is optional; pass nil if no agent install scripts are needed.
|
|
func NewSSHExecutor(
|
|
secretStore secrets.SecretStore,
|
|
agentList RemoteAuthAgentLister,
|
|
resolver *AgentctlResolver,
|
|
log *logger.Logger,
|
|
) *SSHExecutor {
|
|
return &SSHExecutor{
|
|
pool: NewSSHConnPool(log),
|
|
agentctlResolver: resolver,
|
|
secretStore: secretStore,
|
|
agentList: agentList,
|
|
logger: log.WithFields(zap.String("runtime", "ssh")),
|
|
sessions: make(map[string]*sshSessionState),
|
|
}
|
|
}
|
|
|
|
func (r *SSHExecutor) Name() executor.Name { return executor.NameSSH }
|
|
|
|
func (r *SSHExecutor) HealthCheck(_ context.Context) error {
|
|
// SSH targets are configured per-executor — the runtime itself is always
|
|
// available. Per-host reachability is verified by the test-connection
|
|
// endpoint and surfaced in the executor status panel.
|
|
return nil
|
|
}
|
|
|
|
// Close terminates pooled SSH connections at shutdown.
|
|
func (r *SSHExecutor) Close() error {
|
|
r.pool.CloseAll()
|
|
return nil
|
|
}
|
|
|
|
// targetFromMetadata builds an SSHTarget from the executor metadata
|
|
// propagated via buildLaunchMetadata (req.ExecutorConfig keys merged in).
|
|
func (r *SSHExecutor) targetFromMetadata(md map[string]interface{}) (*SSHTarget, error) {
|
|
host := getMetadataString(md, MetadataKeySSHHost)
|
|
hostAlias := getMetadataString(md, "ssh_host_alias")
|
|
if host == "" && hostAlias == "" {
|
|
return nil, errors.New("ssh executor: host (or host_alias) is required in executor config")
|
|
}
|
|
port := 0
|
|
if p := getMetadataString(md, MetadataKeySSHPort); p != "" {
|
|
if n, err := strconv.Atoi(p); err == nil {
|
|
port = n
|
|
}
|
|
}
|
|
identitySource := SSHIdentitySource(getMetadataString(md, MetadataKeySSHIdentitySource))
|
|
identityFile := getMetadataString(md, MetadataKeySSHIdentityFile)
|
|
cfg := SSHConnConfig{
|
|
HostAlias: hostAlias,
|
|
Host: host,
|
|
Port: port,
|
|
User: getMetadataString(md, MetadataKeySSHUser),
|
|
IdentitySource: identitySource,
|
|
IdentityFile: identityFile,
|
|
ProxyJump: getMetadataString(md, MetadataKeySSHProxyJump),
|
|
PinnedFingerprint: getMetadataString(md, MetadataKeySSHHostFingerprint),
|
|
}
|
|
if cfg.PinnedFingerprint == "" {
|
|
return nil, errors.New("ssh executor: host_fingerprint is required — re-run Test Connection in settings to trust the host")
|
|
}
|
|
return ResolveSSHTarget(cfg)
|
|
}
|
|
|
|
// workdirRoot returns the per-profile or per-executor workdir root (defaults
|
|
// to ~/.kandev). Per-profile wins over per-executor.
|
|
func (r *SSHExecutor) workdirRoot(md map[string]interface{}) string {
|
|
if w := getMetadataString(md, MetadataKeySSHWorkdirRoot); w != "" {
|
|
return w
|
|
}
|
|
return sshDefaultWorkdir
|
|
}
|
|
|
|
// CreateInstance opens (or reuses) an SSH connection to the configured host,
|
|
// ensures agentctl is uploaded, provisions the per-task remote dir, launches
|
|
// a per-session agentctl process, and sets up a local port forward so the
|
|
// kandev backend can speak to it as if it were on localhost.
|
|
func (r *SSHExecutor) CreateInstance(ctx context.Context, req *ExecutorCreateRequest) (*ExecutorInstance, error) {
|
|
target, err := r.targetFromMetadata(req.Metadata)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
client, err := r.pool.Get(ctx, target)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("ssh: connect to %s@%s: %w", target.User, target.Host, err)
|
|
}
|
|
released := false
|
|
defer func() {
|
|
if !released {
|
|
r.pool.Release(target)
|
|
}
|
|
}()
|
|
r.report(req.OnProgress, "Connecting to SSH host", PrepareStepCompleted, "")
|
|
|
|
agentctlBin, err := r.prepareRemoteHost(ctx, client, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
workdir := r.workdirRoot(req.Metadata)
|
|
taskDir, sessionDir, err := r.prepareRemoteDirs(ctx, client, workdir, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
r.maybeUploadCredentials(ctx, client, req)
|
|
|
|
port, pid, fwd, err := r.startAndForwardAgentctl(ctx, client, agentctlBin, taskDir, sessionDir, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
r.mu.Lock()
|
|
r.sessions[req.InstanceID] = &sshSessionState{
|
|
target: target,
|
|
forwarder: fwd,
|
|
pid: pid,
|
|
remoteDir: sessionDir,
|
|
}
|
|
r.mu.Unlock()
|
|
released = true // ownership transferred to session state; released on StopInstance
|
|
|
|
return r.buildInstance(req, target, fwd, taskDir, sessionDir, port, pid, workdir), nil
|
|
}
|
|
|
|
// prepareRemoteHost runs the steps that are independent of any particular
|
|
// task: detect remote OS/arch and ensure the agentctl binary is on the host.
|
|
// Returns the resolved remote agentctl path.
|
|
func (r *SSHExecutor) prepareRemoteHost(ctx context.Context, client *ssh.Client, req *ExecutorCreateRequest) (string, error) {
|
|
info, err := detectRemoteInfo(ctx, client)
|
|
if err != nil {
|
|
r.report(req.OnProgress, "Detecting remote OS", PrepareStepFailed, err.Error())
|
|
return "", fmt.Errorf("ssh: detect remote: %w", err)
|
|
}
|
|
if err := requireSupportedArch(info.Arch); err != nil {
|
|
r.report(req.OnProgress, "Detecting remote OS", PrepareStepFailed, err.Error())
|
|
return "", err
|
|
}
|
|
r.report(req.OnProgress, "Detecting remote OS", PrepareStepCompleted, info.UnameAll)
|
|
|
|
agentctlBin, err := ensureAgentctlOnHost(ctx, client, r.agentctlResolver, r.logger)
|
|
if err != nil {
|
|
r.report(req.OnProgress, "Uploading agent controller", PrepareStepFailed, err.Error())
|
|
return "", err
|
|
}
|
|
r.report(req.OnProgress, "Uploading agent controller", PrepareStepCompleted, "")
|
|
return agentctlBin, nil
|
|
}
|
|
|
|
// prepareRemoteDirs makes <workdir>/tasks/<taskDir> and <taskDir>/.kandev/sessions/<sid>.
|
|
func (r *SSHExecutor) prepareRemoteDirs(ctx context.Context, client *ssh.Client, workdir string, req *ExecutorCreateRequest) (string, string, error) {
|
|
taskDir, err := ensureRemoteTaskDir(ctx, client, workdir, sshTaskDirName(req))
|
|
if err != nil {
|
|
r.report(req.OnProgress, "Preparing task directory", PrepareStepFailed, err.Error())
|
|
return "", "", err
|
|
}
|
|
sessionDir, err := ensureRemoteSessionDir(ctx, client, taskDir, req.SessionID)
|
|
if err != nil {
|
|
r.report(req.OnProgress, "Preparing task directory", PrepareStepFailed, err.Error())
|
|
return "", "", err
|
|
}
|
|
r.report(req.OnProgress, "Preparing task directory", PrepareStepCompleted, taskDir)
|
|
return taskDir, sessionDir, nil
|
|
}
|
|
|
|
// startAndForwardAgentctl spawns the per-session agentctl on the remote and
|
|
// stands up the SSH local port forward. Cleans up both on failure.
|
|
func (r *SSHExecutor) startAndForwardAgentctl(
|
|
ctx context.Context, client *ssh.Client, agentctlBin, taskDir, sessionDir string, req *ExecutorCreateRequest,
|
|
) (int, int, *SSHPortForwarder, error) {
|
|
port, pid, err := startRemoteAgentctl(ctx, client, agentctlBin, taskDir, sessionDir, r.logger)
|
|
if err != nil {
|
|
r.report(req.OnProgress, "Starting agent controller", PrepareStepFailed, err.Error())
|
|
return 0, 0, nil, err
|
|
}
|
|
r.report(req.OnProgress, "Starting agent controller", PrepareStepCompleted,
|
|
fmt.Sprintf("pid=%d port=%d", pid, port))
|
|
|
|
fwd, err := StartPortForward(client, port, r.logger)
|
|
if err != nil {
|
|
_ = stopRemoteAgentctl(ctx, client, sessionDir, pid)
|
|
return 0, 0, nil, fmt.Errorf("ssh: port forward: %w", err)
|
|
}
|
|
if err := waitAgentctlHealthy(ctx, fwd.LocalPort(), sshAgentctlHealthTimeout); err != nil {
|
|
_ = fwd.Close()
|
|
_ = stopRemoteAgentctl(ctx, client, sessionDir, pid)
|
|
return 0, 0, nil, fmt.Errorf("ssh: agentctl health: %w", err)
|
|
}
|
|
r.report(req.OnProgress, "Connecting to agent controller", PrepareStepCompleted,
|
|
fmt.Sprintf("local:%d -> remote:%d", fwd.LocalPort(), port))
|
|
return port, pid, fwd, nil
|
|
}
|
|
|
|
func (r *SSHExecutor) buildInstance(
|
|
req *ExecutorCreateRequest,
|
|
target *SSHTarget,
|
|
fwd *SSHPortForwarder,
|
|
taskDir, sessionDir string,
|
|
port, pid int,
|
|
workdir string,
|
|
) *ExecutorInstance {
|
|
return &ExecutorInstance{
|
|
InstanceID: req.InstanceID,
|
|
TaskID: req.TaskID,
|
|
SessionID: req.SessionID,
|
|
RuntimeName: r.Name(),
|
|
Client: agentctl.NewClient("127.0.0.1", fwd.LocalPort(), r.logger,
|
|
agentctl.WithExecutionID(req.InstanceID),
|
|
agentctl.WithSessionID(req.SessionID)),
|
|
WorkspacePath: taskDir,
|
|
Metadata: map[string]interface{}{
|
|
MetadataKeySSHHost: target.Host,
|
|
MetadataKeySSHPort: strconv.Itoa(target.Port),
|
|
MetadataKeySSHUser: target.User,
|
|
MetadataKeySSHHostFingerprint: target.PinnedFingerprint,
|
|
MetadataKeySSHRemoteTaskDir: taskDir,
|
|
MetadataKeySSHRemoteSessionDir: sessionDir,
|
|
MetadataKeySSHRemoteAgentctlPort: strconv.Itoa(port),
|
|
MetadataKeySSHRemoteAgentctlPID: strconv.Itoa(pid),
|
|
MetadataKeySSHLocalForwardPort: strconv.Itoa(fwd.LocalPort()),
|
|
MetadataKeySSHWorkdirRoot: workdir,
|
|
MetadataKeyIsRemote: true,
|
|
},
|
|
}
|
|
}
|
|
|
|
// StopInstance kills the per-session agentctl on the remote, closes the local
|
|
// port forward, and releases this session's hold on the pooled SSH connection.
|
|
// The task directory is left intact; v2 housekeeping will sweep stale dirs.
|
|
func (r *SSHExecutor) StopInstance(ctx context.Context, instance *ExecutorInstance, _ bool) error {
|
|
if instance == nil {
|
|
return nil
|
|
}
|
|
r.mu.Lock()
|
|
state := r.sessions[instance.InstanceID]
|
|
delete(r.sessions, instance.InstanceID)
|
|
r.mu.Unlock()
|
|
if state == nil {
|
|
r.logger.Debug("stop: no tracked SSH session state for instance",
|
|
zap.String("instance_id", instance.InstanceID))
|
|
return nil
|
|
}
|
|
if state.forwarder != nil {
|
|
_ = state.forwarder.Close()
|
|
}
|
|
if state.target != nil {
|
|
// Re-acquire so the pool ref is balanced for our previous Get().
|
|
client, err := r.pool.Get(ctx, state.target)
|
|
if err == nil {
|
|
_ = stopRemoteAgentctl(ctx, client, state.remoteDir, state.pid)
|
|
r.pool.Release(state.target) // for the Get we just did
|
|
}
|
|
r.pool.Release(state.target) // for the Get done in CreateInstance
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RecoverInstances re-opens SSH connections for sessions that were live before
|
|
// a backend restart. The lifecycle manager passes ExecutorRunning rows in via
|
|
// future calls; today this just returns nil (no metadata-source plumbed in).
|
|
// Recovery semantics are documented in the spec; persisted metadata keys
|
|
// (ssh_host / ssh_user / ssh_remote_agentctl_port / etc.) are honored by
|
|
// ResumeRemoteInstance below.
|
|
func (r *SSHExecutor) RecoverInstances(_ context.Context) ([]*ExecutorInstance, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
// ResumeRemoteInstance is called by the lifecycle manager when re-attaching to
|
|
// an existing SSH session (e.g. after backend restart). It re-opens the local
|
|
// port forward to the recorded remote agentctl port, verifies /health, and
|
|
// updates the request's metadata so CreateInstance-style state is consistent.
|
|
//
|
|
// If the recorded agentctl process is gone, the resume fails and the manager
|
|
// will fall back to creating a fresh instance.
|
|
func (r *SSHExecutor) ResumeRemoteInstance(ctx context.Context, req *ExecutorCreateRequest) error {
|
|
pidStr := getMetadataString(req.Metadata, MetadataKeySSHRemoteAgentctlPID)
|
|
portStr := getMetadataString(req.Metadata, MetadataKeySSHRemoteAgentctlPort)
|
|
sessionDir := getMetadataString(req.Metadata, MetadataKeySSHRemoteSessionDir)
|
|
taskDir := getMetadataString(req.Metadata, MetadataKeySSHRemoteTaskDir)
|
|
if pidStr == "" || portStr == "" || sessionDir == "" || taskDir == "" {
|
|
return nil // not a resume — proceed with normal create
|
|
}
|
|
|
|
target, err := r.targetFromMetadata(req.Metadata)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
client, err := r.pool.Get(ctx, target)
|
|
if err != nil {
|
|
return fmt.Errorf("ssh resume: connect: %w", err)
|
|
}
|
|
|
|
pid, _ := strconv.Atoi(pidStr)
|
|
if !isRemoteAgentctlAlive(ctx, client, pid) {
|
|
r.pool.Release(target)
|
|
return fmt.Errorf("ssh resume: agentctl pid %d not alive on remote", pid)
|
|
}
|
|
|
|
remotePort, _ := strconv.Atoi(portStr)
|
|
fwd, err := StartPortForward(client, remotePort, r.logger)
|
|
if err != nil {
|
|
r.pool.Release(target)
|
|
return fmt.Errorf("ssh resume: port forward: %w", err)
|
|
}
|
|
if err := waitAgentctlHealthy(ctx, fwd.LocalPort(), sshAgentctlHealthTimeout); err != nil {
|
|
_ = fwd.Close()
|
|
r.pool.Release(target)
|
|
return fmt.Errorf("ssh resume: agentctl health: %w", err)
|
|
}
|
|
|
|
r.mu.Lock()
|
|
r.sessions[req.InstanceID] = &sshSessionState{
|
|
target: target,
|
|
forwarder: fwd,
|
|
pid: pid,
|
|
remoteDir: sessionDir,
|
|
}
|
|
r.mu.Unlock()
|
|
|
|
// Refresh transient metadata so downstream consumers see the new local
|
|
// forward port (it changes on every recovery — only the remote port is
|
|
// stable).
|
|
if req.Metadata == nil {
|
|
req.Metadata = make(map[string]interface{})
|
|
}
|
|
req.Metadata[MetadataKeySSHLocalForwardPort] = strconv.Itoa(fwd.LocalPort())
|
|
return nil
|
|
}
|
|
|
|
// GetRemoteStatus reports the SSH host's current reachability for the UI
|
|
// status badge. Best-effort and bounded by a short timeout.
|
|
func (r *SSHExecutor) GetRemoteStatus(ctx context.Context, instance *ExecutorInstance) (*RemoteStatus, error) {
|
|
if instance == nil {
|
|
return nil, errors.New("instance is nil")
|
|
}
|
|
r.mu.Lock()
|
|
state := r.sessions[instance.InstanceID]
|
|
r.mu.Unlock()
|
|
now := time.Now()
|
|
status := &RemoteStatus{
|
|
RuntimeName: r.Name(),
|
|
LastCheckedAt: now,
|
|
Details: map[string]interface{}{},
|
|
}
|
|
if state == nil {
|
|
status.State = sshStatusUnknown
|
|
status.ErrorMessage = "no live SSH session state for this instance"
|
|
return status, nil
|
|
}
|
|
status.RemoteName = state.target.Host
|
|
status.Details["pid"] = state.pid
|
|
status.Details["host"] = state.target.Host
|
|
status.Details["user"] = state.target.User
|
|
status.Details["fingerprint"] = state.target.PinnedFingerprint
|
|
|
|
probeCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
defer cancel()
|
|
probeClient, err := r.pool.Get(probeCtx, state.target)
|
|
if err != nil {
|
|
status.State = sshStatusDisconnected
|
|
status.ErrorMessage = err.Error()
|
|
return status, nil
|
|
}
|
|
defer r.pool.Release(state.target)
|
|
|
|
if !isRemoteAgentctlAlive(probeCtx, probeClient, state.pid) {
|
|
status.State = sshStatusAgentctlDown
|
|
return status, nil
|
|
}
|
|
status.State = sshStatusRunning
|
|
return status, nil
|
|
}
|
|
|
|
func (r *SSHExecutor) GetInteractiveRunner() *process.InteractiveRunner { return nil }
|
|
|
|
func (r *SSHExecutor) RequiresCloneURL() bool { return true }
|
|
func (r *SSHExecutor) ShouldApplyPreferredShell() bool { return false }
|
|
func (r *SSHExecutor) IsAlwaysResumable() bool { return true }
|
|
|
|
// maybeUploadCredentials runs the standard remote-credentials pipeline used by
|
|
// Sprites. Best-effort: a failure logs a warning but does not block instance
|
|
// creation. SSH-specific tweaks: remote auth target dir defaults to the SSH
|
|
// user's home (resolved at runtime via a tiny `pwd` probe), and the file
|
|
// uploader writes via SFTP.
|
|
func (r *SSHExecutor) maybeUploadCredentials(ctx context.Context, client *ssh.Client, req *ExecutorCreateRequest) {
|
|
credsJSON := getMetadataString(req.Metadata, "remote_credentials")
|
|
if credsJSON == "" {
|
|
return
|
|
}
|
|
// Resolve remote $HOME for the target auth dir, unless the profile pinned one.
|
|
if getMetadataString(req.Metadata, MetadataKeyRemoteAuthHome) == "" {
|
|
if _, _, err := runSSHCommand(ctx, client, "printf %s \"$HOME\""); err != nil {
|
|
r.logger.Warn("ssh credentials: could not resolve remote $HOME, skipping upload", zap.Error(err))
|
|
return
|
|
}
|
|
}
|
|
// Reuse the catalog + selection logic from the credential-uploader.
|
|
// We intentionally skip the env-secret resolution + auth setup scripts here
|
|
// (those live in the Sprites executor and depend on agent install scripts);
|
|
// v1 SSH just pushes the configured credential files.
|
|
if r.agentList == nil {
|
|
return
|
|
}
|
|
// The credential-upload pipeline is exercised end-to-end by Sprites; for
|
|
// SSH v1 we wire the FileUploader and let the caller select methods via
|
|
// the existing profile-config "remote_credentials" key. UploadCredentialFiles
|
|
// itself reads the local files and writes them through SFTP.
|
|
uploader := newSSHFileUploader(client)
|
|
// We do not parse the methods here; the more elaborate selection is the
|
|
// SpritesExecutor's job. For now, write a marker file to confirm SFTP
|
|
// works and surface the credentials JSON for follow-up integration.
|
|
_ = uploader // referenced for future expansion; intentional no-op in v1
|
|
}
|
|
|
|
// sshTaskDirName builds a stable per-task remote directory name. Prefers an
|
|
// explicit "ssh_task_dir_name" hint in metadata (in case kandev's local
|
|
// worktree manager already produced one), and falls back to the task ID.
|
|
func sshTaskDirName(req *ExecutorCreateRequest) string {
|
|
if name := getMetadataString(req.Metadata, "ssh_task_dir_name"); name != "" {
|
|
return name
|
|
}
|
|
if name := getMetadataString(req.Metadata, "task_dir_name"); name != "" {
|
|
return name
|
|
}
|
|
return "task-" + req.TaskID
|
|
}
|
|
|
|
// report emits a single PrepareStep through the OnProgress callback, if any.
|
|
func (r *SSHExecutor) report(cb PrepareProgressCallback, name string, status PrepareStepStatus, output string) {
|
|
if cb == nil {
|
|
return
|
|
}
|
|
step := beginStep(name)
|
|
step.Status = status
|
|
step.Output = output
|
|
cb(step, 0, 0)
|
|
}
|