summaryrefslogtreecommitdiff
path: root/cmd
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-27 09:01:44 +0300
committerPaul Buetow <paul@buetow.org>2026-04-27 09:01:44 +0300
commit626ff3ae7d43cfc2ec3f2554d340b40f4a5c0586 (patch)
tree56840d4c9815de0a71a41bceac71136371978076 /cmd
parent30e63df03544b94ebc5fcb2a004d18a0d32a4247 (diff)
Externalize sync targets from hardcoded constants to env/config
- Added SyncTargets and SyncRemoteDir to internal/config.Config. - Replaced the package-level syncTargets and syncRemoteDir constants in cmd/snonux/sync.go with default values, populated lazily via resolveSyncConfig(). - Accept --sync-targets and --sync-remote-dir CLI flags; fall back to SNONUX_SYNC_TARGETS and SNONUX_SYNC_REMOTE_DIR env vars; then fall back to the previous hardcoded defaults (pi0/pi1, /var/www/html/snonux/).
Diffstat (limited to 'cmd')
-rw-r--r--cmd/snonux/main.go11
-rw-r--r--cmd/snonux/main_test.go41
-rw-r--r--cmd/snonux/sync.go51
-rw-r--r--cmd/snonux/sync_test.go101
4 files changed, 195 insertions, 9 deletions
diff --git a/cmd/snonux/main.go b/cmd/snonux/main.go
index 75e386e..659bd19 100644
--- a/cmd/snonux/main.go
+++ b/cmd/snonux/main.go
@@ -66,7 +66,7 @@ func main() {
}
if cfg.Sync {
- if err := syncOutput(cfg.OutputDir); err != nil {
+ if err := syncOutput(cfg); err != nil {
log.Fatalf("error: %v", err)
}
}
@@ -91,12 +91,19 @@ func parseFlags(args []string) (*config.Config, cliMode, error) {
fs.StringVar(&cfg.OutputDir, "output", "./dist", "root directory for generated static site output")
fs.StringVar(&cfg.BaseURL, "base-url", "https://snonux.foo", "canonical base URL used in Atom feed links")
fs.StringVar(&cfg.Theme, "theme", "random", "visual theme name, or \"random\" to pick one at random")
- fs.BoolVar(&cfg.Sync, "sync", false, "after a successful run, rsync -output to pi0/pi1 when both are pingable (SSH user: SNONUX_SYNC_USER or login name)")
+ fs.BoolVar(&cfg.Sync, "sync", false, "after a successful run, rsync -output to mirror hosts when all are pingable (SSH user: SNONUX_SYNC_USER or login name)")
+ var syncTargets string
+ fs.StringVar(&syncTargets, "sync-targets", "", "comma-separated list of rsync target hosts (overrides SNONUX_SYNC_TARGETS and defaults)")
+ fs.StringVar(&cfg.SyncRemoteDir, "sync-remote-dir", "", "remote destination directory on target hosts (overrides SNONUX_SYNC_REMOTE_DIR and default)")
if err := fs.Parse(args); err != nil {
return nil, modeRun, fmt.Errorf("%w: %w", errParseFlags, err)
}
+ if syncTargets != "" {
+ cfg.SyncTargets = splitAndTrim(syncTargets)
+ }
+
if showVersion {
return nil, modeVersion, nil
}
diff --git a/cmd/snonux/main_test.go b/cmd/snonux/main_test.go
index a4fe22f..b8a5a82 100644
--- a/cmd/snonux/main_test.go
+++ b/cmd/snonux/main_test.go
@@ -121,6 +121,47 @@ func TestParseFlags_sync(t *testing.T) {
if !cfg.Sync {
t.Fatal("expected cfg.Sync")
}
+ // Defaults should be empty until resolved at sync time.
+ if len(cfg.SyncTargets) != 0 {
+ t.Fatalf("expected no sync targets from flags alone, got %v", cfg.SyncTargets)
+ }
+ if cfg.SyncRemoteDir != "" {
+ t.Fatalf("expected empty remote dir from flags alone, got %q", cfg.SyncRemoteDir)
+ }
+}
+
+func TestParseFlags_syncTargetsAndRemoteDir(t *testing.T) {
+ t.Parallel()
+
+ cfg, mode, err := parseFlags([]string{
+ "-input", "./in",
+ "-output", "./out",
+ "-theme", "neon",
+ "-sync",
+ "-sync-targets", "host1, host2,host3",
+ "-sync-remote-dir", "/var/www/custom/",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if mode != modeRun {
+ t.Fatalf("mode %v", mode)
+ }
+ if !cfg.Sync {
+ t.Fatal("expected cfg.Sync")
+ }
+ want := []string{"host1", "host2", "host3"}
+ if len(cfg.SyncTargets) != len(want) {
+ t.Fatalf("got targets %v, want %v", cfg.SyncTargets, want)
+ }
+ for i, v := range want {
+ if cfg.SyncTargets[i] != v {
+ t.Fatalf("target[%d] = %q, want %q", i, cfg.SyncTargets[i], v)
+ }
+ }
+ if cfg.SyncRemoteDir != "/var/www/custom/" {
+ t.Fatalf("got remote dir %q", cfg.SyncRemoteDir)
+ }
}
func TestResolvePaths(t *testing.T) {
diff --git a/cmd/snonux/sync.go b/cmd/snonux/sync.go
index 46993e2..f5d2e9e 100644
--- a/cmd/snonux/sync.go
+++ b/cmd/snonux/sync.go
@@ -8,23 +8,60 @@ import (
"os/exec"
"os/user"
"path/filepath"
+ "strings"
"time"
+
+ "codeberg.org/snonux/snonux/internal/config"
)
// SNONUX_SYNC_USER overrides the SSH username for rsync (default: current login name).
const envSyncUser = "SNONUX_SYNC_USER"
-var syncTargets = []string{
+// defaultSyncTargets are the built-in mirror hosts used when no configuration overrides them.
+var defaultSyncTargets = []string{
"pi0.lan.buetow.org",
"pi1.lan.buetow.org",
}
-const syncRemoteDir = "/var/www/html/snonux/"
+const defaultSyncRemoteDir = "/var/www/html/snonux/"
+
+// resolveSyncConfig populates cfg.SyncTargets and cfg.SyncRemoteDir from the
+// environment if they are empty, applying sensible defaults.
+func resolveSyncConfig(cfg *config.Config) {
+ if len(cfg.SyncTargets) == 0 {
+ if v := os.Getenv("SNONUX_SYNC_TARGETS"); v != "" {
+ cfg.SyncTargets = splitAndTrim(v)
+ } else {
+ cfg.SyncTargets = append([]string(nil), defaultSyncTargets...)
+ }
+ }
+ if cfg.SyncRemoteDir == "" {
+ if v := os.Getenv("SNONUX_SYNC_REMOTE_DIR"); v != "" {
+ cfg.SyncRemoteDir = strings.TrimSpace(v)
+ } else {
+ cfg.SyncRemoteDir = defaultSyncRemoteDir
+ }
+ }
+}
+
+func splitAndTrim(s string) []string {
+ parts := strings.Split(s, ",")
+ out := make([]string, 0, len(parts))
+ for _, p := range parts {
+ p = strings.TrimSpace(p)
+ if p != "" {
+ out = append(out, p)
+ }
+ }
+ return out
+}
// syncOutput rsyncs localOutput (trailing-slash source) to each sync target over SSH
// port 22. It runs only if every target answers ICMP ping (Linux iputils: ping -c 1 -W …).
-func syncOutput(localOutput string) error {
- for _, host := range syncTargets {
+func syncOutput(cfg *config.Config) error {
+ resolveSyncConfig(cfg)
+
+ for _, host := range cfg.SyncTargets {
if !hostPingable(host) {
log.Printf("sync skipped: %q not pingable (all mirror hosts must be reachable)", host)
return nil
@@ -40,15 +77,15 @@ func syncOutput(localOutput string) error {
sshUser = u.Username
}
- absOut, err := filepath.Abs(localOutput)
+ absOut, err := filepath.Abs(cfg.OutputDir)
if err != nil {
return fmt.Errorf("sync output dir: %w", err)
}
src := filepath.Clean(absOut) + string(filepath.Separator)
ssh := "ssh -p 22 -o BatchMode=yes -o ConnectTimeout=15"
- for _, host := range syncTargets {
- dest := fmt.Sprintf("%s@%s:%s", sshUser, host, syncRemoteDir)
+ for _, host := range cfg.SyncTargets {
+ dest := fmt.Sprintf("%s@%s:%s", sshUser, host, cfg.SyncRemoteDir)
log.Printf("rsync %s -> %s", src, dest)
cmd := exec.Command("rsync", "-az", "-e", ssh, src, dest)
cmd.Stdout = os.Stdout
diff --git a/cmd/snonux/sync_test.go b/cmd/snonux/sync_test.go
new file mode 100644
index 0000000..d45a916
--- /dev/null
+++ b/cmd/snonux/sync_test.go
@@ -0,0 +1,101 @@
+package main
+
+import (
+ "os"
+ "reflect"
+ "testing"
+
+ "codeberg.org/snonux/snonux/internal/config"
+)
+
+func TestSplitAndTrim(t *testing.T) {
+ t.Parallel()
+
+ cases := []struct {
+ in string
+ want []string
+ }{
+ {"a,b,c", []string{"a", "b", "c"}},
+ {" a , b ", []string{"a", "b"}},
+ {"a,,c", []string{"a", "c"}},
+ {"", []string{}},
+ {",,", []string{}},
+ }
+
+ for _, c := range cases {
+ got := splitAndTrim(c.in)
+ if !reflect.DeepEqual(got, c.want) {
+ t.Errorf("splitAndTrim(%q) = %v; want %v", c.in, got, c.want)
+ }
+ }
+}
+
+func TestResolveSyncConfig_defaults(t *testing.T) {
+ t.Parallel()
+
+ cfg := &config.Config{}
+ resolveSyncConfig(cfg)
+ want := []string{"pi0.lan.buetow.org", "pi1.lan.buetow.org"}
+ if !reflect.DeepEqual(cfg.SyncTargets, want) {
+ t.Fatalf("got targets %v, want %v", cfg.SyncTargets, want)
+ }
+ if cfg.SyncRemoteDir != "/var/www/html/snonux/" {
+ t.Fatalf("got remote dir %q", cfg.SyncRemoteDir)
+ }
+}
+
+func TestResolveSyncConfig_envTargets(t *testing.T) {
+ t.Parallel()
+
+ orig := os.Getenv("SNONUX_SYNC_TARGETS")
+ defer os.Setenv("SNONUX_SYNC_TARGETS", orig)
+
+ os.Setenv("SNONUX_SYNC_TARGETS", "h1, h2 ,h3")
+ cfg := &config.Config{}
+ resolveSyncConfig(cfg)
+ want := []string{"h1", "h2", "h3"}
+ if !reflect.DeepEqual(cfg.SyncTargets, want) {
+ t.Fatalf("got targets %v, want %v", cfg.SyncTargets, want)
+ }
+}
+
+func TestResolveSyncConfig_envRemoteDir(t *testing.T) {
+ t.Parallel()
+
+ orig := os.Getenv("SNONUX_SYNC_REMOTE_DIR")
+ defer os.Setenv("SNONUX_SYNC_REMOTE_DIR", orig)
+
+ os.Setenv("SNONUX_SYNC_REMOTE_DIR", "/custom/path/")
+ cfg := &config.Config{}
+ resolveSyncConfig(cfg)
+ if cfg.SyncRemoteDir != "/custom/path/" {
+ t.Fatalf("got remote dir %q", cfg.SyncRemoteDir)
+ }
+}
+
+func TestResolveSyncConfig_flagsOverrideEnv(t *testing.T) {
+ t.Parallel()
+
+ origTargets := os.Getenv("SNONUX_SYNC_TARGETS")
+ origDir := os.Getenv("SNONUX_SYNC_REMOTE_DIR")
+ defer func() {
+ os.Setenv("SNONUX_SYNC_TARGETS", origTargets)
+ os.Setenv("SNONUX_SYNC_REMOTE_DIR", origDir)
+ }()
+
+ os.Setenv("SNONUX_SYNC_TARGETS", "from-env")
+ os.Setenv("SNONUX_SYNC_REMOTE_DIR", "/env/dir/")
+
+ cfg := &config.Config{
+ SyncTargets: []string{"from-flag"},
+ SyncRemoteDir: "/flag/dir/",
+ }
+ resolveSyncConfig(cfg)
+
+ if !reflect.DeepEqual(cfg.SyncTargets, []string{"from-flag"}) {
+ t.Fatalf("flag targets should override env: got %v", cfg.SyncTargets)
+ }
+ if cfg.SyncRemoteDir != "/flag/dir/" {
+ t.Fatalf("flag dir should override env: got %q", cfg.SyncRemoteDir)
+ }
+}