commit e325fcca1a3faa3d72cf39bb25dbe64e4c133183 Author: Sam Hoffman Date: Fri Jan 30 15:14:25 2026 -0500 initial working command diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b3dc617 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.gentoo.party/sam/thanks + +go 1.25.5 diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go new file mode 100644 index 0000000..d356c25 --- /dev/null +++ b/internal/cmd/cmd.go @@ -0,0 +1,28 @@ +package cmd + +import ( + "context" + "fmt" + "os/exec" +) + +type Runner interface { + Run(context.Context, string, ...any) ([]byte, error) +} + +type ZRunner struct { + Prog string + Args []string +} + +func (runner *ZRunner) Run(ctx context.Context, command string, formatArgs ...any) ([]byte, error) { + var subArgs string + if len(formatArgs) > 0 { + subArgs = fmt.Sprintf(command, formatArgs...) + } else { + subArgs = command + } + args := append(runner.Args, subArgs) + cmd := exec.Command(runner.Prog, args...) + return cmd.CombinedOutput() +} diff --git a/internal/cmd/cmd_test.go b/internal/cmd/cmd_test.go new file mode 100644 index 0000000..4262165 --- /dev/null +++ b/internal/cmd/cmd_test.go @@ -0,0 +1,21 @@ +package cmd_test + +import ( + "context" + "testing" + + "git.gentoo.party/sam/thanks/internal/cmd" +) + +func Test_ZCommand(t *testing.T) { + localRunner := &cmd.ZRunner{ + Prog: "/bin/sh", + Args: []string{"-c"}, + } + + zfsList := "zfs list %s" + out, err := localRunner.Run(context.TODO(), zfsList, "zroot") + if err != nil { + t.Errorf("localRunner failed: %s\n\n%s", err.Error(), out) + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..909a771 --- /dev/null +++ b/main.go @@ -0,0 +1,192 @@ +package main + +import ( + "context" + "fmt" + "log" + "os/exec" + "strconv" + "strings" + "time" +) + +type Snapshot struct { + SnapshotName string // Name sans dataset (the part after the '@') + Name string // Full dataset@snapname + Dataset string + Creation time.Time + GUID int64 +} + +func zfsCmd(ctx context.Context, arg string, a ...any) ([]byte, error) { + if len(a) > 0 { + arg = fmt.Sprintf(arg, a...) + } + + cmd := exec.CommandContext(ctx, "/bin/sh", "-c", arg) + + fmt.Printf("zfs: %+v\n", arg) + out, err := cmd.CombinedOutput() + if err != nil { + return out, err + } + return out, err +} + +func (j *BackupJob) listSnapshots(ctx context.Context) ([]Snapshot, error) { + out, err := zfsCmd(ctx, "zfs list -Hp -o name,creation -t snapshot -s creation %s | grep '%s'", j.Source, j.Prefix) + if err != nil { + execErr, ok := err.(*exec.ExitError) + if !ok { + log.Printf("%s\n", out) + return nil, err + } + + if execErr.ExitCode() == 1 { + return nil, nil + } + } + + snapList := strings.Split(string(out), "\n") + snaps := make([]Snapshot, len(snapList)-1) + + for i, snap := range snapList { + params := strings.Split(snap, "\t") // "zroot@foo\tTIME" -> ["zroot@foo", "TIME"] + if len(params) != 2 { + continue + } + log.Printf("%+v\n", params) + t, err := strconv.ParseInt(params[1], 10, 64) + if err != nil { + log.Fatalf("invalid time %s", params[1]) + } + + identifier := strings.Split(params[0], "@") // zroot@foo -> ["zroot", "foo"] + + snaps[i] = Snapshot{ + SnapshotName: identifier[1], + Dataset: identifier[0], + Name: params[0], + Creation: time.Unix(t, 0), + } + } + + return snaps, nil +} + +func (j *BackupJob) snapName() string { + // ts := time.Now().Format(time.RFC3339) + ts := time.Now().UnixMicro() + if j.Prefix == "" { + return fmt.Sprintf("%d", ts) + } else { + return fmt.Sprintf("%s%d", j.Prefix, ts) + } +} + +func (j *BackupJob) Snapshot(ctx context.Context) (string, error) { + snapName := j.snapName() + snap := fmt.Sprintf("%s@%s", j.Source, snapName) + + out, err := zfsCmd( + ctx, + "zfs snapshot %s", + snap, + ) + if err != nil { + log.Printf("zfs-snapshot: error: %s", out) + return snapName, nil + } + return snap, err +} + +func (j *BackupJob) FullSend(ctx context.Context, snap string) { + out, err := zfsCmd(ctx, "zfs send -Lec %s | ssh %s zfs recv -Fu %s", snap, j.TargetHost, j.Target) + if err != nil { + log.Fatalf("zfs-send: error: %s", out) + } +} + +func (j *BackupJob) IncrementalSend(ctx context.Context, prevSnap *Snapshot, newSnap string) { + out, err := zfsCmd( + ctx, + "zfs send -I@%s %s | ssh %s zfs recv %s", + prevSnap.SnapshotName, + newSnap, + j.TargetHost, + j.Target, + ) + if err != nil { + // FIXME: return an error so we can cleanup + log.Fatalf("zfs-send: error: %s", out) + } +} + +type BackupJob struct { + Source string // the source dataset (e.g., zroot) + TargetHost string // SSH-compatible host + Target string // the target dataset + Keep int // number of snapshots to keep + Prefix string // name each snapshot with this prefix + Recursive bool // create recursive snapshots +} + +func (j *BackupJob) Do(ctx context.Context) { + currentSnaps, err := j.listSnapshots(ctx) + if err != nil { + log.Fatal("failed to list existing snapshots") + } + var prev *Snapshot + + if len(currentSnaps) > 0 { + prev = ¤tSnaps[len(currentSnaps)-1] + } + + newSnapshot, err := j.Snapshot(ctx) + if err != nil { + log.Fatalf("error creating new snapshot: %s", err.Error()) + } + + if prev == nil { + // Full Send + j.FullSend(ctx, newSnapshot) + } else { + // Inc send + j.IncrementalSend(ctx, prev, newSnapshot) + } +} + +type ZProperty struct { + Value string +} + +type ZDataset struct { + Name string + Type string + SnapshotName string + Properties map[string]ZProperty +} + +type ZJSON struct { + Datasets map[string]ZDataset +} + +// func (j *BackupJob) getBaseSnap() { +// params := "zfs get -j -d 1 -t snapshot guid,creation %s" +// srcCmd := fmt.Sprintf(params, j.Source) +// dstCmd := fmt.Sprintf(params, j.Target) +// } + +func main() { + myJob := BackupJob{ + Source: "zroot/home/sam/thanks", + Target: "zrust/backup/weller/thanks", + TargetHost: "backup@woodford.gentoo.party", + Keep: 30, + Prefix: "thanks-", + Recursive: false, + } + + ctx := context.Background() + myJob.Do(ctx) +} diff --git a/zfs_test.go b/zfs_test.go new file mode 100644 index 0000000..7d646f9 --- /dev/null +++ b/zfs_test.go @@ -0,0 +1,45 @@ +package main_test + +import ( + "fmt" + "os/exec" + "testing" +) + +type zfscmd struct { + cmd string +} + +type zrunner struct { + cmd string + args []string +} + +func (z *zrunner) Run(zcmd *zfscmd, fargs ...any) ([]byte, error) { + var zargs string + if len(fargs) > 0 { + zargs = fmt.Sprintf(zcmd.cmd, fargs...) + } else { + zargs = zcmd.cmd + } + + largs := append(z.args, zargs) + cmd := exec.Command(z.cmd, largs...) + return cmd.CombinedOutput() +} + +func Test_ZFSCommand(t *testing.T) { + cmd := zfscmd{"zfs list -j %s"} + runner := zrunner{"/bin/sh", []string{"-c"}} + sshRunner := zrunner{"ssh", []string{"root@10.10.10.254"}} + + out, err := runner.Run(&cmd, "zroot/ROOT/gentoo") + if err != nil { + t.Errorf("zrunner failed: %s \n\n%s", err.Error(), out) + } + + out, err = sshRunner.Run(&cmd, "zroot/ROOT") + if err != nil { + t.Errorf("SSH zrunner failed: %s \n\n%s", err.Error(), out) + } +}