carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// The scan sidecar (spec §5): apps/<acct>/<app>/scan.json, the coverage
2// rollup, and the object an audit answer is quoted from.
3//
4// It is a SEPARATE OBJECT from store.json on purpose. The sweep writes once
5// per page over minutes; the create, grant and rotate doors write store.json
6// too. Sharing one object would put a page-write in a compare-and-swap race
7// with every repair walk, and losing that race is the normal case rather than
8// the exception. With its own object and a single writer, the whole CAS story
9// shrinks to "another sweep is ahead of me, stand down".
10package objstore
11
12import (
13 "context"
14 "encoding/json"
15 "errors"
16 "fmt"
17 "os"
18 "time"
19
20 "github.com/carlosframework/platform/internal/release"
21)
22
23const SidecarSchema = 1
24
25// ErrSidecarMismatch is a sidecar whose body disagrees with the path it was
26// read from.
27var ErrSidecarMismatch = errors.New("objstore: scan sidecar names another app")
28
29func ScanKey(acct, app string) string { return release.AppPrefix(acct, app) + "scan.json" }
30
31// Sweep is one reconciliation pass over one bucket.
32//
33// Scanned and Unscanned are NOT complements. Skipped, Denied and Failed are
34// recorded verdicts that are not coverage — an object too large to stream was
35// never judged, and calling it clean would be a lie an auditor is entitled to
36// catch. That is why the audit sentence names two numbers.
37type Sweep struct {
38 StartedAt time.Time `json:"started_at"`
39 At time.Time `json:"at"`
40 Engine string `json:"engine"`
41 // Signature is the engine's signature-database version, verbatim. A clean
42 // verdict from three-month-old signatures should not be indistinguishable
43 // from a fresh one.
44 Signature string `json:"signature,omitempty"`
45 Objects int64 `json:"objects"`
46 Scanned int64 `json:"scanned"`
47 Infected int64 `json:"infected"`
48 Skipped int64 `json:"skipped"`
49 Denied int64 `json:"denied"`
50 Failed int64 `json:"failed"`
51 Unscanned int64 `json:"unscanned"`
52 // Fresh and Reused split Scanned by WHEN the verdict was reached. A clean
53 // tag is reused for as long as the object's ETag matches, so a completed
54 // sweep can be built almost entirely from judgements made months ago,
55 // against a signature database that is not the one named above. Without
56 // this split, Signature reads as a claim about every object in the rollup,
57 // and it is not one.
58 Fresh int64 `json:"fresh"`
59 Reused int64 `json:"reused"`
60 Bytes int64 `json:"bytes"`
61 // After is the last key of the last written page — the resume point.
62 // A key, not a ListObjectsV2 continuation token: a token's lifetime across
63 // processes is unspecified and, on Ceph, unmeasured, while a key is
64 // portable to any S3-compatible store.
65 After string `json:"after,omitempty"`
66}
67
68// ScanSidecar holds the in-flight sweep and the last completed one.
69//
70// TWO SWEEPS, NOT ONE. A single field would have to mean both "in progress"
71// and "the answer", and the tick after a sweep completed would overwrite the
72// completed counts with page-one partials — destroying the compliance artifact
73// on every cycle. Current accumulates; on its last page it moves to
74// LastComplete, and LastComplete is the only thing an audit answer quotes.
75type ScanSidecar struct {
76 Schema int `json:"schema"`
77 Account string `json:"account"`
78 App string `json:"app"`
79 // NotApplicable is a store whose member attested client-side encryption.
80 // A scanner cannot read ciphertext, so no sweep runs and no coverage is
81 // claimed — reporting "all clean" for such a store would be worse than
82 // reporting nothing.
83 NotApplicable bool `json:"not_applicable,omitempty"`
84 Current *Sweep `json:"current,omitempty"`
85 LastComplete *Sweep `json:"last_complete,omitempty"`
86}
87
88// GetSidecar reads the sidecar. Absent is (nil, "", nil) — no sidecar is a
89// state, not an error, the same idiom Get uses for an absent record.
90func GetSidecar(ctx context.Context, s release.Store, acct, app string) (*ScanSidecar, string, error) {
91 if !release.ValidAccount(acct) {
92 return nil, "", fmt.Errorf("objstore: invalid account %q", acct)
93 }
94 if !release.ValidApp(app) {
95 return nil, "", fmt.Errorf("objstore: invalid app %q", app)
96 }
97 raw, etag, err := s.Get(ctx, ScanKey(acct, app))
98 if err != nil {
99 if errors.Is(err, os.ErrNotExist) {
100 return nil, "", nil
101 }
102 return nil, "", err
103 }
104 var sc ScanSidecar
105 if err := json.Unmarshal(raw, &sc); err != nil {
106 return nil, "", fmt.Errorf("objstore: %s: %w", ScanKey(acct, app), err)
107 }
108 // An unknown schema is refused rather than guessed at — the rule
109 // release.ReadAppTombstone already applies to its own shape.
110 if sc.Schema != SidecarSchema {
111 return nil, "", fmt.Errorf("objstore: %s: schema %d is not %d",
112 ScanKey(acct, app), sc.Schema, SidecarSchema)
113 }
114 // `accounts migrate` copies every key under an app's prefix, so a sidecar
115 // can arrive under an account it was never written for, carrying rollups
116 // for a bucket named after the old one. Refusing beats reporting another
117 // account's coverage as this one's.
118 if sc.Account != acct || sc.App != app {
119 return nil, "", fmt.Errorf("%w: body says %s/%s, path says %s/%s",
120 ErrSidecarMismatch, sc.Account, sc.App, acct, app)
121 }
122 return &sc, etag, nil
123}
124
125// PutSidecar writes with the caller's CAS position; etag "" is create-only.
126// It stamps Schema, Account and App the way Put stamps Schema and App, so a
127// caller cannot write a body that disagrees with its own path.
128func claimRawPutSidecar(ctx context.Context, s release.Store, acct, app string, sc *ScanSidecar, etag string) (string, error) {
129 sc.Schema = SidecarSchema
130 sc.Account = acct
131 sc.App = app
132 body, err := json.MarshalIndent(sc, "", " ")
133 if err != nil {
134 return "", err
135 }
136 return s.Put(ctx, ScanKey(acct, app), append(body, '\n'), etag)
137}
138func PutSidecar(ctx context.Context, s release.Store, acct, app string, sc *ScanSidecar, etag string) (claimResult0 string, claimResult1 error) {
139 claimResult1 = release.WithAppMutation(ctx, s, acct, app, "objstore.PutSidecar", func(claimContext context.Context) error {
140 claimResult0, claimResult1 = claimRawPutSidecar(claimContext, s, acct, app, sc, etag)
141 return claimResult1
142 })
143 return
144}
145