carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1// litestream — off-box replication of the fleet's SQLite files, and the daily
2// proof that the replicas restore. Two verbs, both run ON the box (titogo's
3// operator-side SSH orchestration deliberately did not come along — units and
4// credentials are provisioned by bootstrap; this is only the box-side half):
5//
6// carlos ops litestream (re)generate /etc/litestream.yml from the
7// registry + data dir; prints "changed" or
8// "unchanged" so the calling timer/unit knows
9// whether a replicator restart is due
10// carlos ops restore-verify restore every configured database from S3 and
11// integrity-check it
12//
13// The config is always generated, never hand-maintained: `carlos add`/`remove`
14// change the database set, and re-running the verb is the one way the
15// replication set follows. Credentials live only in the unit's EnvironmentFile
16// (0600) — this code never sees or prints them.
17//
18// Ported from titogo internal/ops/litestream.go (config generator, round-trip
19// parser, restore-verify), plus Eleven deploy/fleet-backup.sh's hibernation
20// exclusion.
21package ops
22
23import (
24 "bytes"
25 "errors"
26 "fmt"
27 "os"
28 "os/exec"
29 "path/filepath"
30 "sort"
31 "strconv"
32 "strings"
33 "time"
34
35 "github.com/carlosframework/platform/internal/lease"
36 "github.com/carlosframework/platform/internal/provision"
37 "github.com/carlosframework/platform/internal/registry"
38)
39
40// litestreamVersion is the pinned release provisioned on the box when the
41// binary is missing. Bump deliberately; the restore path is the thing a
42// version change can silently break, and restore-verify is what catches it.
43const litestreamVersion = "0.5.15"
44
45// retryDelay is the pause before restore-verify's one retry; a var so tests
46// can shrink it.
47var retryDelay = 10 * time.Second
48
49// SnapshotInterval and SnapshotRetention pin the fleet's point-in-time
50// recovery window instead of inheriting litestream's 24h default. They are
51// lease's constants, which hibernating instances' replicas use too, so the
52// box-wide window and the per-instance one are one decision; lease.go
53// records why a week.
54const (
55 SnapshotInterval = lease.SnapshotInterval
56 SnapshotRetention = lease.SnapshotRetention
57)
58
59// SnapshotPolicySupported reports whether `litestream version` output names a
60// binary that HONOURS the snapshot stanza LitestreamConfig writes. The policy
61// arrived with litestream 0.5; 0.3 and 0.4 parse the stanza and drop it on the
62// floor.
63//
64// This exists because parsing is not evidence. Verified on the fleet
65// 2026-08-15: the flagship's litestream v0.3.13 accepts the generated config,
66// exits 0, lists the databases — and ignores the retention entirely. So does
67// 0.5.15 when handed a misspelled key. A config that STATES a recovery window
68// nothing enforces is worse than one that states none, because it reads as a
69// decision someone made and checked.
70//
71// Unparseable output is UNSUPPORTED, never assumed-fine: the whole point is to
72// refuse to claim a window we cannot show is real.
73func SnapshotPolicySupported(versionOutput string) bool {
74 f := strings.Fields(strings.TrimSpace(versionOutput))
75 if len(f) == 0 {
76 return false
77 }
78 v := strings.TrimPrefix(f[len(f)-1], "v") // "litestream v0.5.15" or "0.5.15"
79 major, rest, ok := strings.Cut(v, ".")
80 if !ok {
81 return false
82 }
83 minor, _, _ := strings.Cut(rest, ".")
84 maj, err := strconv.Atoi(major)
85 if err != nil {
86 return false
87 }
88 min, err := strconv.Atoi(minor)
89 if err != nil {
90 return false
91 }
92 if maj > 0 {
93 return true // a future 1.x must not regress to "unsupported"
94 }
95 return min >= 5
96}
97
98// litestreamVersionOutput runs `<bin> version`. A binary that is missing or
99// will not answer yields "", which SnapshotPolicySupported treats as
100// unsupported.
101func litestreamVersionOutput(bin string) string {
102 out, err := exec.Command(bin, "version").CombinedOutput()
103 if err != nil {
104 return ""
105 }
106 return string(out)
107}
108
109// LitestreamConfig renders the generated /etc/litestream.yml. Deliberately
110// minimal — path + replica URL + region, plus the snapshot policy above — so
111// it can't rot against litestream's config surface; other tuning belongs in
112// flags on the unit, not here. The snapshot stanza is the considered
113// exception to that minimalism: a recovery window is a decision, and leaving
114// it to a library default meant nobody had made it.
115// Paths are sorted so re-runs with an unchanged fleet produce a byte-identical
116// file (the `litestream` verb's changed/unchanged report depends on it).
117func LitestreamConfig(dbPaths []string, bucket, prefix, region string) string {
118 sorted := append([]string(nil), dbPaths...)
119 sort.Strings(sorted)
120 var b strings.Builder
121 b.WriteString("# GENERATED by `carlos ops litestream` — do not hand-edit; re-run the verb\n")
122 b.WriteString("# after `carlos add`/`remove` so the replication set follows the fleet.\n")
123 // Global snapshot policy: applies to every database below.
124 b.WriteString("snapshot:\n")
125 fmt.Fprintf(&b, " interval: %s\n", SnapshotInterval)
126 fmt.Fprintf(&b, " retention: %s\n", SnapshotRetention)
127 b.WriteString("dbs:\n")
128 for _, p := range sorted {
129 fmt.Fprintf(&b, " - path: %s\n", p)
130 b.WriteString(" replicas:\n")
131 fmt.Fprintf(&b, " - url: s3://%s/%s/%s\n", bucket, prefix, replicaKey(p))
132 if region != "" {
133 fmt.Fprintf(&b, " region: %s\n", region)
134 }
135 }
136 return b.String()
137}
138
139// replicaKey is a database's place in the replica bucket: its full path with
140// the leading separator dropped, so /data/registry.db becomes
141// data/registry.db.
142//
143// It is the full path and not the basename because the basename is NOT
144// unique, and the consequence of pretending otherwise is severe. Every
145// SQLite-backed tenant on this fleet calls its database registry.db or
146// data.db, and the box's own control DB is /data/registry.db; a basename key
147// collapses all of them onto one S3 prefix, where two litestream replicators
148// interleave their WAL into a replica that restores as neither database.
149// Tree discovery (treeDBs) turns that from a curiosity into the normal case —
150// every tenant tree contains a registry.db.
151//
152// Keeping the path readable rather than hashing it is deliberate: the person
153// reading this bucket is doing a restore, probably at speed, and needs to see
154// which database each key is.
155//
156// Changing this scheme moves every database to a new replica lineage, so it
157// is a one-time freedom: no box has ever run a config generated by this verb
158// (the flagship's is hand-written, tito's comes from the legacy `titogo ops
159// litestream`), so there is no live lineage to break. There will be after the
160// first cutover — treat it as fixed then.
161func replicaKey(dbPath string) string {
162 return strings.TrimPrefix(filepath.Clean(dbPath), "/")
163}
164
165// ParseLitestreamDBs recovers the database paths from a generated config.
166// It reads only the exact shape LitestreamConfig writes (the round-trip is
167// pinned by TestConfigRoundTrip) — it is not a YAML parser.
168func ParseLitestreamDBs(cfg string) []string {
169 var dbs []string
170 for _, line := range strings.Split(cfg, "\n") {
171 if p, ok := strings.CutPrefix(line, " - path: "); ok {
172 dbs = append(dbs, strings.TrimSpace(p))
173 }
174 }
175 return dbs
176}
177
178// CoverageShortfall names every database the EXISTING config replicates that
179// the proposed set does not — the databases a write would stop replicating.
180//
181// This is the guard for the one genuinely dangerous moment in this verb's
182// life: cutting a box over from a hand-maintained config to a generated one.
183// The generated list comes from the registry, so anything the registry does
184// not know about is simply absent from it — and nothing downstream would say
185// so. Replication of the dropped database stops, and the drill keeps passing
186// because the drill only checks what the config names. The failure is
187// invisible in both directions at once.
188//
189// Growing the set is always fine and needs no ceremony; shrinking it is a
190// decision someone has to make on purpose.
191func CoverageShortfall(existingConfig string, proposed []string) []string {
192 have := map[string]bool{}
193 for _, p := range proposed {
194 have[filepath.Clean(p)] = true
195 }
196 var missing []string
197 for _, p := range ParseLitestreamDBs(existingConfig) {
198 if !have[filepath.Clean(p)] {
199 missing = append(missing, p)
200 }
201 }
202 sort.Strings(missing)
203 return missing
204}
205
206// FleetDBs lists the non-empty *.db files this box-wide replicator owns:
207// everything found by globbing dataDir (the agent's own exec-child
208// databases, plus the registry itself), PLUS — separately, not via the glob
209// — every BackingUnit route's DBPath when the operator set one.
210//
211// WHAT IS COVERED: a unit tenant (carlos-app@<host>: DynamicUser,
212// StateDirectory=/var/lib/carlos-app/<host>) whose route was added with an
213// explicit `-db <path>`, hibernating or not — its wake/sleep is systemctl
214// and the agent can't even read a DynamicUser's 0700 state dir, so this
215// box-wide replicator (running as root) is the only thing that can stream
216// its writes off-box.
217//
218// WHAT IS NOT COVERED, and why this function does not try to guess it: a
219// unit tenant's database filename inside its state dir is the APP's own
220// choice — the platform enforces no naming convention there the way it does
221// the dataDir/<host>.db convention for exec children — so there is no
222// honest default path to assume. A unit route added without -db is
223// silently invisible here. cli.go's runAdd requires -db for
224// `-backing unit -hibernate` routes for exactly this reason (going
225// unreplicated there is a genuine data-loss risk, not just a missed
226// nicety); a non-hibernating unit tenant without -db is still the
227// operator's to hand-configure. See internal/activator/backend_unit.go's
228// package comment for the fuller operator-facing story.
229//
230// Hibernating EXEC-backed routes' databases (matched by DBPath, found via
231// the dataDir glob) are excluded from the results below: the activator's
232// embedded Litestream owns their replication (to the instances bucket,
233// under the lease) — a second replicator on the same WAL would fight it for
234// checkpointing and corrupt the replica. (Eleven's fleet-backup.sh lesson,
235// carried verbatim.) That exclusion never applies to BackingUnit routes —
236// their replication has no other owner at all, so they are added here
237// rather than filtered.
238// treeDBWalkDepth bounds the descent. It is a loop guard, not a policy: os.Stat
239// follows symlinks (which it must — systemd's StateDirectory is a symlink into
240// /var/lib/private), and a symlink pointing at an ancestor would otherwise
241// recurse forever. No real tenant layout is anywhere near this deep.
242const treeDBWalkDepth = 8
243
244// treeDBs lists the *.db files beneath root.
245//
246// It exists because a unit tenant's "database" is sometimes a TREE. kass on
247// the flagship keeps registry.db plus an instances/ directory that grows as the
248// tenant creates instances; a single -db path cannot say that, which is why
249// that box's config was still hand-maintained — and why it had silently
250// drifted, missing an instance database created after the list was written.
251//
252// Two things are deliberately skipped:
253//
254// - dot-prefixed directories, which is where litestream keeps its own shadow
255// WAL (".<db>-litestream"). Replicating a replicator's bookkeeping would be
256// absurd, and worse, some of those files are named *.db.
257// - anything that is not a non-empty *.db. Sidecars (-wal, -shm) travel with
258// their database and are not replicated separately; an empty file is a
259// placeholder, not a database.
260//
261// Discovery requires the .db convention, unlike a single-file DBPath which is
262// taken verbatim: when the operator names one file we believe them, but when
263// we go looking we only claim what we can recognise.
264func treeDBs(root string) []string {
265 var out []string
266 var walk func(dir string, depth int)
267 walk = func(dir string, depth int) {
268 if depth > treeDBWalkDepth {
269 return
270 }
271 entries, err := os.ReadDir(dir)
272 if err != nil {
273 return // unreadable subtree: nothing to claim, and not this pass's business to fail
274 }
275 for _, e := range entries {
276 name := e.Name()
277 if strings.HasPrefix(name, ".") {
278 continue
279 }
280 p := filepath.Join(dir, name)
281 // Stat, not e.Info(): entries may be symlinks and the target is
282 // what matters.
283 fi, err := os.Stat(p)
284 if err != nil {
285 continue
286 }
287 if fi.IsDir() {
288 walk(p, depth+1)
289 continue
290 }
291 if !strings.HasSuffix(name, ".db") || fi.Size() == 0 {
292 continue
293 }
294 out = append(out, p)
295 }
296 }
297 walk(root, 0)
298 return out
299}
300
301// consoleDBPath resolves where this box's console keeps its database:
302// CONSOLE_DB when set (the same env var cmd/console reads, so a relocated
303// console cannot silently drop out of replication), else the production
304// layout <data>/console/console.db — expressed against dataDir rather than
305// hardcoded to /data so a test box's console is found under its own root and
306// never the real one.
307func consoleDBPath(dataDir string) string {
308 if p := strings.TrimSpace(os.Getenv("CONSOLE_DB")); p != "" {
309 return filepath.Clean(p)
310 }
311 return filepath.Join(dataDir, "console", "console.db")
312}
313
314func FleetDBs(reg *registry.Registry, dataDir string) ([]string, error) {
315 dbs, _, err := FleetDBsWithSkipped(reg, dataDir, nil)
316 return dbs, err
317}
318
319// LayoutJournalPresent is the layout pass's "held" predicate for dataDir: a
320// host has a migration journal unless its journal path is definitely absent.
321// A journal that cannot be read is not proof there is none. It is the same
322// test internal/layout's JournalPresent makes, spelled out here so this
323// package does not depend on the pass.
324func LayoutJournalPresent(dataDir string) func(host string) bool {
325 return func(host string) bool {
326 _, err := os.Lstat(provision.LayoutJournalPath(dataDir, host))
327 return !errors.Is(err, os.ErrNotExist)
328 }
329}
330
331// FleetDBsWithSkipped is FleetDBs, plus the files under dataDir it declined to
332// claim — see the "glob proposes, registry disposes" note inside. The verb
333// prints them; nothing here is dropped without being named.
334//
335// fenced, when non-nil, names hosts the layout pass holds mid-move. Such a
336// host's row does not claim its database, so the file is named among the
337// skipped instead of being replicated from a directory it is leaving.
338func FleetDBsWithSkipped(reg *registry.Registry, dataDir string, fenced func(host string) bool) (dbs []string, skipped []string, err error) {
339 routes, err := reg.List()
340 if err != nil {
341 return nil, nil, err
342 }
343 hibernating := map[string]bool{}
344 unitPaths := map[string]bool{}
345 for _, r := range routes {
346 if r.Backing == registry.BackingUnit {
347 // Unit tenants' data lives outside dataDir (their systemd
348 // StateDirectory), so the glob below can never find them on its
349 // own — DBPath, when the operator set it, is the only way this
350 // replicator learns where they are.
351 if r.DBPath != "" {
352 unitPaths[filepath.Clean(r.DBPath)] = true
353 }
354 continue
355 }
356 if r.Hibernate && r.DBPath != "" {
357 hibernating[filepath.Clean(r.DBPath)] = true
358 }
359 }
360
361 matches, err := filepath.Glob(filepath.Join(dataDir, "*.db"))
362 if err != nil {
363 return nil, nil, err
364 }
365 // A nested row's DB lives one level down, under
366 // <dataDir>/instances/<id>/<host>.db (isolated-exec design §7): the flat
367 // glob above never walks into that directory, so its matches are folded
368 // in here before the claimed-files gate below, which already knows how
369 // to accept or skip each one. A nested row's DBPath was added to
370 // claimed by the very same loop that adds a flat row's.
371 nested, err := filepath.Glob(filepath.Join(dataDir, "instances", "*", "*.db"))
372 if err != nil {
373 return nil, nil, err
374 }
375 matches = append(matches, nested...)
376 // THE GLOB PROPOSES, THE REGISTRY DISPOSES. A *.db under dataDir is
377 // replicated when a route claims it, or when it is the box's own registry
378 // or console. Anything else is somebody's leftover, and streaming leftovers
379 // off-box costs storage forever: a box with a hand-written config can
380 // accumulate a large, root-owned, unrelated *.db file in /data that
381 // matches this glob exactly and would otherwise escape replication
382 // silently.
383 //
384 // Skipped files are NAMED to the caller, never dropped in silence — a
385 // database that someone did want, quietly left out, is the precise failure
386 // this area keeps producing. The shrink guard (CoverageShortfall) is the
387 // second net: if a skipped file is one the live config replicates today,
388 // the write is refused outright.
389 claimed := map[string]bool{
390 filepath.Clean(filepath.Join(dataDir, "registry.db")): true,
391 filepath.Clean(consoleDBPath(dataDir)): true,
392 }
393 for _, r := range routes {
394 if r.DBPath == "" || (fenced != nil && fenced(r.Host)) {
395 continue
396 }
397 claimed[filepath.Clean(r.DBPath)] = true
398 }
399 var globbed []string
400 for _, p := range matches {
401 if claimed[filepath.Clean(p)] {
402 globbed = append(globbed, p)
403 continue
404 }
405 skipped = append(skipped, filepath.Clean(p))
406 }
407 matches = globbed
408
409 // Unit-tenant DBPaths are added on top of the glob: they typically live
410 // entirely outside dataDir, at their StateDirectory path. A DBPath naming
411 // a DIRECTORY is a tree — see treeDBs. These are claimed by construction:
412 // a route named them.
413 for p := range unitPaths {
414 if fi, err := os.Stat(p); err == nil && fi.IsDir() {
415 matches = append(matches, treeDBs(p)...)
416 continue
417 }
418 matches = append(matches, p)
419 }
420 // And the console's own database, for the same reason in miniature: it
421 // sits at <data>/console/console.db — ONE DIRECTORY BELOW the flat glob,
422 // which is exactly why it went unreplicated. It holds no platform state
423 // (routes and channels are read live) but it holds every account,
424 // ownership row, session and the deployment's Keymail credentials: losing
425 // it loses who everyone is. Boxes without a console simply have no file
426 // here and the loop below drops it.
427 matches = append(matches, consoleDBPath(dataDir))
428
429 seen := map[string]bool{}
430 for _, p := range matches {
431 p = filepath.Clean(p)
432 if seen[p] {
433 continue // a unit DBPath that happens to also sit under dataDir
434 }
435 fi, err := os.Stat(p)
436 if err != nil || !fi.Mode().IsRegular() || fi.Size() == 0 {
437 continue // gone, odd, or an empty placeholder — nothing to replicate
438 }
439 if hibernating[p] {
440 continue
441 }
442 seen[p] = true
443 dbs = append(dbs, p)
444 }
445 sort.Strings(skipped)
446 return dbs, skipped, nil
447}
448
449// restoreAndCheckFn is the per-database restore behind a package var, so the
450// one-at-a-time property below is testable without S3 or a litestream binary.
451var restoreAndCheckFn = restoreAndCheck
452
453// RestoreVerify restores every database in the generated config from S3 into
454// tmpDir and proves each opens clean. Returns an error on any failure so the
455// caller (a oneshot unit) can exit non-zero where the fleet sweeps see it.
456//
457// Exactly ONE restored copy exists at a time: each is deleted as soon as it
458// has been checked. This is a memory constraint, not housekeeping —
459// carlos-restore-verify.service runs PrivateTmp=yes, so tmpDir is a tmpfs and
460// every byte written there is charged to the unit's own cgroup against
461// MemoryMax. Keeping all of them meant the drill's peak was the SUM of the
462// fleet's databases: measured on carlos-tito-ie 2026-08-15, 54 databases
463// totalling 247M under MemoryMax=256M, OOM-killed at a "256.2M memory peak"
464// after ~40 clean restores — a drill that grew its own failure as the fleet
465// grew. The peak is now the largest single database.
466func RestoreVerify(litestreamBin, cfgPath, tmpDir string) error {
467 raw, err := os.ReadFile(cfgPath)
468 if err != nil {
469 return fmt.Errorf("restore-verify: %v", err)
470 }
471 dbs := ParseLitestreamDBs(string(raw))
472 if len(dbs) == 0 {
473 return fmt.Errorf("restore-verify: no databases in %s — regenerate it: carlos ops litestream", cfgPath)
474 }
475
476 failures, stale := 0, 0
477 for _, db := range dbs {
478 // Sweep this database's copy however the check ends — a failure must
479 // not leave the wreckage behind for the next restore to be charged for.
480 out := filepath.Join(tmpDir, filepath.Base(db))
481 err := restoreAndCheckFn(litestreamBin, cfgPath, db, tmpDir)
482 if err != nil {
483 removeRestored(out)
484 // One retry after a pause: a database created moments ago may not
485 // have finished its first replication pass yet.
486 time.Sleep(retryDelay)
487 err = restoreAndCheckFn(litestreamBin, cfgPath, db, tmpDir)
488 }
489 // Freshness is judged BEFORE the copy is swept, and only once the
490 // restore itself passed: "restores clean but stopped tracking its
491 // source" is a different finding from "does not restore", and
492 // conflating them would hide whichever came second.
493 var fresh freshness
494 var freshDetail string
495 if err == nil {
496 fresh, freshDetail = replicaFreshness(db, out, sourceModTime(db))
497 }
498 removeRestored(out) // every path, success included
499 if err != nil {
500 failures++
501 fmt.Printf("FAIL %s: %v\n", db, err)
502 continue
503 }
504 switch fresh {
505 case freshnessStale:
506 stale++
507 fmt.Printf("STALE %s: %s\n", db, freshDetail)
508 case freshnessNoSource:
509 stale++
510 fmt.Printf("STALE %s: %s\n", db, freshDetail)
511 case freshnessUnknown:
512 // Never a pass and never an alarm — see the freshness doc.
513 fmt.Printf("ok %s (freshness unknown: %s)\n", db, freshDetail)
514 default:
515 fmt.Printf("ok %s\n", db)
516 }
517 }
518 switch {
519 case failures > 0 && stale > 0:
520 return fmt.Errorf("restore-verify: %d of %d replicas failed to restore clean, and %d more restore but have stopped tracking their source",
521 failures, len(dbs), stale)
522 case failures > 0:
523 return fmt.Errorf("restore-verify: %d of %d replicas failed to restore clean", failures, len(dbs))
524 case stale > 0:
525 // A stale replica restores perfectly and is still a durability
526 // failure: what comes back is the database as it was when replication
527 // stopped. It fails the run for the same reason an unrestorable one
528 // does.
529 return fmt.Errorf("restore-verify: %d of %d replicas restore clean but have STOPPED TRACKING their source — replication is broken for them",
530 stale, len(dbs))
531 }
532 fmt.Printf("restore-verify: all %d replicas restore clean and are current\n", len(dbs))
533 return nil
534}
535
536// removeRestored deletes one restored copy and the sidecars an open may have
537// attached beside it (CheckRestoredDB's read-only DSN does not stop the driver
538// creating a -wal). Errors are dropped: this is reclamation, and a file that
539// is already gone is the outcome wanted.
540func removeRestored(path string) {
541 for _, p := range []string{path, path + "-wal", path + "-shm"} {
542 _ = os.Remove(p)
543 }
544}
545
546// restoreAndCheck pulls one database back from its replica and runs the same
547// checks a disaster recovery would lean on: the file opens, integrity_check
548// says ok, and the schema is non-empty.
549func restoreAndCheck(lsBin, cfgPath, dbPath, tmpDir string) error {
550 out := filepath.Join(tmpDir, filepath.Base(dbPath))
551 _ = os.Remove(out) // a retry must not trip over the failed attempt's file
552 cmd := exec.Command(lsBin, "restore", "-config", cfgPath, "-o", out, dbPath)
553 if b, err := cmd.CombinedOutput(); err != nil {
554 return fmt.Errorf("litestream restore: %v: %s", err, strings.TrimSpace(string(b)))
555 }
556 return CheckRestoredDB(out)
557}
558
559// CheckRestoredDB opens a restored file read-only and proves it is a sane
560// SQLite database: integrity_check == ok and at least one table present.
561//
562// The implementation moved to internal/lease so the WAKE path can run the
563// same check without importing this package — one definition of "restores
564// clean", used by both the daily sweep and the moment an instance is about to
565// serve a restored file. The name stays here because the ops verb and its
566// tests are its documented door.
567func CheckRestoredDB(path string) error { return lease.CheckDB(path) }
568
569// writeIfChanged writes content to path only when it differs, and reports
570// whether it wrote. The byte-identical no-op is what lets a timer re-run the
571// generator freely without a restart-worthy mtime/content churn.
572func writeIfChanged(path, content string) (bool, error) {
573 if old, err := os.ReadFile(path); err == nil && bytes.Equal(old, []byte(content)) {
574 return false, nil
575 }
576 if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
577 return false, err
578 }
579 return true, nil
580}
581