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:
2026-07-19 20:01:11 +02:00
parent 50568127c5
commit 32af91fd30
22 changed files with 4337 additions and 32 deletions
+53
View File
@@ -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)
}
}
+228
View File
@@ -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, "'", `'\''`) + "'"
}