Add per-node action groups with schedules, runs, and logs.
Let operators manage ordered action groups on each host, run them manually or on a schedule over SSH, and inspect disk-backed JSON run logs from a node detail UI.
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var placeholderPattern = regexp.MustCompile(`\{\{\s*([a-zA-Z0-9_.]+)\s*\}\}`)
|
||||
|
||||
// PlaceholderContext holds values used to expand action body placeholders.
|
||||
type PlaceholderContext struct {
|
||||
Host string
|
||||
IP string
|
||||
Username string
|
||||
Env map[string]string
|
||||
Secrets map[string]string
|
||||
}
|
||||
|
||||
// ExpandPlaceholders replaces {{cc.host}}, {{node.ip}}, {{env.NAME}}, etc.
|
||||
// Unknown or unresolved secrets expand to empty strings.
|
||||
func ExpandPlaceholders(body string, ctx PlaceholderContext) string {
|
||||
return placeholderPattern.ReplaceAllStringFunc(body, func(match string) string {
|
||||
submatches := placeholderPattern.FindStringSubmatch(match)
|
||||
if len(submatches) < 2 {
|
||||
return match
|
||||
}
|
||||
key := strings.TrimSpace(submatches[1])
|
||||
switch key {
|
||||
case "cc.host", "node.host":
|
||||
return ctx.Host
|
||||
case "cc.ip", "node.ip":
|
||||
return ctx.IP
|
||||
case "node.username":
|
||||
return ctx.Username
|
||||
default:
|
||||
if strings.HasPrefix(key, "env.") {
|
||||
name := strings.TrimPrefix(key, "env.")
|
||||
if ctx.Env != nil {
|
||||
return ctx.Env[name]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(key, "secret.") {
|
||||
name := strings.TrimPrefix(key, "secret.")
|
||||
if ctx.Secrets != nil {
|
||||
return ctx.Secrets[name]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return ""
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package auth
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestExpandPlaceholders(t *testing.T) {
|
||||
body := "ping {{node.ip}} as {{node.username}} host={{cc.host}} env={{env.FOO}} secret={{secret.TOKEN}} unknown={{nope}}"
|
||||
got := ExpandPlaceholders(body, PlaceholderContext{
|
||||
Host: "web-1",
|
||||
IP: "10.0.0.5",
|
||||
Username: "deploy",
|
||||
Env: map[string]string{"FOO": "bar"},
|
||||
Secrets: map[string]string{},
|
||||
})
|
||||
want := "ping 10.0.0.5 as deploy host=web-1 env=bar secret= unknown="
|
||||
if got != want {
|
||||
t.Fatalf("got %q want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
const defaultSSHRunTimeout = 5 * time.Minute
|
||||
|
||||
// SSHRunResult captures stdout, stderr, and exit status from a remote command.
|
||||
type SSHRunResult struct {
|
||||
Stdout string
|
||||
Stderr string
|
||||
ExitCode int
|
||||
}
|
||||
|
||||
// RunSSHShell runs a single shell command on the remote host over SSH.
|
||||
func RunSSHShell(
|
||||
hostIP string,
|
||||
username string,
|
||||
privateKeyPEM string,
|
||||
passphrase string,
|
||||
command string,
|
||||
env map[string]string,
|
||||
timeout time.Duration,
|
||||
) (SSHRunResult, error) {
|
||||
if strings.TrimSpace(command) == "" {
|
||||
return SSHRunResult{}, fmt.Errorf("command is required")
|
||||
}
|
||||
fullCommand := prependEnvExports(env, command)
|
||||
return runSSHSession(hostIP, username, privateKeyPEM, passphrase, fullCommand, timeout)
|
||||
}
|
||||
|
||||
// RunSSHScript uploads and runs a multi-line script via bash -s on the remote host.
|
||||
func RunSSHScript(
|
||||
hostIP string,
|
||||
username string,
|
||||
privateKeyPEM string,
|
||||
passphrase string,
|
||||
scriptBody string,
|
||||
env map[string]string,
|
||||
timeout time.Duration,
|
||||
) (SSHRunResult, error) {
|
||||
if strings.TrimSpace(scriptBody) == "" {
|
||||
return SSHRunResult{}, fmt.Errorf("script body is required")
|
||||
}
|
||||
|
||||
hostIP = strings.TrimSpace(hostIP)
|
||||
username = strings.TrimSpace(username)
|
||||
if hostIP == "" {
|
||||
return SSHRunResult{}, fmt.Errorf("host IP is required")
|
||||
}
|
||||
if username == "" {
|
||||
return SSHRunResult{}, fmt.Errorf("username is required")
|
||||
}
|
||||
if timeout <= 0 {
|
||||
timeout = defaultSSHRunTimeout
|
||||
}
|
||||
|
||||
signer, err := parseSigner(privateKeyPEM, passphrase)
|
||||
if err != nil {
|
||||
return SSHRunResult{}, err
|
||||
}
|
||||
|
||||
config := &ssh.ClientConfig{
|
||||
User: username,
|
||||
Auth: []ssh.AuthMethod{
|
||||
ssh.PublicKeys(signer),
|
||||
},
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
Timeout: sshDialTimeout,
|
||||
}
|
||||
|
||||
address := net.JoinHostPort(hostIP, defaultSSHPort)
|
||||
client, err := ssh.Dial("tcp", address, config)
|
||||
if err != nil {
|
||||
return SSHRunResult{}, fmt.Errorf("ssh dial %s: %w", address, err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
return SSHRunResult{}, fmt.Errorf("ssh session: %w", err)
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
var stdoutBuf bytes.Buffer
|
||||
var stderrBuf bytes.Buffer
|
||||
session.Stdout = &stdoutBuf
|
||||
session.Stderr = &stderrBuf
|
||||
session.Stdin = strings.NewReader(scriptBody)
|
||||
|
||||
remoteCommand := prependEnvExports(env, "bash -s")
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- session.Run(remoteCommand)
|
||||
}()
|
||||
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
return finishSSHResult(stdoutBuf.String(), stderrBuf.String(), err)
|
||||
case <-timer.C:
|
||||
_ = session.Close()
|
||||
return SSHRunResult{
|
||||
Stdout: stdoutBuf.String(),
|
||||
Stderr: stderrBuf.String(),
|
||||
ExitCode: -1,
|
||||
}, fmt.Errorf("ssh command timed out after %s", timeout)
|
||||
}
|
||||
}
|
||||
|
||||
func runSSHSession(
|
||||
hostIP string,
|
||||
username string,
|
||||
privateKeyPEM string,
|
||||
passphrase string,
|
||||
command string,
|
||||
timeout time.Duration,
|
||||
) (SSHRunResult, error) {
|
||||
hostIP = strings.TrimSpace(hostIP)
|
||||
username = strings.TrimSpace(username)
|
||||
if hostIP == "" {
|
||||
return SSHRunResult{}, fmt.Errorf("host IP is required")
|
||||
}
|
||||
if username == "" {
|
||||
return SSHRunResult{}, fmt.Errorf("username is required")
|
||||
}
|
||||
if timeout <= 0 {
|
||||
timeout = defaultSSHRunTimeout
|
||||
}
|
||||
|
||||
signer, err := parseSigner(privateKeyPEM, passphrase)
|
||||
if err != nil {
|
||||
return SSHRunResult{}, err
|
||||
}
|
||||
|
||||
config := &ssh.ClientConfig{
|
||||
User: username,
|
||||
Auth: []ssh.AuthMethod{
|
||||
ssh.PublicKeys(signer),
|
||||
},
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
Timeout: sshDialTimeout,
|
||||
}
|
||||
|
||||
address := net.JoinHostPort(hostIP, defaultSSHPort)
|
||||
client, err := ssh.Dial("tcp", address, config)
|
||||
if err != nil {
|
||||
return SSHRunResult{}, fmt.Errorf("ssh dial %s: %w", address, err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
return SSHRunResult{}, fmt.Errorf("ssh session: %w", err)
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
var stdoutBuf bytes.Buffer
|
||||
var stderrBuf bytes.Buffer
|
||||
session.Stdout = &stdoutBuf
|
||||
session.Stderr = &stderrBuf
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- session.Run(command)
|
||||
}()
|
||||
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
return finishSSHResult(stdoutBuf.String(), stderrBuf.String(), err)
|
||||
case <-timer.C:
|
||||
_ = session.Close()
|
||||
return SSHRunResult{
|
||||
Stdout: stdoutBuf.String(),
|
||||
Stderr: stderrBuf.String(),
|
||||
ExitCode: -1,
|
||||
}, fmt.Errorf("ssh command timed out after %s", timeout)
|
||||
}
|
||||
}
|
||||
|
||||
func finishSSHResult(stdout string, stderr string, err error) (SSHRunResult, error) {
|
||||
result := SSHRunResult{
|
||||
Stdout: stdout,
|
||||
Stderr: stderr,
|
||||
ExitCode: 0,
|
||||
}
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
if exitErr, ok := err.(*ssh.ExitError); ok {
|
||||
result.ExitCode = exitErr.ExitStatus()
|
||||
return result, nil
|
||||
}
|
||||
result.ExitCode = -1
|
||||
return result, err
|
||||
}
|
||||
|
||||
func prependEnvExports(env map[string]string, command string) string {
|
||||
if len(env) == 0 {
|
||||
return command
|
||||
}
|
||||
var builder strings.Builder
|
||||
for name, value := range env {
|
||||
builder.WriteString("export ")
|
||||
builder.WriteString(shellQuote(name))
|
||||
builder.WriteString("=")
|
||||
builder.WriteString(shellQuote(value))
|
||||
builder.WriteString("; ")
|
||||
}
|
||||
builder.WriteString(command)
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func shellQuote(value string) string {
|
||||
return "'" + strings.ReplaceAll(value, "'", `'\''`) + "'"
|
||||
}
|
||||
Reference in New Issue
Block a user