carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Package instanceupdates is the control socket's read-only view of an
2// instance's update settings (instance update settings spec §3.3, §5.1). It
3// is mounted beside schedule.Mount's verbs with the same bearer and the same
4// fixed-host rule. Writes ship with #155 step 6's instance-bound credential,
5// through the console, never through this socket.
6package instanceupdates
7
8import (
9 "crypto/sha256"
10 "crypto/subtle"
11 "encoding/json"
12 "errors"
13 "net/http"
14
15 "github.com/carlosframework/platform/internal/registry"
16 "github.com/carlosframework/platform/internal/release"
17)
18
19// Report is what GET /updates answers: the box's copy of the settings, what
20// the instance may change, and the installed, serving and desired builds with
21// root's reason they differ.
22type Report struct {
23 Host string `json:"host"`
24 Settings release.UpdateSettings `json:"settings"`
25 MayChange []string `json:"may_change"`
26 Installed string `json:"installed,omitempty"`
27 Serving string `json:"serving,omitempty"`
28 Desired string `json:"desired,omitempty"`
29 Reason string `json:"reason,omitempty"`
30}
31
32// Mount adds GET /updates. host is fixed here and never read from a request;
33// token is read on every call (the env file can change under a running
34// instance), and an empty token refuses everything.
35func Mount(mux *http.ServeMux, host string, token func() (string, error), read func(host string) (Report, error)) {
36 mux.HandleFunc("GET /updates", func(w http.ResponseWriter, r *http.Request) {
37 want, err := token()
38 if err != nil || want == "" {
39 http.Error(w, "unauthorized", http.StatusUnauthorized)
40 return
41 }
42 got := sha256.Sum256([]byte(r.Header.Get("Authorization")))
43 exp := sha256.Sum256([]byte("Bearer " + want))
44 if subtle.ConstantTimeCompare(got[:], exp[:]) != 1 {
45 http.Error(w, "unauthorized", http.StatusUnauthorized)
46 return
47 }
48 rep, err := read(host)
49 if err != nil {
50 http.Error(w, "update settings unavailable", http.StatusServiceUnavailable)
51 return
52 }
53 rep.Host = host
54 if rep.MayChange == nil {
55 rep.MayChange = []string{}
56 }
57 w.Header().Set("Content-Type", "application/json")
58 _ = json.NewEncoder(w).Encode(rep)
59 })
60}
61
62// errNoRoute is what RegistryReader answers for a host the registry has no
63// row for: the verb turns it into a 503, never a 200 with an empty report.
64var errNoRoute = errors.New("instanceupdates: no route for host")
65
66// RegistryReader answers from the box's registry copy: the settings column
67// (with the route's channel when the column names none), the installed
68// build and the serving build. Root's desired build and reason
69// are added by the host's reader, which wraps this one.
70func RegistryReader(reg interface {
71 Get(host string) (*registry.Route, error)
72}) func(string) (Report, error) {
73 return func(host string) (Report, error) {
74 rt, err := reg.Get(host)
75 if err != nil {
76 return Report{}, err
77 }
78 if rt == nil {
79 return Report{}, errNoRoute
80 }
81 // Lenient, as the box's passes read the column: a Control name a newer
82 // console added must not make the whole report unavailable.
83 u, err := release.ParseUpdateSettingsLenient(rt.UpdateSettings)
84 if err != nil {
85 return Report{}, err
86 }
87 // Default settings are stored as "" (Column), which names no channel:
88 // the instance follows its route's. A set column is reconcile's
89 // effective copy and its channel wins; one with no channel falls back
90 // to the route's too. Only the report is filled, never the column.
91 if u.Channel == "" {
92 u.Channel = rt.Channel
93 }
94 return Report{Settings: u, MayChange: u.Control, Installed: rt.Version, Serving: rt.ServingVersion}, nil
95 }
96}
97