v0.24.0 166837a

ci_settings.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close