1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package kvstore
4
5import (
6 "bytes"
7 "context"
8 "errors"
9 "fmt"
10 "slices"
11 "strings"
12 "time"
13
14 kv "bnema.dev/ermite-kv"
15 "google.golang.org/protobuf/proto"
16
17 storagepb "bnema.dev/ermite/gen/ermite/storage"
18 "bnema.dev/ermite/internal/domain"
19 "bnema.dev/ermite/internal/ports"
20)
21
22// Key layout of the secrets, variables and environments of CI. <scope> is
23// domain.SettingsScope.Key: "i", "r/<repo>" or "e/<repo>/<environment>" with
24// the environment lower-cased and path-escaped. Names are upper case and hold
25// letters, digits and "_", so a key never runs into another scope's.
26//
27// secret/<scope>/<NAME> SecretRecord (the value sealed, see secretbox)
28// var/<scope>/<NAME> VariableRecord
29// env/<repo>/<environment> EnvironmentRecord
30// secretskey the key check of ERMITE_SECRETS_KEY
31//
32// Writes under a repository guard its key (guardRepoKey), like the merge
33// requests and the CI runs, so a deleted repository leaves nothing behind.
34// Writes in an environment scope also require the environment record at the
35// version they read, so deleting the environment cannot leave a stray value.
36const (
37 secretPrefix = "secret/"
38 variablePrefix = "var/"
39 envPrefix = "env/"
40 secretsKeyCheck = "secretskey"
41)
42
43// afterSettingsSnapshot is a test hook: it runs between the snapshot of
44// DeleteEnvironment and its batch, to write in that window.
45var afterSettingsSnapshot func()
46
47// Settings is the ermite-kv backed ports.CISettingsStore. It shares the
48// database of the Store it comes from.
49type Settings struct {
50 db *kv.DB
51}
52
53var _ ports.CISettingsStore = (*Settings)(nil)
54
55// Settings returns the CI settings store over the same database. It is valid
56// until the Store is closed.
57func (s *Store) Settings() *Settings { return &Settings{db: s.db} }
58
59func settingKey(prefix string, scope domain.SettingsScope, name string) []byte {
60 return []byte(prefix + scope.Key() + "/" + name)
61}
62func settingPrefix(prefix string, scope domain.SettingsScope) string {
63 return prefix + scope.Key() + "/"
64}
65func envKey(repo domain.RepoName, name string) []byte {
66 return []byte(envPrefix + repo.String() + "/" + domain.EnvironmentKey(name))
67}
68func envRepoPrefix(repo domain.RepoName) string { return envPrefix + repo.String() + "/" }
69
70// settingsKeys lists every key of the snapshot that belongs to the secrets,
71// variables and environments of the repository: its own and its environments'.
72func settingsKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]byte {
73 var keys [][]byte
74 for _, p := range []string{
75 secretPrefix + "r/" + repo.String() + "/", variablePrefix + "r/" + repo.String() + "/",
76 secretPrefix + "e/" + repo.String() + "/", variablePrefix + "e/" + repo.String() + "/",
77 envRepoPrefix(repo),
78 } {
79 for k := range snap.Prefix(ctx, []byte(p)) {
80 keys = append(keys, slices.Clone(k))
81 }
82 }
83 return keys
84}
85
86// BindSecretsKey stores the key check when there is none and compares it
87// otherwise.
88//
89//hexcheck:ignore suspicious-business-logic-in-adapter initializes or compares the persisted encryption-key check with compare-and-swap retries
90func (s *Settings) BindSecretsKey(ctx context.Context, check []byte) error {
91 key := []byte(secretsKeyCheck)
92 for range updateAttempts {
93 item, ok := s.db.Get(ctx, key)
94 if ok {
95 if !bytes.Equal(item.Value, check) {
96 return domain.ErrSecretsKeyMismatch
97 }
98 return nil
99 }
100 var b kv.Batch
101 b.RequireAbsent(key)
102 b.Put(key, check)
103 if _, err := s.db.Apply(ctx, &b); err != nil {
104 if errors.Is(err, kv.ErrConflict) {
105 continue // written meanwhile: compare it
106 }
107 return fmt.Errorf("store the secrets key check: %w", err)
108 }
109 return nil
110 }
111 return errors.New("store the secrets key check: concurrent initialization did not settle")
112}
113
114// stage makes the batch depend on the scope: the repository key is guarded
115// and, for an environment, its record is required at the version read.
116//
117//hexcheck:ignore suspicious-business-logic-in-adapter validates a settings scope and adds repository and environment existence/version preconditions to a storage batch
118func (s *Settings) stage(ctx context.Context, b *kv.Batch, scope domain.SettingsScope) error {
119 if !scope.Valid() {
120 return fmt.Errorf("%w: scope", domain.ErrInvalid)
121 }
122 if scope.Kind == domain.ScopeKindInstance {
123 return nil
124 }
125 if err := guardRepoKey(ctx, s.db, b, scope.Repo); err != nil {
126 return err
127 }
128 if scope.Kind == domain.ScopeKindEnvironment {
129 item, ok := s.db.Get(ctx, envKey(scope.Repo, scope.Env))
130 if !ok {
131 return domain.ErrNotFound
132 }
133 b.RequireVersion(envKey(scope.Repo, scope.Env), item.Version)
134 }
135 return nil
136}
137
138// write applies build's batch, staged for the scope, again when a concurrent
139// write of the repository made it conflict.
140func (s *Settings) write(ctx context.Context, scope domain.SettingsScope, what string, build func(b *kv.Batch) error) error {
141 for range updateAttempts {
142 var b kv.Batch
143 if err := s.stage(ctx, &b, scope); err != nil {
144 return err
145 }
146 if err := build(&b); err != nil {
147 return err
148 }
149 _, err := s.db.Apply(ctx, &b)
150 if errors.Is(err, kv.ErrConflict) {
151 continue
152 }
153 if err != nil {
154 return fmt.Errorf("%s: %w", what, err)
155 }
156 return nil
157 }
158 return fmt.Errorf("%s: %w", what, domain.ErrConflict)
159}
160
161// PutSecret implements ports.CISettingsStore.
162func (s *Settings) PutSecret(ctx context.Context, scope domain.SettingsScope, name string, sealed []byte, at time.Time) (domain.Secret, error) {
163 val := mustMarshal(&storagepb.SecretRecord{Sealed: sealed, UpdatedAt: encodeTime(at)})
164 err := s.write(ctx, scope, "store secret", func(b *kv.Batch) error {
165 b.Put(settingKey(secretPrefix, scope, name), val)
166 return nil
167 })
168 if err != nil {
169 return domain.Secret{}, err
170 }
171 return domain.Secret{Name: name, UpdatedAt: at}, nil
172}
173
174// ListSealedSecrets implements ports.CISettingsStore.
175func (s *Settings) ListSealedSecrets(ctx context.Context, scope domain.SettingsScope) ([]ports.SealedSecret, error) {
176 snap := s.db.Snapshot(ctx)
177 if err := s.existsIn(ctx, snap, scope); err != nil {
178 return nil, err
179 }
180 prefix := settingPrefix(secretPrefix, scope)
181 var out []ports.SealedSecret
182 for key, item := range snap.Prefix(ctx, []byte(prefix)) {
183 var pb storagepb.SecretRecord
184 if err := proto.Unmarshal(item.Value, &pb); err != nil {
185 return nil, fmt.Errorf("decode secret %s: %w", key, err)
186 }
187 out = append(out, ports.SealedSecret{Name: strings.TrimPrefix(string(key), prefix), Sealed: pb.GetSealed(), UpdatedAt: decodeTime(pb.GetUpdatedAt())})
188 }
189 return out, nil
190}
191
192// GetSealedSecret implements ports.CISettingsStore.
193func (s *Settings) GetSealedSecret(ctx context.Context, scope domain.SettingsScope, name string) (ports.SealedSecret, error) {
194 snap := s.db.Snapshot(ctx)
195 if err := s.existsIn(ctx, snap, scope); err != nil {
196 return ports.SealedSecret{}, err
197 }
198 item, ok := snap.Get(ctx, settingKey(secretPrefix, scope, name))
199 if !ok {
200 return ports.SealedSecret{}, domain.ErrNotFound
201 }
202 var pb storagepb.SecretRecord
203 if err := proto.Unmarshal(item.Value, &pb); err != nil {
204 return ports.SealedSecret{}, fmt.Errorf("decode secret %s: %w", name, err)
205 }
206 return ports.SealedSecret{Name: name, Sealed: pb.GetSealed(), UpdatedAt: decodeTime(pb.GetUpdatedAt())}, nil
207}
208
209// existsIn is exists on a snapshot, so a listing and its check agree.
210//
211//hexcheck:ignore suspicious-business-logic-in-adapter validates a settings scope and checks repository and environment keys in the same snapshot as a listing
212func (s *Settings) existsIn(ctx context.Context, snap *kv.Snapshot, scope domain.SettingsScope) error {
213 if !scope.Valid() {
214 return fmt.Errorf("%w: scope", domain.ErrInvalid)
215 }
216 if scope.Kind == domain.ScopeKindInstance {
217 return nil
218 }
219 if _, ok := snap.Get(ctx, repoKey(scope.Repo)); !ok {
220 return domain.ErrNotFound
221 }
222 if scope.Kind == domain.ScopeKindEnvironment {
223 if _, ok := snap.Get(ctx, envKey(scope.Repo, scope.Env)); !ok {
224 return domain.ErrNotFound
225 }
226 }
227 return nil
228}
229
230// DeleteSecret implements ports.CISettingsStore.
231func (s *Settings) DeleteSecret(ctx context.Context, scope domain.SettingsScope, name string) error {
232 return s.deleteOne(ctx, scope, secretPrefix, name, "delete secret")
233}
234
235func (s *Settings) deleteOne(ctx context.Context, scope domain.SettingsScope, prefix, name, what string) error {
236 key := settingKey(prefix, scope, name)
237 return s.write(ctx, scope, what, func(b *kv.Batch) error {
238 item, ok := s.db.Get(ctx, key)
239 if !ok {
240 return domain.ErrNotFound
241 }
242 b.RequireVersion(key, item.Version)
243 b.Delete(key)
244 return nil
245 })
246}
247
248// PutVariable implements ports.CISettingsStore.
249func (s *Settings) PutVariable(ctx context.Context, scope domain.SettingsScope, name, value string, at time.Time) (domain.Variable, error) {
250 val := mustMarshal(&storagepb.VariableRecord{Value: value, UpdatedAt: encodeTime(at)})
251 err := s.write(ctx, scope, "store variable", func(b *kv.Batch) error {
252 b.Put(settingKey(variablePrefix, scope, name), val)
253 return nil
254 })
255 if err != nil {
256 return domain.Variable{}, err
257 }
258 return domain.Variable{Name: name, Value: value, UpdatedAt: at}, nil
259}
260
261func decodeVariable(name string, val []byte) (domain.Variable, error) {
262 var pb storagepb.VariableRecord
263 if err := proto.Unmarshal(val, &pb); err != nil {
264 return domain.Variable{}, fmt.Errorf("decode variable %s: %w", name, err)
265 }
266 return domain.Variable{Name: name, Value: pb.GetValue(), UpdatedAt: decodeTime(pb.GetUpdatedAt())}, nil
267}
268
269// GetVariable implements ports.CISettingsStore.
270func (s *Settings) GetVariable(ctx context.Context, scope domain.SettingsScope, name string) (domain.Variable, error) {
271 snap := s.db.Snapshot(ctx)
272 if err := s.existsIn(ctx, snap, scope); err != nil {
273 return domain.Variable{}, err
274 }
275 item, ok := snap.Get(ctx, settingKey(variablePrefix, scope, name))
276 if !ok {
277 return domain.Variable{}, domain.ErrNotFound
278 }
279 return decodeVariable(name, item.Value)
280}
281
282// ListVariables implements ports.CISettingsStore.
283func (s *Settings) ListVariables(ctx context.Context, scope domain.SettingsScope) ([]domain.Variable, error) {
284 snap := s.db.Snapshot(ctx)
285 if err := s.existsIn(ctx, snap, scope); err != nil {
286 return nil, err
287 }
288 prefix := settingPrefix(variablePrefix, scope)
289 var out []domain.Variable
290 for key, item := range snap.Prefix(ctx, []byte(prefix)) {
291 v, err := decodeVariable(strings.TrimPrefix(string(key), prefix), item.Value)
292 if err != nil {
293 return nil, err
294 }
295 out = append(out, v)
296 }
297 return out, nil
298}
299
300// DeleteVariable implements ports.CISettingsStore.
301func (s *Settings) DeleteVariable(ctx context.Context, scope domain.SettingsScope, name string) error {
302 return s.deleteOne(ctx, scope, variablePrefix, name, "delete variable")
303}
304
305func decodeEnvironment(val []byte) (domain.Environment, error) {
306 var pb storagepb.EnvironmentRecord
307 if err := proto.Unmarshal(val, &pb); err != nil {
308 return domain.Environment{}, fmt.Errorf("decode environment: %w", err)
309 }
310 repo, err := domain.ParseRepoName(pb.GetRepo())
311 if err != nil {
312 return domain.Environment{}, fmt.Errorf("decode environment: %w", err)
313 }
314 return domain.Environment{Repo: repo, Name: pb.GetName(), CreatedAt: decodeTime(pb.GetCreatedAt())}, nil
315}
316
317// CreateEnvironment implements ports.CISettingsStore.
318func (s *Settings) CreateEnvironment(ctx context.Context, repo domain.RepoName, name string, at time.Time) (domain.Environment, error) {
319 key := envKey(repo, name)
320 for range updateAttempts {
321 if item, ok := s.db.Get(ctx, key); ok {
322 return decodeEnvironment(item.Value)
323 }
324 var b kv.Batch
325 if err := guardRepoKey(ctx, s.db, &b, repo); err != nil {
326 return domain.Environment{}, err
327 }
328 b.RequireAbsent(key)
329 b.Put(key, mustMarshal(&storagepb.EnvironmentRecord{Repo: repo.String(), Name: name, CreatedAt: encodeTime(at)}))
330 _, err := s.db.Apply(ctx, &b)
331 if errors.Is(err, kv.ErrConflict) {
332 continue // created or the repository written meanwhile: look again
333 }
334 if err != nil {
335 return domain.Environment{}, fmt.Errorf("create environment: %w", err)
336 }
337 return domain.Environment{Repo: repo, Name: name, CreatedAt: at}, nil
338 }
339 return domain.Environment{}, fmt.Errorf("create environment: %w", domain.ErrConflict)
340}
341
342// ListEnvironments implements ports.CISettingsStore.
343func (s *Settings) ListEnvironments(ctx context.Context, repo domain.RepoName) ([]domain.Environment, error) {
344 snap := s.db.Snapshot(ctx)
345 if _, ok := snap.Get(ctx, repoKey(repo)); !ok {
346 return nil, domain.ErrNotFound
347 }
348 var out []domain.Environment
349 for _, item := range snap.Prefix(ctx, []byte(envRepoPrefix(repo))) {
350 e, err := decodeEnvironment(item.Value)
351 if err != nil {
352 return nil, err
353 }
354 out = append(out, e)
355 }
356 return out, nil
357}
358
359// DeleteEnvironment implements ports.CISettingsStore.
360func (s *Settings) DeleteEnvironment(ctx context.Context, repo domain.RepoName, name string) error {
361 key := envKey(repo, name)
362 scope := domain.EnvironmentScope(repo, name)
363 for range updateAttempts {
364 snap := s.db.Snapshot(ctx)
365 // The repository record is read from the same snapshot as the keys
366 // listed below, then required at that version and rewritten, as
367 // Store.Delete does: a value written after the snapshot rewrote the
368 // record (guardRepoKey), so this batch conflicts and lists again.
369 repoItem, ok := snap.Get(ctx, repoKey(repo))
370 if !ok {
371 return domain.ErrNotFound
372 }
373 item, ok := snap.Get(ctx, key)
374 if !ok {
375 return domain.ErrNotFound
376 }
377 if afterSettingsSnapshot != nil {
378 afterSettingsSnapshot()
379 }
380 var b kv.Batch
381 b.RequireVersion(repoKey(repo), repoItem.Version)
382 b.Put(repoKey(repo), repoItem.Value)
383 b.RequireVersion(key, item.Version)
384 b.Delete(key)
385 for _, p := range []string{settingPrefix(secretPrefix, scope), settingPrefix(variablePrefix, scope)} {
386 for k := range snap.Prefix(ctx, []byte(p)) {
387 b.Delete(slices.Clone(k))
388 }
389 }
390 _, err := s.db.Apply(ctx, &b)
391 if errors.Is(err, kv.ErrConflict) {
392 continue
393 }
394 if err != nil {
395 return fmt.Errorf("delete environment: %w", err)
396 }
397 return nil
398 }
399 return fmt.Errorf("delete environment: %w", domain.ErrConflict)
400}