|
root / src / replace.reef
replace.reef Reef 401 lines 13.4 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
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
/******************************************************************************
                __               ____                __
               / /   ___  ____ _/ __/_____________ _/ /__
              / /   / _ \/ __ `/ /_/ ___/ ___/ __ `/ / _ \
             / /___/  __/ /_/ / __(__  ) /__/ /_/ / /  __/
            /_____/\___/\__,_/_/ /____/\___/\__,_/_/\___/

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

    Project: zyginit
   Filename: replace.reef
    Authors: Chris Tusa <chris.tusa@leafscale.com>
    License: <see LICENSE file included with this source code>
Description: Live-replace state-file handoff (operator-driven PID-1 upgrade)

******************************************************************************/

// Live-replace operator flow: zygctl replace -> old zyginit serializes
// runtime state to /var/run/zyginit/state.toml, execs /sbin/init in
// place; new zyginit reads state.toml, validates boot_time matches
// current utmpx BOOT_TIME (else stale across a real reboot), and
// rebuilds the contract_map / runtime fields in supervisor.ServiceTable.
//
// PID-1 in-place execve preserves proc_t.p_ct_process at the kernel
// level, so contract ownership survives automatically — no
// ct_ctl_adopt call is needed. This module owns userspace bookkeeping.

module replace

import config
import supervisor
import io.file
import sys.fd as fd
import sys.env as sysenv
import core.str
import encoding.toml
import time.time as time

export
    type RecoveredService
    type RecoveredState

    // Accessors — RecoveredState
    fn rs_count(s: RecoveredState): int
    fn rs_service(s: RecoveredState, idx: int): RecoveredService
    fn rs_boot_time(s: RecoveredState): int
    fn rs_is_empty(s: RecoveredState): bool

    // Accessors — RecoveredService
    fn svc_name(rs: RecoveredService): string
    fn svc_contract_id(rs: RecoveredService): int
    fn svc_restart_count(rs: RecoveredService): int
    fn svc_last_start_time(rs: RecoveredService): int
    fn svc_last_exit_code(rs: RecoveredService): int

    // Outgoing flow — write state, return true on success
    fn serialize_state(table: supervisor.ServiceTable, boot_time: int): bool

    // Incoming flow — read state file. Returns an empty RecoveredState
    // if the file is missing, corrupt, or has a mismatched boot_time.
    fn recover_state(current_boot_time: int): RecoveredState

    // Pre-condition check. Returns "" if all services are stable, else
    // a comma-separated "name:state" list suitable for logging.
    fn check_preconditions(table: supervisor.ServiceTable): string

    // FFI for boot-time sentinel (helpers.c)
    fn read_boot_time(): int

    // Constants
    fn STATE_FILE_PATH(): string
    fn STATE_TMP_PATH(): string
    fn STATE_DIR(): string
end export

// FFI — implemented in helpers.c
extern "C" fn zyginit_read_boot_time(): int
extern "C" fn zyginit_read_pid1_start(): int
extern "C" fn zyginit_fsync_path(path: string): int

// FFI — POSIX wrappers from helpers.c
// Direct extern "C" for rename/unlink/fsync would conflict with system
// headers' const char* declarations. Use zyginit_* wrappers (same pattern
// as zyginit_symlink).
extern "C" fn zyginit_rename(oldpath: string, newpath: string): int
extern "C" fn zyginit_unlink(path: string): int
extern "C" fn zyginit_fsync(fd: int): int

// Returns PID 1's process start time, used as the boot-id sentinel.
// Survives execve (proc_t preserved) but changes on real reboot.
// Returns -1 if /proc/1/{stat,psinfo} can't be read.
//
// NOTE: utmpx BOOT_TIME was used previously but pututxline updates rather
// than appends the existing record, so the new zyginit overwrites the
// original timestamp at startup, defeating the staleness check.
fn read_boot_time(): int
    return zyginit_read_pid1_start()
end read_boot_time

// File-system layout. Keep grouped here so a future tmpfs migration
// only edits one place.
fn STATE_DIR(): string
    return sysenv.get_env_or("ZYGINIT_RUN_DIR", "/var/run/zyginit")
end STATE_DIR

fn STATE_FILE_PATH(): string
    return str.concat(STATE_DIR(), "/state.toml")
end STATE_FILE_PATH

fn STATE_TMP_PATH(): string
    return str.concat(STATE_DIR(), "/state.toml.tmp")
end STATE_TMP_PATH

