//go:build linux package host import ( "errors" "fmt" "github.com/carlosframework/platform/internal/layout" "os" "path/filepath" "slices" "syscall" "github.com/carlosframework/platform/internal/hostbox" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/release" ) // claimWindowUnits are the twelve units the attended offline window masks // persistently before journal-migrate (dev cohort-1 runbook 8.1): the five // triggers, the six migration services and the edge. Each mask is the // unit file moved into /held and a /dev/null link in its place. var claimWindowUnits = []string{ "carlos-adopt.timer", "carlos-adopt.path", "carlos-stage.path", "carlos-restart.path", "carlos-system-update.path", "carlos-adopt.service", "carlos-stage.service", "carlos-config-sync.service", "carlos-restart.service", "carlos-system-update.service", "carlos-system-recover.service", "carlos-edge.service", } var ( // errMigrationWindow refuses a --window that is not the attended // window's private directory, or a mask without its held unit. errMigrationWindow = errors.New("the window directory must be root's own private directory outside the unit and data paths, with every masked unit's original in its held directory") ) // verifyMigrationWindow returns the window units that window masks: each // / that is a symlink whose raw target is exactly /dev/null, // with its original unit file held in /held/, a root-owned // regular file with one link. The window and held directories must be // root's, closed to group and other, outside every systemd unit path and // the census roots. Any other symlink at a window unit's path, or a mask // without its held original, refuses. An empty window masks nothing: the // census then refuses masks, as it always has. func verifyMigrationWindow(window string, r ClaimRuntimeResources) (map[string]bool, error) { masked := map[string]bool{} if window == "" { return masked, nil } if !filepath.IsAbs(window) || filepath.Clean(window) != window { return nil, errMigrationWindow } for _, root := range []string{"/etc/systemd", "/run/systemd", "/usr/lib/systemd", "/lib/systemd", r.UnitsDir, r.DataDir, r.OptDir} { if root == "" { continue } if rel, err := filepath.Rel(root, window); err == nil && filepath.IsLocal(rel) || window == root { return nil, errMigrationWindow } } held := filepath.Join(window, "held") for _, dir := range []string{window, held} { if err := rootPrivateEntry(dir, true); err != nil { return nil, err } } for _, unit := range claimWindowUnits { path := filepath.Join(r.UnitsDir, unit) info, err := os.Lstat(path) if errors.Is(err, os.ErrNotExist) { continue } if err != nil { return nil, err } if info.Mode()&os.ModeSymlink == 0 { continue } target, err := os.Readlink(path) if err != nil { return nil, err } if target != "/dev/null" { return nil, errMigrationWindow } if err := rootPrivateEntry(filepath.Join(held, unit), false); err != nil { return nil, err } masked[unit] = true } return masked, nil } // rootPrivateEntry requires path to be root's: a directory closed to group // and other, or a regular file with one link. Never a symlink. func rootPrivateEntry(path string, dir bool) error { info, err := os.Lstat(path) if err != nil { return fmt.Errorf("%w: %v", errMigrationWindow, err) } st, ok := info.Sys().(*syscall.Stat_t) if !ok || st.Uid != 0 { return errMigrationWindow } if dir { if !info.IsDir() || info.Mode().Perm()&0o077 != 0 { return errMigrationWindow } return nil } if !info.Mode().IsRegular() || st.Nlink != 1 { return errMigrationWindow } return nil } // claimMigrationSharedPaths is the migration census's classification: the // shared classes every claim census uses (claimGlobalSharedPaths), plus three // that exist only at migration, each exact. // // - The window's masks: each window unit masked through --window is a // symlink to exactly /dev/null, in place of the regular unit file. // - A legacy route installation the census reports (Route, not Local): // its nested instance directory and its raw directory, holding only // directories and regular files; its program history file; and its live // link, whose target is exactly the version path its row names. // - A retained database: a frozen instance record's leftovers on a box // with no route row and no claim for it (a removed route). The same // paths, with a live link into the record's own app under one version. // Retained data is never reported, so never adopted, and the census // deletes nothing. func claimMigrationSharedPaths(r ClaimRuntimeResources, masked map[string]bool, s rootMigrationSnapshot) (map[string][]string, error) { shared := claimGlobalSharedPaths(r) units := shared["units"][:0:0] for _, entry := range shared["units"] { replaced := false for unit := range masked { if entry == hostbox.CensusFile(unit) { units = append(units, hostbox.CensusLink(unit, "/dev/null")) replaced = true } } if !replaced { units = append(units, entry) } } for unit := range masked { if !slices.Contains(units, hostbox.CensusLink(unit, "/dev/null")) { // A masked unit the platform does not install is not the window's. return nil, errMigrationWindow } } shared["units"] = units if r.Registry == nil { return nil, errRootMigrationBusy } claimed := map[string]bool{} for _, item := range s.Items { claimed[item.Installation.Host] = true } for _, in := range s.Report.Installations { if !in.Route || in.Local { continue } row, err := r.Registry.Get(in.Host) if err != nil { return nil, err } if row == nil || row.Account != in.Account || row.App != in.App || !release.ValidVersion(row.Version) { return nil, errRootMigrationBusy } data, opt := hostInstallationPaths(r, in.Host) shared["data"] = append(shared["data"], data...) shared["opt"] = append(shared["opt"], hostbox.CensusLink(opt, release.VersionPath(r.OptDir, row.Account, row.Version, row.App))) claimed[in.Host] = true } for _, rec := range s.Records { if claimed[rec.Host] { continue } row, err := r.Registry.Get(rec.Host) if err != nil { return nil, err } if row != nil { return nil, errRootMigrationBusy } data, opt := hostInstallationPaths(r, rec.Host) shared["data"] = append(shared["data"], data...) target, err := os.Readlink(filepath.Join(r.OptDir, opt)) if errors.Is(err, os.ErrNotExist) { continue } if err != nil { return nil, err } version := filepath.Base(filepath.Dir(target)) if rec.Account != "" && rec.Account != release.OpsAccount { version = filepath.Base(filepath.Dir(filepath.Dir(target))) } if !release.ValidVersion(version) || target != release.VersionPath(r.OptDir, rec.Account, version, rec.App) { return nil, errRootMigrationBusy } shared["opt"] = append(shared["opt"], hostbox.CensusLink(opt, target)) } return shared, nil } // hostInstallationPaths are one host's paths under the nested layout, // relative to their census roots: the instance directory and its raw // directory (shared trees of directories and regular files), the program // history file, and the live link under . func hostInstallationPaths(r ClaimRuntimeResources, host string) ([]string, string) { id := provision.InstanceID(host) return []string{ hostbox.CensusTree(claimCensusRel(r.DataDir, filepath.Join(r.DataDir, "instances", id))), hostbox.CensusTree(claimCensusRel(r.DataDir, layout.RawInstanceDir(r.DataDir, host))), // release's programPath: the program history the bake keeps per host. hostbox.CensusFile(claimCensusRel(r.DataDir, filepath.Join(r.DataDir, "programs", host+".json"))), }, claimCensusRel(r.OptDir, release.LiveLink(r.OptDir, host)) } // verifyJournalMigrateWindow is journal-migrate's up-front check of // --window, before the migration writes anything. func verifyJournalMigrateWindow(r ClaimRuntimeResources) (map[string]bool, error) { return verifyMigrationWindow(r.MigrationWindow, r) }