carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "log"
9 "log/slog"
10 "os"
11 "path/filepath"
12 "runtime"
13 "strings"
14 "time"
15
16 "github.com/carlosframework/platform/internal/hostactors"
17 "github.com/carlosframework/platform/internal/hostfloor"
18 "github.com/carlosframework/platform/internal/release"
19 "github.com/carlosframework/platform/internal/version"
20)
21
22// hostRunningImage is the census's seam for what this process is running. It
23// is nil in production, where the console reads its guard files off the box
24// (/opt/carlos/system/guard and the systemd drop-in); a test that has no box
25// installs one, so the worker's real write path can be exercised.
26var hostRunningImage func() (hostfloor.Running, error)
27
28func (s *Server) localHostReport(ctx context.Context) (hostactors.Report, error) {
29 if hostRunningImage != nil {
30 return s.hostReport(ctx, hostRunningImage)
31 }
32 disk := hostfloor.Disk{Dir: filepath.Join(release.OptDir(), "system", "guard"), Bucket: s.cfg.Bucket, PublicKey: s.cfg.ReleasePub}
33 return s.hostReport(ctx, func() (hostfloor.Running, error) {
34 return disk.Running(version.Build, runtime.GOOS+"-"+runtime.GOARCH, "console")
35 })
36}
37
38func (s *Server) hostReport(ctx context.Context, runningImage func() (hostfloor.Running, error)) (hostactors.Report, error) {
39 id := s.cfg.HostConsoleID
40 if id == "" {
41 return hostactors.Report{}, hostactors.ErrRoster
42 }
43 d := hostactors.Directory{Store: s.cfg.Releases, PublicKey: s.cfg.ReleasePub, Bucket: s.cfg.Bucket}
44 roster, err := d.Read(ctx)
45 if err != nil {
46 return hostactors.Report{}, err
47 }
48 c, ok := roster.Roster.Consoles[id]
49 if !ok {
50 c, err = d.InvitedConsole(ctx, roster, id)
51 if err != nil {
52 return hostactors.Report{}, err
53 }
54 }
55 if c.Compute != nil {
56 local, err := hostfloor.ReadEnrollment("/etc/carlos/host-actor.json")
57 box, exists := roster.Roster.Boxes[c.Compute.Box]
58 if err != nil || !exists || local.Box != c.Compute.Box || local.Enrollment != c.Compute.Enrollment || box.Enrollment != local.Enrollment || local.InstanceID != box.Cloud.InstanceID {
59 return hostactors.Report{}, hostactors.ErrRoster
60 }
61 }
62 running, err := runningImage()
63 if err != nil {
64 return hostactors.Report{}, err
65 }
66 return hostactors.Report{Compute: c.Compute, Component: "console", GeneratedAt: time.Now().UTC(), Actor: id, Enrollment: c.Enrollment, InstanceID: id, ProcessID: hostReportProcessID(running.BootID), Release: version.Build, ReleaseTag: running.ReleaseTag, Platform: runtime.GOOS + "-" + runtime.GOARCH, BinarySHA256: running.Hash, MovesProtocol: running.Protocol, BootID: running.BootID, FloorSHA256: running.FloorSHA256, FloorGuardReady: running.GuardReady, HostClaimEnabled: s.cfg.HostClaim}, nil
67}
68func (s *Server) StartHostReports(ctx context.Context) {
69 // Reporting before the flag is enabled is harmless and needed for arming.
70 if s.cfg.HostConsoleID == "" || strings.ContainsAny(s.cfg.HostConsoleID, "/\\") || s.cfg.Releases == nil {
71 return
72 }
73 ticker := time.NewTicker(5 * time.Minute)
74 defer ticker.Stop()
75 s.runHostReports(ctx, ticker.C)
76}
77
78func (s *Server) runHostReports(ctx context.Context, ticks <-chan time.Time) {
79 collisionLogged := false
80 for {
81 report, err := s.localHostReport(ctx)
82 if err == nil {
83 key := "status/consoles/" + s.cfg.HostConsoleID + ".json"
84 raw, _ := json.Marshal(report)
85 err = putHostReport(ctx, s.cfg.Releases, key, raw)
86 }
87 if len(s.cfg.Key) != 0 {
88 d := hostactors.Directory{Store: s.cfg.Releases, PublicKey: s.cfg.ReleasePub, Bucket: s.cfg.Bucket}
89 if o, e := d.Read(ctx); e == nil {
90 for id := range o.Roster.Activations {
91 _ = d.ReleaseActivation(ctx, release.RootSigner(s.cfg.Key), id, time.Now())
92 }
93 }
94 }
95 if errors.Is(err, errHostReportCollision) {
96 if !collisionLogged {
97 slog.Info("console: host census report", "collision", err)
98 collisionLogged = true
99 }
100 } else if err != nil {
101 log.Printf("console: host census report: %v", err)
102 } else {
103 collisionLogged = false
104 }
105 select {
106 case <-ctx.Done():
107 return
108 case <-ticks:
109 }
110 }
111}
112
113// putHostReport replaces the console's report, creating it when absent.
114//
115// A fresh report refuses replacement unless its recorded process is provably
116// gone on this boot. The replacement uses the checked ETag.
117func putHostReport(ctx context.Context, store release.Store, key string, raw []byte) error {
118 old, etag, err := store.Get(ctx, key)
119 if err != nil && !errors.Is(err, os.ErrNotExist) { // S3Store wraps it
120 return err
121 }
122 if err == nil {
123 if err := hostReportCollision(old, raw, time.Now()); err != nil {
124 return err
125 }
126 }
127 _, err = store.Put(ctx, key, raw, etag)
128 return err
129}
130
131var errHostReportCollision = errors.New("another console process is using the same CARLOS_CONSOLE_ID")
132
133func hostReportCollision(old, raw []byte, now time.Time) error {
134 var prev, next hostactors.Report
135 if json.Unmarshal(old, &prev) != nil || json.Unmarshal(raw, &next) != nil {
136 return nil
137 }
138 if prev.ProcessID == "" || prev.ProcessID == next.ProcessID || prev.GeneratedAt.IsZero() {
139 return nil
140 }
141 age := now.Sub(prev.GeneratedAt)
142 if age > 15*time.Minute || age < -5*time.Second {
143 return nil
144 }
145 if hostReportProcessGone(prev.ProcessID, next.ProcessID) {
146 return nil
147 }
148 return fmt.Errorf("console report: process %s published this console's report at %s; %w", prev.ProcessID, prev.GeneratedAt.Format(time.RFC3339), errHostReportCollision)
149}
150