// ============================================================================
// Types
// ============================================================================

type RecoveredService = struct
    name: string
    contract_id: int
    restart_count: int
    last_start_time: int
    last_exit_code: int
end RecoveredService

type RecoveredState = struct
    boot_time: int
    services: [RecoveredService]
    count: int
end RecoveredState

fn new_recovered_state(): RecoveredState
    return RecoveredState{
        boot_time: 0 - 1,
        services: new [RecoveredService](0),
        count: 0
    }
end new_recovered_state

// ============================================================================
// Accessors
// ============================================================================

fn rs_count(s: RecoveredState): int
    return s.count
end rs_count

fn rs_service(s: RecoveredState, idx: int): RecoveredService
    return s.services[idx]
end rs_service

fn rs_boot_time(s: RecoveredState): int
    return s.boot_time
end rs_boot_time

fn rs_is_empty(s: RecoveredState): bool
    return s.count == 0
end rs_is_empty

fn svc_name(rs: RecoveredService): string
    return rs.name
end svc_name

fn svc_contract_id(rs: RecoveredService): int
    return rs.contract_id
end svc_contract_id

fn svc_restart_count(rs: RecoveredService): int
    return rs.restart_count
end svc_restart_count

fn svc_last_start_time(rs: RecoveredService): int
    return rs.last_start_time
end svc_last_start_time

fn svc_last_exit_code(rs: RecoveredService): int
    return rs.last_exit_code
end svc_last_exit_code

// ============================================================================
// Outgoing — serialize running service state to state.toml
// ============================================================================

fn serialize_state(table: supervisor.ServiceTable, boot_time: int): bool
    let tmp_path = STATE_TMP_PATH()
    let final_path = STATE_FILE_PATH()
    let dir_path = STATE_DIR()

    // O_WRONLY|O_CREAT|O_TRUNC, mode 0600
    let flags = fd.O_WRONLY() + fd.O_CREAT() + fd.O_TRUNC()
    let out = fd.fd_open(tmp_path, flags, 384)

    if out < 0
        println("replace: serialize: cannot open " + tmp_path)
        return false
    end if

    // Write boot_time header
    let header = str.concat("boot_time = ", int_to_str(boot_time))
    let _ = fd.fd_write(out, str.concat(header, "\n"))

    // Iterate services — only RUNNING ones go in. Skip the rest.
    let count = supervisor.service_count(table)
    mut i = 0
    while i < count
        let rt = supervisor.get_runtime(table, i)
        if supervisor.rt_state(rt) == supervisor.STATE_RUNNING()
            let def = supervisor.rt_def(rt)
            let _ = fd.fd_write(out, "\n[[service]]\n")
            let _ = fd.fd_write(out, str.concat("name = \"", str.concat(config.svc_name(def), "\"\n")))
            let _ = fd.fd_write(out, str.concat("contract_id = ", str.concat(int_to_str(supervisor.rt_contract_id(rt)), "\n")))
            let _ = fd.fd_write(out, str.concat("restart_count = ", str.concat(int_to_str(supervisor.rt_restart_count(rt)), "\n")))
            let _ = fd.fd_write(out, str.concat("last_start_time = ", str.concat(int_to_str(supervisor.rt_last_start_time(rt)), "\n")))
            let _ = fd.fd_write(out, str.concat("last_exit_code = ", str.concat(int_to_str(supervisor.rt_last_exit_code(rt)), "\n")))
        end if
        i = i + 1
    end while

    // fsync + close
    if zyginit_fsync(out) < 0
        println("replace: serialize: fsync failed")
        let _ = fd.fd_close(out)
        let _ = zyginit_unlink(tmp_path)
        return false
    end if
    let _ = fd.fd_close(out)

    // Atomic rename
    if zyginit_rename(tmp_path, final_path) != 0
        println("replace: serialize: rename to " + final_path + " failed")
        let _ = zyginit_unlink(tmp_path)
        return false
    end if

    // fsync the parent dir so the rename is durable
    let _ = zyginit_fsync_path(dir_path)

    return true
end serialize_state

// Local int_to_str — replicate the supervisor.reef pattern. Keeps the
// module standalone (no cross-module helper exposure).
fn int_to_str(n: int): string
    if n == 0
        return "0"
    end if
    mut value = n
    mut neg = false
    if n < 0
        value = 0 - n
        neg = true
    end if
    mut result = ""
    while value > 0
        let digit = value % 10
        result = str.concat(str.substring("0123456789", digit, 1), result)
        value = value / 10
    end while
    if neg
        result = str.concat("-", result)
    end if
    return result
