v0.29.0 55fcc8f

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.
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}

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