carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package instancectl
2
3import (
4 "bufio"
5 "bytes"
6 "context"
7 "errors"
8 "fmt"
9 "io/fs"
10 "net/http"
11 "net/netip"
12 "os"
13 "path/filepath"
14 "sort"
15 "strings"
16 "time"
17
18 "github.com/carlosframework/platform/internal/provision"
19)
20
21// What the control service does beyond start and stop, so the isolation
22// the templates promise holds (#214, #178): it lays out each instance's
23// view of its own build, re-opens the box's DNS resolver through the
24// egress fence, and keeps tenants from filling the shared data disk.
25
26// Paths the instance units and the probe bind from (#214 item 2). The
27// templates name them literally; the units test pins both sides.
28const (
29 // LiveDir is where the adopt pass keeps each host's live link, and
30 // where an instance finds its own build inside its namespace.
31 LiveDir = "/opt/carlos/live"
32 // SystemBinary is the platform binary every instance unit runs as
33 // ExecStart=; the probe's stand-in build.
34 SystemBinary = "/opt/carlos/carlos"
35 // InstancesSlice is the parent of every per-host slice and of the
36 // probe: an IPAddressAllow= on it reaches every instance unit.
37 InstancesSlice = "carlos-instances.slice"
38)
39
40// ensureExe writes the two sources an instance unit binds over its empty
41// /opt/carlos (#214 item 2), under root/<iid>/:
42//
43// app -> target (the host's live link; the probe's: the platform binary)
44// live/<name> -> ../app
45//
46// PID 1 chases app to the build when it binds it at /opt/carlos/app, and
47// binds live/ at /opt/carlos/live, so inside the namespace
48// /opt/carlos/live/<host> resolves to the build and nothing else of the
49// version store exists. Both are links, never copies: a deploy repoints
50// the host's live link and the next start binds the new build, exactly as
51// before. Only root writes under root, so nothing here can be planted;
52// anything else found in live/ is removed all the same, because live/ is
53// bound into a tenant.
54func ensureExe(root, iid, target, name string) error {
55 return ensureExeAs(root, iid, target, name, "app", "live")
56}
57
58// ensureExeAs is ensureExe with the two sources named: app and live under
59// root/<iid>/. The cmd template binds its own pair (CmdExeApp, CmdExeLive;
60// merge ruling M4), so a command's start and an instance's or sidecar's
61// start never write a source the other is about to bind. Inside the
62// namespace both pairs are bound at the same places, so the link in live
63// is ../app either way.
64func ensureExeAs(root, iid, target, name, app, liveName string) error {
65 if root == "" {
66 return errors.New("no exe root")
67 }
68 if iid == "" || strings.ContainsAny(iid, "/\x00") || iid == "." || iid == ".." ||
69 name == "" || strings.ContainsAny(name, "/\x00") || name == "." || name == ".." {
70 return fmt.Errorf("exe: %q/%q is not a name", iid, name)
71 }
72 dir := filepath.Join(root, iid)
73 live := filepath.Join(dir, liveName)
74 for _, d := range []string{root, dir, live} {
75 if err := os.Mkdir(d, 0o755); err != nil && !errors.Is(err, fs.ErrExist) {
76 return err
77 }
78 fi, err := os.Lstat(d)
79 if err != nil {
80 return err
81 }
82 if !fi.IsDir() {
83 return fmt.Errorf("exe: %s is not a directory", d)
84 }
85 }
86 if err := replaceLink(filepath.Join(dir, app), target); err != nil {
87 return err
88 }
89 if err := replaceLink(filepath.Join(live, name), "../app"); err != nil {
90 return err
91 }
92 entries, err := os.ReadDir(live)
93 if err != nil {
94 return err
95 }
96 for _, e := range entries {
97 if e.Name() != name {
98 if err := os.RemoveAll(filepath.Join(live, e.Name())); err != nil {
99 return err
100 }
101 }
102 }
103 return nil
104}
105
106// replaceLink points path at target, atomically: a temp link renamed over
107// whatever was there, so a unit starting at that moment binds the old
108// target or the new one, never nothing.
109func replaceLink(path, target string) error {
110 if cur, err := os.Readlink(path); err == nil && cur == target {
111 return nil
112 }
113 tmp := path + ".tmp"
114 _ = os.Remove(tmp)
115 if err := os.Symlink(target, tmp); err != nil {
116 return err
117 }
118 if err := os.Rename(tmp, path); err != nil {
119 os.Remove(tmp)
120 return err
121 }
122 return nil
123}
124
125// ensureHostExe is ensureExe for a host about to start.
126func (s *Server) ensureHostExe(host string) error {
127 return ensureExe(s.ExeRoot, provision.InstanceID(host), filepath.Join(LiveDir, host), host)
128}
129
130// ParseResolvers returns the nameserver addresses of a resolv.conf, in
131// order, zones dropped. Lines it cannot read are skipped: the resolver
132// library skips them too.
133func ParseResolvers(b []byte) []netip.Addr {
134 var out []netip.Addr
135 sc := bufio.NewScanner(bytes.NewReader(b))
136 for sc.Scan() {
137 f := strings.Fields(sc.Text())
138 if len(f) < 2 || f[0] != "nameserver" {
139 continue
140 }
141 a, err := netip.ParseAddr(f[1])
142 if err != nil {
143 continue
144 }
145 out = append(out, a.WithZone("").Unmap())
146 }
147 return out
148}
149
150// ResolverAllowed says whether a nameserver may be re-opened through the
151// instance egress fence (#214 item 3), and if not, why. IPAddressAllow=
152// opens an address, not a port, and in both directions, so an allowed
153// address must be one where nothing but a resolver can answer:
154//
155// - no loopback address, systemd-resolved's stubs (127.0.0.53/54)
156// included: a tenant can bind the address itself and then reach every
157// listener on the box bound to all addresses through it. Instances
158// resolve through resolved's UPSTREAMS instead (the namespace binds
159// /run/systemd/resolve/resolv.conf over /etc/resolv.conf);
160// - no unspecified address, which means "this box";
161// - no metadata service;
162// - no link-local address but Amazon's resolver, 169.254.169.253. An
163// allow cannot carry a zone, so fe80::1 would open that address on
164// every link, and link-local v4 is where IMDS and the time service
165// live. Boxes resolve through the VPC resolver; a link-local one is
166// refused and the probe's dns line says so.
167//
168// Everything else is opened as a single address: the VPC resolver (VPC
169// base + 2, inside RFC 1918), fd00:ec2::253, or a public one, unless it
170// is one of the box's own addresses, which only the box can say:
171// resolverAllow refuses those against localPrefixes, here, where the
172// allow is written (#126).
173func ResolverAllowed(a netip.Addr) (bool, string) {
174 switch {
175 case !a.IsValid():
176 return false, "not an address"
177 case a == netip.MustParseAddr("127.0.0.53"), a == netip.MustParseAddr("127.0.0.54"):
178 return false, "systemd-resolved's stub is on loopback; instances use its upstreams"
179 case a.IsLoopback():
180 return false, "a loopback resolver shares its address with services on this box"
181 case a.IsUnspecified():
182 return false, "the unspecified address is this box"
183 case a == netip.MustParseAddr("169.254.169.254"), a == netip.MustParseAddr("fd00:ec2::254"):
184 return false, "the metadata service is not a resolver"
185 case a == netip.MustParseAddr("169.254.169.253"):
186 return true, ""
187 case a.IsLinkLocalUnicast():
188 return false, "a link-local resolver cannot be opened for one link only"
189 }
190 return true, ""
191}
192
193// resolverAllow is the IPAddressAllow= value for a resolv.conf: each
194// allowed nameserver as a single-address prefix, sorted and deduplicated
195// so the drop-in systemd writes does not churn. A nameserver inside one
196// of the box's local prefixes is left out, and named in refused.
197func resolverAllow(b []byte, local []netip.Prefix) (allow, refused []string) {
198 seen := map[string]bool{}
199 for _, a := range ParseResolvers(b) {
200 if ok, _ := ResolverAllowed(a); !ok {
201 continue
202 }
203 p := netip.PrefixFrom(a, a.BitLen()).String()
204 if seen[p] {
205 continue
206 }
207 seen[p] = true
208 if lp, ok := isLocal(a, local); ok {
209 refused = append(refused, fmt.Sprintf("%s is an address of this box (%s)", a, lp))
210 continue
211 }
212 allow = append(allow, p)
213 }
214 sort.Strings(allow)
215 return allow, refused
216}
217
218// ResolvedConf is systemd-resolved's list of its upstreams. When it
219// exists, the namespace binds it over /etc/resolv.conf (the "-" bind in
220// the templates), so it is what instances resolve through and what the
221// allow must open.
222const ResolvedConf = "/run/systemd/resolve/resolv.conf"
223
224// resolverFile is the resolv.conf an instance sees: resolved's upstream
225// list when there is one, the box's own otherwise. Mirrors the template.
226func (s *Server) resolverFile() string {
227 if s.ResolvedConf != "" {
228 if _, err := os.Stat(s.ResolvedConf); err == nil {
229 return s.ResolvedConf
230 }
231 }
232 return s.ResolvConf
233}
234
235// allowResolvers re-opens the resolvers instances see on
236// carlos-instances.slice. The empty assignment first resets the list, so
237// a resolver that left the file is closed again. It runs before every
238// probe, so the allow is in place before the first start and follows a
239// resolver change within one probe period; the probe then checks the
240// result from inside. It returns the nameservers it refused for being
241// the box's own. When the box's addresses cannot be read, every resolver
242// is closed and the error returned: an allow that might name the box
243// itself is never written.
244func (s *Server) allowResolvers(ctx context.Context) (refused []string, err error) {
245 b, err := os.ReadFile(s.resolverFile())
246 if err != nil {
247 return nil, err
248 }
249 var allow []string
250 local, lerr := s.localPrefixes()
251 if lerr == nil {
252 allow, refused = resolverAllow(b, local)
253 }
254 args := []string{"set-property", "--runtime", InstancesSlice, "IPAddressAllow="}
255 if len(allow) > 0 {
256 args = append(args, "IPAddressAllow="+strings.Join(allow, " "))
257 }
258 out, err := s.Systemctl(ctx, args...)
259 if err != nil {
260 return nil, fmt.Errorf("%s: %s", strings.TrimSpace(out), err)
261 }
262 if lerr != nil {
263 return nil, fmt.Errorf("this box's addresses: %v; every resolver is closed", lerr)
264 }
265 return refused, nil
266}
267
268// localPrefixes is LocalAddrs, refusing when there is none.
269func (s *Server) localPrefixes() ([]netip.Prefix, error) {
270 if s.LocalAddrs == nil {
271 return nil, errors.New("no way to list them")
272 }
273 return s.LocalAddrs()
274}
275
276// resolverLocalLine is the verdict on allowResolvers' refusals, folded
277// into the probe's report: the probe cannot make this check from inside
278// its unit, which cannot list the box's addresses (#126).
279func resolverLocalLine(refused []string) VerdictLine {
280 if len(refused) > 0 {
281 return VerdictLine{Check: "resolver-local", Verdict: "fail", Detail: strings.Join(refused, "; ")}
282 }
283 return VerdictLine{Check: "resolver-local", Verdict: "ok"}
284}
285
286// The data disk (#214 item 1). Every instance's directory, the registry,
287// the activator's state and litestream's shadow WAL share one filesystem,
288// /data, on the root volume. A per-tenant quota is not achievable there
289// today: ext4 project quotas need the "project" and "quota" features,
290// which tune2fs sets only on an unmounted filesystem, plus a prjquota
291// mount, and the root filesystem is never unmounted on a running box.
292// Every process that writes /data is uid carlos, so reserved blocks
293// cannot tell a tenant from the agent either.
294//
295// Nor can this service attribute usage well enough to stop a tenant for
296// it: space held by unlinked-but-open files appears in no directory, so
297// the guilty tenant can look small while honest ones look large, and a
298// stop from here bypasses the activator's fence and holds (a host mid-
299// migration). So the guard refuses, it never stops: below the floor no
300// instance starts or seeds, and a warning names the shortfall every
301// DiskEvery while it lasts. Instances already awake keep running.
302
303const minStartFloor = 1 << 30 // 1 GiB
304
305// DiskFloor is the free space a start needs on a filesystem of total
306// bytes: the larger of 1 GiB and 10%.
307func DiskFloor(total uint64) uint64 { return max(minStartFloor, total/10) }
308
309// ErrDiskFull is a start refused for disk space.
310var ErrDiskFull = errors.New("data disk too full")
311
312// diskRoom is nil when there is room to start an instance.
313func (s *Server) diskRoom() error {
314 if s.Statfs == nil {
315 return nil
316 }
317 avail, total, err := s.Statfs(s.DataRoot)
318 if err != nil {
319 return fmt.Errorf("statfs %s: %w", s.DataRoot, err)
320 }
321 if floor := DiskFloor(total); avail < floor {
322 return fmt.Errorf("%w: %s free, below the %s floor", ErrDiskFull, humanBytes(avail), humanBytes(floor))
323 }
324 return nil
325}
326
327// refuseStart answers a start the disk or the exe layout cannot take. exe
328// lays out the unit's view of its build (ensureHostExe, or ensureGenExe
329// for a generation). It reports whether it answered.
330func (s *Server) refuseStart(w http.ResponseWriter, host, unit string, exe func() error) bool {
331 if err := s.diskRoom(); err != nil {
332 s.logf("instance-control: %s: not starting %s: %v", host, unit, err)
333 status := http.StatusBadGateway
334 if errors.Is(err, ErrDiskFull) {
335 status = http.StatusInsufficientStorage
336 }
337 http.Error(w, "not starting "+unit+": "+err.Error(), status)
338 return true
339 }
340 if s.ExeRoot != "" && exe != nil {
341 if err := exe(); err != nil {
342 http.Error(w, "exe: "+err.Error(), http.StatusBadGateway)
343 return true
344 }
345 }
346 return false
347}
348
349// warnIfFull logs the alert while the disk is below the floor. It reports
350// whether it warned.
351func (s *Server) warnIfFull() bool {
352 err := s.diskRoom()
353 if errors.Is(err, ErrDiskFull) {
354 s.logf("instance-control: disk: %v; refusing every instance start until there is room (awake instances keep running)", err)
355 return true
356 }
357 if err != nil {
358 s.logf("instance-control: disk: %v", err)
359 }
360 return false
361}
362
363// diskLoop runs warnIfFull every DiskEvery.
364func (s *Server) diskLoop(ctx context.Context) {
365 if s.DiskEvery <= 0 {
366 return
367 }
368 t := time.NewTicker(s.DiskEvery)
369 defer t.Stop()
370 for {
371 select {
372 case <-ctx.Done():
373 return
374 case <-t.C:
375 s.warnIfFull()
376 }
377 }
378}
379
380func humanBytes(n uint64) string {
381 switch {
382 case n >= 1<<30:
383 return fmt.Sprintf("%.1f GiB", float64(n)/(1<<30))
384 case n >= 1<<20:
385 return fmt.Sprintf("%.0f MiB", float64(n)/(1<<20))
386 }
387 return fmt.Sprintf("%d bytes", n)
388}
389