| 1 | //go:build linux |
| 2 | |
| 3 | package host |
| 4 | |
| 5 | import ( |
| 6 | "errors" |
| 7 | "fmt" |
| 8 | "github.com/carlosframework/platform/internal/layout" |
| 9 | "os" |
| 10 | "path/filepath" |
| 11 | "slices" |
| 12 | "syscall" |
| 13 | |
| 14 | "github.com/carlosframework/platform/internal/hostbox" |
| 15 | "github.com/carlosframework/platform/internal/provision" |
| 16 | "github.com/carlosframework/platform/internal/release" |
| 17 | ) |
| 18 | |
| 19 | // claimWindowUnits are the twelve units the attended offline window masks |
| 20 | // persistently before journal-migrate (dev cohort-1 runbook 8.1): the five |
| 21 | // triggers, the six migration services and the edge. Each mask is the |
| 22 | // unit file moved into <window>/held and a /dev/null link in its place. |
| 23 | var claimWindowUnits = []string{ |
| 24 | "carlos-adopt.timer", "carlos-adopt.path", "carlos-stage.path", "carlos-restart.path", "carlos-system-update.path", |
| 25 | "carlos-adopt.service", "carlos-stage.service", "carlos-config-sync.service", "carlos-restart.service", "carlos-system-update.service", "carlos-system-recover.service", |
| 26 | "carlos-edge.service", |
| 27 | } |
| 28 | |
| 29 | var ( |
| 30 | // errMigrationWindow refuses a --window that is not the attended |
| 31 | // window's private directory, or a mask without its held unit. |
| 32 | 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") |
| 33 | ) |
| 34 | |
| 35 | // verifyMigrationWindow returns the window units that window masks: each |
| 36 | // <units>/<unit> that is a symlink whose raw target is exactly /dev/null, |
| 37 | // with its original unit file held in <window>/held/<unit>, a root-owned |
| 38 | // regular file with one link. The window and held directories must be |
| 39 | // root's, closed to group and other, outside every systemd unit path and |
| 40 | // the census roots. Any other symlink at a window unit's path, or a mask |
| 41 | // without its held original, refuses. An empty window masks nothing: the |
| 42 | // census then refuses masks, as it always has. |
| 43 | func verifyMigrationWindow(window string, r ClaimRuntimeResources) (map[string]bool, error) { |
| 44 | masked := map[string]bool{} |
| 45 | if window == "" { |
| 46 | return masked, nil |
| 47 | } |
| 48 | if !filepath.IsAbs(window) || filepath.Clean(window) != window { |
| 49 | return nil, errMigrationWindow |
| 50 | } |
| 51 | for _, root := range []string{"/etc/systemd", "/run/systemd", "/usr/lib/systemd", "/lib/systemd", r.UnitsDir, r.DataDir, r.OptDir} { |
| 52 | if root == "" { |
| 53 | continue |
| 54 | } |
| 55 | if rel, err := filepath.Rel(root, window); err == nil && filepath.IsLocal(rel) || window == root { |
| 56 | return nil, errMigrationWindow |
| 57 | } |
| 58 | } |
| 59 | held := filepath.Join(window, "held") |
| 60 | for _, dir := range []string{window, held} { |
| 61 | if err := rootPrivateEntry(dir, true); err != nil { |
| 62 | return nil, err |
| 63 | } |
| 64 | } |
| 65 | for _, unit := range claimWindowUnits { |
| 66 | path := filepath.Join(r.UnitsDir, unit) |
| 67 | info, err := os.Lstat(path) |
| 68 | if errors.Is(err, os.ErrNotExist) { |
| 69 | continue |
| 70 | } |
| 71 | if err != nil { |
| 72 | return nil, err |
| 73 | } |
| 74 | if info.Mode()&os.ModeSymlink == 0 { |
| 75 | continue |
| 76 | } |
| 77 | target, err := os.Readlink(path) |
| 78 | if err != nil { |
| 79 | return nil, err |
| 80 | } |
| 81 | if target != "/dev/null" { |
| 82 | return nil, errMigrationWindow |
| 83 | } |
| 84 | if err := rootPrivateEntry(filepath.Join(held, unit), false); err != nil { |
| 85 | return nil, err |
| 86 | } |
| 87 | masked[unit] = true |
| 88 | } |
| 89 | return masked, nil |
| 90 | } |
| 91 | |
| 92 | // rootPrivateEntry requires path to be root's: a directory closed to group |
| 93 | // and other, or a regular file with one link. Never a symlink. |
| 94 | func rootPrivateEntry(path string, dir bool) error { |
| 95 | info, err := os.Lstat(path) |
| 96 | if err != nil { |
| 97 | return fmt.Errorf("%w: %v", errMigrationWindow, err) |
| 98 | } |
| 99 | st, ok := info.Sys().(*syscall.Stat_t) |
| 100 | if !ok || st.Uid != 0 { |
| 101 | return errMigrationWindow |
| 102 | } |
| 103 | if dir { |
| 104 | if !info.IsDir() || info.Mode().Perm()&0o077 != 0 { |
| 105 | return errMigrationWindow |
| 106 | } |
| 107 | return nil |
| 108 | } |
| 109 | if !info.Mode().IsRegular() || st.Nlink != 1 { |
| 110 | return errMigrationWindow |
| 111 | } |
| 112 | return nil |
| 113 | } |
| 114 | |
| 115 | // claimMigrationSharedPaths is the migration census's classification: the |
| 116 | // shared classes every claim census uses (claimGlobalSharedPaths), plus three |
| 117 | // that exist only at migration, each exact. |
| 118 | // |
| 119 | // - The window's masks: each window unit masked through --window is a |
| 120 | // symlink to exactly /dev/null, in place of the regular unit file. |
| 121 | // - A legacy route installation the census reports (Route, not Local): |
| 122 | // its nested instance directory and its raw directory, holding only |
| 123 | // directories and regular files; its program history file; and its live |
| 124 | // link, whose target is exactly the version path its row names. |
| 125 | // - A retained database: a frozen instance record's leftovers on a box |
| 126 | // with no route row and no claim for it (a removed route). The same |
| 127 | // paths, with a live link into the record's own app under one version. |
| 128 | // Retained data is never reported, so never adopted, and the census |
| 129 | // deletes nothing. |
| 130 | func claimMigrationSharedPaths(r ClaimRuntimeResources, masked map[string]bool, s rootMigrationSnapshot) (map[string][]string, error) { |
| 131 | shared := claimGlobalSharedPaths(r) |
| 132 | units := shared["units"][:0:0] |
| 133 | for _, entry := range shared["units"] { |
| 134 | replaced := false |
| 135 | for unit := range masked { |
| 136 | if entry == hostbox.CensusFile(unit) { |
| 137 | units = append(units, hostbox.CensusLink(unit, "/dev/null")) |
| 138 | replaced = true |
| 139 | } |
| 140 | } |
| 141 | if !replaced { |
| 142 | units = append(units, entry) |
| 143 | } |
| 144 | } |
| 145 | for unit := range masked { |
| 146 | if !slices.Contains(units, hostbox.CensusLink(unit, "/dev/null")) { |
| 147 | // A masked unit the platform does not install is not the window's. |
| 148 | return nil, errMigrationWindow |
| 149 | } |
| 150 | } |
| 151 | shared["units"] = units |
| 152 | if r.Registry == nil { |
| 153 | return nil, errRootMigrationBusy |
| 154 | } |
| 155 | claimed := map[string]bool{} |
| 156 | for _, item := range s.Items { |
| 157 | claimed[item.Installation.Host] = true |
| 158 | } |
| 159 | for _, in := range s.Report.Installations { |
| 160 | if !in.Route || in.Local { |
| 161 | continue |
| 162 | } |
| 163 | row, err := r.Registry.Get(in.Host) |
| 164 | if err != nil { |
| 165 | return nil, err |
| 166 | } |
| 167 | if row == nil || row.Account != in.Account || row.App != in.App || !release.ValidVersion(row.Version) { |
| 168 | return nil, errRootMigrationBusy |
| 169 | } |
| 170 | data, opt := hostInstallationPaths(r, in.Host) |
| 171 | shared["data"] = append(shared["data"], data...) |
| 172 | shared["opt"] = append(shared["opt"], hostbox.CensusLink(opt, release.VersionPath(r.OptDir, row.Account, row.Version, row.App))) |
| 173 | claimed[in.Host] = true |
| 174 | } |
| 175 | for _, rec := range s.Records { |
| 176 | if claimed[rec.Host] { |
| 177 | continue |
| 178 | } |
| 179 | row, err := r.Registry.Get(rec.Host) |
| 180 | if err != nil { |
| 181 | return nil, err |
| 182 | } |
| 183 | if row != nil { |
| 184 | return nil, errRootMigrationBusy |
| 185 | } |
| 186 | data, opt := hostInstallationPaths(r, rec.Host) |
| 187 | shared["data"] = append(shared["data"], data...) |
| 188 | target, err := os.Readlink(filepath.Join(r.OptDir, opt)) |
| 189 | if errors.Is(err, os.ErrNotExist) { |
| 190 | continue |
| 191 | } |
| 192 | if err != nil { |
| 193 | return nil, err |
| 194 | } |
| 195 | version := filepath.Base(filepath.Dir(target)) |
| 196 | if rec.Account != "" && rec.Account != release.OpsAccount { |
| 197 | version = filepath.Base(filepath.Dir(filepath.Dir(target))) |
| 198 | } |
| 199 | if !release.ValidVersion(version) || target != release.VersionPath(r.OptDir, rec.Account, version, rec.App) { |
| 200 | return nil, errRootMigrationBusy |
| 201 | } |
| 202 | shared["opt"] = append(shared["opt"], hostbox.CensusLink(opt, target)) |
| 203 | } |
| 204 | return shared, nil |
| 205 | } |
| 206 | |
| 207 | // hostInstallationPaths are one host's paths under the nested layout, |
| 208 | // relative to their census roots: the instance directory and its raw |
| 209 | // directory (shared trees of directories and regular files), the program |
| 210 | // history file, and the live link under <opt>. |
| 211 | func hostInstallationPaths(r ClaimRuntimeResources, host string) ([]string, string) { |
| 212 | id := provision.InstanceID(host) |
| 213 | return []string{ |
| 214 | hostbox.CensusTree(claimCensusRel(r.DataDir, filepath.Join(r.DataDir, "instances", id))), |
| 215 | hostbox.CensusTree(claimCensusRel(r.DataDir, layout.RawInstanceDir(r.DataDir, host))), |
| 216 | // release's programPath: the program history the bake keeps per host. |
| 217 | hostbox.CensusFile(claimCensusRel(r.DataDir, filepath.Join(r.DataDir, "programs", host+".json"))), |
| 218 | }, claimCensusRel(r.OptDir, release.LiveLink(r.OptDir, host)) |
| 219 | } |
| 220 | |
| 221 | // verifyJournalMigrateWindow is journal-migrate's up-front check of |
| 222 | // --window, before the migration writes anything. |
| 223 | func verifyJournalMigrateWindow(r ClaimRuntimeResources) (map[string]bool, error) { |
| 224 | return verifyMigrationWindow(r.MigrationWindow, r) |
| 225 | } |
| 226 | |