// "sync" sub-command: sync data to remote hosts
-// Copyright (C) 2021 Simon Ruderich
+// Copyright (C) 2021-2023 Simon Ruderich
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
import (
"flag"
"fmt"
+ "io/fs"
"log"
"os"
+ "runtime"
"sort"
"strings"
- "sync"
"golang.org/x/term"
"ruderich.org/simon/safcm"
"ruderich.org/simon/safcm/cmd/safcm/config"
+ "ruderich.org/simon/safcm/frontend"
"ruderich.org/simon/safcm/rpc"
)
allHosts *config.Hosts // known hosts
allGroups map[string][]string // known groups
- events chan<- Event // all events generated by/for this host
-
isTTY bool
-}
-
-type Event struct {
- Host *config.Host
-
- // Only one of Error, Log and ConnEvent is set in a single event
- Error error
- Log Log
- ConnEvent rpc.ConnEvent
- Escaped bool // true if untrusted input is already escaped
-}
-
-type Log struct {
- Level safcm.LogLevel
- Text string
+ loop *frontend.Loop
+ logFunc func(level safcm.LogLevel, escaped bool, msg string)
}
func MainSync(args []string) error {
"hide successful, non-trigger commands with no output from host changes listing")
optionLog := flag.String("log", "info", "set log `level`; "+
"levels: error, info, verbose, debug, debug2, debug3")
+ optionSshConfig := flag.String("sshconfig", "",
+ "`path` to ssh configuration file; used for tests")
- flag.CommandLine.Parse(args[2:])
-
- var level safcm.LogLevel
- switch *optionLog {
- case "error":
- level = safcm.LogError
- case "info":
- level = safcm.LogInfo
- case "verbose":
- level = safcm.LogVerbose
- case "debug":
- level = safcm.LogDebug
- case "debug2":
- level = safcm.LogDebug2
- case "debug3":
- level = safcm.LogDebug3
- default:
- return fmt.Errorf("invalid -log value %q", *optionLog)
+ flag.CommandLine.Parse(args[2:]) //nolint:errcheck
+
+ level, err := safcm.ParseLogLevel(*optionLog)
+ if err != nil {
+ return fmt.Errorf("-log: %v", err)
}
names := flag.Args()
os.Exit(1)
}
+ if runtime.GOOS == "windows" {
+ log.Print("WARNING: Windows support is experimental!")
+ }
+
cfg, allHosts, allGroups, err := LoadBaseFiles()
if err != nil {
return err
cfg.DryRun = *optionDryRun
cfg.Quiet = *optionQuiet
cfg.LogLevel = level
+ cfg.SshConfig = *optionSshConfig
toSync, err := hostsToSync(names, allHosts, allGroups)
if err != nil {
return fmt.Errorf("no hosts found")
}
- isTTY := term.IsTerminal(int(os.Stdout.Fd()))
-
- done := make(chan bool)
- // Collect events from all hosts and print them
- events := make(chan Event)
- go func() {
- var failed bool
- for {
- x := <-events
- if x.Host == nil {
- break
- }
- logEvent(x, cfg.LogLevel, isTTY, &failed)
- }
- done <- failed
- }()
+ isTTY := term.IsTerminal(int(os.Stdout.Fd())) &&
+ term.IsTerminal(int(os.Stderr.Fd()))
+
+ loop := &frontend.Loop{
+ DebugConn: cfg.LogLevel >= safcm.LogDebug3,
+ LogEventFunc: func(x frontend.Event, failed *bool) {
+ frontend.LogEvent(x, cfg.LogLevel, isTTY, failed)
+ },
+ SyncHostFunc: func(conn *rpc.Conn, host frontend.Host) error {
+ return host.(*Sync).Host(conn)
+ },
+ }
- // Sync all hosts concurrently
- var wg sync.WaitGroup
+ var hosts []frontend.Host
for _, x := range toSync {
- x := x
-
- // Once in sync.Host() and once in the go func below
- wg.Add(2)
-
- go func() {
- sync := Sync{
- host: x,
- config: cfg,
- allHosts: allHosts,
- allGroups: allGroups,
- events: events,
- isTTY: isTTY,
- }
- err := sync.Host(&wg)
- if err != nil {
- events <- Event{
- Host: x,
- Error: err,
- }
- }
- wg.Done()
- }()
+ s := &Sync{
+ host: x,
+ config: cfg,
+ allHosts: allHosts,
+ allGroups: allGroups,
+ isTTY: isTTY,
+ loop: loop,
+ }
+ s.logFunc = func(level safcm.LogLevel, escaped bool,
+ msg string) {
+ s.loop.Log(s, level, escaped, msg)
+ }
+ hosts = append(hosts, s)
}
- wg.Wait()
- events <- Event{} // poison pill
- failed := <-done
+ succ := loop.Run(hosts)
- if failed {
+ if !succ {
// Exit instead of returning an error to prevent an extra log
// message from main()
os.Exit(1)
func hostsToSync(names []string, allHosts *config.Hosts,
allGroups map[string][]string) ([]*config.Host, error) {
+ detectedMap := config.TransitivelyDetectedGroups(allGroups)
+
+ const detectedErr = `
+
+Groups depending on "detected" groups cannot be used to select hosts as these
+are only available after the hosts were contacted.
+`
+
nameMap := make(map[string]bool)
for _, x := range names {
+ if detectedMap[x] {
+ return nil, fmt.Errorf(
+ "group %q depends on \"detected\" groups%s",
+ x, detectedErr)
+ }
nameMap[x] = true
}
nameMatched := make(map[string]bool)
- // To detect typos we must check all given names but only want to add
- // each match once
- hostMatched := make(map[string]bool)
+ // To detect typos we must check all given names but one host can be
+ // matched by multiple names (e.g. two groups with overlapping hosts)
+ hostAdded := make(map[string]bool)
var res []*config.Host
for _, host := range allHosts.List {
if nameMap[host.Name] {
res = append(res, host)
- hostMatched[host.Name] = true
+ hostAdded[host.Name] = true
nameMatched[host.Name] = true
}
- // TODO: don't permit groups which contain "detected" groups
- // because these are not available yet
groups, err := config.ResolveHostGroups(host.Name,
allGroups, nil)
if err != nil {
}
for _, x := range groups {
if nameMap[x] {
- if !hostMatched[host.Name] {
+ if !hostAdded[host.Name] {
res = append(res, host)
- hostMatched[host.Name] = true
+ hostAdded[host.Name] = true
}
nameMatched[x] = true
}
return res, nil
}
-func logEvent(x Event, level safcm.LogLevel, isTTY bool, failed *bool) {
- // We have multiple event sources so this is somewhat ugly.
- var prefix, data string
- var color Color
- if x.Error != nil {
- prefix = "[error]"
- data = x.Error.Error()
- color = ColorRed
- // We logged an error, tell the caller
- *failed = true
- } else if x.Log.Level != 0 {
- // LogError and LogDebug3 should not occur here
- switch x.Log.Level {
- case safcm.LogInfo:
- prefix = "[info]"
- case safcm.LogVerbose:
- prefix = "[verbose]"
- case safcm.LogDebug:
- prefix = "[debug]"
- case safcm.LogDebug2:
- prefix = "[debug2]"
- default:
- prefix = fmt.Sprintf("[INVALID=%d]", x.Log.Level)
- color = ColorRed
- }
- data = x.Log.Text
- } else {
- switch x.ConnEvent.Type {
- case rpc.ConnEventStderr:
- prefix = "[stderr]"
- case rpc.ConnEventDebug:
- prefix = "[debug3]"
- case rpc.ConnEventUpload:
- if level < safcm.LogInfo {
- return
- }
- prefix = "[info]"
- x.ConnEvent.Data = "remote helper upload in progress"
- default:
- prefix = fmt.Sprintf("[INVALID=%d]", x.ConnEvent.Type)
- color = ColorRed
- }
- data = x.ConnEvent.Data
- }
-
- host := x.Host.Name
- if color != 0 {
- host = ColorString(isTTY, color, host)
- }
- // Make sure to escape control characters to prevent terminal
- // injection attacks
- if !x.Escaped {
- data = EscapeControlCharacters(isTTY, data)
- }
- log.Printf("%-9s [%s] %s", prefix, host, data)
+func (s *Sync) Name() string {
+ return s.host.Name
}
-func (s *Sync) Host(wg *sync.WaitGroup) error {
- conn := rpc.NewConn(s.config.LogLevel >= safcm.LogDebug3)
- // Pass all connection events to main loop
- go func() {
- for {
- x, ok := <-conn.Events
- if !ok {
- break
- }
- s.events <- Event{
- Host: s.host,
- ConnEvent: x,
- }
- }
- wg.Done()
- }()
-
- // Connect to remote host
- err := conn.DialSSH(s.host.SshUser, s.host.Name)
+func (s *Sync) Dial(conn *rpc.Conn) error {
+ helpers, err := fs.Sub(RemoteHelpers, "remote")
if err != nil {
return err
}
- defer conn.Kill()
+ // Connect to remote host
+ user := s.host.SshUser
+ if user == "" {
+ user = s.config.SshUser
+ }
+ return conn.DialSSH(rpc.SSHConfig{
+ Host: s.host.Name,
+ User: user,
+ SshConfig: s.config.SshConfig,
+ RemoteHelpers: helpers,
+ })
+}
+
+func (s *Sync) Host(conn *rpc.Conn) error {
// Collect information about remote host
detectedGroups, err := s.hostInfo(conn)
if err != nil {
return err
}
- // Terminate connection to remote host
- err = conn.Send(safcm.MsgQuitReq{})
- if err != nil {
- return err
- }
- _, err = conn.Recv()
- if err != nil {
- return err
- }
- err = conn.Wait()
- if err != nil {
- return err
- }
-
return nil
}
-func (s *Sync) logf(level safcm.LogLevel, escaped bool,
- format string, a ...interface{}) {
-
- if s.config.LogLevel < level {
- return
- }
- s.events <- Event{
- Host: s.host,
- Log: Log{
- Level: level,
- Text: fmt.Sprintf(format, a...),
- },
- Escaped: escaped,
- }
+func (s *Sync) log(level safcm.LogLevel, escaped bool, msg string) {
+ s.logFunc(level, escaped, msg)
}
func (s *Sync) logDebugf(format string, a ...interface{}) {
- s.logf(safcm.LogDebug, false, format, a...)
+ s.log(safcm.LogDebug, false, fmt.Sprintf(format, a...))
}
func (s *Sync) logVerbosef(format string, a ...interface{}) {
- s.logf(safcm.LogVerbose, false, format, a...)
-}
-
-// sendRecv sends a message over conn and waits for the response. Any MsgLog
-// messages received before the final (non MsgLog) response are passed to
-// s.log.
-func (s *Sync) sendRecv(conn *rpc.Conn, msg safcm.Msg) (safcm.Msg, error) {
- err := conn.Send(msg)
- if err != nil {
- return nil, err
- }
- for {
- x, err := conn.Recv()
- if err != nil {
- return nil, err
- }
- log, ok := x.(safcm.MsgLog)
- if ok {
- s.logf(log.Level, false, "%s", log.Text)
- continue
- }
- return x, nil
- }
+ s.log(safcm.LogVerbose, false, fmt.Sprintf(format, a...))
}