end int_to_str

// ============================================================================
// Incoming — recover serialized state on restart
// ============================================================================

// Local int parser with default. Handles negative numbers.
// Empty string or non-numeric input yields default_val.
fn str_to_int_or(s: string, default_val: int): int
    let n = str.length(s)
    if n == 0
        return default_val
    end if
    mut start = 0
    mut neg = false
    if str.char_at(s, 0) == '-'
        neg = true
        start = 1
    end if
    if start >= n
        return default_val
    end if
    mut result = 0
    mut i = start
    while i < n
        let c = str.char_at(s, i)
        if c < '0' or c > '9'
            return default_val
        end if
        result = result * 10 + (c - '0')
        i = i + 1
    end while
    if neg
        return 0 - result
    end if
    return result
end str_to_int_or

fn recover_state(current_boot_time: int): RecoveredState
    let path = STATE_FILE_PATH()

    if not file.fileExists(path)
        return new_recovered_state()
    end if

    let content = file.readFile(path)
    if str.length(content) == 0
        let _ = zyginit_unlink(path)
        return new_recovered_state()
    end if

    let keys = toml.toml_alloc_keys()
    let vals = toml.toml_alloc_values()
    let count = toml.toml_parse(content, keys, vals)

    if count == 0
        println("replace: recover: parse error or empty in " + path + " — ignoring")
        let _ = zyginit_unlink(path)
        return new_recovered_state()
    end if

    if not toml.toml_has_key(keys, vals, count, "boot_time")
        println("replace: recover: missing boot_time in " + path + " — ignoring")
        let _ = zyginit_unlink(path)
        return new_recovered_state()
    end if

    let saved_boot = toml.toml_get_int(keys, vals, count, "boot_time")
    if saved_boot != current_boot_time or current_boot_time < 0
        println("replace: recover: stale state file (boot_time " + int_to_str(saved_boot) +
                " != current " + int_to_str(current_boot_time) + "); ignoring")
        let _ = zyginit_unlink(path)
        return new_recovered_state()
    end if

    let n = toml.toml_array_count(keys, count, "service")
    let arr = new [RecoveredService](n)
    mut i = 0
    while i < n
        let name     = toml.toml_array_get(keys, vals, count, "service", i, "name")
        let ctid_str = toml.toml_array_get(keys, vals, count, "service", i, "contract_id")
        let rc_str   = toml.toml_array_get(keys, vals, count, "service", i, "restart_count")
        let lst_str  = toml.toml_array_get(keys, vals, count, "service", i, "last_start_time")
        let lec_str  = toml.toml_array_get(keys, vals, count, "service", i, "last_exit_code")

        arr[i] = RecoveredService{
            name: name,
            contract_id: str_to_int_or(ctid_str, 0 - 1),
            restart_count: str_to_int_or(rc_str, 0),
            last_start_time: str_to_int_or(lst_str, 0),
            last_exit_code: str_to_int_or(lec_str, 0 - 1)
        }
        i = i + 1
    end while

    // State file consumed — delete to avoid panic-restart loops.
    let _ = zyginit_unlink(path)

    return RecoveredState{
        boot_time: saved_boot,
        services: arr,
        count: n
    }
end recover_state

// ============================================================================
// Pre-condition check — transient state detection
// ============================================================================

fn check_preconditions(table: supervisor.ServiceTable): string
    let count = supervisor.service_count(table)
    mut report = ""
    mut found = 0
    mut i = 0
    while i < count
        let rt = supervisor.get_runtime(table, i)
        let st = supervisor.rt_state(rt)
        // STATE_WAITING here means "restart delay pending" — services
        // filtered by runlevel are transitioned to STATE_STOPPED at
        // boot time (see supervisor.mark_runlevel_filtered), and the
        // table is fully started before the event loop entry that
        // processes the replace flag, so "not-yet-started" doesn't
        // occur here either.
        if st == supervisor.STATE_STARTING() or st == supervisor.STATE_STOPPING() or st == supervisor.STATE_WAITING()
            let entry = str.concat(config.svc_name(supervisor.rt_def(rt)),
                str.concat(":", supervisor.state_name(st)))
            if found > 0
                report = str.concat(report, ", ")
            end if
            report = str.concat(report, entry)
            found = found + 1
        end if
        i = i + 1
    end while
    return report
end check_preconditions

end module