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.
88func (s *Settings) BindSecretsKey(ctx context.Context, check []byte) error {
89 key := []byte(secretsKeyCheck)
90 for range updateAttempts {
91 item, ok := s.db.Get(ctx, key)
92 if ok {
93 if !bytes.Equal(item.Value, check) {
94 return domain.ErrSecretsKeyMismatch
95 }
96 return nil
97 }
98 var b kv.Batch
99 b.RequireAbsent(key)
100 b.Put(key, check)
101 if _, err := s.db.Apply(ctx, &b); err != nil {
102 if errors.Is(err, kv.ErrConflict) {
103 continue // written meanwhile: compare it
104 }
105 return fmt.Errorf("store the secrets key check: %w", err)
106 }
107 return nil
108 }
109 return errors.New("store the secrets key check: concurrent initialization did not settle")
110}
111
112// stage makes the batch depend on the scope: the repository key is guarded
113// and, for an environment, its record is required at the version read.
114func (s *Settings) stage(ctx context.Context, b *kv.Batch, scope domain.SettingsScope) error {
115 if !scope.Valid() {
116 return fmt.Errorf("%w: scope", domain.ErrInvalid)
117 }
118 if scope.Kind == domain.ScopeKindInstance {
119 return nil
120 }
121 if err := guardRepoKey(ctx, s.db, b, scope.Repo); err != nil {
122 return err
123 }
124 if scope.Kind == domain.ScopeKindEnvironment {
125 item, ok := s.db.Get(ctx, envKey(scope.Repo, scope.Env))
126 if !ok {
127 return domain.ErrNotFound
128 }
129 b.RequireVersion(envKey(scope.Repo, scope.Env), item.Version)
130 }
131 return nil
132}
133
134// write applies build's batch, staged for the scope, again when a concurrent
135// write of the repository made it conflict.
136func (s *Settings) write(ctx context.Context, scope domain.SettingsScope, what string, build func(b *kv.Batch) error) error {
137 for range updateAttempts {
138 var b kv.Batch
139 if err := s.stage(ctx, &b, scope); err != nil {
140 return err
141 }
142 if err := build(&b); err != nil {
143 return err
144 }
145 _, err := s.db.Apply(ctx, &b)
146 if errors.Is(err, kv.ErrConflict) {
147 continue
148 }
149 if err != nil {
150 return fmt.Errorf("%s: %w", what, err)
151 }
152 return nil
153 }
154 return fmt.Errorf("%s: %w", what, domain.ErrConflict)
155}
156
157// PutSecret implements ports.CISettingsStore.
158func (s *Settings) PutSecret(ctx context.Context, scope domain.SettingsScope, name string, sealed []byte, at time.Time) (domain.Secret, error) {
159 val := mustMarshal(&storagepb.SecretRecord{Sealed: sealed, UpdatedAt: encodeTime(at)})
160 err := s.write(ctx, scope, "store secret", func(b *kv.Batch) error {
161 b.Put(settingKey(secretPrefix, scope, name), val)
162 return nil
163 })
164 if err != nil {
165 return domain.Secret{}, err
166 }
167 return domain.Secret{Name: name, UpdatedAt: at}, nil
168}
169
170// ListSealedSecrets implements ports.CISettingsStore.
171func (s *Settings) ListSealedSecrets(ctx context.Context, scope domain.SettingsScope) ([]ports.SealedSecret, error) {
172 snap := s.db.Snapshot(ctx)
173 if err := s.existsIn(ctx, snap, scope); err != nil {
174 return nil, err
175 }
176 prefix := settingPrefix(secretPrefix, scope)
177 var out []ports.SealedSecret
178 for key, item := range snap.Prefix(ctx, []byte(prefix)) {
179 var pb storagepb.SecretRecord
180 if err := proto.Unmarshal(item.Value, &pb); err != nil {
181 return nil, fmt.Errorf("decode secret %s: %w", key, err)
182 }
183 out = append(out, ports.SealedSecret{Name: strings.TrimPrefix(string(key), prefix), Sealed: pb.GetSealed(), UpdatedAt: decodeTime(pb.GetUpdatedAt())})
184 }
185 return out, nil
186}
187
188// GetSealedSecret implements ports.CISettingsStore.
189func (s *Settings) GetSealedSecret(ctx context.Context, scope domain.SettingsScope, name string) (ports.SealedSecret, error) {
190 snap := s.db.Snapshot(ctx)
191 if err := s.existsIn(ctx, snap, scope); err != nil {
192 return ports.SealedSecret{}, err
193 }
194 item, ok := snap.Get(ctx, settingKey(secretPrefix, scope, name))
195 if !ok {
196 return ports.SealedSecret{}, domain.ErrNotFound
197 }
198 var pb storagepb.SecretRecord
199 if err := proto.Unmarshal(item.Value, &pb); err != nil {
200 return ports.SealedSecret{}, fmt.Errorf("decode secret %s: %w", name, err)
201 }
202 return ports.SealedSecret{Name: name, Sealed: pb.GetSealed(), UpdatedAt: decodeTime(pb.GetUpdatedAt())}, nil
203}
204
205// existsIn is exists on a snapshot, so a listing and its check agree.
206func (s *Settings) existsIn(ctx context.Context, snap *kv.Snapshot, scope domain.SettingsScope) error {
207 if !scope.Valid() {
208 return fmt.Errorf("%w: scope", domain.ErrInvalid)
209 }
210 if scope.Kind == domain.ScopeKindInstance {
211 return nil
212 }
213 if _, ok := snap.Get(ctx, repoKey(scope.Repo)); !ok {
214 return domain.ErrNotFound
215 }
216 if scope.Kind == domain.ScopeKindEnvironment {
217 if _, ok := snap.Get(ctx, envKey(scope.Repo, scope.Env)); !ok {
218 return domain.ErrNotFound
219 }
220 }
221 return nil
222}
223
224// DeleteSecret implements ports.CISettingsStore.
225func (s *Settings) DeleteSecret(ctx context.Context, scope domain.SettingsScope, name string) error {
226 return s.deleteOne(ctx, scope, secretPrefix, name, "delete secret")
227}
228
229func (s *Settings) deleteOne(ctx context.Context, scope domain.SettingsScope, prefix, name, what string) error {
230 key := settingKey(prefix, scope, name)
231 return s.write(ctx, scope, what, func(b *kv.Batch) error {
232 item, ok := s.db.Get(ctx, key)
233 if !ok {
234 return domain.ErrNotFound
235 }
236 b.RequireVersion(key, item.Version)
237 b.Delete(key)
238 return nil
239 })
240}
241
242// PutVariable implements ports.CISettingsStore.
243func (s *Settings) PutVariable(ctx context.Context, scope domain.SettingsScope, name, value string, at time.Time) (domain.Variable, error) {
244 val := mustMarshal(&storagepb.VariableRecord{Value: value, UpdatedAt: encodeTime(at)})
245 err := s.write(ctx, scope, "store variable", func(b *kv.Batch) error {
246 b.Put(settingKey(variablePrefix, scope, name), val)
247 return nil
248 })
249 if err != nil {
250 return domain.Variable{}, err
251 }
252 return domain.Variable{Name: name, Value: value, UpdatedAt: at}, nil
253}
254
255func decodeVariable(name string, val []byte) (domain.Variable, error) {
256 var pb storagepb.VariableRecord
257 if err := proto.Unmarshal(val, &pb); err != nil {
258 return domain.Variable{}, fmt.Errorf("decode variable %s: %w", name, err)
259 }
260 return domain.Variable{Name: name, Value: pb.GetValue(), UpdatedAt: decodeTime(pb.GetUpdatedAt())}, nil
261}
262
263// GetVariable implements ports.CISettingsStore.
264func (s *Settings) GetVariable(ctx context.Context, scope domain.SettingsScope, name string) (domain.Variable, error) {
265 snap := s.db.Snapshot(ctx)
266 if err := s.existsIn(ctx, snap, scope); err != nil {
267 return domain.Variable{}, err
268 }
269 item, ok := snap.Get(ctx, settingKey(variablePrefix, scope, name))
270 if !ok {
271 return domain.Variable{}, domain.ErrNotFound
272 }
273 return decodeVariable(name, item.Value)
274}
275
276// ListVariables implements ports.CISettingsStore.
277func (s *Settings) ListVariables(ctx context.Context, scope domain.SettingsScope) ([]domain.Variable, error) {
278 snap := s.db.Snapshot(ctx)
279 if err := s.existsIn(ctx, snap, scope); err != nil {
280 return nil, err
281 }
282 prefix := settingPrefix(variablePrefix, scope)
283 var out []domain.Variable
284 for key, item := range snap.Prefix(ctx, []byte(prefix)) {
285 v, err := decodeVariable(strings.TrimPrefix(string(key), prefix), item.Value)
286 if err != nil {
287 return nil, err
288 }
289 out = append(out, v)
290 }
291 return out, nil
292}
293
294// DeleteVariable implements ports.CISettingsStore.
295func (s *Settings) DeleteVariable(ctx context.Context, scope domain.SettingsScope, name string) error {
296 return s.deleteOne(ctx, scope, variablePrefix, name, "delete variable")
297}
298
299func decodeEnvironment(val []byte) (domain.Environment, error) {
300 var pb storagepb.EnvironmentRecord
301 if err := proto.Unmarshal(val, &pb); err != nil {
302 return domain.Environment{}, fmt.Errorf("decode environment: %w", err)
303 }
304 repo, err := domain.ParseRepoName(pb.GetRepo())
305 if err != nil {
306 return domain.Environment{}, fmt.Errorf("decode environment: %w", err)
307 }
308 return domain.Environment{Repo: repo, Name: pb.GetName(), CreatedAt: decodeTime(pb.GetCreatedAt())}, nil
309}
310
311// CreateEnvironment implements ports.CISettingsStore.
312//
313//hexcheck:ignore suspicious-business-logic-in-adapter create-if-absent under a version precondition: a storage concern, no policy
314func (s *Settings) CreateEnvironment(ctx context.Context, repo domain.RepoName, name string, at time.Time) (domain.Environment, error) {
315 key := envKey(repo, name)
316 for range updateAttempts {
317 if item, ok := s.db.Get(ctx, key); ok {
318 return decodeEnvironment(item.Value)
319 }
320 var b kv.Batch
321 if err := guardRepoKey(ctx, s.db, &b, repo); err != nil {
322 return domain.Environment{}, err
323 }
324 b.RequireAbsent(key)
325 b.Put(key, mustMarshal(&storagepb.EnvironmentRecord{Repo: repo.String(), Name: name, CreatedAt: encodeTime(at)}))
326 _, err := s.db.Apply(ctx, &b)
327 if errors.Is(err, kv.ErrConflict) {
328 continue // created or the repository written meanwhile: look again
329 }
330 if err != nil {
331 return domain.Environment{}, fmt.Errorf("create environment: %w", err)
332 }
333 return domain.Environment{Repo: repo, Name: name, CreatedAt: at}, nil
334 }
335 return domain.Environment{}, fmt.Errorf("create environment: %w", domain.ErrConflict)
336}
337
338// ListEnvironments implements ports.CISettingsStore.
339func (s *Settings) ListEnvironments(ctx context.Context, repo domain.RepoName) ([]domain.Environment, error) {
340 snap := s.db.Snapshot(ctx)
341 if _, ok := snap.Get(ctx, repoKey(repo)); !ok {
342 return nil, domain.ErrNotFound
343 }
344 var out []domain.Environment
345 for _, item := range snap.Prefix(ctx, []byte(envRepoPrefix(repo))) {
346 e, err := decodeEnvironment(item.Value)
347 if err != nil {
348 return nil, err
349 }
350 out = append(out, e)
351 }
352 return out, nil
353}
354
355// DeleteEnvironment implements ports.CISettingsStore.
356//
357//hexcheck:ignore suspicious-business-logic-in-adapter removes the keys of an environment in one batch: a storage concern, no policy
358func (s *Settings) DeleteEnvironment(ctx context.Context, repo domain.RepoName, name string) error {
359 key := envKey(repo, name)
360 scope := domain.EnvironmentScope(repo, name)
361 for range updateAttempts {
362 snap := s.db.Snapshot(ctx)
363 // The repository record is read from the same snapshot as the keys
364 // listed below, then required at that version and rewritten, as
365 // Store.Delete does: a value written after the snapshot rewrote the
366 // record (guardRepoKey), so this batch conflicts and lists again.
367 repoItem, ok := snap.Get(ctx, repoKey(repo))
368 if !ok {
369 return domain.ErrNotFound
370 }
371 item, ok := snap.Get(ctx, key)
372 if !ok {
373 return domain.ErrNotFound
374 }
375 if afterSettingsSnapshot != nil {
376 afterSettingsSnapshot()
377 }
378 var b kv.Batch
379 b.RequireVersion(repoKey(repo), repoItem.Version)
380 b.Put(repoKey(repo), repoItem.Value)
381 b.RequireVersion(key, item.Version)
382 b.Delete(key)
383 for _, p := range []string{settingPrefix(secretPrefix, scope), settingPrefix(variablePrefix, scope)} {
384 for k := range snap.Prefix(ctx, []byte(p)) {
385 b.Delete(slices.Clone(k))
386 }
387 }
388 _, err := s.db.Apply(ctx, &b)
389 if errors.Is(err, kv.ErrConflict) {
390 continue
391 }
392 if err != nil {
393 return fmt.Errorf("delete environment: %w", err)
394 }
395 return nil
396 }
397 return fmt.Errorf("delete environment: %w", domain.ErrConflict)
398}