carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1//go:build linux
2
3package host
4
5import (
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.
23var 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
29var (
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.
43func 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.
94func 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.
130func 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>.
211func 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.
223func verifyJournalMigrateWindow(r ClaimRuntimeResources) (map[string]bool, error) {
224 return verifyMigrationWindow(r.MigrationWindow, r)
225}
226