|
root / src / sync.reef
sync.reef Reef 188 lines 5.7 KB
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
/******************************************************************************
                __               ____                __   
               / /   ___  ____ _/ __/_____________ _/ /__ 
              / /   / _ \/ __ `/ /_/ ___/ ___/ __ `/ / _ \
             / /___/  __/ /_/ / __(__  ) /__/ /_/ / /  __/
            /_____/\___/\__,_/_/ /____/\___/\__,_/_/\___/ 

    (C)opyright 2026, Leafscale, LLC -  https://www.leafscale.com

    Project: repoman
   Filename: src/sync.reef
    Authors: Chris Tusa <chris.tusa@leafscale.com>
    License: <see LICENSE file included with this source code>
Description: rsync-to-NFS backup driver (cmd_sync) with autofs/mount awareness
     
******************************************************************************/

module sync

import core.str
import core.result_generic as rg
import sys.process as p
import sys.fd as fd

export
    fn build_rsync_args(src: string, dst: string, dry_run: bool, no_delete: bool, is_tty: bool, excluded_repos: [string]): [string]
    fn ensure_nfs_mounted(backup_root: string, verbose: bool): rg.Result[bool, string]
    fn run_rsync(args: [string]): int
end export

// Hardcoded excludes matching bash prototype.
fn standard_excludes(): [string]
    return [
        "node_modules/",
        "target/",
        "build/",
        "dist/",
        ".next/",
        "__pycache__/",
        "*.pyc",
        ".venv/",
        "venv/",
        ".cache/",
        ".tox/",
        ".pytest_cache/",
        ".mypy_cache/",
        ".ruff_cache/"
    ]
end standard_excludes

fn build_rsync_args(src: string, dst: string, dry_run: bool, no_delete: bool, is_tty: bool, excluded_repos: [string]): [string]
    let std: [string] = standard_excludes()
    let std_n: int = std.length()
    let ex_n: int = excluded_repos.length()

    // Capacity: 1 (-aHAX) + up to 3 info flags + 1 (--delete) + std_n excludes + ex_n excludes + 2 positionals
    let cap: int = 1 + 3 + 1 + std_n + ex_n + 2
    mut buf: [string] = new [string](cap)
    mut k: int = 0

    buf[k] = "-aHAX"
    k = k + 1

    if dry_run
        buf[k] = "--dry-run"
        k = k + 1
        buf[k] = "--itemize-changes"
        k = k + 1
        buf[k] = "--info=stats2"
        k = k + 1
    elif is_tty
        buf[k] = "--info=stats2,progress2"
        k = k + 1
    else
        buf[k] = "--info=stats2"
        k = k + 1
    end if

    if not no_delete
        buf[k] = "--delete"
        k = k + 1
    end if

    mut i: int = 0
    while i < std_n
        buf[k] = "--exclude=" + std[i]
        k = k + 1
        i = i + 1
    end while

    mut j: int = 0
    while j < ex_n
        buf[k] = "--exclude=" + excluded_repos[j] + "/"
        k = k + 1
        j = j + 1
    end while

    buf[k] = src
    k = k + 1
    buf[k] = dst
    k = k + 1

    // Trim to actual size
    mut out: [string] = new [string](k)
    mut m: int = 0
    while m < k
        out[m] = buf[m]
        m = m + 1
    end while
    return out
end build_rsync_args

// Silent variant of process_run: child's stdout + stderr go to /dev/null.
// Used for the NFS preflight probes where stat/findmnt would otherwise dump
// file metadata and mount tables to the user's terminal on success, or
// noisy "cannot stat" messages on failure (we provide our own diagnostic).
fn process_run_silent(program: string, args: [string]): int
    let pid: int = p.process_fork()
    if pid < 0
        return -1
    end if
    if pid == 0
        let dev_null: int = fd.fd_open("/dev/null", fd.O_WRONLY(), 0)
        if dev_null >= 0
            let _o: int = fd.fd_dup2(dev_null, fd.STDOUT())
            let _e: int = fd.fd_dup2(dev_null, fd.STDERR())
            let _c: int = fd.fd_close(dev_null)
        end if
        let _x: int = p.process_run_exec(program, args)
        p.exit_now(127)
    end if
    return pid
end process_run_silent

// Helper: spawn `program args` either silent (quiet mode) or with stdio
// passing through (verbose mode). Returns the child PID.
fn spawn_probe(program: string, args: [string], verbose: bool): int
    if verbose
        return p.process_run(program, args)
    end if
    return process_run_silent(program, args)
end spawn_probe

fn ensure_nfs_mounted(backup_root: string, verbose: bool): rg.Result[bool, string]
    // Step 1: stat triggers autofs
    let pid1: int = spawn_probe("stat", [backup_root], verbose)
    if pid1 < 0
        return @Result[bool, string].Err("cannot spawn stat")
    end if
    if p.process_wait(pid1) != 0
        return @Result[bool, string].Err("cannot stat " + backup_root + " — autofs misconfigured or server unreachable")
    end if

    // Step 2: mountpoint
    let pid2: int = spawn_probe("mountpoint", ["-q", backup_root], verbose)
    if pid2 < 0
        return @Result[bool, string].Err("cannot spawn mountpoint")
    end if
    if p.process_wait(pid2) != 0
        return @Result[bool, string].Err(backup_root + " exists but is not a mount — NFS server unreachable?")
    end if

    // Step 3: findmnt -t nfs4
    let pid3: int = spawn_probe("findmnt", ["-t", "nfs4", backup_root], verbose)
    if pid3 < 0
        return @Result[bool, string].Err("cannot spawn findmnt")
    end if
    if p.process_wait(pid3) != 0
        return @Result[bool, string].Err(backup_root + " is mounted but not as nfs4 — check /etc/auto.nfs")
    end if

    return @Result[bool, string].Ok(true)
end ensure_nfs_mounted

// Spawn rsync with the given argv. Inherits parent stdio (no capture).
// Returns rsync's exit code, or -1 if spawn failed.
// Named `run_rsync` because bare `run` collides with Reef's active-object
// lifecycle keyword.
fn run_rsync(args: [string]): int
    let pid: int = p.process_run("rsync", args)
    if pid < 0
        return -1
    end if
    return p.process_wait(pid)
end run_rsync

end module