detached 0633e44

feat(ci): secrets, variables, environments and run trust (#28)

commit
0633e44e1a06178504d9a6e9cfded1c49d385f88
author
brice · · 2026-10-14 21:44 UTC
committer
bnema · · 2026-10-14 21:44 UTC
parent
e4c85c7
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
refs
tag: v0.11.0
61 files · +7376 -67
M README.md +1 -0
@@ -29,6 +29,7 @@ ermite serve
2929 | `ERMITE_COMMITTER_EMAIL` | `ermite@<public host>` | committer email of squash merges |
3030 | `ERMITE_OWNER` | `owner` | owner login that CI shows in `github.repository` (`<owner>/<repo>`) and `github.repository_owner`; follows the rules of repository names |
3131 | `ERMITE_CI_LOG_RETENTION` | `90d` | how long CI runs, jobs, checks and logs are kept: `Nd` (days) or a Go duration such as `720h`, at least one hour |
32+| `ERMITE_SECRETS_KEY` | empty | 32 random bytes in base64 (`head -c 32 /dev/urandom \| base64`) that encrypt CI secrets at rest (AES-256-GCM); keep it in the deployment secret store like `ERMITE_ADMIN_TOKEN`. Without it `ermite secret set` fails and jobs receive no secrets (variables and environments still work). The server refuses to start with another key than the one the stored secrets were sealed with; losing the key loses every secret (set them again), a backup without the key reveals none |
3233 | `ERMITE_CI_POLL_TIMEOUT` | `50s` | how long the server holds a runner's `FetchJob` call when no job is queued, from `1s` to `55s`; set it below the response timeout of a reverse proxy in front of ermite (Gordon cuts a response that has no headers after 30 s, so use `25s` behind it) |
3334
3435 `ermite version` prints the version (and the server's when a server URL is configured, see Remote CLI).
M gen/ermite/storage/ci.pb.go +29 -7 generated file: collapsed
A gen/ermite/storage/settings.pb.go +271 -0 generated file: collapsed
M go.mod +2 -2
@@ -13,7 +13,7 @@ tool (
1313
1414 require (
1515 bnema.dev/ermite-actions v0.2.0
16- bnema.dev/ermite-api v0.2.0
16+ bnema.dev/ermite-api v0.2.1-0.20261014200206-5c335b855824
1717 bnema.dev/ermite-git v0.1.0
1818 bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3
1919 connectrpc.com/connect v1.21.0
@@ -27,6 +27,7 @@ require (
2727 github.com/spf13/cobra v1.10.2
2828 github.com/stretchr/testify v1.12.1
2929 github.com/yuin/goldmark v1.8.6
30+ go.yaml.in/yaml/v3 v3.0.5
3031 golang.org/x/crypto v0.57.0
3132 golang.org/x/sys v0.49.0
3233 golang.org/x/term v0.46.0
@@ -157,7 +158,6 @@ require (
157158 go.opentelemetry.io/otel/trace v1.46.0 // indirect
158159 go.uber.org/multierr v1.11.0 // indirect
159160 go.uber.org/zap v1.28.0 // indirect
160- go.yaml.in/yaml/v3 v3.0.5 // indirect
161161 golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa // indirect
162162 golang.org/x/mod v0.41.0 // indirect
163163 golang.org/x/net v0.59.0 // indirect
M go.sum +2 -2 generated file: collapsed
M internal/adapters/client/client.go +15 -0
@@ -90,6 +90,21 @@ func (c *Client) MergeRequests() ermiteconnect.MergeRequestServiceClient {
9090 return ermiteconnect.NewMergeRequestServiceClient(c.http, c.base, c.opts...)
9191 }
9292
93+// Secrets returns the SecretService client.
94+func (c *Client) Secrets() ermiteconnect.SecretServiceClient {
95+ return ermiteconnect.NewSecretServiceClient(c.http, c.base, c.opts...)
96+}
97+
98+// Variables returns the VariableService client.
99+func (c *Client) Variables() ermiteconnect.VariableServiceClient {
100+ return ermiteconnect.NewVariableServiceClient(c.http, c.base, c.opts...)
101+}
102+
103+// Environments returns the EnvironmentService client.
104+func (c *Client) Environments() ermiteconnect.EnvironmentServiceClient {
105+ return ermiteconnect.NewEnvironmentServiceClient(c.http, c.base, c.opts...)
106+}
107+
93108 // Server returns the ServerService client.
94109 func (c *Client) Server() ermiteconnect.ServerServiceClient {
95110 return ermiteconnect.NewServerServiceClient(c.http, c.base, c.opts...)
M internal/adapters/client/client_test.go +3 -3
@@ -139,16 +139,16 @@ func TestDescribeBoundsLongMessages(t *testing.T) {
139139 func TestMissingFeatures(t *testing.T) {
140140 assert.Empty(t, client.MissingFeatures(client.UsedFeatures()))
141141 assert.Equal(t,
142- []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners},
142+ []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets},
143143 client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, client.FeaturePasskeys, "other"}))
144144 assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{
145145 client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSession,
146- client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners,
146+ client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets,
147147 client.FeaturePasskeys,
148148 }))
149149 assert.Equal(t, []string{client.FeaturePasskeys}, client.MissingFeatures([]string{
150150 client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureSession,
151- client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners,
151+ client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets,
152152 }))
153153 assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil))
154154 }
M internal/adapters/client/features.go +4 -1
@@ -37,11 +37,14 @@ const (
3737 // calls (`ermite runner`).
3838 FeatureCIRuns = "ci.runs"
3939 FeatureCIRunners = "ci.runners"
40+ // FeatureCISecrets is SecretService, VariableService and EnvironmentService
41+ // (`ermite secret`, `variable`, `environment`).
42+ FeatureCISecrets = "ci.secrets"
4043 )
4144
4245 // UsedFeatures lists the features the CLI uses, in the order they are reported.
4346 func UsedFeatures() []string {
44- return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners, FeaturePasskeys}
47+ return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners, FeatureCISecrets, FeaturePasskeys}
4548 }
4649
4750 // MissingFeatures returns the features the CLI uses that have is lacking.
M internal/adapters/kvstore/ci.go +4 -4
@@ -275,7 +275,7 @@ func encodeRun(r domain.CIRun) ([]byte, error) {
275275 Event: r.Event, Ref: r.Ref, Sha: r.Sha, Actor: r.Actor,
276276 Status: encodeStatus(r.Status), Conclusion: encodeConclusion(r.Conclusion), Error: r.Error,
277277 CreatedAt: encodeTime(r.CreatedAt), StartedAt: encodeTime(r.StartedAt), CompletedAt: encodeTime(r.CompletedAt),
278- CancelRequested: r.CancelRequested, CancelRequestedAt: encodeTime(r.CancelRequestedAt),
278+ CancelRequested: r.CancelRequested, CancelRequestedAt: encodeTime(r.CancelRequestedAt), Trusted: r.Trusted,
279279 }), nil
280280 }
281281
@@ -299,7 +299,7 @@ func decodeRun(item kv.Item) (domain.CIRun, error) {
299299 Status: decodeStatus(pb.GetStatus()), Conclusion: decodeConclusion(pb.GetConclusion()), Error: pb.GetError(),
300300 CreatedAt: decodeTime(pb.GetCreatedAt()), StartedAt: decodeTime(pb.GetStartedAt()), CompletedAt: decodeTime(pb.GetCompletedAt()),
301301 CancelRequested: pb.GetCancelRequested(), CancelRequestedAt: decodeTime(pb.GetCancelRequestedAt()),
302- Version: item.Version,
302+ Trusted: pb.GetTrusted(), Version: item.Version,
303303 }, nil
304304 }
305305
@@ -330,7 +330,7 @@ func encodeJob(j domain.CIJob) ([]byte, error) {
330330 Outputs: j.Outputs, ReadyAt: encodeTime(j.ReadyAt), AssignedAt: encodeTime(j.AssignedAt),
331331 AckedAt: encodeTime(j.AckedAt), AssignCancels: int32(j.AssignCancels), //nolint:gosec // at most 3
332332 TokenValidUntil: encodeTime(j.TokenValidUntil), CancelReason: encodeCancelReason(j.CancelReason), CancelMessage: j.CancelMessage,
333- CancelRequestedAt: encodeTime(j.CancelRequestedAt), Planned: j.Planned,
333+ CancelRequestedAt: encodeTime(j.CancelRequestedAt), Planned: j.Planned, Environment: j.Environment,
334334 }
335335 if j.HasToken() {
336336 pb.TokenHash = j.TokenHash[:]
@@ -360,7 +360,7 @@ func decodeJob(item kv.Item) (domain.CIJob, error) {
360360 TokenValidUntil: decodeTime(pb.GetTokenValidUntil()), ReadyAt: decodeTime(pb.GetReadyAt()),
361361 AssignedAt: decodeTime(pb.GetAssignedAt()), AckedAt: decodeTime(pb.GetAckedAt()), AssignCancels: int(pb.GetAssignCancels()),
362362 CancelMessage: pb.GetCancelMessage(), CancelRequestedAt: decodeTime(pb.GetCancelRequestedAt()),
363- Version: item.Version,
363+ Environment: pb.GetEnvironment(), Version: item.Version,
364364 }
365365 j.CancelReason = decodeCancelReason(pb.GetCancelReason())
366366 copy(j.TokenHash[:], pb.GetTokenHash())
A internal/adapters/kvstore/ci_settings.go +398 -0
@@ -0,0 +1,398 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package kvstore
4+
5+import (
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.
36+const (
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.
45+var afterSettingsSnapshot func()
46+
47+// Settings is the ermite-kv backed ports.CISettingsStore. It shares the
48+// database of the Store it comes from.
49+type Settings struct {
50+ db *kv.DB
51+}
52+
53+var _ 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.
57+func (s *Store) Settings() *Settings { return &Settings{db: s.db} }
58+
59+func settingKey(prefix string, scope domain.SettingsScope, name string) []byte {
60+ return []byte(prefix + scope.Key() + "/" + name)
61+}
62+func settingPrefix(prefix string, scope domain.SettingsScope) string {
63+ return prefix + scope.Key() + "/"
64+}
65+func envKey(repo domain.RepoName, name string) []byte {
66+ return []byte(envPrefix + repo.String() + "/" + domain.EnvironmentKey(name))
67+}
68+func 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'.
72+func 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+func (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.
114+func (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.
136+func (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.
158+func (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.
171+func (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.
189+func (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.
206+func (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.
225+func (s *Settings) DeleteSecret(ctx context.Context, scope domain.SettingsScope, name string) error {
226+ return s.deleteOne(ctx, scope, secretPrefix, name, "delete secret")
227+}
228+
229+func (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.
243+func (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+
255+func 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.
264+func (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.
277+func (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.
295+func (s *Settings) DeleteVariable(ctx context.Context, scope domain.SettingsScope, name string) error {
296+ return s.deleteOne(ctx, scope, variablePrefix, name, "delete variable")
297+}
298+
299+func 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
314+func (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.
339+func (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
358+func (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+}
A internal/adapters/kvstore/ci_settings_test.go +261 -0
@@ -0,0 +1,261 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package kvstore_test
4+
5+import (
6+ "context"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "bnema.dev/ermite/internal/adapters/kvstore"
13+ "bnema.dev/ermite/internal/domain"
14+)
15+
16+func TestSettingsSecretsPerScopeAreSortedAndIsolated(t *testing.T) {
17+ ctx := context.Background()
18+ s, _, repo := ciRepo(t)
19+ st := s.Settings()
20+ other := name(t, "other")
21+ require.NoError(t, s.Create(ctx, domain.Repo{Name: other, Visibility: domain.VisibilityPrivate, CreatedAt: created}))
22+ _, err := st.CreateEnvironment(ctx, repo, "Prod", created)
23+ require.NoError(t, err)
24+
25+ inst, repoScope, env := domain.InstanceScope(), domain.RepoScope(repo), domain.EnvironmentScope(repo, "prod")
26+ for _, c := range []struct {
27+ scope domain.SettingsScope
28+ name string
29+ }{{inst, "B"}, {inst, "A"}, {repoScope, "R"}, {env, "E"}, {domain.RepoScope(other), "O"}} {
30+ _, err := st.PutSecret(ctx, c.scope, c.name, []byte("sealed-"+c.name), created)
31+ require.NoError(t, err)
32+ }
33+ list := func(sc domain.SettingsScope) []string {
34+ got, err := st.ListSealedSecrets(ctx, sc)
35+ require.NoError(t, err)
36+ var out []string
37+ for _, g := range got {
38+ out = append(out, g.Name+"="+string(g.Sealed))
39+ }
40+ return out
41+ }
42+ assert.Equal(t, []string{"A=sealed-A", "B=sealed-B"}, list(inst))
43+ assert.Equal(t, []string{"R=sealed-R"}, list(repoScope))
44+ assert.Equal(t, []string{"E=sealed-E"}, list(domain.EnvironmentScope(repo, "PROD")), "an environment is found without regard to case")
45+ assert.Equal(t, []string{"O=sealed-O"}, list(domain.RepoScope(other)))
46+
47+ _, err = st.PutSecret(ctx, inst, "A", []byte("new"), created.Add(1))
48+ require.NoError(t, err)
49+ assert.Equal(t, []string{"A=new", "B=sealed-B"}, list(inst), "replaced")
50+
51+ require.NoError(t, st.DeleteSecret(ctx, inst, "A"))
52+ assert.ErrorIs(t, st.DeleteSecret(ctx, inst, "A"), domain.ErrNotFound)
53+ assert.Equal(t, []string{"B=sealed-B"}, list(inst))
54+}
55+
56+func TestSettingsMissingRepositoryOrEnvironmentIsNotFound(t *testing.T) {
57+ ctx := context.Background()
58+ s, _, repo := ciRepo(t)
59+ st := s.Settings()
60+ ghost := name(t, "ghost")
61+ for _, sc := range []domain.SettingsScope{domain.RepoScope(ghost), domain.EnvironmentScope(repo, "nope"), domain.EnvironmentScope(ghost, "x")} {
62+ _, err := st.PutSecret(ctx, sc, "A", []byte("x"), created)
63+ assert.ErrorIs(t, err, domain.ErrNotFound, sc.Key())
64+ _, err = st.PutVariable(ctx, sc, "A", "x", created)
65+ assert.ErrorIs(t, err, domain.ErrNotFound, sc.Key())
66+ _, err = st.ListSealedSecrets(ctx, sc)
67+ assert.ErrorIs(t, err, domain.ErrNotFound, sc.Key())
68+ _, err = st.ListVariables(ctx, sc)
69+ assert.ErrorIs(t, err, domain.ErrNotFound, sc.Key())
70+ }
71+ _, err := st.CreateEnvironment(ctx, ghost, "x", created)
72+ assert.ErrorIs(t, err, domain.ErrNotFound)
73+ _, err = st.ListEnvironments(ctx, ghost)
74+ assert.ErrorIs(t, err, domain.ErrNotFound)
75+ assert.ErrorIs(t, st.DeleteEnvironment(ctx, repo, "nope"), domain.ErrNotFound)
76+ assert.Error(t, func() error { _, err := st.PutSecret(ctx, domain.SettingsScope{}, "A", nil, created); return err }(), "an invalid scope")
77+}
78+
79+func TestSettingsVariablesRoundTrip(t *testing.T) {
80+ ctx := context.Background()
81+ s, _, repo := ciRepo(t)
82+ st := s.Settings()
83+ sc := domain.RepoScope(repo)
84+ _, err := st.PutVariable(ctx, sc, "Z", "", created)
85+ require.NoError(t, err)
86+ _, err = st.PutVariable(ctx, sc, "A", "multi\nline", created.Add(1))
87+ require.NoError(t, err)
88+ got, err := st.GetVariable(ctx, sc, "A")
89+ require.NoError(t, err)
90+ assert.Equal(t, domain.Variable{Name: "A", Value: "multi\nline", UpdatedAt: created.Add(1)}, got)
91+ _, err = st.GetVariable(ctx, sc, "NONE")
92+ assert.ErrorIs(t, err, domain.ErrNotFound)
93+ all, err := st.ListVariables(ctx, sc)
94+ require.NoError(t, err)
95+ require.Len(t, all, 2)
96+ assert.Equal(t, "A", all[0].Name)
97+ assert.Equal(t, "", all[1].Value, "an empty value is kept")
98+ require.NoError(t, st.DeleteVariable(ctx, sc, "A"))
99+ assert.ErrorIs(t, st.DeleteVariable(ctx, sc, "A"), domain.ErrNotFound)
100+}
101+
102+func TestSettingsEnvironmentsAreCaseInsensitiveAndDeleteTheirContent(t *testing.T) {
103+ ctx := context.Background()
104+ s, _, repo := ciRepo(t)
105+ st := s.Settings()
106+ e, err := st.CreateEnvironment(ctx, repo, "Prod", created)
107+ require.NoError(t, err)
108+ assert.Equal(t, "Prod", e.Name)
109+ again, err := st.CreateEnvironment(ctx, repo, "PROD", created.Add(1))
110+ require.NoError(t, err)
111+ assert.Equal(t, domain.Environment{Repo: repo, Name: "Prod", CreatedAt: created}, again, "the existing one, unchanged")
112+ _, err = st.CreateEnvironment(ctx, repo, "a/b", created)
113+ require.NoError(t, err)
114+ _, err = st.CreateEnvironment(ctx, repo, "a", created)
115+ require.NoError(t, err)
116+
117+ sc := domain.EnvironmentScope(repo, "prod")
118+ _, err = st.PutSecret(ctx, sc, "S", []byte("x"), created)
119+ require.NoError(t, err)
120+ _, err = st.PutVariable(ctx, sc, "V", "v", created)
121+ require.NoError(t, err)
122+ _, err = st.PutSecret(ctx, domain.EnvironmentScope(repo, "a"), "KEEP", []byte("x"), created)
123+ require.NoError(t, err)
124+ _, err = st.PutSecret(ctx, domain.EnvironmentScope(repo, "a/b"), "KEEP", []byte("x"), created)
125+ require.NoError(t, err)
126+
127+ all, err := st.ListEnvironments(ctx, repo)
128+ require.NoError(t, err)
129+ require.Len(t, all, 3)
130+
131+ require.NoError(t, st.DeleteEnvironment(ctx, repo, "PROD"))
132+ _, err = st.ListSealedSecrets(ctx, sc)
133+ assert.ErrorIs(t, err, domain.ErrNotFound)
134+ // A write to the deleted environment cannot leave a stray value.
135+ _, err = st.PutSecret(ctx, sc, "S", []byte("x"), created)
136+ assert.ErrorIs(t, err, domain.ErrNotFound)
137+ // Its neighbours, whose names share a prefix with it, are untouched.
138+ for _, env := range []string{"a", "a/b"} {
139+ got, err := st.ListSealedSecrets(ctx, domain.EnvironmentScope(repo, env))
140+ require.NoError(t, err, env)
141+ assert.Len(t, got, 1, env)
142+ }
143+}
144+
145+func TestSettingsDieWithTheRepository(t *testing.T) {
146+ ctx := context.Background()
147+ s, _, repo := ciRepo(t)
148+ st := s.Settings()
149+ keep := name(t, "proj2") // its name starts like the deleted one's
150+ require.NoError(t, s.Create(ctx, domain.Repo{Name: keep, Visibility: domain.VisibilityPrivate, CreatedAt: created}))
151+ for _, r := range []domain.RepoName{repo, keep} {
152+ _, err := st.CreateEnvironment(ctx, r, "prod", created)
153+ require.NoError(t, err)
154+ for _, sc := range []domain.SettingsScope{domain.RepoScope(r), domain.EnvironmentScope(r, "prod")} {
155+ _, err = st.PutSecret(ctx, sc, "S", []byte("x"), created)
156+ require.NoError(t, err)
157+ _, err = st.PutVariable(ctx, sc, "V", "v", created)
158+ require.NoError(t, err)
159+ }
160+ }
161+ inst := domain.InstanceScope()
162+ _, err := st.PutSecret(ctx, inst, "S", []byte("x"), created)
163+ require.NoError(t, err)
164+
165+ require.NoError(t, s.Delete(ctx, repo))
166+
167+ // A repository created again under the name starts clean.
168+ require.NoError(t, s.Create(ctx, domain.Repo{Name: repo, Visibility: domain.VisibilityPrivate, CreatedAt: created}))
169+ envs, err := st.ListEnvironments(ctx, repo)
170+ require.NoError(t, err)
171+ assert.Empty(t, envs)
172+ secrets, err := st.ListSealedSecrets(ctx, domain.RepoScope(repo))
173+ require.NoError(t, err)
174+ assert.Empty(t, secrets)
175+ vars, err := st.ListVariables(ctx, domain.RepoScope(repo))
176+ require.NoError(t, err)
177+ assert.Empty(t, vars)
178+ _, err = st.ListSealedSecrets(ctx, domain.EnvironmentScope(repo, "prod"))
179+ assert.ErrorIs(t, err, domain.ErrNotFound)
180+
181+ // Other scopes stay.
182+ secrets, err = st.ListSealedSecrets(ctx, domain.EnvironmentScope(keep, "prod"))
183+ require.NoError(t, err)
184+ assert.Len(t, secrets, 1)
185+ secrets, err = st.ListSealedSecrets(ctx, inst)
186+ require.NoError(t, err)
187+ assert.Len(t, secrets, 1)
188+}
189+
190+func TestBindSecretsKeyRefusesAnotherKeyAcrossARestart(t *testing.T) {
191+ ctx := context.Background()
192+ dir := t.TempDir()
193+ s, err := kvstore.Open(ctx, dir)
194+ require.NoError(t, err)
195+ require.NoError(t, s.Settings().BindSecretsKey(ctx, []byte("check-one")))
196+ require.NoError(t, s.Settings().BindSecretsKey(ctx, []byte("check-one")), "the same key again")
197+ require.NoError(t, s.Close(ctx))
198+
199+ s, err = kvstore.Open(ctx, dir)
200+ require.NoError(t, err)
201+ t.Cleanup(func() { _ = s.Close(ctx) })
202+ assert.ErrorIs(t, s.Settings().BindSecretsKey(ctx, []byte("check-two")), domain.ErrSecretsKeyMismatch)
203+ assert.NoError(t, s.Settings().BindSecretsKey(ctx, []byte("check-one")))
204+}
205+
206+// A value written into an environment after DeleteEnvironment took its snapshot
207+// is not missed: the delete conflicts and lists again, so nothing survives to be
208+// resurrected when the environment is created again.
209+func TestDeleteEnvironmentSeesAConcurrentInsertion(t *testing.T) {
210+ ctx := context.Background()
211+ s, _, repo := ciRepo(t)
212+ st := s.Settings()
213+ sc := domain.EnvironmentScope(repo, "prod")
214+ _, err := st.CreateEnvironment(ctx, repo, "prod", created)
215+ require.NoError(t, err)
216+ _, err = st.PutSecret(ctx, sc, "OLD", []byte("x"), created)
217+ require.NoError(t, err)
218+
219+ calls := 0
220+ restore := kvstore.SetAfterSettingsSnapshot(func() {
221+ calls++
222+ if calls == 1 { // once, in the window after the first snapshot
223+ _, perr := st.PutSecret(ctx, sc, "LATE", []byte("x"), created)
224+ require.NoError(t, perr)
225+ _, perr = st.PutVariable(ctx, sc, "LATE_VAR", "v", created)
226+ require.NoError(t, perr)
227+ }
228+ })
229+ defer restore()
230+ require.NoError(t, st.DeleteEnvironment(ctx, repo, "prod"))
231+ assert.GreaterOrEqual(t, calls, 2, "the first attempt conflicted and listed again")
232+ restore()
233+
234+ _, err = st.CreateEnvironment(ctx, repo, "prod", created)
235+ require.NoError(t, err)
236+ secrets, err := st.ListSealedSecrets(ctx, sc)
237+ require.NoError(t, err)
238+ assert.Empty(t, secrets, "nothing resurrected")
239+ vars, err := st.ListVariables(ctx, sc)
240+ require.NoError(t, err)
241+ assert.Empty(t, vars)
242+}
243+
244+func TestGetSealedSecretIsAPointRead(t *testing.T) {
245+ ctx := context.Background()
246+ s, _, repo := ciRepo(t)
247+ st := s.Settings()
248+ sc := domain.RepoScope(repo)
249+ _, err := st.PutSecret(ctx, sc, "A", []byte("sealed-a"), created)
250+ require.NoError(t, err)
251+ got, err := st.GetSealedSecret(ctx, sc, "A")
252+ require.NoError(t, err)
253+ assert.Equal(t, "A", got.Name)
254+ assert.Equal(t, []byte("sealed-a"), got.Sealed)
255+ _, err = st.GetSealedSecret(ctx, sc, "B")
256+ assert.ErrorIs(t, err, domain.ErrNotFound)
257+ _, err = st.GetSealedSecret(ctx, domain.RepoScope(name(t, "ghost")), "A")
258+ assert.ErrorIs(t, err, domain.ErrNotFound)
259+ _, err = st.GetSealedSecret(ctx, domain.EnvironmentScope(repo, "nope"), "A")
260+ assert.ErrorIs(t, err, domain.ErrNotFound)
261+}
M internal/adapters/kvstore/ci_test.go +21 -0
@@ -403,3 +403,24 @@ func TestCIRunNameRoundTripsAndDefaultsToTheWorkflowName(t *testing.T) {
403403 assert.Equal(t, "run push on branch by someone", got.Name)
404404 assert.Equal(t, "ci", got.WorkflowName, "the workflow name is unchanged")
405405 }
406+
407+func TestCIRunTrustedRoundTripsAndDefaultsToFalse(t *testing.T) {
408+ ctx := context.Background()
409+ _, ci, n := ciRepo(t)
410+
411+ old, err := ci.CreateRun(ctx, n, ".github/workflows/ci.yml", draftOf(n, ".github/workflows/ci.yml", 0))
412+ require.NoError(t, err)
413+ got, err := ci.GetRun(ctx, n, old.Run.ID)
414+ require.NoError(t, err)
415+ assert.False(t, got.Trusted, "a run without the flag is not trusted")
416+
417+ trusted, err := ci.CreateRun(ctx, n, ".github/workflows/ci.yml", func(id, number int64) ports.CIRunDraft {
418+ d := draftOf(n, ".github/workflows/ci.yml", 0)(id, number)
419+ d.Run.Trusted = true
420+ return d
421+ })
422+ require.NoError(t, err)
423+ got, err = ci.GetRun(ctx, n, trusted.Run.ID)
424+ require.NoError(t, err)
425+ assert.True(t, got.Trusted)
426+}
M internal/adapters/kvstore/ci_token.go +10 -0
@@ -206,3 +206,13 @@ func repoReaderKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName
206206 }
207207 return keys
208208 }
209+
210+// CountRepoTokens counts the job repository tokens that read the repository
211+// (every job's, archive tokens included). It serves tests and diagnostics.
212+func (c *CI) CountRepoTokens(ctx context.Context, repo domain.RepoName) (int, error) {
213+ n := 0
214+ for range c.db.Snapshot(ctx).Prefix(ctx, []byte(repoTokenRepoPrefixOf(repo))) {
215+ n++
216+ }
217+ return n, nil
218+}
A internal/adapters/kvstore/export_test.go +10 -0
@@ -0,0 +1,10 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package kvstore
4+
5+// SetAfterSettingsSnapshot installs the hook that runs between the snapshot of
6+// DeleteEnvironment and its batch, and returns the function that removes it.
7+func SetAfterSettingsSnapshot(f func()) (restore func()) {
8+ afterSettingsSnapshot = f
9+ return func() { afterSettingsSnapshot = nil }
10+}
M internal/adapters/kvstore/repos.go +1 -0
@@ -144,6 +144,7 @@ func (s *Store) Delete(ctx context.Context, name domain.RepoName) error {
144144 snap := s.db.Snapshot(ctx)
145145 repoItem, repoOK := snap.Get(ctx, repoKey(name))
146146 keys := append(mergeRequestKeys(ctx, snap, name), ciKeys(ctx, snap, name)...)
147+ keys = append(keys, settingsKeys(ctx, snap, name)...)
147148 for len(keys) > deleteChunk {
148149 var b kv.Batch
149150 for _, k := range keys[:deleteChunk] {
M internal/adapters/rpc/api.go +19 -2
@@ -32,7 +32,8 @@ const RunnerMaxRequestBytes = MaxRequestBytes + 64<<10
3232
3333 // Features are the optional capabilities of the contract that this server
3434 // always implements, as GetVersion lists them. FeaturePasskeys comes on top
35-// when Config.Passkeys is set.
35+// when Config.Passkeys is set, and FeatureCISecrets when the settings services
36+// are.
3637 var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries"}
3738
3839 // FeaturePasskeys is listed by GetVersion when passkeys are available.
@@ -69,6 +70,13 @@ type Config struct {
6970 // not available (the RPCs answer failed_precondition and GetVersion does
7071 // not list "passkeys"). Same rule as Sessions about typed nil pointers.
7172 Passkeys ports.PasskeyService
73+ // Secrets, Variables and Environments serve SecretService, VariableService
74+ // and EnvironmentService, and GetVersion lists "ci.secrets", when all three
75+ // are set (they are one use case). Same rule as Sessions about typed nil
76+ // pointers.
77+ Secrets ports.SecretService
78+ Variables ports.VariableService
79+ Environments ports.EnvironmentService
7280 }
7381
7482 // API is the set of Connect handlers of the contract.
@@ -99,17 +107,26 @@ func New(cfg Config) *API {
99107 if cfg.Passkeys != nil {
100108 features = append(features, FeaturePasskeys)
101109 }
110+ settings := cfg.Secrets != nil && cfg.Variables != nil && cfg.Environments != nil
111+ if settings {
112+ features = append(features, FeatureCISecrets)
113+ }
102114 mux.Handle(ermiteconnect.NewServerServiceHandler(&serverService{version: cfg.Version, features: features, key: cfg.SigningKey}, opts...))
103115 mux.Handle(ermiteconnect.NewMergeRequestServiceHandler(&mergeRequestService{cfg.MergeRequests}, opts...))
104116 if cfg.Sessions != nil {
105117 mux.Handle(ermiteconnect.NewSessionServiceHandler(&sessionService{sessions: cfg.Sessions, passkeys: cfg.Passkeys}, opts...))
106118 }
119+ if settings {
120+ mux.Handle(ermiteconnect.NewSecretServiceHandler(&secretService{cfg.Secrets}, opts...))
121+ mux.Handle(ermiteconnect.NewVariableServiceHandler(&variableService{cfg.Variables}, opts...))
122+ mux.Handle(ermiteconnect.NewEnvironmentServiceHandler(&environmentService{cfg.Environments}, opts...))
123+ }
107124 if cfg.Runner != nil {
108125 runnerOpts := append(slices.Clone(opts), connect.WithReadMaxBytes(RunnerMaxRequestBytes))
109126 mux.Handle(ermiteconnect.NewRunnerServiceHandler(&runnerService{runners: cfg.Runner, limiter: cfg.Limiter}, runnerOpts...))
110127 }
111128 if cfg.Runs != nil {
112- mux.Handle(ermiteconnect.NewRunServiceHandler(&runService{cfg.Runs}, opts...))
129+ mux.Handle(ermiteconnect.NewRunServiceHandler(&runService{runs: cfg.Runs}, opts...))
113130 }
114131 return &API{handler: mux}
115132 }
M internal/adapters/rpc/ci.go +6 -1
@@ -207,7 +207,12 @@ func (s *runnerService) UploadSummary(ctx context.Context, req *connect.Request[
207207 }
208208
209209 // runService implements ermiteconnect.RunServiceHandler.
210-type runService struct{ runs ports.RunService }
210+type runService struct {
211+ // DispatchWorkflow is not implemented yet: the feature "ci.dispatch" is not
212+ // listed and the RPC answers unimplemented.
213+ ermiteconnect.UnimplementedRunServiceHandler
214+ runs ports.RunService
215+}
211216
212217 func repoOf(s string) (domain.RepoName, error) { return domain.ParseRepoName(s) }
213218
M internal/adapters/rpc/ci_convert.go +2 -0
@@ -145,6 +145,7 @@ func runToPB(r domain.CIRun) *pb.Run {
145145 WorkflowPath: r.WorkflowPath, WorkflowName: r.WorkflowName, Name: runName(r), Title: r.Title, Event: r.Event,
146146 Ref: r.Ref, Sha: r.Sha, Actor: r.Actor, Status: statusToPB(r.Status), Conclusion: conclusionToPB(r.Conclusion),
147147 Error: r.Error, CreatedAt: timestamp(r.CreatedAt), StartedAt: timestamp(r.StartedAt), CompletedAt: timestamp(r.CompletedAt),
148+ Trusted: r.Trusted,
148149 Version: int64(r.Version), //nolint:gosec // a store sequence, far below 2^63
149150 }
150151 }
@@ -219,6 +220,7 @@ func runnerJobToPB(j *ports.RunnerJob) *pb.Job {
219220 WorkflowPath: j.WorkflowPath, WorkflowYaml: j.WorkflowYAML, JobKey: j.Job.Key, Matrix: j.Job.MatrixJSON,
220221 Contexts: j.Job.ContextsJSON, Labels: j.Job.Labels, TimeoutMinutes: j.Job.TimeoutMinutes,
221222 Repo: j.Repo.String(), Sha: j.Sha, Ref: j.Ref, GithubToken: j.GithubToken,
223+ Secrets: j.Secrets, Vars: j.Vars, Environment: j.Environment, Trusted: j.Trusted,
222224 }
223225 }
224226
A internal/adapters/rpc/ci_settings.go +202 -0
@@ -0,0 +1,202 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package rpc
4+
5+import (
6+ "context"
7+
8+ "connectrpc.com/connect"
9+
10+ pb "bnema.dev/ermite-api/gen/ermite"
11+ "bnema.dev/ermite/internal/domain"
12+ "bnema.dev/ermite/internal/ports"
13+)
14+
15+// FeatureCISecrets is listed by GetVersion when the server serves the secret,
16+// variable and environment services and sends secrets, variables and the trust
17+// flag to runners.
18+const FeatureCISecrets = "ci.secrets"
19+
20+// settingsScopeFromPB reads a SettingsScope. A scope that is not set, a false instance
21+// and a name that breaks the rules are invalid_argument.
22+func settingsScopeFromPB(s *pb.SettingsScope) (domain.SettingsScope, error) {
23+ bad := func(reason string) error {
24+ return &domain.InvalidFieldsError{Fields: []domain.FieldViolation{{Field: "scope", Reason: reason}}}
25+ }
26+ switch w := s.GetWhere().(type) {
27+ case *pb.SettingsScope_Instance:
28+ if !w.Instance {
29+ return domain.SettingsScope{}, bad("instance must be true when set")
30+ }
31+ return domain.InstanceScope(), nil
32+ case *pb.SettingsScope_Repo:
33+ name, err := domain.ParseRepoName(w.Repo)
34+ if err != nil {
35+ return domain.SettingsScope{}, err
36+ }
37+ return domain.RepoScope(name), nil
38+ case *pb.SettingsScope_Environment:
39+ name, err := domain.ParseRepoName(w.Environment.GetRepo())
40+ if err != nil {
41+ return domain.SettingsScope{}, err
42+ }
43+ env, err := domain.ParseEnvironmentName(w.Environment.GetName())
44+ if err != nil {
45+ return domain.SettingsScope{}, bad("environment name " + err.Error())
46+ }
47+ return domain.EnvironmentScope(name, env), nil
48+ }
49+ return domain.SettingsScope{}, bad("is required")
50+}
51+
52+func secretToPB(s domain.Secret) *pb.Secret {
53+ return &pb.Secret{Name: s.Name, UpdatedAt: timestamp(s.UpdatedAt)} // never a value
54+}
55+
56+func variableToPB(v domain.Variable) *pb.Variable {
57+ return &pb.Variable{Name: v.Name, Value: v.Value, UpdatedAt: timestamp(v.UpdatedAt)}
58+}
59+
60+func environmentToPB(e domain.Environment) *pb.Environment {
61+ return &pb.Environment{Repo: e.Repo.String(), Name: e.Name, CreatedAt: timestamp(e.CreatedAt)}
62+}
63+
64+// secretService implements ermiteconnect.SecretServiceHandler. A request holds
65+// a secret value: nothing of it is logged here, and the interceptor logs
66+// procedures only.
67+type secretService struct{ secrets ports.SecretService }
68+
69+func (s *secretService) SetSecret(ctx context.Context, req *connect.Request[pb.SetSecretRequest]) (*connect.Response[pb.SetSecretResponse], error) {
70+ scope, err := settingsScopeFromPB(req.Msg.GetScope())
71+ if err != nil {
72+ return nil, connectError(ctx, err)
73+ }
74+ sec, err := s.secrets.SetSecret(ctx, caller(ctx), scope, req.Msg.GetName(), req.Msg.GetValue())
75+ if err != nil {
76+ return nil, connectError(ctx, err)
77+ }
78+ return connect.NewResponse(&pb.SetSecretResponse{Secret: secretToPB(sec)}), nil
79+}
80+
81+func (s *secretService) ListSecrets(ctx context.Context, req *connect.Request[pb.ListSecretsRequest]) (*connect.Response[pb.ListSecretsResponse], error) {
82+ scope, err := settingsScopeFromPB(req.Msg.GetScope())
83+ if err != nil {
84+ return nil, connectError(ctx, err)
85+ }
86+ page, err := s.secrets.ListSecrets(ctx, caller(ctx), scope, int(req.Msg.GetPageSize()), req.Msg.GetPageToken())
87+ if err != nil {
88+ return nil, connectError(ctx, err)
89+ }
90+ out := &pb.ListSecretsResponse{NextPageToken: page.NextPageToken, Secrets: make([]*pb.Secret, 0, len(page.Secrets))}
91+ for _, x := range page.Secrets {
92+ out.Secrets = append(out.Secrets, secretToPB(x))
93+ }
94+ return connect.NewResponse(out), nil
95+}
96+
97+func (s *secretService) DeleteSecret(ctx context.Context, req *connect.Request[pb.DeleteSecretRequest]) (*connect.Response[pb.DeleteSecretResponse], error) {
98+ scope, err := settingsScopeFromPB(req.Msg.GetScope())
99+ if err != nil {
100+ return nil, connectError(ctx, err)
101+ }
102+ if err := s.secrets.DeleteSecret(ctx, caller(ctx), scope, req.Msg.GetName()); err != nil {
103+ return nil, connectError(ctx, err)
104+ }
105+ return connect.NewResponse(&pb.DeleteSecretResponse{}), nil
106+}
107+
108+// variableService implements ermiteconnect.VariableServiceHandler.
109+type variableService struct{ vars ports.VariableService }
110+
111+func (s *variableService) SetVariable(ctx context.Context, req *connect.Request[pb.SetVariableRequest]) (*connect.Response[pb.SetVariableResponse], error) {
112+ scope, err := settingsScopeFromPB(req.Msg.GetScope())
113+ if err != nil {
114+ return nil, connectError(ctx, err)
115+ }
116+ v, err := s.vars.SetVariable(ctx, caller(ctx), scope, req.Msg.GetName(), req.Msg.GetValue())
117+ if err != nil {
118+ return nil, connectError(ctx, err)
119+ }
120+ return connect.NewResponse(&pb.SetVariableResponse{Variable: variableToPB(v)}), nil
121+}
122+
123+func (s *variableService) GetVariable(ctx context.Context, req *connect.Request[pb.GetVariableRequest]) (*connect.Response[pb.GetVariableResponse], error) {
124+ scope, err := settingsScopeFromPB(req.Msg.GetScope())
125+ if err != nil {
126+ return nil, connectError(ctx, err)
127+ }
128+ v, err := s.vars.GetVariable(ctx, caller(ctx), scope, req.Msg.GetName())
129+ if err != nil {
130+ return nil, connectError(ctx, err)
131+ }
132+ return connect.NewResponse(&pb.GetVariableResponse{Variable: variableToPB(v)}), nil
133+}
134+
135+func (s *variableService) ListVariables(ctx context.Context, req *connect.Request[pb.ListVariablesRequest]) (*connect.Response[pb.ListVariablesResponse], error) {
136+ scope, err := settingsScopeFromPB(req.Msg.GetScope())
137+ if err != nil {
138+ return nil, connectError(ctx, err)
139+ }
140+ page, err := s.vars.ListVariables(ctx, caller(ctx), scope, int(req.Msg.GetPageSize()), req.Msg.GetPageToken())
141+ if err != nil {
142+ return nil, connectError(ctx, err)
143+ }
144+ out := &pb.ListVariablesResponse{NextPageToken: page.NextPageToken, Variables: make([]*pb.Variable, 0, len(page.Variables))}
145+ for _, x := range page.Variables {
146+ out.Variables = append(out.Variables, variableToPB(x))
147+ }
148+ return connect.NewResponse(out), nil
149+}
150+
151+func (s *variableService) DeleteVariable(ctx context.Context, req *connect.Request[pb.DeleteVariableRequest]) (*connect.Response[pb.DeleteVariableResponse], error) {
152+ scope, err := settingsScopeFromPB(req.Msg.GetScope())
153+ if err != nil {
154+ return nil, connectError(ctx, err)
155+ }
156+ if err := s.vars.DeleteVariable(ctx, caller(ctx), scope, req.Msg.GetName()); err != nil {
157+ return nil, connectError(ctx, err)
158+ }
159+ return connect.NewResponse(&pb.DeleteVariableResponse{}), nil
160+}
161+
162+// environmentService implements ermiteconnect.EnvironmentServiceHandler.
163+type environmentService struct{ envs ports.EnvironmentService }
164+
165+func (s *environmentService) CreateEnvironment(ctx context.Context, req *connect.Request[pb.CreateEnvironmentRequest]) (*connect.Response[pb.CreateEnvironmentResponse], error) {
166+ repo, err := repoOf(req.Msg.GetRepo())
167+ if err != nil {
168+ return nil, connectError(ctx, err)
169+ }
170+ e, err := s.envs.CreateEnvironment(ctx, caller(ctx), repo, req.Msg.GetName())
171+ if err != nil {
172+ return nil, connectError(ctx, err)
173+ }
174+ return connect.NewResponse(&pb.CreateEnvironmentResponse{Environment: environmentToPB(e)}), nil
175+}
176+
177+func (s *environmentService) ListEnvironments(ctx context.Context, req *connect.Request[pb.ListEnvironmentsRequest]) (*connect.Response[pb.ListEnvironmentsResponse], error) {
178+ repo, err := repoOf(req.Msg.GetRepo())
179+ if err != nil {
180+ return nil, connectError(ctx, err)
181+ }
182+ page, err := s.envs.ListEnvironments(ctx, caller(ctx), repo, int(req.Msg.GetPageSize()), req.Msg.GetPageToken())
183+ if err != nil {
184+ return nil, connectError(ctx, err)
185+ }
186+ out := &pb.ListEnvironmentsResponse{NextPageToken: page.NextPageToken, Environments: make([]*pb.Environment, 0, len(page.Environments))}
187+ for _, x := range page.Environments {
188+ out.Environments = append(out.Environments, environmentToPB(x))
189+ }
190+ return connect.NewResponse(out), nil
191+}
192+
193+func (s *environmentService) DeleteEnvironment(ctx context.Context, req *connect.Request[pb.DeleteEnvironmentRequest]) (*connect.Response[pb.DeleteEnvironmentResponse], error) {
194+ repo, err := repoOf(req.Msg.GetRepo())
195+ if err != nil {
196+ return nil, connectError(ctx, err)
197+ }
198+ if err := s.envs.DeleteEnvironment(ctx, caller(ctx), repo, req.Msg.GetName()); err != nil {
199+ return nil, connectError(ctx, err)
200+ }
201+ return connect.NewResponse(&pb.DeleteEnvironmentResponse{}), nil
202+}
A internal/adapters/secretbox/secretbox.go +82 -0
@@ -0,0 +1,82 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Package secretbox seals the values of CI secrets with AES-256-GCM under the
4+// server's master key (ERMITE_SECRETS_KEY, ADR 0019). Each value gets its own
5+// random nonce, and the additional data given by the caller (the scope and the
6+// name of the secret) is authenticated with it, so a sealed value moved to
7+// another secret does not open.
8+package secretbox
9+
10+import (
11+ "crypto/aes"
12+ "crypto/cipher"
13+ "crypto/hmac"
14+ "crypto/rand"
15+ "crypto/sha256"
16+ "errors"
17+ "fmt"
18+
19+ "bnema.dev/ermite/internal/ports"
20+)
21+
22+// KeyLen is the length of the master key.
23+const KeyLen = 32
24+
25+// version is the first byte of a sealed value, so that the format can change.
26+const version byte = 1
27+
28+// Box seals and opens secret values. It is safe for concurrent use.
29+type Box struct {
30+ aead cipher.AEAD
31+ check []byte
32+}
33+
34+var _ ports.SecretSealer = (*Box)(nil)
35+
36+// New builds a Box from a key of KeyLen bytes. The error never holds the key.
37+func New(key []byte) (*Box, error) {
38+ if len(key) != KeyLen {
39+ return nil, fmt.Errorf("the secrets key must be %d bytes, got %d", KeyLen, len(key))
40+ }
41+ block, err := aes.NewCipher(key)
42+ if err != nil {
43+ return nil, errors.New("the secrets key is not usable")
44+ }
45+ aead, err := cipher.NewGCM(block)
46+ if err != nil {
47+ return nil, errors.New("the secrets key is not usable")
48+ }
49+ mac := hmac.New(sha256.New, key)
50+ mac.Write([]byte("ermite secrets key check v1"))
51+ return &Box{aead: aead, check: mac.Sum(nil)}, nil
52+}
53+
54+// Seal returns version, nonce and ciphertext (with its tag) of plaintext,
55+// bound to aad.
56+func (b *Box) Seal(plaintext, aad []byte) []byte {
57+ out := make([]byte, 1, 1+b.aead.NonceSize()+len(plaintext)+b.aead.Overhead())
58+ out[0] = version
59+ nonce := make([]byte, b.aead.NonceSize())
60+ _, _ = rand.Read(nonce) // crypto/rand does not fail: it aborts the program
61+ out = append(out, nonce...)
62+ return b.aead.Seal(out, nonce, plaintext, aad)
63+}
64+
65+// Open returns the plaintext of a value sealed with the same key and aad. Any
66+// other key, aad or a changed byte is ports.ErrSecretNotOpened.
67+func (b *Box) Open(sealed, aad []byte) ([]byte, error) {
68+ ns := b.aead.NonceSize()
69+ if len(sealed) < 1+ns+b.aead.Overhead() || sealed[0] != version {
70+ return nil, ports.ErrSecretNotOpened
71+ }
72+ plain, err := b.aead.Open(nil, sealed[1:1+ns], sealed[1+ns:], aad)
73+ if err != nil {
74+ return nil, ports.ErrSecretNotOpened
75+ }
76+ return plain, nil
77+}
78+
79+// KeyCheck is a value derived from the key that does not reveal it (an HMAC of
80+// a constant), stored beside the secrets so that the server can tell, at start,
81+// that it runs with another key than the one they were sealed with.
82+func (b *Box) KeyCheck() []byte { return append([]byte(nil), b.check...) }
A internal/adapters/secretbox/secretbox_test.go +106 -0
@@ -0,0 +1,106 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package secretbox_test
4+
5+import (
6+ "bytes"
7+ "strings"
8+ "testing"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "bnema.dev/ermite/internal/adapters/secretbox"
14+ "bnema.dev/ermite/internal/domain"
15+ "bnema.dev/ermite/internal/ports"
16+)
17+
18+func keyOf(b byte) []byte { return bytes.Repeat([]byte{b}, secretbox.KeyLen) }
19+
20+func box(t *testing.T, b byte) *secretbox.Box {
21+ t.Helper()
22+ x, err := secretbox.New(keyOf(b))
23+ require.NoError(t, err)
24+ return x
25+}
26+
27+func TestSealOpenRoundTrip(t *testing.T) {
28+ t.Parallel()
29+ b := box(t, 1)
30+ repo, _ := domain.ParseRepoName("proj")
31+ aad := domain.SecretAAD(domain.RepoScope(repo), "TOKEN")
32+ for _, plain := range []string{"x", "multi\nline\r\nvalue", strings.Repeat("é", 20000), "\x00\xff"} {
33+ sealed := b.Seal([]byte(plain), aad)
34+ assert.NotContains(t, string(sealed), plain, "the value is not stored in clear")
35+ got, err := b.Open(sealed, aad)
36+ require.NoError(t, err)
37+ assert.Equal(t, plain, string(got))
38+ }
39+}
40+
41+func TestEachValueHasItsOwnNonce(t *testing.T) {
42+ t.Parallel()
43+ b := box(t, 1)
44+ a := b.Seal([]byte("same"), []byte("aad"))
45+ c := b.Seal([]byte("same"), []byte("aad"))
46+ assert.NotEqual(t, a, c, "the same value sealed twice differs")
47+}
48+
49+// A sealed value moved to another secret (another name or another scope) does
50+// not open: the additional data binds it.
51+func TestAdditionalDataBindsScopeAndName(t *testing.T) {
52+ t.Parallel()
53+ b := box(t, 1)
54+ repo, _ := domain.ParseRepoName("proj")
55+ other, _ := domain.ParseRepoName("other")
56+ sealed := b.Seal([]byte("v"), domain.SecretAAD(domain.RepoScope(repo), "TOKEN"))
57+
58+ for name, aad := range map[string][]byte{
59+ "other name": domain.SecretAAD(domain.RepoScope(repo), "OTHER"),
60+ "instance scope": domain.SecretAAD(domain.InstanceScope(), "TOKEN"),
61+ "other repository": domain.SecretAAD(domain.RepoScope(other), "TOKEN"),
62+ "an environment": domain.SecretAAD(domain.EnvironmentScope(repo, "prod"), "TOKEN"),
63+ "no additional data": nil,
64+ } {
65+ _, err := b.Open(sealed, aad)
66+ assert.ErrorIs(t, err, ports.ErrSecretNotOpened, name)
67+ }
68+}
69+
70+func TestAnotherKeyOrDamagedValueDoesNotOpen(t *testing.T) {
71+ t.Parallel()
72+ a, other := box(t, 1), box(t, 2)
73+ sealed := a.Seal([]byte("v"), []byte("aad"))
74+ _, err := other.Open(sealed, []byte("aad"))
75+ assert.ErrorIs(t, err, ports.ErrSecretNotOpened)
76+
77+ for i := range sealed {
78+ damaged := bytes.Clone(sealed)
79+ damaged[i] ^= 1
80+ _, err := a.Open(damaged, []byte("aad"))
81+ assert.ErrorIs(t, err, ports.ErrSecretNotOpened, "byte %d", i)
82+ }
83+ for _, short := range [][]byte{nil, {1}, sealed[:10], sealed[:len(sealed)-1]} {
84+ _, err := a.Open(short, []byte("aad"))
85+ assert.ErrorIs(t, err, ports.ErrSecretNotOpened)
86+ }
87+}
88+
89+func TestKeyCheckTellsKeysApartWithoutRevealingThem(t *testing.T) {
90+ t.Parallel()
91+ a, a2, other := box(t, 1), box(t, 1), box(t, 2)
92+ assert.Equal(t, a.KeyCheck(), a2.KeyCheck(), "stable for a key")
93+ assert.NotEqual(t, a.KeyCheck(), other.KeyCheck())
94+ assert.NotContains(t, string(a.KeyCheck()), string(keyOf(1)))
95+ a.KeyCheck()[0] ^= 1
96+ assert.Equal(t, a2.KeyCheck(), a.KeyCheck(), "the caller cannot change the stored check")
97+}
98+
99+func TestNewRefusesAWrongKeyLengthWithoutEchoingIt(t *testing.T) {
100+ t.Parallel()
101+ for _, n := range []int{0, 16, 31, 33, 64} {
102+ _, err := secretbox.New(bytes.Repeat([]byte("k"), n))
103+ require.Error(t, err)
104+ assert.NotContains(t, err.Error(), "kkkk")
105+ }
106+}
M internal/app/api_helpers_test.go +8 -0
@@ -35,6 +35,10 @@ type clients struct {
3535 session ermiteconnect.SessionServiceClient
3636 runner ermiteconnect.RunnerServiceClient
3737 run ermiteconnect.RunServiceClient
38+
39+ secret ermiteconnect.SecretServiceClient
40+ vars ermiteconnect.VariableServiceClient
41+ env ermiteconnect.EnvironmentServiceClient
3842 }
3943
4044 // bearerTransport adds the Authorization header of a secret, if any.
@@ -85,6 +89,10 @@ func newClientsOn(hc connect.HTTPClient, base string, opts ...connect.ClientOpti
8589 session: ermiteconnect.NewSessionServiceClient(hc, base, opts...),
8690 runner: ermiteconnect.NewRunnerServiceClient(hc, base, opts...),
8791 run: ermiteconnect.NewRunServiceClient(hc, base, opts...),
92+
93+ secret: ermiteconnect.NewSecretServiceClient(hc, base, opts...),
94+ vars: ermiteconnect.NewVariableServiceClient(hc, base, opts...),
95+ env: ermiteconnect.NewEnvironmentServiceClient(hc, base, opts...),
8896 }
8997 }
9098
M internal/app/api_test.go +1 -1
@@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) {
196196 for _, who := range allPrincipals {
197197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
198198 require.NoError(t, err, who)
199- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries"}, resp.Msg.GetFeatures(), who)
199+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.secrets"}, resp.Msg.GetFeatures(), who)
200200 assert.NotEmpty(t, resp.Msg.GetVersion())
201201 }
202202 })
M internal/app/ci_flow_test.go +2 -2
@@ -324,14 +324,14 @@ func TestCIUnsupportedFeatureFailsTheJob(t *testing.T) {
324324 jobs:
325325 j:
326326 runs-on: ubuntu-latest
327- environment: production
327+ concurrency: group
328328 steps:
329329 - run: echo
330330 `}, "")
331331 c.waitFor("the run fails", func() bool { return len(c.runs()) == 1 && c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
332332 j := c.getRun(c.runs()[0].GetId()).GetJobs()[0]
333333 assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, j.GetConclusion())
334- assert.Equal(t, "not supported yet: environment", j.GetError())
334+ assert.Equal(t, "not supported yet: concurrency", j.GetError())
335335 }
336336
337337 // A job for a label no runner has is "waiting for a runner" and fails after the
M internal/app/ci_parity_test.go +2 -2
@@ -308,7 +308,8 @@ jobs:
308308 }
309309
310310 // CI-2: planning runs what CI-1 refused (uses, container, services, a step
311-// timeout) and still refuses job-level uses, environment and concurrency.
311+// timeout) and CI-3 environments, and still refuses job-level uses and
312+// concurrency.
312313 func TestCIPlanningAcceptsCI2Features(t *testing.T) {
313314 c := newCIHarness(t, nil)
314315 runner := c.registerRunner("alpha", "ubuntu-latest")
@@ -330,7 +331,6 @@ jobs:
330331
331332 for _, tc := range []struct{ name, job, want string }{
332333 {"reusable", "uses: ./.github/workflows/x.yml", "not supported yet: reusable workflows (uses at the job level)"},
333- {"environment", "runs-on: ubuntu-latest\n environment: prod\n steps:\n - run: echo", "not supported yet: environment"},
334334 {"concurrency", "runs-on: ubuntu-latest\n concurrency: g\n steps:\n - run: echo", "not supported yet: concurrency"},
335335 } {
336336 t.Run(tc.name, func(t *testing.T) {
A internal/app/ci_settings_test.go +876 -0
@@ -0,0 +1,876 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "context"
7+ "encoding/base64"
8+ "errors"
9+ "fmt"
10+ "net/http/httptest"
11+ "strings"
12+ "sync/atomic"
13+ "testing"
14+
15+ "connectrpc.com/connect"
16+ "github.com/stretchr/testify/assert"
17+ "github.com/stretchr/testify/require"
18+ "google.golang.org/protobuf/encoding/protojson"
19+ "google.golang.org/protobuf/proto"
20+
21+ pb "bnema.dev/ermite-api/gen/ermite"
22+ "bnema.dev/ermite/internal/config"
23+ "bnema.dev/ermite/internal/domain"
24+ "bnema.dev/ermite/internal/ports"
25+ "bnema.dev/ermite/internal/usecase"
26+)
27+
28+var secretsKeyA = base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef"))
29+var secretsKeyB = base64.StdEncoding.EncodeToString([]byte("fedcba9876543210fedcba9876543210"))
30+
31+// newSecretsHarness is a CI harness whose server has ERMITE_SECRETS_KEY.
32+func newSecretsHarness(t *testing.T, key string) *ciHarness {
33+ t.Helper()
34+ o := harnessOpts{}
35+ if key != "" {
36+ o.env = map[string]string{config.EnvSecretsKey: key}
37+ }
38+ h := newHarnessWith(t, o, nil)
39+ c := &ciHarness{harness: h, root: h.api(adminToken), repo: "proj"}
40+ _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name("proj"), Visibility: domain.VisibilityPrivate})
41+ require.NoError(t, err)
42+ c.wt = h.workTree("main")
43+ return c
44+}
45+
46+func instanceScope() *pb.SettingsScope {
47+ return &pb.SettingsScope{Where: &pb.SettingsScope_Instance{Instance: true}}
48+}
49+func repoScope(r string) *pb.SettingsScope {
50+ return &pb.SettingsScope{Where: &pb.SettingsScope_Repo{Repo: r}}
51+}
52+func envScope(r, e string) *pb.SettingsScope {
53+ return &pb.SettingsScope{Where: &pb.SettingsScope_Environment{Environment: &pb.EnvironmentRef{Repo: r, Name: e}}}
54+}
55+
56+func (c *ciHarness) setSecret(sc *pb.SettingsScope, name, value string) {
57+ c.t.Helper()
58+ _, err := c.root.secret.SetSecret(c.ctx, connect.NewRequest(&pb.SetSecretRequest{Scope: sc, Name: name, Value: value}))
59+ require.NoError(c.t, err)
60+}
61+
62+func (c *ciHarness) setVar(sc *pb.SettingsScope, name, value string) {
63+ c.t.Helper()
64+ _, err := c.root.vars.SetVariable(c.ctx, connect.NewRequest(&pb.SetVariableRequest{Scope: sc, Name: name, Value: value}))
65+ require.NoError(c.t, err)
66+}
67+
68+const settingsWorkflow = `name: s
69+on:
70+ push:
71+ branches: ['**']
72+env:
73+ FROM_WORKFLOW: ${{ secrets.WF_SECRET }}
74+jobs:
75+ plain:
76+ runs-on: ubuntu-latest
77+ steps:
78+ - run: echo ${{ vars.GREETING }}
79+ uses-one:
80+ runs-on: ubuntu-latest
81+ steps:
82+ - run: echo "$TOKEN"
83+ env:
84+ TOKEN: ${{ secrets.api_token }}
85+ OTHER: ${{ secrets['Bracket_One'] }}
86+ uses-all:
87+ runs-on: ubuntu-latest
88+ steps:
89+ - run: echo ${{ toJSON(secrets) }}
90+ deploy:
91+ runs-on: ubuntu-latest
92+ environment: Production
93+ steps:
94+ - run: echo ${{ secrets.ENV_ONLY }} ${{ vars.GREETING }}
95+`
96+
97+// jobsByKey fetches jobs from the runner until it holds one per key.
98+func (r *fakeRunner) jobsByKey(want int) map[string]*pb.Job {
99+ r.c.t.Helper()
100+ out := map[string]*pb.Job{}
101+ for len(out) < want {
102+ j := r.await()
103+ out[j.GetJobKey()] = j
104+ }
105+ return out
106+}
107+
108+func secretNames(j *pb.Job) []string {
109+ var out []string
110+ for k := range j.GetSecrets() {
111+ out = append(out, k)
112+ }
113+ return out
114+}
115+
116+// AC1/AC2: what a job receives. The owner's run gets the secrets its job
117+// refers to and no others, variables merged with environment over repository
118+// over instance, and the environment is created on first use.
119+func TestCIJobReceivesOnlyTheSecretsItRefersTo(t *testing.T) {
120+ c := newSecretsHarness(t, secretsKeyA)
121+ runner := c.registerRunner("alpha", "ubuntu-latest")
122+
123+ c.setSecret(instanceScope(), "api_token", "instance-token")
124+ c.setSecret(repoScope("proj"), "API_TOKEN", "repo-token")
125+ c.setSecret(repoScope("proj"), "bracket_one", "bracket-value")
126+ c.setSecret(repoScope("proj"), "WF_SECRET", "workflow-level-secret")
127+ c.setSecret(repoScope("proj"), "UNUSED", "never-sent")
128+ c.setSecret(instanceScope(), "ENV_ONLY", "instance-env-only")
129+ c.setVar(instanceScope(), "GREETING", "from-instance")
130+ c.setVar(instanceScope(), "ONLY_INSTANCE", "i")
131+ c.setVar(repoScope("proj"), "GREETING", "from-repo")
132+
133+ c.push("main", map[string]string{".github/workflows/s.yml": settingsWorkflow}, "")
134+ jobs := runner.jobsByKey(4)
135+
136+ // A job that refers to no secret gets none, and the workflow-level env
137+ // counts for every job.
138+ assert.ElementsMatch(t, []string{"WF_SECRET"}, secretNames(jobs["plain"]))
139+ assert.Equal(t, "workflow-level-secret", jobs["plain"].GetSecrets()["WF_SECRET"])
140+
141+ // secrets.NAME and secrets['NAME'], case-insensitive, repository over instance.
142+ one := jobs["uses-one"].GetSecrets()
143+ assert.ElementsMatch(t, []string{"API_TOKEN", "BRACKET_ONE", "WF_SECRET"}, secretNames(jobs["uses-one"]))
144+ assert.Equal(t, "repo-token", one["API_TOKEN"], "repository over instance")
145+ assert.Equal(t, "bracket-value", one["BRACKET_ONE"])
146+ assert.NotContains(t, one, "UNUSED")
147+
148+ // toJSON(secrets) is any other use: every secret of the scopes.
149+ assert.ElementsMatch(t, []string{"API_TOKEN", "BRACKET_ONE", "WF_SECRET", "UNUSED", "ENV_ONLY"}, secretNames(jobs["uses-all"]))
150+
151+ // The serialized Job holds only the values that were sent: the unused
152+ // secret is nowhere in the bytes, and no job but the one that reads
153+ // everything holds it.
154+ for key, j := range jobs {
155+ raw, err := proto.Marshal(j)
156+ require.NoError(t, err)
157+ if key == "uses-all" {
158+ assert.Contains(t, string(raw), "never-sent")
159+ continue
160+ }
161+ assert.NotContains(t, string(raw), "never-sent", key)
162+ assert.NotContains(t, string(raw), "instance-token", key)
163+ }
164+
165+ // Variables are merged, and the same values are in the vars context.
166+ for key, j := range jobs {
167+ want := "from-repo"
168+ if key == "deploy" {
169+ assert.Equal(t, "Production", j.GetEnvironment())
170+ } else {
171+ assert.Empty(t, j.GetEnvironment(), key)
172+ }
173+ assert.Equal(t, want, j.GetVars()["GREETING"], key)
174+ assert.Equal(t, "i", j.GetVars()["ONLY_INSTANCE"], key)
175+ assert.Contains(t, j.GetContexts(), `"GREETING":"from-repo"`, key)
176+ assert.True(t, j.GetTrusted(), key)
177+ }
178+
179+ // The environment was created by its first use, and a job that names one
180+ // reads the secrets and variables set in it afterwards.
181+ envs, err := c.root.env.ListEnvironments(c.ctx, connect.NewRequest(&pb.ListEnvironmentsRequest{Repo: "proj"}))
182+ require.NoError(t, err)
183+ require.Len(t, envs.Msg.GetEnvironments(), 1)
184+ assert.Equal(t, "Production", envs.Msg.GetEnvironments()[0].GetName())
185+}
186+
187+func TestCIEnvironmentOverridesVariablesAndSecrets(t *testing.T) {
188+ c := newSecretsHarness(t, secretsKeyA)
189+ runner := c.registerRunner("alpha", "ubuntu-latest")
190+ _, err := c.root.env.CreateEnvironment(c.ctx, connect.NewRequest(&pb.CreateEnvironmentRequest{Repo: "proj", Name: "production"}))
191+ require.NoError(t, err)
192+
193+ c.setSecret(instanceScope(), "ENV_ONLY", "instance")
194+ c.setSecret(repoScope("proj"), "ENV_ONLY", "repo")
195+ c.setSecret(envScope("proj", "PRODUCTION"), "ENV_ONLY", "environment")
196+ c.setVar(instanceScope(), "GREETING", "instance")
197+ c.setVar(repoScope("proj"), "GREETING", "repo")
198+ c.setVar(envScope("proj", "Production"), "GREETING", "environment")
199+
200+ c.push("main", map[string]string{".github/workflows/s.yml": settingsWorkflow}, "")
201+ jobs := runner.jobsByKey(4)
202+ deploy := jobs["deploy"]
203+ assert.Equal(t, "environment", deploy.GetSecrets()["ENV_ONLY"], "environment over repository over instance")
204+ assert.Equal(t, "environment", deploy.GetVars()["GREETING"])
205+ assert.Equal(t, "repo", jobs["plain"].GetVars()["GREETING"], "a job without the environment does not see its variables")
206+ assert.NotContains(t, jobs["plain"].GetSecrets(), "ENV_ONLY")
207+ assert.Contains(t, deploy.GetContexts(), `"GREETING":"environment"`)
208+ envs, err := c.root.env.ListEnvironments(c.ctx, connect.NewRequest(&pb.ListEnvironmentsRequest{Repo: "proj"}))
209+ require.NoError(t, err)
210+ assert.Len(t, envs.Msg.GetEnvironments(), 1, "an existing environment is not created again, whatever the case")
211+}
212+
213+// D1 / AC1: the run of a token limited to branch prefixes is not trusted: no
214+// secret, and an owner who runs it again does not unlock them. An owner's run
215+// that an agent runs again loses them too.
216+func TestCIUntrustedRunsGetNoSecretsAndRerunsNeverRaiseTrust(t *testing.T) {
217+ c := newSecretsHarness(t, secretsKeyA)
218+ runner := c.registerRunner("alpha", "ubuntu-latest")
219+ c.setSecret(repoScope("proj"), "API_TOKEN", "repo-token")
220+ c.setSecret(repoScope("proj"), "WF_SECRET", "wf")
221+ c.setVar(repoScope("proj"), "GREETING", "hello")
222+
223+ agent, _ := c.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeCIWrite), []string{"agent/x/"}, "proj")
224+ agentAPI := c.api(agent)
225+
226+ // The owner's push: trusted.
227+ c.push("main", map[string]string{".github/workflows/s.yml": settingsWorkflow}, "")
228+ for _, j := range runner.jobsByKey(4) {
229+ assert.True(t, j.GetTrusted())
230+ assert.NotEmpty(t, j.GetSecrets(), j.GetJobKey())
231+ runner.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
232+ }
233+ c.waitFor("main done", func() bool { return len(c.runs()) == 1 && c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
234+ assert.True(t, c.runs()[0].GetTrusted(), "Run.trusted")
235+ mainRun := c.runs()[0]
236+
237+ // The agent's push: not trusted. It still gets the variables.
238+ c.pushAs(agent, "agent/x/a", map[string]string{"f.txt": "x"})
239+ c.waitFor("the agent run", func() bool { return len(c.runs()) == 2 })
240+ var agentRun *pb.Run
241+ for _, r := range c.runs() {
242+ if r.GetRef() == "refs/heads/agent/x/a" {
243+ agentRun = r
244+ }
245+ }
246+ require.NotNil(t, agentRun)
247+ assert.False(t, agentRun.GetTrusted())
248+ for _, j := range runner.jobsByKey(4) {
249+ assert.False(t, j.GetTrusted(), j.GetJobKey())
250+ assert.Empty(t, j.GetSecrets(), "no secret for an agent's run: "+j.GetJobKey())
251+ raw, err := proto.Marshal(j)
252+ require.NoError(t, err)
253+ assert.NotContains(t, string(raw), "repo-token", "untrusted: no secret value anywhere in the job")
254+ assert.Equal(t, "hello", j.GetVars()["GREETING"], "variables are not secret")
255+ runner.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
256+ }
257+ c.waitFor("agent run done", func() bool { return c.getRun(agentRun.GetId()).GetRun().GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
258+
259+ // The owner re-runs the agent's run: still not trusted.
260+ _, err := c.root.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: agentRun.GetId()}))
261+ require.NoError(t, err)
262+ assert.False(t, c.getRun(agentRun.GetId()).GetRun().GetTrusted(), "a re-run never raises trust")
263+ for _, j := range runner.jobsByKey(4) {
264+ assert.Empty(t, j.GetSecrets(), "the owner re-running an agent's run does not unlock its secrets: "+j.GetJobKey())
265+ assert.False(t, j.GetTrusted())
266+ runner.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
267+ }
268+
269+ // An agent re-running the owner's run on its branch: only the branches under
270+ // its prefix are allowed, so a re-run of main is refused, and the trusted
271+ // flag of main is untouched.
272+ _, err = agentAPI.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: mainRun.GetId()}))
273+ assert.Equal(t, connect.CodePermissionDenied, connect.CodeOf(err))
274+ assert.True(t, c.getRun(mainRun.GetId()).GetRun().GetTrusted())
275+
276+ // The owner re-running their own run keeps it.
277+ _, err = c.root.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: mainRun.GetId()}))
278+ require.NoError(t, err)
279+ assert.True(t, c.getRun(mainRun.GetId()).GetRun().GetTrusted())
280+ for _, j := range runner.jobsByKey(4) {
281+ assert.NotEmpty(t, j.GetSecrets(), j.GetJobKey())
282+ runner.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
283+ }
284+}
285+
286+// Without ERMITE_SECRETS_KEY secrets cannot be set (failed_precondition) and
287+// jobs get none; variables and environments work.
288+func TestCIWithoutASecretsKey(t *testing.T) {
289+ c := newSecretsHarness(t, "")
290+ runner := c.registerRunner("alpha", "ubuntu-latest")
291+ _, err := c.root.secret.SetSecret(c.ctx, connect.NewRequest(&pb.SetSecretRequest{Scope: repoScope("proj"), Name: "A", Value: "v"}))
292+ assert.Equal(t, connect.CodeFailedPrecondition, connect.CodeOf(err))
293+ c.setVar(repoScope("proj"), "GREETING", "still works")
294+ _, err = c.root.secret.ListSecrets(c.ctx, connect.NewRequest(&pb.ListSecretsRequest{Scope: repoScope("proj")}))
295+ require.NoError(t, err, "names can be listed")
296+
297+ c.push("main", map[string]string{".github/workflows/s.yml": settingsWorkflow}, "")
298+ for _, j := range runner.jobsByKey(4) {
299+ assert.Empty(t, j.GetSecrets(), j.GetJobKey())
300+ assert.Equal(t, "still works", j.GetVars()["GREETING"])
301+ }
302+}
303+
304+// The server refuses to start with another key than the one the secrets were
305+// sealed with, and starts again with the right one or after they are gone.
306+func TestAnotherSecretsKeyRefusesToStart(t *testing.T) {
307+ c := newSecretsHarness(t, secretsKeyA)
308+ c.setSecret(repoScope("proj"), "A", "value")
309+
310+ cfg := c.forge.cfg
311+ c.srv.Close()
312+ c.forge.Close(c.ctx)
313+
314+ other, err := base64.StdEncoding.DecodeString(secretsKeyB)
315+ require.NoError(t, err)
316+ bad := cfg
317+ bad.SecretsKey = other
318+ _, err = newForge(c.ctx, bad, options{hookExe: binary(t)})
319+ require.ErrorIs(t, err, domain.ErrSecretsKeyMismatch)
320+ assert.NotContains(t, err.Error(), secretsKeyB)
321+ assert.NotContains(t, err.Error(), secretsKeyA)
322+
323+ f, err := newForge(c.ctx, cfg, options{hookExe: binary(t)})
324+ require.NoError(t, err, "the right key starts")
325+ srv := httptest.NewServer(f.server.Handler())
326+ t.Cleanup(func() { srv.Close(); f.Close(context.WithoutCancel(c.ctx)) })
327+ list, err := newClients(srv.URL+"/-/api", adminToken).secret.ListSecrets(c.ctx, connect.NewRequest(&pb.ListSecretsRequest{Scope: repoScope("proj")}))
328+ require.NoError(t, err)
329+ assert.Len(t, list.Msg.GetSecrets(), 1)
330+}
331+
332+// Secret values never appear in a response of the settings services, in the
333+// ermite log, or in the run record.
334+func TestCISecretValuesAreNeverReturnedOrLogged(t *testing.T) {
335+ c := newSecretsHarness(t, secretsKeyA)
336+ runner := c.registerRunner("alpha", "ubuntu-latest")
337+ const value = "SUPER-SECRET-VALUE-0123456789"
338+ c.setSecret(instanceScope(), "I", value+"-i")
339+ c.setSecret(repoScope("proj"), "R", value+"-r")
340+ _, err := c.root.env.CreateEnvironment(c.ctx, connect.NewRequest(&pb.CreateEnvironmentRequest{Repo: "proj", Name: "prod"}))
341+ require.NoError(t, err)
342+ c.setSecret(envScope("proj", "prod"), "E", value+"-e")
343+
344+ var seen []proto.Message
345+ set, err := c.root.secret.SetSecret(c.ctx, connect.NewRequest(&pb.SetSecretRequest{Scope: repoScope("proj"), Name: "r", Value: value + "-r2"}))
346+ require.NoError(t, err)
347+ seen = append(seen, set.Msg)
348+ for _, sc := range []*pb.SettingsScope{instanceScope(), repoScope("proj"), envScope("proj", "PROD")} {
349+ list, err := c.root.secret.ListSecrets(c.ctx, connect.NewRequest(&pb.ListSecretsRequest{Scope: sc}))
350+ require.NoError(t, err)
351+ require.NotEmpty(t, list.Msg.GetSecrets())
352+ seen = append(seen, list.Msg)
353+ for _, s := range list.Msg.GetSecrets() {
354+ assert.Equal(t, strings.ToUpper(s.GetName()), s.GetName(), "names are stored in upper case")
355+ assert.NotNil(t, s.GetUpdatedAt())
356+ }
357+ }
358+ for _, m := range seen {
359+ assert.NotContains(t, protojson.Format(m), value)
360+ }
361+
362+ // An invalid request echoes no value either.
363+ _, err = c.root.secret.SetSecret(c.ctx, connect.NewRequest(&pb.SetSecretRequest{Scope: repoScope("proj"), Name: "GITHUB_X", Value: value}))
364+ require.Error(t, err)
365+ assert.NotContains(t, err.Error(), value)
366+
367+ c.push("main", map[string]string{".github/workflows/s.yml": "on: push\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo ${{ secrets.R }}\n"}, "")
368+ job := runner.await()
369+ assert.Equal(t, value+"-r2", job.GetSecrets()["R"])
370+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
371+ c.waitFor("done", func() bool { return len(c.runs()) == 1 && c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
372+
373+ // Nothing the owner can read about the run holds the value.
374+ run := c.getRun(c.runs()[0].GetId())
375+ assert.NotContains(t, protojson.Format(run), value)
376+ src, err := c.forge.store.CI().GetRunSource(c.ctx, c.runs()[0].GetId())
377+ require.NoError(t, err)
378+ assert.NotContains(t, src.GithubJSON, value)
379+ assert.NotContains(t, src.WorkflowYAML, value)
380+ jobs, err := c.forge.store.CI().ListJobs(c.ctx, c.runs()[0].GetId(), 1)
381+ require.NoError(t, err)
382+ for _, j := range jobs {
383+ assert.NotContains(t, j.ContextsJSON, value)
384+ }
385+ assert.NotContains(t, c.logs.String(), value, "the ermite log")
386+}
387+
388+// Branch-limited tokens never manage secrets, whatever their scopes (ADR 0027);
389+// a token needs secrets:write on the repository, and the instance scope needs
390+// one that is not limited to repositories.
391+func TestSettingsServicesAccessRules(t *testing.T) {
392+ c := newSecretsHarness(t, secretsKeyA)
393+ _, err := c.forge.Repos.Create(c.ctx, domain.Root(), ports.CreateRepoParams{Name: c.name("other"), Visibility: domain.VisibilityPublic})
394+ require.NoError(t, err)
395+
396+ limited, _ := c.branchToken(scopes(domain.ScopeSecretsWrite, domain.ScopeRepoRead), []string{"agent/"})
397+ reader := c.token(scopes(domain.ScopeRepoRead), "proj")
398+ writer := c.token(scopes(domain.ScopeSecretsWrite, domain.ScopeRepoRead), "proj")
399+ writerAll := c.token(scopes(domain.ScopeSecretsWrite))
400+
401+ code := func(secret string, sc *pb.SettingsScope) (set, list, vset, vget, del connect.Code) {
402+ api := c.api(secret)
403+ codeOf := func(err error) connect.Code {
404+ if err == nil {
405+ return 0 // ok
406+ }
407+ return connect.CodeOf(err)
408+ }
409+ _, err := api.secret.SetSecret(c.ctx, connect.NewRequest(&pb.SetSecretRequest{Scope: sc, Name: "A", Value: "v"}))
410+ set = codeOf(err)
411+ _, err = api.secret.ListSecrets(c.ctx, connect.NewRequest(&pb.ListSecretsRequest{Scope: sc}))
412+ list = codeOf(err)
413+ _, err = api.vars.SetVariable(c.ctx, connect.NewRequest(&pb.SetVariableRequest{Scope: sc, Name: "A", Value: "v"}))
414+ vset = codeOf(err)
415+ _, err = api.vars.GetVariable(c.ctx, connect.NewRequest(&pb.GetVariableRequest{Scope: sc, Name: "A"}))
416+ vget = codeOf(err)
417+ _, err = api.secret.DeleteSecret(c.ctx, connect.NewRequest(&pb.DeleteSecretRequest{Scope: sc, Name: "ZZ"}))
418+ del = codeOf(err)
419+ return
420+ }
421+ allOf := func(want connect.Code, got ...connect.Code) {
422+ t.Helper()
423+ for i, g := range got {
424+ assert.Equal(t, want, g, "call %d", i)
425+ }
426+ }
427+
428+ // A branch-limited token with secrets:write is refused everywhere.
429+ for _, sc := range []*pb.SettingsScope{repoScope("proj"), instanceScope(), envScope("proj", "x")} {
430+ a, b, d, e, f := code(limited, sc)
431+ allOf(connect.CodePermissionDenied, a, b, d, e, f)
432+ }
433+ _, err = c.api(limited).env.CreateEnvironment(c.ctx, connect.NewRequest(&pb.CreateEnvironmentRequest{Repo: "proj", Name: "x"}))
434+ assert.Equal(t, connect.CodePermissionDenied, connect.CodeOf(err))
435+
436+ // Without secrets:write, a repository the token reads: permission denied.
437+ a, b, d, e, f := code(reader, repoScope("proj"))
438+ allOf(connect.CodePermissionDenied, a, b, d, e, f)
439+ // Anonymous.
440+ a, b, d, e, f = code("", repoScope("proj"))
441+ allOf(connect.CodeUnauthenticated, a, b, d, e, f)
442+ // With it: allowed (the delete of a missing secret is not found).
443+ a, b, d, e, f = code(writer, repoScope("proj"))
444+ assert.Equal(t, []connect.Code{0, 0, 0, 0, connect.CodeNotFound}, []connect.Code{a, b, d, e, f})
445+ // The writer is limited to one repository: not the instance, not another one.
446+ a, b, d, e, f = code(writer, instanceScope())
447+ allOf(connect.CodePermissionDenied, a, b, d, e, f)
448+ a, b, d, e, f = code(writer, repoScope("other"))
449+ allOf(connect.CodePermissionDenied, a, b, d, e, f) // can read it (public), may not write there
450+ // Not restricted to repositories: the instance scope works.
451+ a, b, d, e, f = code(writerAll, instanceScope())
452+ assert.Equal(t, []connect.Code{0, 0, 0, 0, connect.CodeNotFound}, []connect.Code{a, b, d, e, f})
453+
454+ // A repository the caller cannot see is not found.
455+ _, err = c.forge.Repos.Create(c.ctx, domain.Root(), ports.CreateRepoParams{Name: c.name("hidden"), Visibility: domain.VisibilityPrivate})
456+ require.NoError(t, err)
457+ a, b, d, e, f = code(writer, repoScope("hidden"))
458+ allOf(connect.CodeNotFound, a, b, d, e, f)
459+ a, _, _, _, _ = code(writer, repoScope("ghost"))
460+ assert.Equal(t, connect.CodeNotFound, a)
461+
462+ // Validation.
463+ root := c.root
464+ _, err = root.secret.SetSecret(c.ctx, connect.NewRequest(&pb.SetSecretRequest{Scope: repoScope("proj"), Name: "1BAD", Value: "v"}))
465+ assert.Equal(t, connect.CodeInvalidArgument, connect.CodeOf(err))
466+ _, err = root.secret.SetSecret(c.ctx, connect.NewRequest(&pb.SetSecretRequest{Scope: repoScope("proj"), Name: "github_token", Value: "v"}))
467+ assert.Equal(t, connect.CodeInvalidArgument, connect.CodeOf(err))
468+ _, err = root.secret.SetSecret(c.ctx, connect.NewRequest(&pb.SetSecretRequest{Scope: repoScope("proj"), Name: "OK", Value: ""}))
469+ assert.Equal(t, connect.CodeInvalidArgument, connect.CodeOf(err), "an empty secret")
470+ _, err = root.secret.SetSecret(c.ctx, connect.NewRequest(&pb.SetSecretRequest{Scope: repoScope("proj"), Name: "OK", Value: strings.Repeat("x", 49153)}))
471+ assert.Equal(t, connect.CodeInvalidArgument, connect.CodeOf(err), "over 48 KiB")
472+ _, err = root.secret.SetSecret(c.ctx, connect.NewRequest(&pb.SetSecretRequest{Scope: repoScope("proj"), Name: "OK", Value: strings.Repeat("x", 49152)}))
473+ assert.NoError(t, err, "exactly 48 KiB")
474+ _, err = root.secret.SetSecret(c.ctx, connect.NewRequest(&pb.SetSecretRequest{Name: "OK", Value: "v"}))
475+ assert.Equal(t, connect.CodeInvalidArgument, connect.CodeOf(err), "no scope")
476+ _, err = root.secret.SetSecret(c.ctx, connect.NewRequest(&pb.SetSecretRequest{Scope: &pb.SettingsScope{Where: &pb.SettingsScope_Instance{}}, Name: "OK", Value: "v"}))
477+ assert.Equal(t, connect.CodeInvalidArgument, connect.CodeOf(err), "instance: false")
478+ _, err = root.vars.SetVariable(c.ctx, connect.NewRequest(&pb.SetVariableRequest{Scope: repoScope("proj"), Name: "EMPTY", Value: ""}))
479+ assert.NoError(t, err, "an empty variable is allowed")
480+ _, err = root.secret.SetSecret(c.ctx, connect.NewRequest(&pb.SetSecretRequest{Scope: envScope("proj", "missing"), Name: "OK", Value: "v"}))
481+ assert.Equal(t, connect.CodeNotFound, connect.CodeOf(err), "an environment that does not exist")
482+ _, err = root.env.CreateEnvironment(c.ctx, connect.NewRequest(&pb.CreateEnvironmentRequest{Repo: "proj", Name: "bad\nname"}))
483+ assert.Equal(t, connect.CodeInvalidArgument, connect.CodeOf(err))
484+ _, err = root.env.DeleteEnvironment(c.ctx, connect.NewRequest(&pb.DeleteEnvironmentRequest{Repo: "proj", Name: "missing"}))
485+ assert.Equal(t, connect.CodeNotFound, connect.CodeOf(err))
486+}
487+
488+func TestSettingsServicesPagingAndCrud(t *testing.T) {
489+ c := newSecretsHarness(t, secretsKeyA)
490+ for _, n := range []string{"c", "a", "b"} {
491+ c.setSecret(repoScope("proj"), n, "v")
492+ c.setVar(repoScope("proj"), n, "val-"+n)
493+ }
494+ var names []string
495+ token := ""
496+ for range 5 {
497+ res, err := c.root.secret.ListSecrets(c.ctx, connect.NewRequest(&pb.ListSecretsRequest{Scope: repoScope("proj"), PageSize: 2, PageToken: token}))
498+ require.NoError(t, err)
499+ for _, s := range res.Msg.GetSecrets() {
500+ names = append(names, s.GetName())
501+ }
502+ if token = res.Msg.GetNextPageToken(); token == "" {
503+ break
504+ }
505+ }
506+ assert.Equal(t, []string{"A", "B", "C"}, names)
507+
508+ v, err := c.root.vars.GetVariable(c.ctx, connect.NewRequest(&pb.GetVariableRequest{Scope: repoScope("proj"), Name: "b"}))
509+ require.NoError(t, err)
510+ assert.Equal(t, "val-b", v.Msg.GetVariable().GetValue())
511+ vl, err := c.root.vars.ListVariables(c.ctx, connect.NewRequest(&pb.ListVariablesRequest{Scope: repoScope("proj")}))
512+ require.NoError(t, err)
513+ require.Len(t, vl.Msg.GetVariables(), 3)
514+ assert.Equal(t, "A", vl.Msg.GetVariables()[0].GetName())
515+ _, err = c.root.vars.DeleteVariable(c.ctx, connect.NewRequest(&pb.DeleteVariableRequest{Scope: repoScope("proj"), Name: "A"}))
516+ require.NoError(t, err)
517+ _, err = c.root.vars.GetVariable(c.ctx, connect.NewRequest(&pb.GetVariableRequest{Scope: repoScope("proj"), Name: "a"}))
518+ assert.Equal(t, connect.CodeNotFound, connect.CodeOf(err))
519+ _, err = c.root.secret.DeleteSecret(c.ctx, connect.NewRequest(&pb.DeleteSecretRequest{Scope: repoScope("proj"), Name: "b"}))
520+ require.NoError(t, err)
521+ _, err = c.root.secret.ListSecrets(c.ctx, connect.NewRequest(&pb.ListSecretsRequest{Scope: repoScope("proj"), PageToken: "garbage"}))
522+ assert.Equal(t, connect.CodeInvalidArgument, connect.CodeOf(err))
523+
524+ // Environments: create twice (any case), list, delete with their content.
525+ e1, err := c.root.env.CreateEnvironment(c.ctx, connect.NewRequest(&pb.CreateEnvironmentRequest{Repo: "proj", Name: "Staging"}))
526+ require.NoError(t, err)
527+ e2, err := c.root.env.CreateEnvironment(c.ctx, connect.NewRequest(&pb.CreateEnvironmentRequest{Repo: "proj", Name: "STAGING"}))
528+ require.NoError(t, err)
529+ assert.Equal(t, "Staging", e2.Msg.GetEnvironment().GetName(), "the case it was given is kept")
530+ assert.Equal(t, e1.Msg.GetEnvironment().GetCreatedAt().AsTime(), e2.Msg.GetEnvironment().GetCreatedAt().AsTime())
531+ c.setSecret(envScope("proj", "staging"), "S", "v")
532+ _, err = c.root.env.DeleteEnvironment(c.ctx, connect.NewRequest(&pb.DeleteEnvironmentRequest{Repo: "proj", Name: "staging"}))
533+ require.NoError(t, err)
534+ _, err = c.root.secret.ListSecrets(c.ctx, connect.NewRequest(&pb.ListSecretsRequest{Scope: envScope("proj", "Staging")}))
535+ assert.Equal(t, connect.CodeNotFound, connect.CodeOf(err))
536+
537+ // Deleting the repository removes its scopes.
538+ require.NoError(t, c.forge.Repos.Delete(c.ctx, domain.Root(), c.name("proj")))
539+ _, err = c.forge.Repos.Create(c.ctx, domain.Root(), ports.CreateRepoParams{Name: c.name("proj"), Visibility: domain.VisibilityPrivate})
540+ require.NoError(t, err)
541+ res, err := c.root.secret.ListSecrets(c.ctx, connect.NewRequest(&pb.ListSecretsRequest{Scope: repoScope("proj")}))
542+ require.NoError(t, err)
543+ assert.Empty(t, res.Msg.GetSecrets())
544+ vl, err = c.root.vars.ListVariables(c.ctx, connect.NewRequest(&pb.ListVariablesRequest{Scope: repoScope("proj")}))
545+ require.NoError(t, err)
546+ assert.Empty(t, vl.Msg.GetVariables())
547+}
548+
549+// environment: as a mapping (the url is ignored) and as an expression of the
550+// matrix; an untrusted run does not add environments to the repository.
551+func TestCIEnvironmentForms(t *testing.T) {
552+ c := newSecretsHarness(t, secretsKeyA)
553+ runner := c.registerRunner("alpha", "ubuntu-latest")
554+ _, err := c.root.vars.SetVariable(c.ctx, connect.NewRequest(&pb.SetVariableRequest{Scope: envScope("proj", "alpha"), Name: "V", Value: "x"}))
555+ require.Equal(t, connect.CodeNotFound, connect.CodeOf(err), "not an environment yet")
556+ _, err = c.root.env.CreateEnvironment(c.ctx, connect.NewRequest(&pb.CreateEnvironmentRequest{Repo: "proj", Name: "Alpha"}))
557+ require.NoError(t, err)
558+ c.setVar(envScope("proj", "alpha"), "V", "from-alpha")
559+
560+ c.push("main", map[string]string{".github/workflows/e.yml": `on: push
561+jobs:
562+ mapping:
563+ runs-on: ubuntu-latest
564+ environment:
565+ name: alpha
566+ url: https://example.test
567+ steps:
568+ - run: echo
569+ matrix:
570+ runs-on: ubuntu-latest
571+ strategy:
572+ matrix:
573+ env: [Alpha, beta]
574+ environment: ${{ matrix.env }}
575+ steps:
576+ - run: echo
577+`}, "")
578+ jobs := map[string]*pb.Job{}
579+ for len(jobs) < 3 {
580+ j := runner.await()
581+ key := j.GetJobKey() + "/" + j.GetEnvironment()
582+ jobs[key] = j
583+ }
584+ assert.Equal(t, "from-alpha", jobs["mapping/alpha"].GetVars()["V"])
585+ assert.Equal(t, "from-alpha", jobs["matrix/Alpha"].GetVars()["V"])
586+ assert.NotContains(t, jobs["matrix/beta"].GetVars(), "V")
587+
588+ names := func() []string {
589+ res, err := c.root.env.ListEnvironments(c.ctx, connect.NewRequest(&pb.ListEnvironmentsRequest{Repo: "proj"}))
590+ require.NoError(t, err)
591+ var out []string
592+ for _, e := range res.Msg.GetEnvironments() {
593+ out = append(out, e.GetName())
594+ }
595+ return out
596+ }
597+ assert.Equal(t, []string{"Alpha", "beta"}, names(), "beta was created on first use; alpha already existed")
598+
599+ // An agent's run names a new environment: it is not created.
600+ agent, _ := c.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite), []string{"agent/x/"}, "proj")
601+ c.pushAs(agent, "agent/x/a", map[string]string{".github/workflows/e2.yml": "on: push\njobs:\n j:\n runs-on: ubuntu-latest\n environment: agent-made\n steps:\n - run: echo\n"})
602+ var seen *pb.Job
603+ for seen == nil || seen.GetEnvironment() != "agent-made" {
604+ seen = runner.await()
605+ }
606+ assert.False(t, seen.GetTrusted())
607+ assert.Equal(t, []string{"Alpha", "beta"}, names())
608+}
609+
610+// flakySettings fails the next n resolutions, then works again.
611+type flakySettings struct {
612+ inner ports.CIJobSettings
613+ fail atomic.Int32
614+ // err is what a failure returns; nil is a plain error.
615+ err error
616+}
617+
618+func (f *flakySettings) Resolve(ctx context.Context, req ports.JobSettingsRequest) (ports.JobSettings, error) {
619+ if f.fail.Add(-1) >= 0 {
620+ if f.err != nil {
621+ return ports.JobSettings{}, f.err
622+ }
623+ return ports.JobSettings{}, errors.New("injected failure")
624+ }
625+ f.fail.Store(0)
626+ return f.inner.Resolve(ctx, req)
627+}
628+
629+func newFlakyHarness(t *testing.T) (*ciHarness, *flakySettings) {
630+ t.Helper()
631+ flaky := &flakySettings{}
632+ h := newHarnessWith(t, harnessOpts{env: map[string]string{config.EnvSecretsKey: secretsKeyA}}, func(o *options) {
633+ o.ci = func(cfg *usecase.CIConfig) { flaky.inner = cfg.Settings; cfg.Settings = flaky }
634+ })
635+ c := &ciHarness{harness: h, root: h.api(adminToken), repo: "proj"}
636+ _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name("proj"), Visibility: domain.VisibilityPrivate})
637+ require.NoError(t, err)
638+ c.wt = h.workTree("main")
639+ return c, flaky
640+}
641+
642+// jobRecord reads the stored record of the only job.
643+func (c *ciHarness) jobRecord(runID int64) domain.CIJob {
644+ c.t.Helper()
645+ jobs, err := c.forge.store.CI().ListJobs(c.ctx, runID, 1)
646+ require.NoError(c.t, err)
647+ require.Len(c.t, jobs, 1)
648+ return jobs[0]
649+}
650+
651+const oneSecretJob = "on: push\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo ${{ secrets.A }}\n"
652+
653+// A settings resolver that fails at the assignment leaves the job ready: no
654+// write, no token; the runner's retry gets it. The same holds for the repeat of
655+// a poll, which reads the settings again.
656+func TestCIAssignmentSurvivesASettingsFailure(t *testing.T) {
657+ c, flaky := newFlakyHarness(t)
658+ runner := c.registerRunner("alpha", "ubuntu-latest")
659+ c.setSecret(repoScope("proj"), "A", "value")
660+ c.push("main", map[string]string{".github/workflows/s.yml": oneSecretJob}, "")
661+ c.waitFor("a ready job", func() bool {
662+ ready, err := c.forge.store.CI().ListReadyJobs(c.ctx, 0)
663+ return err == nil && len(ready) == 1
664+ })
665+ runID := c.runs()[0].GetId()
666+ before := c.jobRecord(runID)
667+ require.Equal(t, domain.CIPhaseReady, before.Phase)
668+
669+ flaky.fail.Store(1)
670+ _, err := runner.fetch("")
671+ require.Error(t, err, "the poll fails instead of losing the job")
672+ after := c.jobRecord(runID)
673+ assert.Equal(t, domain.CIPhaseReady, after.Phase)
674+ assert.Equal(t, before.Version, after.Version, "no assignment write")
675+ assert.False(t, after.HasToken(), "no job token")
676+ ready, err := c.forge.store.CI().ListReadyJobs(c.ctx, 0)
677+ require.NoError(t, err)
678+ assert.Len(t, ready, 1, "still in the queue")
679+ assert.Zero(t, c.repoTokens(), "no repository token issued")
680+
681+ // The retry works.
682+ job, err := runner.fetch("poll-1")
683+ require.NoError(t, err)
684+ require.NotNil(t, job)
685+ assert.Equal(t, "value", job.GetSecrets()["A"])
686+ assigned := c.jobRecord(runID)
687+ assert.Equal(t, domain.CIPhaseAssigned, assigned.Phase)
688+ assert.Equal(t, 1, c.repoTokens())
689+
690+ // A repeat of the poll reads the settings again: a failure is an error, the
691+ // assignment stays, and the next repeat returns the very same job.
692+ flaky.fail.Store(1)
693+ _, err = runner.fetch("poll-1")
694+ require.Error(t, err)
695+ same := c.jobRecord(runID)
696+ assert.Equal(t, assigned.Version, same.Version)
697+ assert.Equal(t, assigned.TokenHash, same.TokenHash)
698+ assert.Equal(t, 1, c.repoTokens())
699+ again, err := runner.fetch("poll-1")
700+ require.NoError(t, err)
701+ require.NotNil(t, again)
702+ assert.Equal(t, job.GetJobToken(), again.GetJobToken())
703+ assert.Equal(t, job.GetGithubToken(), again.GetGithubToken())
704+ assert.Equal(t, "value", again.GetSecrets()["A"])
705+}
706+
707+// repoTokens counts the job repository tokens stored for the repository.
708+func (c *ciHarness) repoTokens() int {
709+ c.t.Helper()
710+ n, err := c.forge.store.CI().CountRepoTokens(c.ctx, c.name("proj"))
711+ require.NoError(c.t, err)
712+ return n
713+}
714+
715+const varsEverywhereWorkflow = `name: v
716+run-name: run ${{ vars.RUN }}
717+on: push
718+jobs:
719+ j:
720+ if: ${{ vars.GATE == 'on' }}
721+ runs-on: ${{ vars.LABEL }}
722+ strategy:
723+ matrix:
724+ n: ${{ fromJSON(vars.LEGS) }}
725+ environment: ${{ vars.ENVNAME }}
726+ steps:
727+ - run: echo ${{ vars.LATE }}
728+`
729+
730+// Variables are read where the workflow reads them: in run-name, a job's if,
731+// strategy, runs-on and environment name, when the job is planned; and again
732+// when the job is assigned, without planning it again.
733+func TestCIVarsInPlanningAndAtAssignment(t *testing.T) {
734+ c := newSecretsHarness(t, secretsKeyA)
735+ runner := c.registerRunner("alpha", "ubuntu-latest")
736+ for k, v := range map[string]string{"RUN": "first", "GATE": "on", "LABEL": "ubuntu-latest", "LEGS": "[1, 2]", "ENVNAME": "Prod", "LATE": "before"} {
737+ c.setVar(repoScope("proj"), k, v)
738+ }
739+ c.push("main", map[string]string{".github/workflows/v.yml": varsEverywhereWorkflow}, "")
740+ c.waitFor("two ready legs", func() bool {
741+ ready, err := c.forge.store.CI().ListReadyJobs(c.ctx, 0)
742+ return err == nil && len(ready) == 2
743+ })
744+ assert.Equal(t, "run first", c.runs()[0].GetName(), "run-name")
745+
746+ // Change variables after the jobs are ready and before they are fetched.
747+ c.setVar(repoScope("proj"), "LATE", "after")
748+ c.setVar(repoScope("proj"), "GATE", "off")
749+ c.setVar(repoScope("proj"), "LABEL", "gpu")
750+ c.setVar(repoScope("proj"), "LEGS", "[1, 2, 3, 4]")
751+ c.setVar(repoScope("proj"), "RUN", "second")
752+
753+ got := map[string]*pb.Job{}
754+ for len(got) < 2 {
755+ j := runner.await()
756+ got[j.GetJobId()] = j
757+ }
758+ assert.Len(t, got, 2, "the strategy is not planned again: two legs, not four")
759+ for _, j := range got {
760+ assert.Equal(t, "after", j.GetVars()["LATE"], "the assignment sees the new value")
761+ assert.Contains(t, j.GetContexts(), `"LATE":"after"`)
762+ assert.Contains(t, j.GetContexts(), `"GATE":"off"`, "all the variables are current")
763+ assert.Equal(t, []string{"ubuntu-latest"}, j.GetLabels(), "runs-on is the planned one")
764+ assert.Equal(t, "Prod", j.GetEnvironment())
765+ }
766+ assert.Equal(t, "run first", c.runs()[0].GetName(), "the run is not renamed")
767+ // The environment named by a variable is created on first use, at assignment.
768+ envs, err := c.root.env.ListEnvironments(c.ctx, connect.NewRequest(&pb.ListEnvironmentsRequest{Repo: "proj"}))
769+ require.NoError(t, err)
770+ require.Len(t, envs.Msg.GetEnvironments(), 1)
771+ assert.Equal(t, "Prod", envs.Msg.GetEnvironments()[0].GetName(), "environment name")
772+}
773+
774+// One rule for environment names: what the API accepts is what a workflow can
775+// name, with the same spelling; a name with surrounding whitespace is refused by
776+// the API and fails the job with a clear error instead of being trimmed.
777+func TestCIEnvironmentNameRoundTrip(t *testing.T) {
778+ c := newSecretsHarness(t, secretsKeyA)
779+ runner := c.registerRunner("alpha", "ubuntu-latest")
780+ const name = "Prod EU/2"
781+ _, err := c.root.env.CreateEnvironment(c.ctx, connect.NewRequest(&pb.CreateEnvironmentRequest{Repo: "proj", Name: name}))
782+ require.NoError(t, err)
783+ c.setVar(envScope("proj", name), "WHERE", "eu")
784+ for _, bad := range []string{" Prod", "Prod ", "\tProd", "Prod\u00a0"} {
785+ _, err := c.root.env.CreateEnvironment(c.ctx, connect.NewRequest(&pb.CreateEnvironmentRequest{Repo: "proj", Name: bad}))
786+ assert.Equal(t, connect.CodeInvalidArgument, connect.CodeOf(err), "%q", bad)
787+ }
788+
789+ c.push("main", map[string]string{
790+ ".github/workflows/ok.yml": "on: push\njobs:\n ok:\n runs-on: ubuntu-latest\n environment: '" + name + "'\n steps:\n - run: echo\n",
791+ ".github/workflows/bad.yml": "on: push\njobs:\n bad:\n runs-on: ubuntu-latest\n environment: ' Prod EU/2 '\n steps:\n - run: echo\n",
792+ }, "")
793+ job := runner.await()
794+ assert.Equal(t, "ok", job.GetJobKey())
795+ assert.Equal(t, name, job.GetEnvironment())
796+ assert.Equal(t, "eu", job.GetVars()["WHERE"], "the job reads the environment the API created")
797+
798+ c.waitFor("the bad job fails", func() bool {
799+ for _, r := range c.runs() {
800+ if r.GetWorkflowPath() == ".github/workflows/bad.yml" && r.GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED {
801+ return true
802+ }
803+ }
804+ return false
805+ })
806+ for _, r := range c.runs() {
807+ if r.GetWorkflowPath() != ".github/workflows/bad.yml" {
808+ continue
809+ }
810+ j := c.getRun(r.GetId()).GetJobs()[0]
811+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, j.GetConclusion())
812+ assert.Contains(t, j.GetError(), "environment name")
813+ assert.Contains(t, j.GetError(), "whitespace")
814+ }
815+ envs, err := c.root.env.ListEnvironments(c.ctx, connect.NewRequest(&pb.ListEnvironmentsRequest{Repo: "proj"}))
816+ require.NoError(t, err)
817+ assert.Len(t, envs.Msg.GetEnvironments(), 1, "no trimmed copy was created")
818+}
819+
820+// A settings failure that wraps ErrConflict (an environment creation that ran
821+// out of retries) or ErrNotFound is not "nothing to repeat" and not "taken
822+// meanwhile": the repeated poll fails, and no other job is assigned in its
823+// place. Fetching without a poll id fails too and leaves every job ready.
824+func TestCIRepeatedPollPropagatesSettingsConflicts(t *testing.T) {
825+ for name, injected := range map[string]error{
826+ "conflict": fmt.Errorf("create environment: %w", domain.ErrConflict),
827+ "not found": fmt.Errorf("environment: %w", domain.ErrNotFound),
828+ } {
829+ t.Run(name, func(t *testing.T) {
830+ c, flaky := newFlakyHarness(t)
831+ runner := c.registerRunner("alpha", "ubuntu-latest")
832+ c.setSecret(repoScope("proj"), "A", "value")
833+ c.push("main", map[string]string{
834+ ".github/workflows/one.yml": oneSecretJob,
835+ ".github/workflows/two.yml": oneSecretJob,
836+ }, "")
837+ c.waitFor("two ready jobs", func() bool {
838+ ready, err := c.forge.store.CI().ListReadyJobs(c.ctx, 0)
839+ return err == nil && len(ready) == 2
840+ })
841+
842+ first, err := runner.fetch("poll-1")
843+ require.NoError(t, err)
844+ require.NotNil(t, first)
845+ ready, err := c.forge.store.CI().ListReadyJobs(c.ctx, 0)
846+ require.NoError(t, err)
847+ require.Len(t, ready, 1, "the other job waits")
848+
849+ flaky.err = injected
850+ flaky.fail.Store(1)
851+ res, err := runner.fetch("poll-1")
852+ require.Error(t, err, "the repeat fails")
853+ assert.Nil(t, res, "and gives no other job")
854+ ready, err = c.forge.store.CI().ListReadyJobs(c.ctx, 0)
855+ require.NoError(t, err)
856+ assert.Len(t, ready, 1, "the second job was not assigned in its place")
857+ assert.Equal(t, 1, c.repoTokens(), "no token was issued for another job")
858+
859+ // A poll without an id fails the same way and assigns nothing.
860+ flaky.fail.Store(1)
861+ _, err = runner.fetch("")
862+ require.Error(t, err)
863+ ready, err = c.forge.store.CI().ListReadyJobs(c.ctx, 0)
864+ require.NoError(t, err)
865+ assert.Len(t, ready, 1)
866+
867+ // Once it works again, the repeat is the first job, unchanged.
868+ flaky.err = nil
869+ again, err := runner.fetch("poll-1")
870+ require.NoError(t, err)
871+ require.NotNil(t, again)
872+ assert.Equal(t, first.GetJobId(), again.GetJobId())
873+ assert.Equal(t, first.GetJobToken(), again.GetJobToken())
874+ })
875+ }
876+}
M internal/app/cli_e2e_test.go +1 -1
@@ -326,7 +326,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
326326 ver := decode[versionOut](t, res.stdout)
327327 assert.Equal(t, c.url, ver.Server.URL)
328328 assert.NotEmpty(t, ver.Server.Version)
329- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries"}, ver.Server.Features)
329+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.secrets"}, ver.Server.Features)
330330 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)
331331 stdout, stderr, code = c.bin(t, secret, "version")
332332 require.Equal(t, 0, code, stderr)
A internal/app/cli_settings.go +652 -0
@@ -0,0 +1,652 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "bufio"
7+ "bytes"
8+ "errors"
9+ "fmt"
10+ "io"
11+ "os"
12+ "strings"
13+
14+ "connectrpc.com/connect"
15+ "github.com/spf13/cobra"
16+ "golang.org/x/term"
17+
18+ "bnema.dev/ermite-api/gen/ermite"
19+ "bnema.dev/ermite/internal/domain"
20+)
21+
22+// `ermite secret`, `ermite variable` and `ermite environment`: the secrets,
23+// variables and environments CI jobs receive (ADR 0019), with the flags of
24+// `gh secret` and `gh variable`.
25+
26+// settingsFlags are the scope flags of secret and variable commands.
27+type settingsFlags struct {
28+ repo string
29+ env string
30+ instance bool
31+}
32+
33+func (f *settingsFlags) add(cmd *cobra.Command, env *cliEnv, what string) {
34+ cmd.Flags().StringVarP(&f.repo, "repo", "R", "", "the repository whose "+what+" to use")
35+ cmd.Flags().StringVarP(&f.env, "env", "e", "", "the environment of --repo (default: the repository's own)")
36+ cmd.Flags().BoolVar(&f.instance, "instance", false, "the "+what+" of the whole server (every repository)")
37+ cmd.MarkFlagsMutuallyExclusive("instance", "repo")
38+ cmd.MarkFlagsMutuallyExclusive("instance", "env")
39+ _ = cmd.RegisterFlagCompletionFunc("repo", func(c *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
40+ return repoNames(env, c, toComplete), cobra.ShellCompDirectiveNoFileComp
41+ })
42+}
43+
44+// scope builds the scope of the request and the words that name it in output.
45+func (f *settingsFlags) scope() (*ermite.SettingsScope, string, error) {
46+ switch {
47+ case f.instance:
48+ return &ermite.SettingsScope{Where: &ermite.SettingsScope_Instance{Instance: true}}, "the instance", nil
49+ case f.repo == "":
50+ return nil, "", errors.New("choose where it lives: --repo <repo> (with --env <environment> for an environment), or --instance")
51+ case f.env != "":
52+ return &ermite.SettingsScope{Where: &ermite.SettingsScope_Environment{Environment: &ermite.EnvironmentRef{Repo: f.repo, Name: f.env}}},
53+ "environment " + f.env + " of " + f.repo, nil
54+ }
55+ return &ermite.SettingsScope{Where: &ermite.SettingsScope_Repo{Repo: f.repo}}, f.repo, nil
56+}
57+
58+func (f *settingsFlags) check() error {
59+ if f.env != "" && f.repo == "" {
60+ return errors.New("--env needs --repo: an environment belongs to a repository")
61+ }
62+ return nil
63+}
64+
65+// --- values ---
66+
67+// readValue returns the value of a secret or variable: --body, else standard
68+// input. A terminal is asked for it, without echo for a secret.
69+func readValue(cmd *cobra.Command, body string, bodySet, secret bool, name string) (string, error) {
70+ if bodySet {
71+ return body, nil
72+ }
73+ in := cmd.InOrStdin()
74+ if f, ok := in.(*os.File); ok && term.IsTerminal(int(f.Fd())) {
75+ _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Paste the value of %s: ", name)
76+ var raw []byte
77+ var err error
78+ if secret {
79+ raw, err = term.ReadPassword(int(f.Fd()))
80+ _, _ = fmt.Fprintln(cmd.ErrOrStderr())
81+ } else {
82+ var line string
83+ line, err = bufio.NewReader(f).ReadString('\n')
84+ raw = []byte(strings.TrimRight(line, "\r\n"))
85+ if errors.Is(err, io.EOF) && line != "" {
86+ err = nil
87+ }
88+ }
89+ if err != nil {
90+ return "", fmt.Errorf("read the value: %w", err)
91+ }
92+ return string(raw), nil
93+ }
94+ // A pipe: everything, minus the line break that ends it (like gh). The read
95+ // stops one byte past the cap, so input that exhausts it is refused and
96+ // never silently cut.
97+ const slack = 1024 // room for line breaks around a value of the largest size
98+ raw, err := io.ReadAll(io.LimitReader(in, domain.MaxSettingValueBytes+slack+1))
99+ if err != nil {
100+ return "", fmt.Errorf("read the value from standard input: %w", err)
101+ }
102+ if len(raw) > domain.MaxSettingValueBytes+slack {
103+ return "", fmt.Errorf("the value on standard input is larger than %d bytes (48 KiB)", domain.MaxSettingValueBytes)
104+ }
105+ return strings.TrimRight(string(raw), "\r\n"), nil
106+}
107+
108+// parseEnvFile reads KEY=VALUE lines: blank lines and # comments are skipped,
109+// "export " is allowed, and a value may be in matching single quotes (literal)
110+// or double quotes (with \n, \r, \t, \" and \\ escapes). A value is one line.
111+func parseEnvFile(r io.Reader) ([][2]string, error) {
112+ var out [][2]string
113+ sc := bufio.NewScanner(r)
114+ sc.Buffer(make([]byte, 0, 64<<10), domain.MaxSettingValueBytes*2)
115+ for n := 1; sc.Scan(); n++ {
116+ line := strings.TrimSpace(strings.TrimSuffix(sc.Text(), "\r"))
117+ if line == "" || strings.HasPrefix(line, "#") {
118+ continue
119+ }
120+ line = strings.TrimSpace(strings.TrimPrefix(line, "export "))
121+ k, v, ok := strings.Cut(line, "=")
122+ k = strings.TrimSpace(k)
123+ if !ok || k == "" {
124+ return nil, fmt.Errorf("line %d: expected NAME=value", n)
125+ }
126+ v = strings.TrimSpace(v)
127+ switch {
128+ case len(v) >= 2 && v[0] == '\'' && v[len(v)-1] == '\'':
129+ v = v[1 : len(v)-1]
130+ case len(v) >= 2 && v[0] == '"' && v[len(v)-1] == '"':
131+ v = strings.NewReplacer(`\n`, "\n", `\r`, "\r", `\t`, "\t", `\"`, `"`, `\\`, `\`).Replace(v[1 : len(v)-1])
132+ }
133+ out = append(out, [2]string{k, v})
134+ }
135+ if err := sc.Err(); err != nil {
136+ return nil, fmt.Errorf("read the file: %w", err)
137+ }
138+ return out, nil
139+}
140+
141+// readPairs reads what `set` was asked to set: the NAME argument with its value
142+// (--body, standard input or a prompt), or the NAME=value lines of --env-file.
143+// noun is "secret" or "variable"; secret hides the prompt's echo.
144+func readPairs(cmd *cobra.Command, args []string, body, envFile, noun string, secret bool) ([][2]string, error) {
145+ switch {
146+ case envFile != "" && len(args) > 0:
147+ return nil, errors.New("give either a NAME or --env-file, not both")
148+ case envFile != "":
149+ pairs, err := openEnvFile(cmd, envFile)
150+ if err != nil {
151+ return nil, err
152+ }
153+ if len(pairs) == 0 {
154+ return nil, fmt.Errorf("%s holds no NAME=value line", envFile)
155+ }
156+ return pairs, nil
157+ case len(args) == 0:
158+ return nil, fmt.Errorf("give the NAME of the %s, or --env-file", noun)
159+ }
160+ value, err := readValue(cmd, body, cmd.Flags().Changed("body"), secret, args[0])
161+ if err != nil {
162+ return nil, err
163+ }
164+ return [][2]string{{args[0], value}}, nil
165+}
166+
167+// openEnvFile reads the --env-file; "-" is standard input.
168+func openEnvFile(cmd *cobra.Command, path string) ([][2]string, error) {
169+ if path == "-" {
170+ return parseEnvFile(cmd.InOrStdin())
171+ }
172+ raw, err := os.ReadFile(path) //nolint:gosec // the user names the file
173+ if err != nil {
174+ return nil, fmt.Errorf("read %s: %w", path, err)
175+ }
176+ return parseEnvFile(bytes.NewReader(raw))
177+}
178+
179+// --- secret ---
180+
181+func newSecretCmd(env *cliEnv) *cobra.Command {
182+ secret := &cobra.Command{
183+ Use: "secret",
184+ Short: "Manage the secrets CI jobs receive",
185+ Long: "Set, list and delete the secrets that workflows read as ${{ secrets.NAME }}, like `gh secret`.\n" +
186+ "A secret lives in a repository, in one of its environments (--env) or on the whole instance\n" +
187+ "(--instance); the environment wins over the repository, and the repository over the instance.\n" +
188+ "Values are write-only: no command prints one. Names are letters, digits and underscores, do not\n" +
189+ "start with a digit or GITHUB_, and are stored in upper case; a value is up to 48 KiB.\n\n" +
190+ "Needs the secrets:write scope (on a token limited to some repositories, only for them; --instance\n" +
191+ "needs a token that is not). A token limited to branch prefixes never manages secrets. Runs started\n" +
192+ "by such a token do not receive secrets either. The server needs ERMITE_SECRETS_KEY to store them.",
193+ }
194+ secret.AddCommand(newSecretSetCmd(env), newSecretListCmd(env), newSecretDeleteCmd(env))
195+ return secret
196+}
197+
198+func newSecretSetCmd(env *cliEnv) *cobra.Command {
199+ var (
200+ f settingsFlags
201+ body string
202+ envFile string
203+ )
204+ cmd := &cobra.Command{
205+ Use: "set [NAME]",
206+ Short: "Create or update a secret",
207+ Long: "Create or update a secret. The value comes from --body, from standard input (a pipe or a\n" +
208+ "redirection) or, on a terminal, from a prompt that does not echo it. A trailing line break of\n" +
209+ "standard input is dropped. --env-file sets several secrets at once from a file of NAME=value lines\n" +
210+ "(\"-\" reads standard input); NAME is then not given.\n\n" +
211+ " ermite secret set AUR_KEY --repo dumber < key.pem\n" +
212+ " ermite secret set CODECOV_TOKEN --repo gordon --body \"$TOKEN\"\n" +
213+ " ermite secret set REGISTRY_PASSWORD --repo app --env production\n" +
214+ " ermite secret set --env-file .env --instance",
215+ Args: cobra.MaximumNArgs(1),
216+ }
217+ f.add(cmd, env, "secret")
218+ cmd.Flags().StringVarP(&body, "body", "b", "", "the value (visible in the process list: prefer standard input)")
219+ cmd.Flags().StringVarP(&envFile, "env-file", "f", "", "set the secrets of a file of NAME=value lines (\"-\" is standard input)")
220+ cmd.MarkFlagsMutuallyExclusive("body", "env-file")
221+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
222+ if err := f.check(); err != nil {
223+ return err
224+ }
225+ scope, where, err := f.scope()
226+ if err != nil {
227+ return err
228+ }
229+ pairs, err := readPairs(cmd, args, body, envFile, "secret", true)
230+ if err != nil {
231+ return err
232+ }
233+ c, err := env.client(cmd)
234+ if err != nil {
235+ return err
236+ }
237+ var errs []error
238+ for _, p := range pairs {
239+ res, err := c.Secrets().SetSecret(env.ctx(cmd), connect.NewRequest(&ermite.SetSecretRequest{Scope: scope, Name: p[0], Value: p[1]}))
240+ if err != nil {
241+ errs = append(errs, fmt.Errorf("%s: %w", clean(p[0]), c.Describe(err)))
242+ continue
243+ }
244+ if _, err := fmt.Fprintf(cmd.OutOrStdout(), "set secret %s for %s\n", clean(res.Msg.GetSecret().GetName()), clean(where)); err != nil {
245+ return err
246+ }
247+ }
248+ return errors.Join(errs...)
249+ }
250+ return cmd
251+}
252+
253+type jsonSecret struct {
254+ Name string `json:"name"`
255+ UpdatedAt *string `json:"updated_at"`
256+}
257+
258+func newSecretListCmd(env *cliEnv) *cobra.Command {
259+ var f settingsFlags
260+ cmd := &cobra.Command{
261+ Use: "list",
262+ Short: "List the names of the secrets",
263+ Long: "List the secrets of one scope with their last update. Only the secrets set in that scope are\n" +
264+ "listed, not the ones it inherits. Values are never shown.\n\n" +
265+ "JSON fields of a secret (--json): name, updated_at (RFC 3339 UTC, null when unset).",
266+ Args: cobra.NoArgs,
267+ }
268+ f.add(cmd, env, "secrets")
269+ asJSON := addJSONFlag(cmd)
270+ cmd.RunE = func(cmd *cobra.Command, _ []string) error {
271+ if err := f.check(); err != nil {
272+ return err
273+ }
274+ scope, _, err := f.scope()
275+ if err != nil {
276+ return err
277+ }
278+ c, err := env.client(cmd)
279+ if err != nil {
280+ return err
281+ }
282+ ctx := env.ctx(cmd)
283+ items, err := collect(ctx, func(token string) ([]*ermite.Secret, string, error) {
284+ res, err := c.Secrets().ListSecrets(ctx, connect.NewRequest(&ermite.ListSecretsRequest{Scope: scope, PageToken: token}))
285+ if err != nil {
286+ return nil, "", c.Describe(err)
287+ }
288+ return res.Msg.GetSecrets(), res.Msg.GetNextPageToken(), nil
289+ })
290+ if err != nil {
291+ return err
292+ }
293+ if *asJSON {
294+ out := make([]jsonSecret, 0, len(items))
295+ for _, s := range items {
296+ out = append(out, jsonSecret{Name: s.GetName(), UpdatedAt: jsonTime(s.GetUpdatedAt())})
297+ }
298+ return writeJSON(cmd.OutOrStdout(), out)
299+ }
300+ if len(items) == 0 {
301+ noticef(cmd, "no secrets")
302+ return nil
303+ }
304+ rows := make([][]string, 0, len(items))
305+ for _, s := range items {
306+ rows = append(rows, []string{s.GetName(), shortTime(s.GetUpdatedAt(), "")})
307+ }
308+ return table(cmd.OutOrStdout(), []string{"NAME", "UPDATED"}, rows)
309+ }
310+ return cmd
311+}
312+
313+func newSecretDeleteCmd(env *cliEnv) *cobra.Command {
314+ var f settingsFlags
315+ cmd := &cobra.Command{
316+ Use: "delete NAME",
317+ Short: "Delete a secret",
318+ Args: cobra.ExactArgs(1),
319+ }
320+ f.add(cmd, env, "secret")
321+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
322+ if err := f.check(); err != nil {
323+ return err
324+ }
325+ scope, where, err := f.scope()
326+ if err != nil {
327+ return err
328+ }
329+ c, err := env.client(cmd)
330+ if err != nil {
331+ return err
332+ }
333+ if _, err := c.Secrets().DeleteSecret(env.ctx(cmd), connect.NewRequest(&ermite.DeleteSecretRequest{Scope: scope, Name: args[0]})); err != nil {
334+ return c.Describe(err)
335+ }
336+ _, err = fmt.Fprintf(cmd.OutOrStdout(), "deleted secret %s from %s\n", clean(strings.ToUpper(args[0])), clean(where))
337+ return err
338+ }
339+ return cmd
340+}
341+
342+// --- variable ---
343+
344+func newVariableCmd(env *cliEnv) *cobra.Command {
345+ v := &cobra.Command{
346+ Use: "variable",
347+ Short: "Manage the variables CI jobs receive",
348+ Long: "Set, get, list and delete the configuration variables that workflows read as ${{ vars.NAME }},\n" +
349+ "like `gh variable`. Scopes, names, precedence and access are those of `ermite secret`; unlike a\n" +
350+ "secret, a variable can be read back and may appear in logs.",
351+ }
352+ v.AddCommand(newVariableSetCmd(env), newVariableGetCmd(env), newVariableListCmd(env), newVariableDeleteCmd(env))
353+ return v
354+}
355+
356+func newVariableSetCmd(env *cliEnv) *cobra.Command {
357+ var (
358+ f settingsFlags
359+ body string
360+ envFile string
361+ )
362+ cmd := &cobra.Command{
363+ Use: "set [NAME]",
364+ Short: "Create or update a variable",
365+ Long: "Create or update a variable. The value comes from --body, from standard input or, on a terminal,\n" +
366+ "from a prompt. --env-file sets several from a file of NAME=value lines, as `ermite secret set`.",
367+ Args: cobra.MaximumNArgs(1),
368+ }
369+ f.add(cmd, env, "variable")
370+ cmd.Flags().StringVarP(&body, "body", "b", "", "the value")
371+ cmd.Flags().StringVarP(&envFile, "env-file", "f", "", "set the variables of a file of NAME=value lines (\"-\" is standard input)")
372+ cmd.MarkFlagsMutuallyExclusive("body", "env-file")
373+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
374+ if err := f.check(); err != nil {
375+ return err
376+ }
377+ scope, where, err := f.scope()
378+ if err != nil {
379+ return err
380+ }
381+ pairs, err := readPairs(cmd, args, body, envFile, "variable", false)
382+ if err != nil {
383+ return err
384+ }
385+ c, err := env.client(cmd)
386+ if err != nil {
387+ return err
388+ }
389+ var errs []error
390+ for _, p := range pairs {
391+ res, err := c.Variables().SetVariable(env.ctx(cmd), connect.NewRequest(&ermite.SetVariableRequest{Scope: scope, Name: p[0], Value: p[1]}))
392+ if err != nil {
393+ errs = append(errs, fmt.Errorf("%s: %w", clean(p[0]), c.Describe(err)))
394+ continue
395+ }
396+ if _, err := fmt.Fprintf(cmd.OutOrStdout(), "set variable %s for %s\n", clean(res.Msg.GetVariable().GetName()), clean(where)); err != nil {
397+ return err
398+ }
399+ }
400+ return errors.Join(errs...)
401+ }
402+ return cmd
403+}
404+
405+type jsonVariable struct {
406+ Name string `json:"name"`
407+ Value string `json:"value"`
408+ UpdatedAt *string `json:"updated_at"`
409+}
410+
411+func variableJSON(v *ermite.Variable) jsonVariable {
412+ return jsonVariable{Name: v.GetName(), Value: v.GetValue(), UpdatedAt: jsonTime(v.GetUpdatedAt())}
413+}
414+
415+func newVariableGetCmd(env *cliEnv) *cobra.Command {
416+ var f settingsFlags
417+ cmd := &cobra.Command{
418+ Use: "get NAME",
419+ Short: "Print the value of a variable",
420+ Long: "Print the value of a variable of one scope, and nothing else, so that it can be used in a script.\n\n" +
421+ "JSON fields (--json): name, value, updated_at (RFC 3339 UTC, null when unset).",
422+ Args: cobra.ExactArgs(1),
423+ }
424+ f.add(cmd, env, "variable")
425+ asJSON := addJSONFlag(cmd)
426+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
427+ if err := f.check(); err != nil {
428+ return err
429+ }
430+ scope, _, err := f.scope()
431+ if err != nil {
432+ return err
433+ }
434+ c, err := env.client(cmd)
435+ if err != nil {
436+ return err
437+ }
438+ res, err := c.Variables().GetVariable(env.ctx(cmd), connect.NewRequest(&ermite.GetVariableRequest{Scope: scope, Name: args[0]}))
439+ if err != nil {
440+ return c.Describe(err)
441+ }
442+ if *asJSON {
443+ return writeJSON(cmd.OutOrStdout(), variableJSON(res.Msg.GetVariable()))
444+ }
445+ _, err = fmt.Fprintln(cmd.OutOrStdout(), res.Msg.GetVariable().GetValue())
446+ return err
447+ }
448+ return cmd
449+}
450+
451+func newVariableListCmd(env *cliEnv) *cobra.Command {
452+ var f settingsFlags
453+ cmd := &cobra.Command{
454+ Use: "list",
455+ Short: "List the variables with their values",
456+ Long: "List the variables of one scope with their values and last update; the ones the scope inherits are\n" +
457+ "not listed.\n\nJSON fields of a variable (--json): name, value, updated_at (RFC 3339 UTC, null when unset).",
458+ Args: cobra.NoArgs,
459+ }
460+ f.add(cmd, env, "variables")
461+ asJSON := addJSONFlag(cmd)
462+ cmd.RunE = func(cmd *cobra.Command, _ []string) error {
463+ if err := f.check(); err != nil {
464+ return err
465+ }
466+ scope, _, err := f.scope()
467+ if err != nil {
468+ return err
469+ }
470+ c, err := env.client(cmd)
471+ if err != nil {
472+ return err
473+ }
474+ ctx := env.ctx(cmd)
475+ items, err := collect(ctx, func(token string) ([]*ermite.Variable, string, error) {
476+ res, err := c.Variables().ListVariables(ctx, connect.NewRequest(&ermite.ListVariablesRequest{Scope: scope, PageToken: token}))
477+ if err != nil {
478+ return nil, "", c.Describe(err)
479+ }
480+ return res.Msg.GetVariables(), res.Msg.GetNextPageToken(), nil
481+ })
482+ if err != nil {
483+ return err
484+ }
485+ if *asJSON {
486+ out := make([]jsonVariable, 0, len(items))
487+ for _, v := range items {
488+ out = append(out, variableJSON(v))
489+ }
490+ return writeJSON(cmd.OutOrStdout(), out)
491+ }
492+ if len(items) == 0 {
493+ noticef(cmd, "no variables")
494+ return nil
495+ }
496+ rows := make([][]string, 0, len(items))
497+ for _, v := range items {
498+ rows = append(rows, []string{v.GetName(), v.GetValue(), shortTime(v.GetUpdatedAt(), "")})
499+ }
500+ return table(cmd.OutOrStdout(), []string{"NAME", "VALUE", "UPDATED"}, rows)
501+ }
502+ return cmd
503+}
504+
505+func newVariableDeleteCmd(env *cliEnv) *cobra.Command {
506+ var f settingsFlags
507+ cmd := &cobra.Command{
508+ Use: "delete NAME",
509+ Short: "Delete a variable",
510+ Args: cobra.ExactArgs(1),
511+ }
512+ f.add(cmd, env, "variable")
513+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
514+ if err := f.check(); err != nil {
515+ return err
516+ }
517+ scope, where, err := f.scope()
518+ if err != nil {
519+ return err
520+ }
521+ c, err := env.client(cmd)
522+ if err != nil {
523+ return err
524+ }
525+ if _, err := c.Variables().DeleteVariable(env.ctx(cmd), connect.NewRequest(&ermite.DeleteVariableRequest{Scope: scope, Name: args[0]})); err != nil {
526+ return c.Describe(err)
527+ }
528+ _, err = fmt.Fprintf(cmd.OutOrStdout(), "deleted variable %s from %s\n", clean(strings.ToUpper(args[0])), clean(where))
529+ return err
530+ }
531+ return cmd
532+}
533+
534+// --- environment ---
535+
536+type jsonEnvironment struct {
537+ Repo string `json:"repo"`
538+ Name string `json:"name"`
539+ CreatedAt *string `json:"created_at"`
540+}
541+
542+func newEnvironmentCmd(env *cliEnv) *cobra.Command {
543+ e := &cobra.Command{
544+ Use: "environment",
545+ Short: "Manage the environments of a repository",
546+ Long: "Create, list and delete the environments of a repository, to which secrets and variables are attached\n" +
547+ "(`ermite secret set --env`). A job that names one with `environment:` creates it on first use. An\n" +
548+ "environment has no protection rules and no deployments. Access is that of `ermite secret`.",
549+ }
550+ e.AddCommand(newEnvironmentCreateCmd(env), newEnvironmentListCmd(env), newEnvironmentDeleteCmd(env))
551+ return e
552+}
553+
554+func addRepoFlag(cmd *cobra.Command, env *cliEnv, repo *string) {
555+ cmd.Flags().StringVarP(repo, "repo", "R", "", "the repository")
556+ _ = cmd.MarkFlagRequired("repo")
557+ _ = cmd.RegisterFlagCompletionFunc("repo", func(c *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
558+ return repoNames(env, c, toComplete), cobra.ShellCompDirectiveNoFileComp
559+ })
560+}
561+
562+func newEnvironmentCreateCmd(env *cliEnv) *cobra.Command {
563+ var repo string
564+ cmd := &cobra.Command{
565+ Use: "create NAME --repo REPO",
566+ Short: "Create an environment",
567+ Long: "Create an environment. One that exists already (in any case) is left as it is.",
568+ Args: cobra.ExactArgs(1),
569+ }
570+ addRepoFlag(cmd, env, &repo)
571+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
572+ c, err := env.client(cmd)
573+ if err != nil {
574+ return err
575+ }
576+ res, err := c.Environments().CreateEnvironment(env.ctx(cmd), connect.NewRequest(&ermite.CreateEnvironmentRequest{Repo: repo, Name: args[0]}))
577+ if err != nil {
578+ return c.Describe(err)
579+ }
580+ _, err = fmt.Fprintf(cmd.OutOrStdout(), "environment %s of %s\n", clean(res.Msg.GetEnvironment().GetName()), clean(repo))
581+ return err
582+ }
583+ return cmd
584+}
585+
586+func newEnvironmentListCmd(env *cliEnv) *cobra.Command {
587+ var repo string
588+ cmd := &cobra.Command{
589+ Use: "list --repo REPO",
590+ Short: "List the environments of a repository",
591+ Long: "List the environments of a repository ordered by name.\n\nJSON fields of an environment (--json): repo, name, created_at (RFC 3339 UTC, null when unset).",
592+ Args: cobra.NoArgs,
593+ }
594+ addRepoFlag(cmd, env, &repo)
595+ asJSON := addJSONFlag(cmd)
596+ cmd.RunE = func(cmd *cobra.Command, _ []string) error {
597+ c, err := env.client(cmd)
598+ if err != nil {
599+ return err
600+ }
601+ ctx := env.ctx(cmd)
602+ items, err := collect(ctx, func(token string) ([]*ermite.Environment, string, error) {
603+ res, err := c.Environments().ListEnvironments(ctx, connect.NewRequest(&ermite.ListEnvironmentsRequest{Repo: repo, PageToken: token}))
604+ if err != nil {
605+ return nil, "", c.Describe(err)
606+ }
607+ return res.Msg.GetEnvironments(), res.Msg.GetNextPageToken(), nil
608+ })
609+ if err != nil {
610+ return err
611+ }
612+ if *asJSON {
613+ out := make([]jsonEnvironment, 0, len(items))
614+ for _, e := range items {
615+ out = append(out, jsonEnvironment{Repo: e.GetRepo(), Name: e.GetName(), CreatedAt: jsonTime(e.GetCreatedAt())})
616+ }
617+ return writeJSON(cmd.OutOrStdout(), out)
618+ }
619+ if len(items) == 0 {
620+ noticef(cmd, "no environments")
621+ return nil
622+ }
623+ rows := make([][]string, 0, len(items))
624+ for _, e := range items {
625+ rows = append(rows, []string{e.GetName(), shortTime(e.GetCreatedAt(), "")})
626+ }
627+ return table(cmd.OutOrStdout(), []string{"NAME", "CREATED"}, rows)
628+ }
629+ return cmd
630+}
631+
632+func newEnvironmentDeleteCmd(env *cliEnv) *cobra.Command {
633+ var repo string
634+ cmd := &cobra.Command{
635+ Use: "delete NAME --repo REPO",
636+ Short: "Delete an environment with its secrets and variables",
637+ Args: cobra.ExactArgs(1),
638+ }
639+ addRepoFlag(cmd, env, &repo)
640+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
641+ c, err := env.client(cmd)
642+ if err != nil {
643+ return err
644+ }
645+ if _, err := c.Environments().DeleteEnvironment(env.ctx(cmd), connect.NewRequest(&ermite.DeleteEnvironmentRequest{Repo: repo, Name: args[0]})); err != nil {
646+ return c.Describe(err)
647+ }
648+ _, err = fmt.Fprintf(cmd.OutOrStdout(), "deleted environment %s of %s\n", clean(args[0]), clean(repo))
649+ return err
650+ }
651+ return cmd
652+}
A internal/app/cli_settings_test.go +229 -0
@@ -0,0 +1,229 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+
14+ "bnema.dev/ermite/internal/config"
15+ "bnema.dev/ermite/internal/domain"
16+)
17+
18+func TestParseEnvFile(t *testing.T) {
19+ got, err := parseEnvFile(strings.NewReader(`# comment
20+
21+A=1
22+export B = two words
23+C='single $quoted\n'
24+D="double\nquoted \"q\" \\ end"
25+E=
26+F=a=b
27+G="unterminated
28+`))
29+ require.NoError(t, err)
30+ assert.Equal(t, [][2]string{
31+ {"A", "1"}, {"B", "two words"}, {"C", `single $quoted\n`}, {"D", "double\nquoted \"q\" \\ end"},
32+ {"E", ""}, {"F", "a=b"}, {"G", `"unterminated`},
33+ }, got)
34+
35+ for _, bad := range []string{"no equals sign\n", "=value\n", " = x\n"} {
36+ _, err := parseEnvFile(strings.NewReader(bad))
37+ require.Error(t, err, bad)
38+ assert.Contains(t, err.Error(), "line 1")
39+ }
40+}
41+
42+func newSecretsCLI(t *testing.T) *cliHarness {
43+ t.Helper()
44+ h := newHarnessWith(t, harnessOpts{env: map[string]string{config.EnvSecretsKey: secretsKeyA}}, nil)
45+ h.createRepo("proj", domain.VisibilityPrivate)
46+ return &cliHarness{harness: h, url: h.srv.URL}
47+}
48+
49+// `ermite secret|variable|environment` against a real server: scopes, value
50+// sources, listings, and that no command ever prints a secret value.
51+func TestCLISecretsVariablesEnvironments(t *testing.T) {
52+ h := newSecretsCLI(t)
53+ run := func(stdin string, args ...string) result {
54+ return runCLI(t, h.vars(adminToken), stdin, args...)
55+ }
56+ const value = "S3CRET-VALUE-do-not-print"
57+ var outputs strings.Builder
58+ record := func(r result) result { outputs.WriteString(r.stdout + "\n" + r.stderr + "\n"); return r }
59+
60+ // A value from standard input, with its trailing line break dropped; one
61+ // from --body; and a multi-line one.
62+ r := record(run(value+"\n", "secret", "set", "api_token", "--repo", "proj").ok(t))
63+ assert.Equal(t, "set secret API_TOKEN for proj\n", r.stdout)
64+ record(run("", "secret", "set", "BODY_ONE", "--repo", "proj", "--body", value+"-body").ok(t))
65+ record(run("line one\nline two\n", "secret", "set", "MULTI", "-R", "proj").ok(t))
66+ record(run(value+"-inst", "secret", "set", "INST", "--instance").ok(t))
67+
68+ // An environment: created by the command, then used with --env.
69+ r = run("", "environment", "create", "Production", "--repo", "proj").ok(t)
70+ assert.Equal(t, "environment Production of proj\n", r.stdout)
71+ run("", "environment", "create", "production", "-R", "proj").ok(t)
72+ record(run(value+"-env", "secret", "set", "ENV_ONE", "--repo", "proj", "--env", "PRODUCTION").ok(t))
73+
74+ // list: names and dates only, ordered, per scope; --json too.
75+ r = record(run("", "secret", "list", "--repo", "proj").ok(t))
76+ assert.Contains(t, r.stdout, "NAME")
77+ assert.Regexp(t, `(?s)API_TOKEN.*BODY_ONE.*MULTI`, r.stdout)
78+ assert.NotContains(t, r.stdout, "ENV_ONE", "the environment's own scope is not mixed in")
79+ r = record(run("", "secret", "list", "--repo", "proj", "--env", "production", "--json").ok(t))
80+ secrets := decode[[]jsonSecret](t, r.stdout)
81+ require.Len(t, secrets, 1)
82+ assert.Equal(t, "ENV_ONE", secrets[0].Name)
83+ assert.NotNil(t, secrets[0].UpdatedAt)
84+ assert.Equal(t, []string{"name", "updated_at"}, keys(t, strings.TrimSpace(strings.Trim(strings.TrimSpace(r.stdout), "[]"))))
85+ r = record(run("", "secret", "list", "--instance", "--json").ok(t))
86+ assert.Equal(t, "INST", decode[[]jsonSecret](t, r.stdout)[0].Name)
87+
88+ // delete
89+ r = run("", "secret", "delete", "body_one", "--repo", "proj").ok(t)
90+ assert.Equal(t, "deleted secret BODY_ONE from proj\n", r.stdout)
91+ gone := run("", "secret", "delete", "BODY_ONE", "--repo", "proj")
92+ require.Error(t, gone.err)
93+ r = record(run("", "secret", "list", "--repo", "proj", "--json").ok(t))
94+ assert.Len(t, decode[[]jsonSecret](t, r.stdout), 2)
95+
96+ // --env-file sets several at once, in a repository and on the instance.
97+ envFile := filepath.Join(t.TempDir(), ".env")
98+ require.NoError(t, os.WriteFile(envFile, []byte("# secrets\nFROM_FILE_A="+value+"-a\nexport FROM_FILE_B='"+value+"-b'\n"), 0o600))
99+ r = record(run("", "secret", "set", "--env-file", envFile, "--repo", "proj").ok(t))
100+ assert.Equal(t, "set secret FROM_FILE_A for proj\nset secret FROM_FILE_B for proj\n", r.stdout)
101+ record(run("X_STDIN="+value+"-x\n", "secret", "set", "-f", "-", "--instance").ok(t))
102+ r = record(run("", "secret", "list", "--instance", "--json").ok(t))
103+ assert.Len(t, decode[[]jsonSecret](t, r.stdout), 2)
104+
105+ // Variables are readable.
106+ run("hello world\n", "variable", "set", "greeting", "--repo", "proj").ok(t)
107+ run("", "variable", "set", "GREETING", "--instance", "--body", "hi").ok(t)
108+ run("", "variable", "set", "ENV_VAR", "--repo", "proj", "--env", "Production", "--body", "e").ok(t)
109+ run("", "variable", "set", "EMPTY", "--repo", "proj", "--body", "").ok(t)
110+ assert.Equal(t, "hello world\n", run("", "variable", "get", "GREETING", "--repo", "proj").ok(t).stdout)
111+ assert.Equal(t, "hi\n", run("", "variable", "get", "greeting", "--instance").ok(t).stdout)
112+ r = run("", "variable", "get", "GREETING", "--repo", "proj", "--json").ok(t)
113+ v := decode[jsonVariable](t, r.stdout)
114+ assert.Equal(t, jsonVariable{Name: "GREETING", Value: "hello world", UpdatedAt: v.UpdatedAt}, v)
115+ r = run("", "variable", "list", "--repo", "proj").ok(t)
116+ assert.Regexp(t, `(?s)EMPTY.*GREETING\s+hello world`, r.stdout)
117+ assert.Equal(t, "e\n", run("", "variable", "get", "ENV_VAR", "-R", "proj", "-e", "production").ok(t).stdout)
118+ r = run("", "variable", "delete", "empty", "--repo", "proj").ok(t)
119+ assert.Equal(t, "deleted variable EMPTY from proj\n", r.stdout)
120+ require.Error(t, run("", "variable", "get", "EMPTY", "--repo", "proj").err)
121+
122+ // environments: list, delete (with their content).
123+ r = run("", "environment", "list", "--repo", "proj", "--json").ok(t)
124+ envs := decode[[]jsonEnvironment](t, r.stdout)
125+ require.Len(t, envs, 1)
126+ assert.Equal(t, "Production", envs[0].Name)
127+ assert.Equal(t, "proj", envs[0].Repo)
128+ assert.Contains(t, run("", "environment", "list", "--repo", "proj").ok(t).stdout, "Production")
129+ r = run("", "environment", "delete", "production", "--repo", "proj").ok(t)
130+ assert.Equal(t, "deleted environment production of proj\n", r.stdout)
131+ assert.Contains(t, run("", "environment", "list", "--repo", "proj").ok(t).stderr, "no environments")
132+ require.Error(t, run("", "secret", "list", "--repo", "proj", "--env", "production").err, "its secrets went with it")
133+
134+ // No command printed a value, and none of them was logged by the server.
135+ for _, secret := range []string{value, "line one", "line two"} {
136+ assert.NotContains(t, outputs.String(), secret)
137+ assert.NotContains(t, h.logs.String(), secret)
138+ }
139+}
140+
141+func TestCLISecretsArgumentsAndErrors(t *testing.T) {
142+ h := newSecretsCLI(t)
143+ run := func(stdin string, args ...string) result {
144+ return runCLI(t, h.vars(adminToken), stdin, args...)
145+ }
146+ for name, args := range map[string][]string{
147+ "no scope": {"secret", "set", "A", "--body", "v"},
148+ "env without repo": {"secret", "set", "A", "--body", "v", "--env", "prod"},
149+ "instance with repo": {"secret", "set", "A", "--body", "v", "--instance", "--repo", "proj"},
150+ "instance with env": {"secret", "set", "A", "--body", "v", "--instance", "--env", "prod"},
151+ "no name": {"secret", "set", "--repo", "proj", "--body", "v"},
152+ "name and env-file": {"secret", "set", "A", "--env-file", "x", "--repo", "proj"},
153+ "body and env-file": {"secret", "set", "--body", "v", "--env-file", "x", "--repo", "proj"},
154+ "missing env-file": {"secret", "set", "--env-file", "/nonexistent/.env", "--repo", "proj"},
155+ "environment without repo": {"environment", "list"},
156+ } {
157+ require.Error(t, run("", args...).err, name)
158+ }
159+
160+ // A bad name or value is the server's refusal, naming the field, not the value.
161+ r := run("", "secret", "set", "GITHUB_TOKEN", "--repo", "proj", "--body", "leak-me")
162+ require.Error(t, r.err)
163+ assert.Contains(t, r.err.Error(), "GITHUB_")
164+ assert.NotContains(t, r.err.Error()+r.stderr+r.stdout, "leak-me")
165+ r = run("", "secret", "set", "BIG", "--repo", "proj", "--body", strings.Repeat("x", domain.MaxSettingValueBytes+1))
166+ require.Error(t, r.err)
167+ assert.Contains(t, r.err.Error(), "49,152")
168+ // A file with one bad name sets the others and reports the bad one.
169+ f := filepath.Join(t.TempDir(), "e")
170+ require.NoError(t, os.WriteFile(f, []byte("GOOD=1\n9BAD=2\nALSO_GOOD=3\n"), 0o600))
171+ r = run("", "secret", "set", "-f", f, "--repo", "proj")
172+ require.Error(t, r.err)
173+ assert.Contains(t, r.err.Error(), "9BAD")
174+ assert.Equal(t, "set secret GOOD for proj\nset secret ALSO_GOOD for proj\n", r.stdout)
175+ r = run("", "secret", "set", "X", "--repo", "ghost", "--body", "v")
176+ require.Error(t, r.err)
177+
178+ // Not authorised: a token with the scope on another repository only.
179+ reader := h.token([]domain.Scope{domain.ScopeRepoRead}, "proj")
180+ r = runCLI(t, h.vars(reader), "", "secret", "list", "--repo", "proj")
181+ require.Error(t, r.err)
182+ assert.Contains(t, r.err.Error(), "secrets:write")
183+}
184+
185+// Standard input larger than the cap is refused with a size error, also when
186+// the excess is only line breaks: it is never silently cut. The exact maximum
187+// (and its line break) still goes through to the server.
188+func TestCLISecretSetStdinSize(t *testing.T) {
189+ h := newSecretsCLI(t)
190+ run := func(stdin string, args ...string) result {
191+ return runCLI(t, h.vars(adminToken), stdin, args...)
192+ }
193+ max := domain.MaxSettingValueBytes
194+ for name, in := range map[string]string{
195+ "value over the cap": strings.Repeat("x", max+2000),
196+ "newline-padded over cap": strings.Repeat("x", max) + strings.Repeat("\n", 5000),
197+ "newlines only, over cap": strings.Repeat("\n", max+2000),
198+ "cap plus slack plus a byte": strings.Repeat("x", max+1025),
199+ } {
200+ r := run(in, "secret", "set", "BIG", "--repo", "proj")
201+ require.Error(t, r.err, name)
202+ assert.Contains(t, r.err.Error(), "larger than", name)
203+ assert.Empty(t, r.stdout, name)
204+ }
205+ r := run(strings.Repeat("x", max)+"\n", "secret", "set", "EXACT", "--repo", "proj").ok(t)
206+ assert.Equal(t, "set secret EXACT for proj\n", r.stdout)
207+}
208+
209+// Secret and variable `set` share the reading of their pairs.
210+func TestCLISetPairModes(t *testing.T) {
211+ h := newSecretsCLI(t)
212+ run := func(stdin string, args ...string) result {
213+ return runCLI(t, h.vars(adminToken), stdin, args...)
214+ }
215+ for _, noun := range []string{"secret", "variable"} {
216+ r := run("", noun, "set", "--repo", "proj")
217+ require.Error(t, r.err, noun)
218+ assert.Contains(t, r.err.Error(), "give the NAME of the "+noun)
219+ r = run("", noun, "set", "A", "--env-file", "x", "--repo", "proj")
220+ require.Error(t, r.err, noun)
221+ assert.Contains(t, r.err.Error(), "not both")
222+ empty := filepath.Join(t.TempDir(), "empty")
223+ require.NoError(t, os.WriteFile(empty, []byte("# nothing\n"), 0o600))
224+ r = run("", noun, "set", "-f", empty, "--repo", "proj")
225+ require.Error(t, r.err, noun)
226+ assert.Contains(t, r.err.Error(), "holds no NAME=value")
227+ run("viaStdin\n", noun, "set", "S"+strings.ToUpper(noun[:1]), "--repo", "proj").ok(t)
228+ }
229+}
M internal/app/cli_version.go +1 -0
@@ -118,5 +118,6 @@ var featureUse = map[string]string{
118118 client.FeatureTokenBranchPrefixes: "`ermite token create --branch-prefix` needs: the command is refused, and `token list` does not show the limits",
119119 client.FeatureCIRuns: "`ermite run` needs: the commands will fail on this server",
120120 client.FeatureCIRunners: "`ermite runner` needs: the commands will fail on this server",
121+ client.FeatureCISecrets: "`ermite secret`, `variable` and `environment` need: the commands will fail on this server",
121122 client.FeaturePasskeys: "`ermite web passkey` needs: the commands will fail on this server (passkeys need an https public URL with a DNS host name)",
122123 }
M internal/app/cli_version_test.go +5 -5
@@ -17,7 +17,7 @@ import (
1717 )
1818
1919 // allFeatures are the features this client uses.
20-var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "passkeys"}
20+var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "passkeys"}
2121
2222 // Every feature the client uses needs a sentence for the version warning.
2323 func TestEveryUsedFeatureExplainsWhatBreaks(t *testing.T) {
@@ -45,15 +45,15 @@ func TestVersionWithServer(t *testing.T) {
4545 f := versionFake(allFeatures...)
4646 url := f.serve(t)
4747 res := runCLI(t, envFor(url), "", "version").ok(t)
48- assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, signing_key, session, token.branch_prefixes, ci.runs, ci.runners, passkeys\n", res.stdout)
48+ assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, signing_key, session, token.branch_prefixes, ci.runs, ci.runners, ci.secrets, passkeys\n", res.stdout)
4949 assert.Empty(t, res.stderr, "no warning when every feature is there")
5050
5151 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
52- assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","passkeys"],"missing_features":[]}}`, res.stdout)
52+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","passkeys"],"missing_features":[]}}`, res.stdout)
5353 }
5454
5555 func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
56- f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "passkeys")
56+ f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "passkeys")
5757 url := f.serve(t)
5858 res := runCLI(t, envFor(url), "", "version").ok(t)
5959 assert.Contains(t, res.stdout, "v0.4.0")
@@ -61,7 +61,7 @@ func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
6161 assert.NotContains(t, res.stderr, "repo.update")
6262
6363 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
64- assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","passkeys"],"missing_features":["events.resume"]}}`, res.stdout)
64+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","passkeys"],"missing_features":["events.resume"]}}`, res.stdout)
6565 assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json")
6666
6767 f = versionFake()
M internal/app/e2e_helpers_test.go +5 -0
@@ -50,6 +50,8 @@ type harnessOpts struct {
5050 revalidateEvery time.Duration
5151 // publicURL overrides ERMITE_PUBLIC_URL (http://example.test) when set.
5252 publicURL string
53+ // env adds or overrides variables of the server's environment.
54+ env map[string]string
5355 }
5456
5557 // newHarness starts a forge. The data directory is short: the hook socket
@@ -77,6 +79,9 @@ func newHarnessWith(t *testing.T, o harnessOpts, tweak func(*options)) *harness
7779 if o.publicURL != "" {
7880 env[config.EnvPublicURL] = o.publicURL
7981 }
82+ for k, v := range o.env {
83+ env[k] = v
84+ }
8085 if o.trustedProxies != "" {
8186 env[config.EnvTrustedProxies] = o.trustedProxies
8287 }
M internal/app/forge.go +28 -4
@@ -26,6 +26,7 @@ import (
2626 "bnema.dev/ermite/internal/adapters/httpserver"
2727 "bnema.dev/ermite/internal/adapters/kvstore"
2828 "bnema.dev/ermite/internal/adapters/rpc"
29+ "bnema.dev/ermite/internal/adapters/secretbox"
2930 "bnema.dev/ermite/internal/adapters/sshsig"
3031 "bnema.dev/ermite/internal/adapters/web"
3132 "bnema.dev/ermite/internal/adapters/webauthn"
@@ -100,9 +101,11 @@ type forge struct {
100101 EventLog *usecase.Events
101102 // CI is the CI engine: workflow trigger, planner, runner protocol and the
102103 // owner side. It starts with the server and stops with it.
103- CI *usecase.CI
104- API *rpc.API
105- Bus ports.EventBus
104+ CI *usecase.CI
105+ // Settings are the secrets, variables and environments of CI.
106+ Settings *usecase.CISettings
107+ API *rpc.API
108+ Bus ports.EventBus
106109 }
107110
108111 // newSigningSeed makes the seed of a new ed25519 signing key.
@@ -210,6 +213,23 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
210213 log.Info().Str("reader", cfg.GitReader).Msg("git reader selected")
211214 f.Browse = usecase.NewBrowse(store, reader)
212215
216+ // Secrets, variables and environments (ADR 0019, CI-3). With a key the
217+ // secrets are sealed under it, and the server refuses to start when the
218+ // key is not the one they were sealed with; without one they cannot be set
219+ // and jobs receive none.
220+ var sealer ports.SecretSealer // a nil *secretbox.Box must not become a non-nil interface
221+ if cfg.SecretsKey != nil {
222+ box, err := secretbox.New(cfg.SecretsKey)
223+ if err != nil {
224+ return nil, fmt.Errorf("%s: %w", config.EnvSecretsKey, err)
225+ }
226+ sealer = box
227+ }
228+ f.Settings, err = usecase.NewCISettings(ctx, store, store.Settings(), sealer, clock)
229+ if err != nil {
230+ return nil, fmt.Errorf("%s: %w", config.EnvSecretsKey, err)
231+ }
232+
213233 // CI (ADR 0025): the engine reads workflows through the reader, and plans
214234 // them in the background; a push only queues its refs.
215235 ciCfg := usecase.CIConfig{
@@ -221,7 +241,8 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
221241 Bin: cfg.Git, Path: path, Home: filepath.Join(cfg.DataDir, "run", "git-home"),
222242 Proxy: gitremote.ProxyEnv(os.Getenv),
223243 })),
224- Tokens: store.Tokens(),
244+ Tokens: store.Tokens(),
245+ Settings: f.Settings,
225246 }
226247 if opts.ci != nil {
227248 opts.ci(&ciCfg)
@@ -291,6 +312,9 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
291312 Passkeys: apiPasskeys,
292313 Runner: f.CI,
293314 Runs: f.CI,
315+ Secrets: f.Settings,
316+ Variables: f.Settings,
317+ Environments: f.Settings,
294318 Version: serverVersion(),
295319 TrustedProxies: cfg.TrustedProxies,
296320 Limiter: limiter,
M internal/app/root.go +1 -1
@@ -32,7 +32,7 @@ func newRootCmdWith(env *cliEnv) *cobra.Command {
3232 newServeCmd(), newHookCmd(),
3333 newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env),
3434 newMRCmd(env), newSigningKeyCmd(env), newWebCmd(env), newConfigCmd(env),
35- newRunCmd(env), newRunnerCmd(env),
35+ newRunCmd(env), newRunnerCmd(env), newSecretCmd(env), newVariableCmd(env), newEnvironmentCmd(env),
3636 )
3737 return root
3838 }
M internal/config/config.go +32 -0
@@ -7,6 +7,7 @@ package config
77
88 import (
99 "crypto/sha256"
10+ "encoding/base64"
1011 "fmt"
1112 "net"
1213 "net/netip"
@@ -42,8 +43,15 @@ const (
4243 // EnvCIPollTimeout is how long FetchJob holds a runner's call. Lower it
4344 // when a reverse proxy cuts idle responses sooner than the default 50 s.
4445 EnvCIPollTimeout = "ERMITE_CI_POLL_TIMEOUT"
46+ // EnvSecretsKey is the master key that encrypts CI secrets at rest: 32
47+ // random bytes in base64, provided by the deployment secret store like
48+ // ERMITE_ADMIN_TOKEN. Without it secrets cannot be set and jobs get none.
49+ EnvSecretsKey = "ERMITE_SECRETS_KEY"
4550 )
4651
52+// SecretsKeyLen is the length of the secrets key in bytes (AES-256).
53+const SecretsKeyLen = 32
54+
4755 // Defaults.
4856 const (
4957 DefaultAddr = ":7341"
@@ -112,6 +120,10 @@ type Config struct {
112120 // CIPollTimeout is how long FetchJob holds a call; 0 (variable unset)
113121 // leaves the default to the CI use case.
114122 CIPollTimeout time.Duration
123+ // SecretsKey is the decoded ERMITE_SECRETS_KEY (SecretsKeyLen bytes); nil
124+ // when the variable is unset. It is a secret: never log it or put it in an
125+ // error.
126+ SecretsKey []byte
115127 }
116128
117129 // Error lists every configuration problem found, in variable order.
@@ -273,12 +285,32 @@ func Load(get func(string) string) (Config, error) {
273285 }
274286 }
275287
288+ if raw := strings.TrimSpace(get(EnvSecretsKey)); raw != "" {
289+ // The message never repeats the value.
290+ if key, ok := decodeSecretsKey(raw); !ok {
291+ bad(EnvSecretsKey, "must be %d random bytes in base64 (for example `head -c 32 /dev/urandom | base64`)", SecretsKeyLen)
292+ } else {
293+ c.SecretsKey = key
294+ }
295+ }
296+
276297 if len(problems) > 0 {
277298 return Config{}, &Error{Problems: problems}
278299 }
279300 return c, nil
280301 }
281302
303+// decodeSecretsKey reads 32 bytes in standard or URL-safe base64, with or
304+// without padding.
305+func decodeSecretsKey(raw string) ([]byte, bool) {
306+ for _, enc := range []*base64.Encoding{base64.StdEncoding, base64.RawStdEncoding, base64.URLEncoding, base64.RawURLEncoding} {
307+ if key, err := enc.DecodeString(raw); err == nil {
308+ return key, len(key) == SecretsKeyLen
309+ }
310+ }
311+ return nil, false
312+}
313+
282314 // parseRetention reads a duration in days ("90d") or in Go's syntax ("720h"),
283315 // at least one hour.
284316 func parseRetention(raw string) (time.Duration, error) {
M internal/config/config_test.go +34 -0
@@ -4,6 +4,7 @@ package config_test
44
55 import (
66 "crypto/sha256"
7+ "encoding/base64"
78 "errors"
89 "net/netip"
910 "strings"
@@ -285,3 +286,36 @@ func TestCIPollTimeout(t *testing.T) {
285286 assert.Contains(t, ce.Problems[0], config.EnvCIPollTimeout, in)
286287 }
287288 }
289+
290+func TestSecretsKey(t *testing.T) {
291+ key := make([]byte, config.SecretsKeyLen)
292+ for i := range key {
293+ key[i] = byte(i + 1)
294+ }
295+ std := base64.StdEncoding.EncodeToString(key)
296+
297+ cfg, err := config.Load(lookup(valid(nil)))
298+ require.NoError(t, err)
299+ assert.Nil(t, cfg.SecretsKey, "unset: secrets are off")
300+
301+ for name, enc := range map[string]string{
302+ "std": std, "raw": base64.RawStdEncoding.EncodeToString(key),
303+ "url": base64.URLEncoding.EncodeToString(key), "rawurl": base64.RawURLEncoding.EncodeToString(key), "spaces": " " + std + "\n",
304+ } {
305+ cfg, err = config.Load(lookup(valid(map[string]string{config.EnvSecretsKey: enc})))
306+ require.NoError(t, err, name)
307+ assert.Equal(t, key, cfg.SecretsKey, name)
308+ }
309+
310+ for name, bad := range map[string]string{
311+ "short": base64.StdEncoding.EncodeToString(key[:16]),
312+ "long": base64.StdEncoding.EncodeToString(append(key, 1)),
313+ "not base64": "not base64 at all!!",
314+ } {
315+ _, err = config.Load(lookup(valid(map[string]string{config.EnvSecretsKey: bad})))
316+ var ce *config.Error
317+ require.ErrorAs(t, err, &ce, name)
318+ assert.Contains(t, err.Error(), config.EnvSecretsKey, name)
319+ assert.NotContains(t, err.Error(), bad, "the value is never repeated: "+name)
320+ }
321+}
M internal/domain/ci.go +9 -0
@@ -178,6 +178,11 @@ type CIRun struct {
178178 CompletedAt time.Time
179179 CancelRequested bool
180180 CancelRequestedAt time.Time
181+ // Trusted is set when the actor of the run is the owner (TrustedActor):
182+ // only then do its jobs receive secrets and a write permission. A re-run
183+ // keeps or lowers it, never raises it (RerunTrusted). False for a run
184+ // stored before the field existed: the safe reading.
185+ Trusted bool
181186 // Version is the version of the stored record, which grows at every
182187 // change and never goes back.
183188 Version uint64
@@ -261,6 +266,10 @@ type CIJob struct {
261266 // need it unaffected (needs.<job>.result is success). The conclusion of
262267 // the job itself stays failure.
263268 ContinueOnError bool
269+ // Environment is the name of the environment of the job (its evaluated
270+ // "environment:"), "" for none. Set when the job is planned; the secrets
271+ // and variables of the environment are read when the job is assigned.
272+ Environment string
264273 // MaxParallel is the max-parallel of the strategy, 0 for no limit.
265274 MaxParallel int
266275 Outputs map[string]string
A internal/domain/ci_settings.go +199 -0
@@ -0,0 +1,199 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import (
6+ "errors"
7+ "net/url"
8+ "strings"
9+ "time"
10+ "unicode"
11+ "unicode/utf8"
12+)
13+
14+// Secrets, variables and environments (ADR 0019, CI-3 plan D2).
15+
16+// Limits of names and values.
17+const (
18+ // MaxSettingValueBytes is the largest secret or variable: 48 KB as
19+ // docs.github.com writes it, taken as 48 KiB.
20+ MaxSettingValueBytes = 48 * 1024
21+ // MaxSettingNameLen is the longest name of a secret, variable or
22+ // environment, in characters.
23+ MaxSettingNameLen = 255
24+ // ReservedSettingPrefix starts no secret or variable name: GitHub keeps it
25+ // for its own.
26+ ReservedSettingPrefix = "GITHUB_"
27+)
28+
29+// ErrSecretsKeyMismatch means the data was sealed with another
30+// ERMITE_SECRETS_KEY than the one the server runs with: it refuses to start
31+// rather than hand out values it cannot read or seal new ones with a key the
32+// old ones do not open with.
33+var ErrSecretsKeyMismatch = errors.New("ERMITE_SECRETS_KEY is not the key the stored secrets were sealed with")
34+
35+// ParseSettingName checks the name of a secret or variable and returns it in
36+// upper case, the way it is stored: letters, digits and "_", not starting with
37+// a digit or with GITHUB_, 1 to 255 characters, case-insensitive.
38+func ParseSettingName(s string) (string, error) {
39+ if s == "" || len(s) > MaxSettingNameLen {
40+ return "", &TextError{Reason: "must be 1 to 255 characters"}
41+ }
42+ for i := 0; i < len(s); i++ {
43+ c := s[i]
44+ switch {
45+ case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c == '_':
46+ case c >= '0' && c <= '9' && i > 0:
47+ default:
48+ return "", &TextError{Reason: "may only hold letters, digits and underscores, and must not start with a digit"}
49+ }
50+ }
51+ up := strings.ToUpper(s)
52+ if strings.HasPrefix(up, ReservedSettingPrefix) {
53+ return "", &TextError{Reason: "must not start with " + ReservedSettingPrefix}
54+ }
55+ return up, nil
56+}
57+
58+// CheckSecretValue checks the value of a secret: 1 byte to 48 KiB.
59+func CheckSecretValue(v string) error {
60+ switch {
61+ case v == "":
62+ return &TextError{Reason: "must not be empty"}
63+ case len(v) > MaxSettingValueBytes:
64+ return &TextError{Reason: "must be at most 49,152 bytes (48 KiB)"}
65+ }
66+ return nil
67+}
68+
69+// CheckVariableValue checks the value of a variable: at most 48 KiB, may be
70+// empty.
71+func CheckVariableValue(v string) error {
72+ if len(v) > MaxSettingValueBytes {
73+ return &TextError{Reason: "must be at most 49,152 bytes (48 KiB)"}
74+ }
75+ return nil
76+}
77+
78+// ParseEnvironmentName checks the name of an environment: 1 to 255 characters
79+// of valid UTF-8 without control characters or leading and trailing whitespace
80+// (the one rule for the API, the CLI and workflows: a name is never trimmed).
81+// Names are unique without regard to case; the case given is kept.
82+func ParseEnvironmentName(s string) (string, error) {
83+ if s == "" || utf8.RuneCountInString(s) > MaxSettingNameLen {
84+ return "", &TextError{Reason: "must be 1 to 255 characters"}
85+ }
86+ if !utf8.ValidString(s) {
87+ return "", &TextError{Reason: "must be valid UTF-8"}
88+ }
89+ first, _ := utf8.DecodeRuneInString(s)
90+ last, _ := utf8.DecodeLastRuneInString(s)
91+ if unicode.IsSpace(first) || unicode.IsSpace(last) {
92+ return "", &TextError{Reason: "must not start or end with whitespace"}
93+ }
94+ for _, r := range s {
95+ if unicode.IsControl(r) || isBidiControl(r) {
96+ return "", &TextError{Reason: "must not hold control characters"}
97+ }
98+ }
99+ return s, nil
100+}
101+
102+// SettingsScopeKind tells where secrets and variables live.
103+type SettingsScopeKind uint8
104+
105+// Scope kinds.
106+const (
107+ // ScopeKindInstance is the whole server.
108+ ScopeKindInstance SettingsScopeKind = iota + 1
109+ // ScopeKindRepo is one repository.
110+ ScopeKindRepo
111+ // ScopeKindEnvironment is one environment of a repository.
112+ ScopeKindEnvironment
113+)
114+
115+// SettingsScope says where a secret or a variable lives. Build it with
116+// InstanceScope, RepoScope or EnvironmentScope; the zero value is invalid.
117+type SettingsScope struct {
118+ Kind SettingsScopeKind
119+ Repo RepoName
120+ // Env is the environment name in the case it was given. Scopes compare by
121+ // Key, which folds the case.
122+ Env string
123+}
124+
125+// InstanceScope is the scope of the whole server.
126+func InstanceScope() SettingsScope { return SettingsScope{Kind: ScopeKindInstance} }
127+
128+// RepoScope is the scope of a repository.
129+func RepoScope(repo RepoName) SettingsScope { return SettingsScope{Kind: ScopeKindRepo, Repo: repo} }
130+
131+// EnvironmentScope is the scope of an environment of a repository; the name
132+// must follow ParseEnvironmentName.
133+func EnvironmentScope(repo RepoName, env string) SettingsScope {
134+ return SettingsScope{Kind: ScopeKindEnvironment, Repo: repo, Env: env}
135+}
136+
137+// Valid reports whether the scope is well formed.
138+func (s SettingsScope) Valid() bool {
139+ switch s.Kind {
140+ case ScopeKindInstance:
141+ return true
142+ case ScopeKindRepo:
143+ return !s.Repo.IsZero()
144+ case ScopeKindEnvironment:
145+ if s.Repo.IsZero() {
146+ return false
147+ }
148+ _, err := ParseEnvironmentName(s.Env)
149+ return err == nil
150+ }
151+ return false
152+}
153+
154+// Key is the scope as the storage and the encryption name it: "i",
155+// "r/<repo>" or "e/<repo>/<environment>". The environment is lower-cased and
156+// escaped so that its key holds no "/" and two names that differ by case are
157+// one scope.
158+func (s SettingsScope) Key() string {
159+ switch s.Kind {
160+ case ScopeKindInstance:
161+ return "i"
162+ case ScopeKindRepo:
163+ return "r/" + s.Repo.String()
164+ case ScopeKindEnvironment:
165+ return "e/" + s.Repo.String() + "/" + EnvironmentKey(s.Env)
166+ }
167+ return ""
168+}
169+
170+// EnvironmentKey is the part of a storage key that names an environment: its
171+// name lower-cased and path-escaped.
172+func EnvironmentKey(name string) string { return url.PathEscape(strings.ToLower(name)) }
173+
174+// SecretAAD is the additional data a secret is sealed with. It binds the
175+// ciphertext to its scope and name, so a stored value moved to another secret
176+// does not open.
177+func SecretAAD(scope SettingsScope, name string) []byte {
178+ return []byte("ermite-secret\x00" + scope.Key() + "\x00" + name)
179+}
180+
181+// Secret describes a stored secret; it never holds the value.
182+type Secret struct {
183+ Name string
184+ UpdatedAt time.Time
185+}
186+
187+// Variable is a configuration variable.
188+type Variable struct {
189+ Name string
190+ Value string
191+ UpdatedAt time.Time
192+}
193+
194+// Environment is a named deployment target of a repository.
195+type Environment struct {
196+ Repo RepoName
197+ Name string
198+ CreatedAt time.Time
199+}
A internal/domain/ci_settings_test.go +80 -0
@@ -0,0 +1,80 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain_test
4+
5+import (
6+ "strings"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "bnema.dev/ermite/internal/domain"
13+)
14+
15+func TestParseSettingName(t *testing.T) {
16+ t.Parallel()
17+ ok := map[string]string{"a": "A", "My_Secret1": "MY_SECRET1", "_x": "_X", "X": "X", strings.Repeat("a", 255): strings.Repeat("A", 255)}
18+ for in, want := range ok {
19+ got, err := domain.ParseSettingName(in)
20+ require.NoError(t, err, in)
21+ assert.Equal(t, want, got, "stored upper-cased")
22+ }
23+ for _, bad := range []string{"", "1abc", "a-b", "a b", "a.b", "é", "GITHUB_TOKEN", "github_x", "Github_", strings.Repeat("a", 256), "a\n"} {
24+ _, err := domain.ParseSettingName(bad)
25+ require.Error(t, err, bad)
26+ assert.True(t, domain.IsInvalid(err), bad)
27+ }
28+ // "GITHUB" alone, and names that merely contain the prefix, are allowed.
29+ for _, fine := range []string{"GITHUB", "MY_GITHUB_X", "GITHUBX"} {
30+ _, err := domain.ParseSettingName(fine)
31+ assert.NoError(t, err, fine)
32+ }
33+}
34+
35+func TestSettingValueSize(t *testing.T) {
36+ t.Parallel()
37+ assert.Equal(t, 49152, domain.MaxSettingValueBytes, "48 KB as docs.github.com says it, taken as 48 KiB")
38+ assert.NoError(t, domain.CheckSecretValue("x"))
39+ assert.NoError(t, domain.CheckSecretValue(strings.Repeat("x", 49152)))
40+ assert.Error(t, domain.CheckSecretValue(strings.Repeat("x", 49153)))
41+ assert.Error(t, domain.CheckSecretValue(""), "an empty secret is refused")
42+ assert.NoError(t, domain.CheckVariableValue(""), "an empty variable is allowed")
43+ assert.NoError(t, domain.CheckVariableValue(strings.Repeat("x", 49152)))
44+ assert.Error(t, domain.CheckVariableValue(strings.Repeat("x", 49153)))
45+}
46+
47+func TestParseEnvironmentName(t *testing.T) {
48+ t.Parallel()
49+ for _, ok := range []string{"production", "Staging 2", "prod/eu", "é", strings.Repeat("é", 255)} {
50+ _, err := domain.ParseEnvironmentName(ok)
51+ assert.NoError(t, err, ok)
52+ }
53+ for _, bad := range []string{"", "a\nb", "a\x00", "a\u202eb", strings.Repeat("é", 256), "\xff", " lead", "trail ", "\tx", "x\u00a0", " "} {
54+ _, err := domain.ParseEnvironmentName(bad)
55+ require.Error(t, err, bad)
56+ assert.True(t, domain.IsInvalid(err), bad)
57+ }
58+}
59+
60+func TestSettingsScopeKeysAreDistinctAndFoldEnvironmentCase(t *testing.T) {
61+ t.Parallel()
62+ repo, _ := domain.ParseRepoName("proj")
63+ assert.Equal(t, "i", domain.InstanceScope().Key())
64+ assert.Equal(t, "r/proj", domain.RepoScope(repo).Key())
65+ assert.Equal(t, domain.EnvironmentScope(repo, "Prod").Key(), domain.EnvironmentScope(repo, "prod").Key(), "environments are unique without regard to case")
66+ assert.NotContains(t, domain.EnvironmentScope(repo, "a/b").Key()[len("e/proj/"):], "/", "no separator inside the environment part")
67+ assert.NotEqual(t, domain.EnvironmentScope(repo, "a/b").Key(), domain.EnvironmentScope(repo, "a%2Fb").Key())
68+ assert.False(t, domain.SettingsScope{}.Valid())
69+ assert.False(t, domain.EnvironmentScope(repo, "").Valid())
70+ assert.True(t, domain.EnvironmentScope(repo, "x").Valid())
71+}
72+
73+func TestSecretAADBindsScopeAndName(t *testing.T) {
74+ t.Parallel()
75+ repo, _ := domain.ParseRepoName("proj")
76+ a := domain.SecretAAD(domain.RepoScope(repo), "TOKEN")
77+ assert.NotEqual(t, a, domain.SecretAAD(domain.RepoScope(repo), "OTHER"))
78+ assert.NotEqual(t, a, domain.SecretAAD(domain.InstanceScope(), "TOKEN"))
79+ assert.NotEqual(t, a, domain.SecretAAD(domain.EnvironmentScope(repo, "prod"), "TOKEN"))
80+}
A internal/domain/ci_trust.go +21 -0
@@ -0,0 +1,21 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+// TrustedActor reports whether a run started by p is trusted: its jobs get
6+// secrets and, later, the write permissions of their workflow (CI-3, D1).
7+//
8+// Only the owner is trusted: the root token, the owner signed in on the web, and
9+// a stored token with the owner's rights (every scope, no repository list, no
10+// branch prefixes: Principal.HasOwnerRights). Everything else is not: a token
11+// limited to branch prefixes (an automated agent could otherwise push a
12+// workflow that prints any secret), a token limited to some repositories or
13+// scopes, a job token and an anonymous caller. When in doubt the answer is no:
14+// an untrusted run only misses its secrets, a trusted one by mistake leaks them.
15+func TrustedActor(p Principal) bool { return p.HasOwnerRights() }
16+
17+// RerunTrusted is the trusted flag of a new attempt of a run: the run was
18+// trusted and the caller of the re-run is trusted too. A re-run keeps the flag
19+// or lowers it and never raises it: the owner who runs an agent's run again
20+// does not unlock its secrets.
21+func RerunTrusted(original bool, caller Principal) bool { return original && TrustedActor(caller) }
A internal/domain/ci_trust_test.go +48 -0
@@ -0,0 +1,48 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain_test
4+
5+import (
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+
10+ "bnema.dev/ermite/internal/domain"
11+)
12+
13+func tokenOf(t *testing.T, tok domain.Token) domain.Principal {
14+ t.Helper()
15+ return domain.TokenPrincipal(tok)
16+}
17+
18+func TestTrustedActor(t *testing.T) {
19+ t.Parallel()
20+ repo, _ := domain.ParseRepoName("proj")
21+ all := domain.AllScopes()
22+ cases := []struct {
23+ name string
24+ p domain.Principal
25+ want bool
26+ }{
27+ {"owner session", domain.Owner(), true},
28+ {"root token", domain.Root(), true},
29+ {"unlimited token with every scope", tokenOf(t, domain.Token{ID: "a", Scopes: all}), true},
30+ {"branch-limited token with every scope", tokenOf(t, domain.Token{ID: "b", Scopes: all, BranchPrefixes: []string{"agent/x/"}}), false},
31+ {"token limited to a repository", tokenOf(t, domain.Token{ID: "c", Scopes: all, Repos: []domain.RepoName{repo}}), false},
32+ {"token without every scope", tokenOf(t, domain.Token{ID: "d", Scopes: []domain.Scope{domain.ScopeRepoWrite}}), false},
33+ {"job token", domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "e", Repo: repo}), false},
34+ {"anonymous", domain.Anonymous(), false},
35+ }
36+ for _, tc := range cases {
37+ assert.Equal(t, tc.want, domain.TrustedActor(tc.p), tc.name)
38+ }
39+}
40+
41+func TestRerunTrustedNeverRaises(t *testing.T) {
42+ t.Parallel()
43+ agent := tokenOf(t, domain.Token{ID: "b", Scopes: domain.AllScopes(), BranchPrefixes: []string{"agent/"}})
44+ assert.True(t, domain.RerunTrusted(true, domain.Owner()), "owner re-runs an owner's run")
45+ assert.False(t, domain.RerunTrusted(true, agent), "an agent re-running an owner's run lowers it")
46+ assert.False(t, domain.RerunTrusted(false, domain.Owner()), "the owner re-running an agent's run does not unlock it")
47+ assert.False(t, domain.RerunTrusted(false, domain.Root()), "nor does root")
48+}
M internal/ports/ci.go +113 -0
@@ -5,6 +5,7 @@ package ports
55 import (
66 "context"
77 "errors"
8+ "fmt"
89 "strconv"
910 "time"
1011
@@ -286,8 +287,29 @@ type RunnerJob struct {
286287 Sha, Ref string
287288 WorkflowPath string
288289 WorkflowYAML string
290+ // Environment is the environment the job deploys to, "" for none.
291+ Environment string
292+ // Vars are the merged variables of the job (environment over repository
293+ // over instance), already in its "vars" context.
294+ Vars map[string]string
295+ // Secrets are the secrets the job refers to, in plain text: empty when the
296+ // run is not trusted, when the job refers to none and when the server has
297+ // no key. They go to the runner and nowhere else: never into the run
298+ // record, the github context or a log.
299+ Secrets map[string]string
300+ // Trusted is the trusted flag of the run: secrets were allowed.
301+ Trusted bool
289302 }
290303
304+// String describes the job without its secrets and tokens, so that a job
305+// printed by mistake leaks nothing.
306+func (j RunnerJob) String() string {
307+ return fmt.Sprintf("RunnerJob{job:%s run:%d repo:%s secrets:%d trusted:%t}", j.Job.ID, j.Job.RunID, j.Repo, len(j.Secrets), j.Trusted)
308+}
309+
310+// GoString is String for %#v.
311+func (j RunnerJob) GoString() string { return j.String() }
312+
291313 // FetchJobParams selects the job a runner asks for.
292314 type FetchJobParams struct {
293315 Labels []string
@@ -542,3 +564,94 @@ type RunService interface {
542564 ListRunners(ctx context.Context, p domain.Principal, size int, token string) (RunnerPage, error)
543565 RemoveRunner(ctx context.Context, p domain.Principal, id string) error
544566 }
567+
568+// ErrSecretNotOpened means a sealed secret does not open: another key, other
569+// additional data (a value moved to another scope or name) or a damaged value.
570+var ErrSecretNotOpened = errors.New("secret does not open")
571+
572+// SecretSealer encrypts the values of secrets at rest. The adapter holds the
573+// master key; without one the use cases get no sealer and refuse secrets.
574+type SecretSealer interface {
575+ // Seal returns plaintext sealed and bound to aad. It never fails.
576+ Seal(plaintext, aad []byte) []byte
577+ // Open reverses Seal with the same aad, or returns ErrSecretNotOpened.
578+ Open(sealed, aad []byte) ([]byte, error)
579+ // KeyCheck is a value derived from the key that does not reveal it.
580+ KeyCheck() []byte
581+}
582+
583+// SealedSecret is a stored secret: its value is sealed.
584+type SealedSecret struct {
585+ Name string
586+ Sealed []byte
587+ UpdatedAt time.Time
588+}
589+
590+// CISettingsStore keeps the secrets, variables and environments of CI (ADR
591+// 0019). A scope that names a missing repository or environment is
592+// domain.ErrNotFound for every write and every read of its content. Names are
593+// already upper-cased (domain.ParseSettingName).
594+type CISettingsStore interface {
595+ // BindSecretsKey records the key check beside the secrets when none is
596+ // stored, and returns domain.ErrSecretsKeyMismatch when the stored one
597+ // differs.
598+ BindSecretsKey(ctx context.Context, check []byte) error
599+ // PutSecret creates or replaces a secret.
600+ PutSecret(ctx context.Context, scope domain.SettingsScope, name string, sealed []byte, at time.Time) (domain.Secret, error)
601+ // GetSealedSecret reads one secret of a scope, or domain.ErrNotFound when
602+ // it, the repository or the environment does not exist.
603+ GetSealedSecret(ctx context.Context, scope domain.SettingsScope, name string) (SealedSecret, error)
604+ // ListSealedSecrets lists the secrets of one scope ordered by name.
605+ ListSealedSecrets(ctx context.Context, scope domain.SettingsScope) ([]SealedSecret, error)
606+ // DeleteSecret deletes a secret, or returns domain.ErrNotFound.
607+ DeleteSecret(ctx context.Context, scope domain.SettingsScope, name string) error
608+ // PutVariable creates or replaces a variable.
609+ PutVariable(ctx context.Context, scope domain.SettingsScope, name, value string, at time.Time) (domain.Variable, error)
610+ // GetVariable returns a variable, or domain.ErrNotFound.
611+ GetVariable(ctx context.Context, scope domain.SettingsScope, name string) (domain.Variable, error)
612+ // ListVariables lists the variables of one scope ordered by name.
613+ ListVariables(ctx context.Context, scope domain.SettingsScope) ([]domain.Variable, error)
614+ // DeleteVariable deletes a variable, or returns domain.ErrNotFound.
615+ DeleteVariable(ctx context.Context, scope domain.SettingsScope, name string) error
616+ // CreateEnvironment creates the environment, or returns the existing one
617+ // (compared without regard to case) unchanged.
618+ CreateEnvironment(ctx context.Context, repo domain.RepoName, name string, at time.Time) (domain.Environment, error)
619+ // ListEnvironments lists the environments of a repository ordered by name.
620+ ListEnvironments(ctx context.Context, repo domain.RepoName) ([]domain.Environment, error)
621+ // DeleteEnvironment deletes the environment with its secrets and
622+ // variables, or returns domain.ErrNotFound.
623+ DeleteEnvironment(ctx context.Context, repo domain.RepoName, name string) error
624+}
625+
626+// JobSettingsRequest asks for the variables, and when wanted the secrets, a job
627+// sees.
628+type JobSettingsRequest struct {
629+ Repo domain.RepoName
630+ // Environment is the environment of the job, "" for none.
631+ Environment string
632+ // CreateEnvironment creates the environment first when it does not exist
633+ // (a job that names one creates it on first use).
634+ CreateEnvironment bool
635+ // WantSecrets asks for secrets at all: false for a run that is not
636+ // trusted. SecretNames are the (upper-cased) names to return, or AllSecrets
637+ // for every secret of the scopes.
638+ WantSecrets bool
639+ SecretNames []string
640+ AllSecrets bool
641+}
642+
643+// JobSettings are what a job sees: the merged variables and secrets, with the
644+// environment over the repository over the instance.
645+type JobSettings struct {
646+ Vars map[string]string
647+ Secrets map[string]string
648+ // SecretsOff is set when secrets were wanted but the server has no
649+ // ERMITE_SECRETS_KEY, so none can be given.
650+ SecretsOff bool
651+}
652+
653+// CIJobSettings resolves the variables and secrets of a job. *usecase.CISettings
654+// implements it.
655+type CIJobSettings interface {
656+ Resolve(ctx context.Context, req JobSettingsRequest) (JobSettings, error)
657+}
A internal/ports/mocks/CIJobSettings.go +114 -0
@@ -0,0 +1,114 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/ports"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewCIJobSettings creates a new instance of CIJobSettings. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewCIJobSettings(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *CIJobSettings {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &CIJobSettings{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// CIJobSettings is an autogenerated mock type for the CIJobSettings type
38+type CIJobSettings struct {
39+ mock.Mock
40+}
41+
42+type CIJobSettings_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *CIJobSettings) EXPECT() *CIJobSettings_Expecter {
47+ return &CIJobSettings_Expecter{mock: &_m.Mock}
48+}
49+
50+// Resolve provides a mock function for the type CIJobSettings
51+func (_mock *CIJobSettings) Resolve(ctx context.Context, req ports.JobSettingsRequest) (ports.JobSettings, error) {
52+ ret := _mock.Called(ctx, req)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for Resolve")
56+ }
57+
58+ var r0 ports.JobSettings
59+ var r1 error
60+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.JobSettingsRequest) (ports.JobSettings, error)); ok {
61+ return returnFunc(ctx, req)
62+ }
63+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.JobSettingsRequest) ports.JobSettings); ok {
64+ r0 = returnFunc(ctx, req)
65+ } else {
66+ r0 = ret.Get(0).(ports.JobSettings)
67+ }
68+ if returnFunc, ok := ret.Get(1).(func(context.Context, ports.JobSettingsRequest) error); ok {
69+ r1 = returnFunc(ctx, req)
70+ } else {
71+ r1 = ret.Error(1)
72+ }
73+ return r0, r1
74+}
75+
76+// CIJobSettings_Resolve_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Resolve'
77+type CIJobSettings_Resolve_Call struct {
78+ *mock.Call
79+}
80+
81+// Resolve is a helper method to define mock.On call
82+// - ctx context.Context
83+// - req ports.JobSettingsRequest
84+func (_e *CIJobSettings_Expecter) Resolve(ctx any, req any) *CIJobSettings_Resolve_Call {
85+ return &CIJobSettings_Resolve_Call{Call: _e.mock.On("Resolve", ctx, req)}
86+}
87+
88+func (_c *CIJobSettings_Resolve_Call) Run(run func(ctx context.Context, req ports.JobSettingsRequest)) *CIJobSettings_Resolve_Call {
89+ _c.Call.Run(func(args mock.Arguments) {
90+ var arg0 context.Context
91+ if args[0] != nil {
92+ arg0 = args[0].(context.Context)
93+ }
94+ var arg1 ports.JobSettingsRequest
95+ if args[1] != nil {
96+ arg1 = args[1].(ports.JobSettingsRequest)
97+ }
98+ run(
99+ arg0,
100+ arg1,
101+ )
102+ })
103+ return _c
104+}
105+
106+func (_c *CIJobSettings_Resolve_Call) Return(jobSettings ports.JobSettings, err error) *CIJobSettings_Resolve_Call {
107+ _c.Call.Return(jobSettings, err)
108+ return _c
109+}
110+
111+func (_c *CIJobSettings_Resolve_Call) RunAndReturn(run func(ctx context.Context, req ports.JobSettingsRequest) (ports.JobSettings, error)) *CIJobSettings_Resolve_Call {
112+ _c.Call.Return(run)
113+ return _c
114+}
A internal/ports/mocks/CISettingsStore.go +890 -0
@@ -0,0 +1,890 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+ "time"
10+
11+ "bnema.dev/ermite/internal/domain"
12+ "bnema.dev/ermite/internal/ports"
13+ mock "github.com/stretchr/testify/mock"
14+)
15+
16+// NewCISettingsStore creates a new instance of CISettingsStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
17+// The first argument is typically a *testing.T value.
18+func NewCISettingsStore(t interface {
19+ mock.TestingT
20+ Cleanup(func())
21+}) *CISettingsStore {
22+ if helper, ok := t.(interface{ Helper() }); ok {
23+ helper.Helper()
24+ }
25+
26+ mock := &CISettingsStore{}
27+ mock.Mock.Test(t)
28+
29+ t.Cleanup(func() {
30+ if helper, ok := t.(interface{ Helper() }); ok {
31+ helper.Helper()
32+ }
33+ mock.AssertExpectations(t)
34+ })
35+
36+ return mock
37+}
38+
39+// CISettingsStore is an autogenerated mock type for the CISettingsStore type
40+type CISettingsStore struct {
41+ mock.Mock
42+}
43+
44+type CISettingsStore_Expecter struct {
45+ mock *mock.Mock
46+}
47+
48+func (_m *CISettingsStore) EXPECT() *CISettingsStore_Expecter {
49+ return &CISettingsStore_Expecter{mock: &_m.Mock}
50+}
51+
52+// BindSecretsKey provides a mock function for the type CISettingsStore
53+func (_mock *CISettingsStore) BindSecretsKey(ctx context.Context, check []byte) error {
54+ ret := _mock.Called(ctx, check)
55+
56+ if len(ret) == 0 {
57+ panic("no return value specified for BindSecretsKey")
58+ }
59+
60+ var r0 error
61+ if returnFunc, ok := ret.Get(0).(func(context.Context, []byte) error); ok {
62+ r0 = returnFunc(ctx, check)
63+ } else {
64+ r0 = ret.Error(0)
65+ }
66+ return r0
67+}
68+
69+// CISettingsStore_BindSecretsKey_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BindSecretsKey'
70+type CISettingsStore_BindSecretsKey_Call struct {
71+ *mock.Call
72+}
73+
74+// BindSecretsKey is a helper method to define mock.On call
75+// - ctx context.Context
76+// - check []byte
77+func (_e *CISettingsStore_Expecter) BindSecretsKey(ctx any, check any) *CISettingsStore_BindSecretsKey_Call {
78+ return &CISettingsStore_BindSecretsKey_Call{Call: _e.mock.On("BindSecretsKey", ctx, check)}
79+}
80+
81+func (_c *CISettingsStore_BindSecretsKey_Call) Run(run func(ctx context.Context, check []byte)) *CISettingsStore_BindSecretsKey_Call {
82+ _c.Call.Run(func(args mock.Arguments) {
83+ var arg0 context.Context
84+ if args[0] != nil {
85+ arg0 = args[0].(context.Context)
86+ }
87+ var arg1 []byte
88+ if args[1] != nil {
89+ arg1 = args[1].([]byte)
90+ }
91+ run(
92+ arg0,
93+ arg1,
94+ )
95+ })
96+ return _c
97+}
98+
99+func (_c *CISettingsStore_BindSecretsKey_Call) Return(err error) *CISettingsStore_BindSecretsKey_Call {
100+ _c.Call.Return(err)
101+ return _c
102+}
103+
104+func (_c *CISettingsStore_BindSecretsKey_Call) RunAndReturn(run func(ctx context.Context, check []byte) error) *CISettingsStore_BindSecretsKey_Call {
105+ _c.Call.Return(run)
106+ return _c
107+}
108+
109+// CreateEnvironment provides a mock function for the type CISettingsStore
110+func (_mock *CISettingsStore) CreateEnvironment(ctx context.Context, repo domain.RepoName, name string, at time.Time) (domain.Environment, error) {
111+ ret := _mock.Called(ctx, repo, name, at)
112+
113+ if len(ret) == 0 {
114+ panic("no return value specified for CreateEnvironment")
115+ }
116+
117+ var r0 domain.Environment
118+ var r1 error
119+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, string, time.Time) (domain.Environment, error)); ok {
120+ return returnFunc(ctx, repo, name, at)
121+ }
122+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, string, time.Time) domain.Environment); ok {
123+ r0 = returnFunc(ctx, repo, name, at)
124+ } else {
125+ r0 = ret.Get(0).(domain.Environment)
126+ }
127+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, string, time.Time) error); ok {
128+ r1 = returnFunc(ctx, repo, name, at)
129+ } else {
130+ r1 = ret.Error(1)
131+ }
132+ return r0, r1
133+}
134+
135+// CISettingsStore_CreateEnvironment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateEnvironment'
136+type CISettingsStore_CreateEnvironment_Call struct {
137+ *mock.Call
138+}
139+
140+// CreateEnvironment is a helper method to define mock.On call
141+// - ctx context.Context
142+// - repo domain.RepoName
143+// - name string
144+// - at time.Time
145+func (_e *CISettingsStore_Expecter) CreateEnvironment(ctx any, repo any, name any, at any) *CISettingsStore_CreateEnvironment_Call {
146+ return &CISettingsStore_CreateEnvironment_Call{Call: _e.mock.On("CreateEnvironment", ctx, repo, name, at)}
147+}
148+
149+func (_c *CISettingsStore_CreateEnvironment_Call) Run(run func(ctx context.Context, repo domain.RepoName, name string, at time.Time)) *CISettingsStore_CreateEnvironment_Call {
150+ _c.Call.Run(func(args mock.Arguments) {
151+ var arg0 context.Context
152+ if args[0] != nil {
153+ arg0 = args[0].(context.Context)
154+ }
155+ var arg1 domain.RepoName
156+ if args[1] != nil {
157+ arg1 = args[1].(domain.RepoName)
158+ }
159+ var arg2 string
160+ if args[2] != nil {
161+ arg2 = args[2].(string)
162+ }
163+ var arg3 time.Time
164+ if args[3] != nil {
165+ arg3 = args[3].(time.Time)
166+ }
167+ run(
168+ arg0,
169+ arg1,
170+ arg2,
171+ arg3,
172+ )
173+ })
174+ return _c
175+}
176+
177+func (_c *CISettingsStore_CreateEnvironment_Call) Return(environment domain.Environment, err error) *CISettingsStore_CreateEnvironment_Call {
178+ _c.Call.Return(environment, err)
179+ return _c
180+}
181+
182+func (_c *CISettingsStore_CreateEnvironment_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, name string, at time.Time) (domain.Environment, error)) *CISettingsStore_CreateEnvironment_Call {
183+ _c.Call.Return(run)
184+ return _c
185+}
186+
187+// DeleteEnvironment provides a mock function for the type CISettingsStore
188+func (_mock *CISettingsStore) DeleteEnvironment(ctx context.Context, repo domain.RepoName, name string) error {
189+ ret := _mock.Called(ctx, repo, name)
190+
191+ if len(ret) == 0 {
192+ panic("no return value specified for DeleteEnvironment")
193+ }
194+
195+ var r0 error
196+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, string) error); ok {
197+ r0 = returnFunc(ctx, repo, name)
198+ } else {
199+ r0 = ret.Error(0)
200+ }
201+ return r0
202+}
203+
204+// CISettingsStore_DeleteEnvironment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteEnvironment'
205+type CISettingsStore_DeleteEnvironment_Call struct {
206+ *mock.Call
207+}
208+
209+// DeleteEnvironment is a helper method to define mock.On call
210+// - ctx context.Context
211+// - repo domain.RepoName
212+// - name string
213+func (_e *CISettingsStore_Expecter) DeleteEnvironment(ctx any, repo any, name any) *CISettingsStore_DeleteEnvironment_Call {
214+ return &CISettingsStore_DeleteEnvironment_Call{Call: _e.mock.On("DeleteEnvironment", ctx, repo, name)}
215+}
216+
217+func (_c *CISettingsStore_DeleteEnvironment_Call) Run(run func(ctx context.Context, repo domain.RepoName, name string)) *CISettingsStore_DeleteEnvironment_Call {
218+ _c.Call.Run(func(args mock.Arguments) {
219+ var arg0 context.Context
220+ if args[0] != nil {
221+ arg0 = args[0].(context.Context)
222+ }
223+ var arg1 domain.RepoName
224+ if args[1] != nil {
225+ arg1 = args[1].(domain.RepoName)
226+ }
227+ var arg2 string
228+ if args[2] != nil {
229+ arg2 = args[2].(string)
230+ }
231+ run(
232+ arg0,
233+ arg1,
234+ arg2,
235+ )
236+ })
237+ return _c
238+}
239+
240+func (_c *CISettingsStore_DeleteEnvironment_Call) Return(err error) *CISettingsStore_DeleteEnvironment_Call {
241+ _c.Call.Return(err)
242+ return _c
243+}
244+
245+func (_c *CISettingsStore_DeleteEnvironment_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, name string) error) *CISettingsStore_DeleteEnvironment_Call {
246+ _c.Call.Return(run)
247+ return _c
248+}
249+
250+// DeleteSecret provides a mock function for the type CISettingsStore
251+func (_mock *CISettingsStore) DeleteSecret(ctx context.Context, scope domain.SettingsScope, name string) error {
252+ ret := _mock.Called(ctx, scope, name)
253+
254+ if len(ret) == 0 {
255+ panic("no return value specified for DeleteSecret")
256+ }
257+
258+ var r0 error
259+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.SettingsScope, string) error); ok {
260+ r0 = returnFunc(ctx, scope, name)
261+ } else {
262+ r0 = ret.Error(0)
263+ }
264+ return r0
265+}
266+
267+// CISettingsStore_DeleteSecret_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteSecret'
268+type CISettingsStore_DeleteSecret_Call struct {
269+ *mock.Call
270+}
271+
272+// DeleteSecret is a helper method to define mock.On call
273+// - ctx context.Context
274+// - scope domain.SettingsScope
275+// - name string
276+func (_e *CISettingsStore_Expecter) DeleteSecret(ctx any, scope any, name any) *CISettingsStore_DeleteSecret_Call {
277+ return &CISettingsStore_DeleteSecret_Call{Call: _e.mock.On("DeleteSecret", ctx, scope, name)}
278+}
279+
280+func (_c *CISettingsStore_DeleteSecret_Call) Run(run func(ctx context.Context, scope domain.SettingsScope, name string)) *CISettingsStore_DeleteSecret_Call {
281+ _c.Call.Run(func(args mock.Arguments) {
282+ var arg0 context.Context
283+ if args[0] != nil {
284+ arg0 = args[0].(context.Context)
285+ }
286+ var arg1 domain.SettingsScope
287+ if args[1] != nil {
288+ arg1 = args[1].(domain.SettingsScope)
289+ }
290+ var arg2 string
291+ if args[2] != nil {
292+ arg2 = args[2].(string)
293+ }
294+ run(
295+ arg0,
296+ arg1,
297+ arg2,
298+ )
299+ })
300+ return _c
301+}
302+
303+func (_c *CISettingsStore_DeleteSecret_Call) Return(err error) *CISettingsStore_DeleteSecret_Call {
304+ _c.Call.Return(err)
305+ return _c
306+}
307+
308+func (_c *CISettingsStore_DeleteSecret_Call) RunAndReturn(run func(ctx context.Context, scope domain.SettingsScope, name string) error) *CISettingsStore_DeleteSecret_Call {
309+ _c.Call.Return(run)
310+ return _c
311+}
312+
313+// DeleteVariable provides a mock function for the type CISettingsStore
314+func (_mock *CISettingsStore) DeleteVariable(ctx context.Context, scope domain.SettingsScope, name string) error {
315+ ret := _mock.Called(ctx, scope, name)
316+
317+ if len(ret) == 0 {
318+ panic("no return value specified for DeleteVariable")
319+ }
320+
321+ var r0 error
322+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.SettingsScope, string) error); ok {
323+ r0 = returnFunc(ctx, scope, name)
324+ } else {
325+ r0 = ret.Error(0)
326+ }
327+ return r0
328+}
329+
330+// CISettingsStore_DeleteVariable_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteVariable'
331+type CISettingsStore_DeleteVariable_Call struct {
332+ *mock.Call
333+}
334+
335+// DeleteVariable is a helper method to define mock.On call
336+// - ctx context.Context
337+// - scope domain.SettingsScope
338+// - name string
339+func (_e *CISettingsStore_Expecter) DeleteVariable(ctx any, scope any, name any) *CISettingsStore_DeleteVariable_Call {
340+ return &CISettingsStore_DeleteVariable_Call{Call: _e.mock.On("DeleteVariable", ctx, scope, name)}
341+}
342+
343+func (_c *CISettingsStore_DeleteVariable_Call) Run(run func(ctx context.Context, scope domain.SettingsScope, name string)) *CISettingsStore_DeleteVariable_Call {
344+ _c.Call.Run(func(args mock.Arguments) {
345+ var arg0 context.Context
346+ if args[0] != nil {
347+ arg0 = args[0].(context.Context)
348+ }
349+ var arg1 domain.SettingsScope
350+ if args[1] != nil {
351+ arg1 = args[1].(domain.SettingsScope)
352+ }
353+ var arg2 string
354+ if args[2] != nil {
355+ arg2 = args[2].(string)
356+ }
357+ run(
358+ arg0,
359+ arg1,
360+ arg2,
361+ )
362+ })
363+ return _c
364+}
365+
366+func (_c *CISettingsStore_DeleteVariable_Call) Return(err error) *CISettingsStore_DeleteVariable_Call {
367+ _c.Call.Return(err)
368+ return _c
369+}
370+
371+func (_c *CISettingsStore_DeleteVariable_Call) RunAndReturn(run func(ctx context.Context, scope domain.SettingsScope, name string) error) *CISettingsStore_DeleteVariable_Call {
372+ _c.Call.Return(run)
373+ return _c
374+}
375+
376+// GetSealedSecret provides a mock function for the type CISettingsStore
377+func (_mock *CISettingsStore) GetSealedSecret(ctx context.Context, scope domain.SettingsScope, name string) (ports.SealedSecret, error) {
378+ ret := _mock.Called(ctx, scope, name)
379+
380+ if len(ret) == 0 {
381+ panic("no return value specified for GetSealedSecret")
382+ }
383+
384+ var r0 ports.SealedSecret
385+ var r1 error
386+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.SettingsScope, string) (ports.SealedSecret, error)); ok {
387+ return returnFunc(ctx, scope, name)
388+ }
389+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.SettingsScope, string) ports.SealedSecret); ok {
390+ r0 = returnFunc(ctx, scope, name)
391+ } else {
392+ r0 = ret.Get(0).(ports.SealedSecret)
393+ }
394+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.SettingsScope, string) error); ok {
395+ r1 = returnFunc(ctx, scope, name)
396+ } else {
397+ r1 = ret.Error(1)
398+ }
399+ return r0, r1
400+}
401+
402+// CISettingsStore_GetSealedSecret_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSealedSecret'
403+type CISettingsStore_GetSealedSecret_Call struct {
404+ *mock.Call
405+}
406+
407+// GetSealedSecret is a helper method to define mock.On call
408+// - ctx context.Context
409+// - scope domain.SettingsScope
410+// - name string
411+func (_e *CISettingsStore_Expecter) GetSealedSecret(ctx any, scope any, name any) *CISettingsStore_GetSealedSecret_Call {
412+ return &CISettingsStore_GetSealedSecret_Call{Call: _e.mock.On("GetSealedSecret", ctx, scope, name)}
413+}
414+
415+func (_c *CISettingsStore_GetSealedSecret_Call) Run(run func(ctx context.Context, scope domain.SettingsScope, name string)) *CISettingsStore_GetSealedSecret_Call {
416+ _c.Call.Run(func(args mock.Arguments) {
417+ var arg0 context.Context
418+ if args[0] != nil {
419+ arg0 = args[0].(context.Context)
420+ }
421+ var arg1 domain.SettingsScope
422+ if args[1] != nil {
423+ arg1 = args[1].(domain.SettingsScope)
424+ }
425+ var arg2 string
426+ if args[2] != nil {
427+ arg2 = args[2].(string)
428+ }
429+ run(
430+ arg0,
431+ arg1,
432+ arg2,
433+ )
434+ })
435+ return _c
436+}
437+
438+func (_c *CISettingsStore_GetSealedSecret_Call) Return(sealedSecret ports.SealedSecret, err error) *CISettingsStore_GetSealedSecret_Call {
439+ _c.Call.Return(sealedSecret, err)
440+ return _c
441+}
442+
443+func (_c *CISettingsStore_GetSealedSecret_Call) RunAndReturn(run func(ctx context.Context, scope domain.SettingsScope, name string) (ports.SealedSecret, error)) *CISettingsStore_GetSealedSecret_Call {
444+ _c.Call.Return(run)
445+ return _c
446+}
447+
448+// GetVariable provides a mock function for the type CISettingsStore
449+func (_mock *CISettingsStore) GetVariable(ctx context.Context, scope domain.SettingsScope, name string) (domain.Variable, error) {
450+ ret := _mock.Called(ctx, scope, name)
451+
452+ if len(ret) == 0 {
453+ panic("no return value specified for GetVariable")
454+ }
455+
456+ var r0 domain.Variable
457+ var r1 error
458+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.SettingsScope, string) (domain.Variable, error)); ok {
459+ return returnFunc(ctx, scope, name)
460+ }
461+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.SettingsScope, string) domain.Variable); ok {
462+ r0 = returnFunc(ctx, scope, name)
463+ } else {
464+ r0 = ret.Get(0).(domain.Variable)
465+ }
466+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.SettingsScope, string) error); ok {
467+ r1 = returnFunc(ctx, scope, name)
468+ } else {
469+ r1 = ret.Error(1)
470+ }
471+ return r0, r1
472+}
473+
474+// CISettingsStore_GetVariable_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetVariable'
475+type CISettingsStore_GetVariable_Call struct {
476+ *mock.Call
477+}
478+
479+// GetVariable is a helper method to define mock.On call
480+// - ctx context.Context
481+// - scope domain.SettingsScope
482+// - name string
483+func (_e *CISettingsStore_Expecter) GetVariable(ctx any, scope any, name any) *CISettingsStore_GetVariable_Call {
484+ return &CISettingsStore_GetVariable_Call{Call: _e.mock.On("GetVariable", ctx, scope, name)}
485+}
486+
487+func (_c *CISettingsStore_GetVariable_Call) Run(run func(ctx context.Context, scope domain.SettingsScope, name string)) *CISettingsStore_GetVariable_Call {
488+ _c.Call.Run(func(args mock.Arguments) {
489+ var arg0 context.Context
490+ if args[0] != nil {
491+ arg0 = args[0].(context.Context)
492+ }
493+ var arg1 domain.SettingsScope
494+ if args[1] != nil {
495+ arg1 = args[1].(domain.SettingsScope)
496+ }
497+ var arg2 string
498+ if args[2] != nil {
499+ arg2 = args[2].(string)
500+ }
501+ run(
502+ arg0,
503+ arg1,
504+ arg2,
505+ )
506+ })
507+ return _c
508+}
509+
510+func (_c *CISettingsStore_GetVariable_Call) Return(variable domain.Variable, err error) *CISettingsStore_GetVariable_Call {
511+ _c.Call.Return(variable, err)
512+ return _c
513+}
514+
515+func (_c *CISettingsStore_GetVariable_Call) RunAndReturn(run func(ctx context.Context, scope domain.SettingsScope, name string) (domain.Variable, error)) *CISettingsStore_GetVariable_Call {
516+ _c.Call.Return(run)
517+ return _c
518+}
519+
520+// ListEnvironments provides a mock function for the type CISettingsStore
521+func (_mock *CISettingsStore) ListEnvironments(ctx context.Context, repo domain.RepoName) ([]domain.Environment, error) {
522+ ret := _mock.Called(ctx, repo)
523+
524+ if len(ret) == 0 {
525+ panic("no return value specified for ListEnvironments")
526+ }
527+
528+ var r0 []domain.Environment
529+ var r1 error
530+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) ([]domain.Environment, error)); ok {
531+ return returnFunc(ctx, repo)
532+ }
533+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) []domain.Environment); ok {
534+ r0 = returnFunc(ctx, repo)
535+ } else {
536+ if ret.Get(0) != nil {
537+ r0 = ret.Get(0).([]domain.Environment)
538+ }
539+ }
540+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName) error); ok {
541+ r1 = returnFunc(ctx, repo)
542+ } else {
543+ r1 = ret.Error(1)
544+ }
545+ return r0, r1
546+}
547+
548+// CISettingsStore_ListEnvironments_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListEnvironments'
549+type CISettingsStore_ListEnvironments_Call struct {
550+ *mock.Call
551+}
552+
553+// ListEnvironments is a helper method to define mock.On call
554+// - ctx context.Context
555+// - repo domain.RepoName
556+func (_e *CISettingsStore_Expecter) ListEnvironments(ctx any, repo any) *CISettingsStore_ListEnvironments_Call {
557+ return &CISettingsStore_ListEnvironments_Call{Call: _e.mock.On("ListEnvironments", ctx, repo)}
558+}
559+
560+func (_c *CISettingsStore_ListEnvironments_Call) Run(run func(ctx context.Context, repo domain.RepoName)) *CISettingsStore_ListEnvironments_Call {
561+ _c.Call.Run(func(args mock.Arguments) {
562+ var arg0 context.Context
563+ if args[0] != nil {
564+ arg0 = args[0].(context.Context)
565+ }
566+ var arg1 domain.RepoName
567+ if args[1] != nil {
568+ arg1 = args[1].(domain.RepoName)
569+ }
570+ run(
571+ arg0,
572+ arg1,
573+ )
574+ })
575+ return _c
576+}
577+
578+func (_c *CISettingsStore_ListEnvironments_Call) Return(environments []domain.Environment, err error) *CISettingsStore_ListEnvironments_Call {
579+ _c.Call.Return(environments, err)
580+ return _c
581+}
582+
583+func (_c *CISettingsStore_ListEnvironments_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName) ([]domain.Environment, error)) *CISettingsStore_ListEnvironments_Call {
584+ _c.Call.Return(run)
585+ return _c
586+}
587+
588+// ListSealedSecrets provides a mock function for the type CISettingsStore
589+func (_mock *CISettingsStore) ListSealedSecrets(ctx context.Context, scope domain.SettingsScope) ([]ports.SealedSecret, error) {
590+ ret := _mock.Called(ctx, scope)
591+
592+ if len(ret) == 0 {
593+ panic("no return value specified for ListSealedSecrets")
594+ }
595+
596+ var r0 []ports.SealedSecret
597+ var r1 error
598+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.SettingsScope) ([]ports.SealedSecret, error)); ok {
599+ return returnFunc(ctx, scope)
600+ }
601+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.SettingsScope) []ports.SealedSecret); ok {
602+ r0 = returnFunc(ctx, scope)
603+ } else {
604+ if ret.Get(0) != nil {
605+ r0 = ret.Get(0).([]ports.SealedSecret)
606+ }
607+ }
608+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.SettingsScope) error); ok {
609+ r1 = returnFunc(ctx, scope)
610+ } else {
611+ r1 = ret.Error(1)
612+ }
613+ return r0, r1
614+}
615+
616+// CISettingsStore_ListSealedSecrets_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListSealedSecrets'
617+type CISettingsStore_ListSealedSecrets_Call struct {
618+ *mock.Call
619+}
620+
621+// ListSealedSecrets is a helper method to define mock.On call
622+// - ctx context.Context
623+// - scope domain.SettingsScope
624+func (_e *CISettingsStore_Expecter) ListSealedSecrets(ctx any, scope any) *CISettingsStore_ListSealedSecrets_Call {
625+ return &CISettingsStore_ListSealedSecrets_Call{Call: _e.mock.On("ListSealedSecrets", ctx, scope)}
626+}
627+
628+func (_c *CISettingsStore_ListSealedSecrets_Call) Run(run func(ctx context.Context, scope domain.SettingsScope)) *CISettingsStore_ListSealedSecrets_Call {
629+ _c.Call.Run(func(args mock.Arguments) {
630+ var arg0 context.Context
631+ if args[0] != nil {
632+ arg0 = args[0].(context.Context)
633+ }
634+ var arg1 domain.SettingsScope
635+ if args[1] != nil {
636+ arg1 = args[1].(domain.SettingsScope)
637+ }
638+ run(
639+ arg0,
640+ arg1,
641+ )
642+ })
643+ return _c
644+}
645+
646+func (_c *CISettingsStore_ListSealedSecrets_Call) Return(sealedSecrets []ports.SealedSecret, err error) *CISettingsStore_ListSealedSecrets_Call {
647+ _c.Call.Return(sealedSecrets, err)
648+ return _c
649+}
650+
651+func (_c *CISettingsStore_ListSealedSecrets_Call) RunAndReturn(run func(ctx context.Context, scope domain.SettingsScope) ([]ports.SealedSecret, error)) *CISettingsStore_ListSealedSecrets_Call {
652+ _c.Call.Return(run)
653+ return _c
654+}
655+
656+// ListVariables provides a mock function for the type CISettingsStore
657+func (_mock *CISettingsStore) ListVariables(ctx context.Context, scope domain.SettingsScope) ([]domain.Variable, error) {
658+ ret := _mock.Called(ctx, scope)
659+
660+ if len(ret) == 0 {
661+ panic("no return value specified for ListVariables")
662+ }
663+
664+ var r0 []domain.Variable
665+ var r1 error
666+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.SettingsScope) ([]domain.Variable, error)); ok {
667+ return returnFunc(ctx, scope)
668+ }
669+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.SettingsScope) []domain.Variable); ok {
670+ r0 = returnFunc(ctx, scope)
671+ } else {
672+ if ret.Get(0) != nil {
673+ r0 = ret.Get(0).([]domain.Variable)
674+ }
675+ }
676+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.SettingsScope) error); ok {
677+ r1 = returnFunc(ctx, scope)
678+ } else {
679+ r1 = ret.Error(1)
680+ }
681+ return r0, r1
682+}
683+
684+// CISettingsStore_ListVariables_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListVariables'
685+type CISettingsStore_ListVariables_Call struct {
686+ *mock.Call
687+}
688+
689+// ListVariables is a helper method to define mock.On call
690+// - ctx context.Context
691+// - scope domain.SettingsScope
692+func (_e *CISettingsStore_Expecter) ListVariables(ctx any, scope any) *CISettingsStore_ListVariables_Call {
693+ return &CISettingsStore_ListVariables_Call{Call: _e.mock.On("ListVariables", ctx, scope)}
694+}
695+
696+func (_c *CISettingsStore_ListVariables_Call) Run(run func(ctx context.Context, scope domain.SettingsScope)) *CISettingsStore_ListVariables_Call {
697+ _c.Call.Run(func(args mock.Arguments) {
698+ var arg0 context.Context
699+ if args[0] != nil {
700+ arg0 = args[0].(context.Context)
701+ }
702+ var arg1 domain.SettingsScope
703+ if args[1] != nil {
704+ arg1 = args[1].(domain.SettingsScope)
705+ }
706+ run(
707+ arg0,
708+ arg1,
709+ )
710+ })
711+ return _c
712+}
713+
714+func (_c *CISettingsStore_ListVariables_Call) Return(variables []domain.Variable, err error) *CISettingsStore_ListVariables_Call {
715+ _c.Call.Return(variables, err)
716+ return _c
717+}
718+
719+func (_c *CISettingsStore_ListVariables_Call) RunAndReturn(run func(ctx context.Context, scope domain.SettingsScope) ([]domain.Variable, error)) *CISettingsStore_ListVariables_Call {
720+ _c.Call.Return(run)
721+ return _c
722+}
723+
724+// PutSecret provides a mock function for the type CISettingsStore
725+func (_mock *CISettingsStore) PutSecret(ctx context.Context, scope domain.SettingsScope, name string, sealed []byte, at time.Time) (domain.Secret, error) {
726+ ret := _mock.Called(ctx, scope, name, sealed, at)
727+
728+ if len(ret) == 0 {
729+ panic("no return value specified for PutSecret")
730+ }
731+
732+ var r0 domain.Secret
733+ var r1 error
734+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.SettingsScope, string, []byte, time.Time) (domain.Secret, error)); ok {
735+ return returnFunc(ctx, scope, name, sealed, at)
736+ }
737+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.SettingsScope, string, []byte, time.Time) domain.Secret); ok {
738+ r0 = returnFunc(ctx, scope, name, sealed, at)
739+ } else {
740+ r0 = ret.Get(0).(domain.Secret)
741+ }
742+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.SettingsScope, string, []byte, time.Time) error); ok {
743+ r1 = returnFunc(ctx, scope, name, sealed, at)
744+ } else {
745+ r1 = ret.Error(1)
746+ }
747+ return r0, r1
748+}
749+
750+// CISettingsStore_PutSecret_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PutSecret'
751+type CISettingsStore_PutSecret_Call struct {
752+ *mock.Call
753+}
754+
755+// PutSecret is a helper method to define mock.On call
756+// - ctx context.Context
757+// - scope domain.SettingsScope
758+// - name string
759+// - sealed []byte
760+// - at time.Time
761+func (_e *CISettingsStore_Expecter) PutSecret(ctx any, scope any, name any, sealed any, at any) *CISettingsStore_PutSecret_Call {
762+ return &CISettingsStore_PutSecret_Call{Call: _e.mock.On("PutSecret", ctx, scope, name, sealed, at)}
763+}
764+
765+func (_c *CISettingsStore_PutSecret_Call) Run(run func(ctx context.Context, scope domain.SettingsScope, name string, sealed []byte, at time.Time)) *CISettingsStore_PutSecret_Call {
766+ _c.Call.Run(func(args mock.Arguments) {
767+ var arg0 context.Context
768+ if args[0] != nil {
769+ arg0 = args[0].(context.Context)
770+ }
771+ var arg1 domain.SettingsScope
772+ if args[1] != nil {
773+ arg1 = args[1].(domain.SettingsScope)
774+ }
775+ var arg2 string
776+ if args[2] != nil {
777+ arg2 = args[2].(string)
778+ }
779+ var arg3 []byte
780+ if args[3] != nil {
781+ arg3 = args[3].([]byte)
782+ }
783+ var arg4 time.Time
784+ if args[4] != nil {
785+ arg4 = args[4].(time.Time)
786+ }
787+ run(
788+ arg0,
789+ arg1,
790+ arg2,
791+ arg3,
792+ arg4,
793+ )
794+ })
795+ return _c
796+}
797+
798+func (_c *CISettingsStore_PutSecret_Call) Return(secret domain.Secret, err error) *CISettingsStore_PutSecret_Call {
799+ _c.Call.Return(secret, err)
800+ return _c
801+}
802+
803+func (_c *CISettingsStore_PutSecret_Call) RunAndReturn(run func(ctx context.Context, scope domain.SettingsScope, name string, sealed []byte, at time.Time) (domain.Secret, error)) *CISettingsStore_PutSecret_Call {
804+ _c.Call.Return(run)
805+ return _c
806+}
807+
808+// PutVariable provides a mock function for the type CISettingsStore
809+func (_mock *CISettingsStore) PutVariable(ctx context.Context, scope domain.SettingsScope, name string, value string, at time.Time) (domain.Variable, error) {
810+ ret := _mock.Called(ctx, scope, name, value, at)
811+
812+ if len(ret) == 0 {
813+ panic("no return value specified for PutVariable")
814+ }
815+
816+ var r0 domain.Variable
817+ var r1 error
818+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.SettingsScope, string, string, time.Time) (domain.Variable, error)); ok {
819+ return returnFunc(ctx, scope, name, value, at)
820+ }
821+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.SettingsScope, string, string, time.Time) domain.Variable); ok {
822+ r0 = returnFunc(ctx, scope, name, value, at)
823+ } else {
824+ r0 = ret.Get(0).(domain.Variable)
825+ }
826+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.SettingsScope, string, string, time.Time) error); ok {
827+ r1 = returnFunc(ctx, scope, name, value, at)
828+ } else {
829+ r1 = ret.Error(1)
830+ }
831+ return r0, r1
832+}
833+
834+// CISettingsStore_PutVariable_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PutVariable'
835+type CISettingsStore_PutVariable_Call struct {
836+ *mock.Call
837+}
838+
839+// PutVariable is a helper method to define mock.On call
840+// - ctx context.Context
841+// - scope domain.SettingsScope
842+// - name string
843+// - value string
844+// - at time.Time
845+func (_e *CISettingsStore_Expecter) PutVariable(ctx any, scope any, name any, value any, at any) *CISettingsStore_PutVariable_Call {
846+ return &CISettingsStore_PutVariable_Call{Call: _e.mock.On("PutVariable", ctx, scope, name, value, at)}
847+}
848+
849+func (_c *CISettingsStore_PutVariable_Call) Run(run func(ctx context.Context, scope domain.SettingsScope, name string, value string, at time.Time)) *CISettingsStore_PutVariable_Call {
850+ _c.Call.Run(func(args mock.Arguments) {
851+ var arg0 context.Context
852+ if args[0] != nil {
853+ arg0 = args[0].(context.Context)
854+ }
855+ var arg1 domain.SettingsScope
856+ if args[1] != nil {
857+ arg1 = args[1].(domain.SettingsScope)
858+ }
859+ var arg2 string
860+ if args[2] != nil {
861+ arg2 = args[2].(string)
862+ }
863+ var arg3 string
864+ if args[3] != nil {
865+ arg3 = args[3].(string)
866+ }
867+ var arg4 time.Time
868+ if args[4] != nil {
869+ arg4 = args[4].(time.Time)
870+ }
871+ run(
872+ arg0,
873+ arg1,
874+ arg2,
875+ arg3,
876+ arg4,
877+ )
878+ })
879+ return _c
880+}
881+
882+func (_c *CISettingsStore_PutVariable_Call) Return(variable domain.Variable, err error) *CISettingsStore_PutVariable_Call {
883+ _c.Call.Return(variable, err)
884+ return _c
885+}
886+
887+func (_c *CISettingsStore_PutVariable_Call) RunAndReturn(run func(ctx context.Context, scope domain.SettingsScope, name string, value string, at time.Time) (domain.Variable, error)) *CISettingsStore_PutVariable_Call {
888+ _c.Call.Return(run)
889+ return _c
890+}
A internal/ports/mocks/EnvironmentService.go +280 -0
@@ -0,0 +1,280 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewEnvironmentService creates a new instance of EnvironmentService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewEnvironmentService(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *EnvironmentService {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
25+ mock := &EnvironmentService{}
26+ mock.Mock.Test(t)
27+
28+ t.Cleanup(func() {
29+ if helper, ok := t.(interface{ Helper() }); ok {
30+ helper.Helper()
31+ }
32+ mock.AssertExpectations(t)
33+ })
34+
35+ return mock
36+}
37+
38+// EnvironmentService is an autogenerated mock type for the EnvironmentService type
39+type EnvironmentService struct {
40+ mock.Mock
41+}
42+
43+type EnvironmentService_Expecter struct {
44+ mock *mock.Mock
45+}
46+
47+func (_m *EnvironmentService) EXPECT() *EnvironmentService_Expecter {
48+ return &EnvironmentService_Expecter{mock: &_m.Mock}
49+}
50+
51+// CreateEnvironment provides a mock function for the type EnvironmentService
52+func (_mock *EnvironmentService) CreateEnvironment(ctx context.Context, p domain.Principal, repo domain.RepoName, name string) (domain.Environment, error) {
53+ ret := _mock.Called(ctx, p, repo, name)
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for CreateEnvironment")
57+ }
58+
59+ var r0 domain.Environment
60+ var r1 error
61+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, string) (domain.Environment, error)); ok {
62+ return returnFunc(ctx, p, repo, name)
63+ }
64+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, string) domain.Environment); ok {
65+ r0 = returnFunc(ctx, p, repo, name)
66+ } else {
67+ r0 = ret.Get(0).(domain.Environment)
68+ }
69+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, string) error); ok {
70+ r1 = returnFunc(ctx, p, repo, name)
71+ } else {
72+ r1 = ret.Error(1)
73+ }
74+ return r0, r1
75+}
76+
77+// EnvironmentService_CreateEnvironment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateEnvironment'
78+type EnvironmentService_CreateEnvironment_Call struct {
79+ *mock.Call
80+}
81+
82+// CreateEnvironment is a helper method to define mock.On call
83+// - ctx context.Context
84+// - p domain.Principal
85+// - repo domain.RepoName
86+// - name string
87+func (_e *EnvironmentService_Expecter) CreateEnvironment(ctx any, p any, repo any, name any) *EnvironmentService_CreateEnvironment_Call {
88+ return &EnvironmentService_CreateEnvironment_Call{Call: _e.mock.On("CreateEnvironment", ctx, p, repo, name)}
89+}
90+
91+func (_c *EnvironmentService_CreateEnvironment_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, name string)) *EnvironmentService_CreateEnvironment_Call {
92+ _c.Call.Run(func(args mock.Arguments) {
93+ var arg0 context.Context
94+ if args[0] != nil {
95+ arg0 = args[0].(context.Context)
96+ }
97+ var arg1 domain.Principal
98+ if args[1] != nil {
99+ arg1 = args[1].(domain.Principal)
100+ }
101+ var arg2 domain.RepoName
102+ if args[2] != nil {
103+ arg2 = args[2].(domain.RepoName)
104+ }
105+ var arg3 string
106+ if args[3] != nil {
107+ arg3 = args[3].(string)
108+ }
109+ run(
110+ arg0,
111+ arg1,
112+ arg2,
113+ arg3,
114+ )
115+ })
116+ return _c
117+}
118+
119+func (_c *EnvironmentService_CreateEnvironment_Call) Return(environment domain.Environment, err error) *EnvironmentService_CreateEnvironment_Call {
120+ _c.Call.Return(environment, err)
121+ return _c
122+}
123+
124+func (_c *EnvironmentService_CreateEnvironment_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, name string) (domain.Environment, error)) *EnvironmentService_CreateEnvironment_Call {
125+ _c.Call.Return(run)
126+ return _c
127+}
128+
129+// DeleteEnvironment provides a mock function for the type EnvironmentService
130+func (_mock *EnvironmentService) DeleteEnvironment(ctx context.Context, p domain.Principal, repo domain.RepoName, name string) error {
131+ ret := _mock.Called(ctx, p, repo, name)
132+
133+ if len(ret) == 0 {
134+ panic("no return value specified for DeleteEnvironment")
135+ }
136+
137+ var r0 error
138+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, string) error); ok {
139+ r0 = returnFunc(ctx, p, repo, name)
140+ } else {
141+ r0 = ret.Error(0)
142+ }
143+ return r0
144+}
145+
146+// EnvironmentService_DeleteEnvironment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteEnvironment'
147+type EnvironmentService_DeleteEnvironment_Call struct {
148+ *mock.Call
149+}
150+
151+// DeleteEnvironment is a helper method to define mock.On call
152+// - ctx context.Context
153+// - p domain.Principal
154+// - repo domain.RepoName
155+// - name string
156+func (_e *EnvironmentService_Expecter) DeleteEnvironment(ctx any, p any, repo any, name any) *EnvironmentService_DeleteEnvironment_Call {
157+ return &EnvironmentService_DeleteEnvironment_Call{Call: _e.mock.On("DeleteEnvironment", ctx, p, repo, name)}
158+}
159+
160+func (_c *EnvironmentService_DeleteEnvironment_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, name string)) *EnvironmentService_DeleteEnvironment_Call {
161+ _c.Call.Run(func(args mock.Arguments) {
162+ var arg0 context.Context
163+ if args[0] != nil {
164+ arg0 = args[0].(context.Context)
165+ }
166+ var arg1 domain.Principal
167+ if args[1] != nil {
168+ arg1 = args[1].(domain.Principal)
169+ }
170+ var arg2 domain.RepoName
171+ if args[2] != nil {
172+ arg2 = args[2].(domain.RepoName)
173+ }
174+ var arg3 string
175+ if args[3] != nil {
176+ arg3 = args[3].(string)
177+ }
178+ run(
179+ arg0,
180+ arg1,
181+ arg2,
182+ arg3,
183+ )
184+ })
185+ return _c
186+}
187+
188+func (_c *EnvironmentService_DeleteEnvironment_Call) Return(err error) *EnvironmentService_DeleteEnvironment_Call {
189+ _c.Call.Return(err)
190+ return _c
191+}
192+
193+func (_c *EnvironmentService_DeleteEnvironment_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, name string) error) *EnvironmentService_DeleteEnvironment_Call {
194+ _c.Call.Return(run)
195+ return _c
196+}
197+
198+// ListEnvironments provides a mock function for the type EnvironmentService
199+func (_mock *EnvironmentService) ListEnvironments(ctx context.Context, p domain.Principal, repo domain.RepoName, size int, token string) (ports.EnvironmentPage, error) {
200+ ret := _mock.Called(ctx, p, repo, size, token)
201+
202+ if len(ret) == 0 {
203+ panic("no return value specified for ListEnvironments")
204+ }
205+
206+ var r0 ports.EnvironmentPage
207+ var r1 error
208+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int, string) (ports.EnvironmentPage, error)); ok {
209+ return returnFunc(ctx, p, repo, size, token)
210+ }
211+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int, string) ports.EnvironmentPage); ok {
212+ r0 = returnFunc(ctx, p, repo, size, token)
213+ } else {
214+ r0 = ret.Get(0).(ports.EnvironmentPage)
215+ }
216+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int, string) error); ok {
217+ r1 = returnFunc(ctx, p, repo, size, token)
218+ } else {
219+ r1 = ret.Error(1)
220+ }
221+ return r0, r1
222+}
223+
224+// EnvironmentService_ListEnvironments_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListEnvironments'
225+type EnvironmentService_ListEnvironments_Call struct {
226+ *mock.Call
227+}
228+
229+// ListEnvironments is a helper method to define mock.On call
230+// - ctx context.Context
231+// - p domain.Principal
232+// - repo domain.RepoName
233+// - size int
234+// - token string
235+func (_e *EnvironmentService_Expecter) ListEnvironments(ctx any, p any, repo any, size any, token any) *EnvironmentService_ListEnvironments_Call {
236+ return &EnvironmentService_ListEnvironments_Call{Call: _e.mock.On("ListEnvironments", ctx, p, repo, size, token)}
237+}
238+
239+func (_c *EnvironmentService_ListEnvironments_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, size int, token string)) *EnvironmentService_ListEnvironments_Call {
240+ _c.Call.Run(func(args mock.Arguments) {
241+ var arg0 context.Context
242+ if args[0] != nil {
243+ arg0 = args[0].(context.Context)
244+ }
245+ var arg1 domain.Principal
246+ if args[1] != nil {
247+ arg1 = args[1].(domain.Principal)
248+ }
249+ var arg2 domain.RepoName
250+ if args[2] != nil {
251+ arg2 = args[2].(domain.RepoName)
252+ }
253+ var arg3 int
254+ if args[3] != nil {
255+ arg3 = args[3].(int)
256+ }
257+ var arg4 string
258+ if args[4] != nil {
259+ arg4 = args[4].(string)
260+ }
261+ run(
262+ arg0,
263+ arg1,
264+ arg2,
265+ arg3,
266+ arg4,
267+ )
268+ })
269+ return _c
270+}
271+
272+func (_c *EnvironmentService_ListEnvironments_Call) Return(environmentPage ports.EnvironmentPage, err error) *EnvironmentService_ListEnvironments_Call {
273+ _c.Call.Return(environmentPage, err)
274+ return _c
275+}
276+
277+func (_c *EnvironmentService_ListEnvironments_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, size int, token string) (ports.EnvironmentPage, error)) *EnvironmentService_ListEnvironments_Call {
278+ _c.Call.Return(run)
279+ return _c
280+}
A internal/ports/mocks/SecretSealer.go +218 -0
@@ -0,0 +1,218 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ mock "github.com/stretchr/testify/mock"
9+)
10+
11+// NewSecretSealer creates a new instance of SecretSealer. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
12+// The first argument is typically a *testing.T value.
13+func NewSecretSealer(t interface {
14+ mock.TestingT
15+ Cleanup(func())
16+}) *SecretSealer {
17+ if helper, ok := t.(interface{ Helper() }); ok {
18+ helper.Helper()
19+ }
20+
21+ mock := &SecretSealer{}
22+ mock.Mock.Test(t)
23+
24+ t.Cleanup(func() {
25+ if helper, ok := t.(interface{ Helper() }); ok {
26+ helper.Helper()
27+ }
28+ mock.AssertExpectations(t)
29+ })
30+
31+ return mock
32+}
33+
34+// SecretSealer is an autogenerated mock type for the SecretSealer type
35+type SecretSealer struct {
36+ mock.Mock
37+}
38+
39+type SecretSealer_Expecter struct {
40+ mock *mock.Mock
41+}
42+
43+func (_m *SecretSealer) EXPECT() *SecretSealer_Expecter {
44+ return &SecretSealer_Expecter{mock: &_m.Mock}
45+}
46+
47+// KeyCheck provides a mock function for the type SecretSealer
48+func (_mock *SecretSealer) KeyCheck() []byte {
49+ ret := _mock.Called()
50+
51+ if len(ret) == 0 {
52+ panic("no return value specified for KeyCheck")
53+ }
54+
55+ var r0 []byte
56+ if returnFunc, ok := ret.Get(0).(func() []byte); ok {
57+ r0 = returnFunc()
58+ } else {
59+ if ret.Get(0) != nil {
60+ r0 = ret.Get(0).([]byte)
61+ }
62+ }
63+ return r0
64+}
65+
66+// SecretSealer_KeyCheck_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'KeyCheck'
67+type SecretSealer_KeyCheck_Call struct {
68+ *mock.Call
69+}
70+
71+// KeyCheck is a helper method to define mock.On call
72+func (_e *SecretSealer_Expecter) KeyCheck() *SecretSealer_KeyCheck_Call {
73+ return &SecretSealer_KeyCheck_Call{Call: _e.mock.On("KeyCheck")}
74+}
75+
76+func (_c *SecretSealer_KeyCheck_Call) Run(run func()) *SecretSealer_KeyCheck_Call {
77+ _c.Call.Run(func(args mock.Arguments) {
78+ run()
79+ })
80+ return _c
81+}
82+
83+func (_c *SecretSealer_KeyCheck_Call) Return(bytes []byte) *SecretSealer_KeyCheck_Call {
84+ _c.Call.Return(bytes)
85+ return _c
86+}
87+
88+func (_c *SecretSealer_KeyCheck_Call) RunAndReturn(run func() []byte) *SecretSealer_KeyCheck_Call {
89+ _c.Call.Return(run)
90+ return _c
91+}
92+
93+// Open provides a mock function for the type SecretSealer
94+func (_mock *SecretSealer) Open(sealed []byte, aad []byte) ([]byte, error) {
95+ ret := _mock.Called(sealed, aad)
96+
97+ if len(ret) == 0 {
98+ panic("no return value specified for Open")
99+ }
100+
101+ var r0 []byte
102+ var r1 error
103+ if returnFunc, ok := ret.Get(0).(func([]byte, []byte) ([]byte, error)); ok {
104+ return returnFunc(sealed, aad)
105+ }
106+ if returnFunc, ok := ret.Get(0).(func([]byte, []byte) []byte); ok {
107+ r0 = returnFunc(sealed, aad)
108+ } else {
109+ if ret.Get(0) != nil {
110+ r0 = ret.Get(0).([]byte)
111+ }
112+ }
113+ if returnFunc, ok := ret.Get(1).(func([]byte, []byte) error); ok {
114+ r1 = returnFunc(sealed, aad)
115+ } else {
116+ r1 = ret.Error(1)
117+ }
118+ return r0, r1
119+}
120+
121+// SecretSealer_Open_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Open'
122+type SecretSealer_Open_Call struct {
123+ *mock.Call
124+}
125+
126+// Open is a helper method to define mock.On call
127+// - sealed []byte
128+// - aad []byte
129+func (_e *SecretSealer_Expecter) Open(sealed any, aad any) *SecretSealer_Open_Call {
130+ return &SecretSealer_Open_Call{Call: _e.mock.On("Open", sealed, aad)}
131+}
132+
133+func (_c *SecretSealer_Open_Call) Run(run func(sealed []byte, aad []byte)) *SecretSealer_Open_Call {
134+ _c.Call.Run(func(args mock.Arguments) {
135+ var arg0 []byte
136+ if args[0] != nil {
137+ arg0 = args[0].([]byte)
138+ }
139+ var arg1 []byte
140+ if args[1] != nil {
141+ arg1 = args[1].([]byte)
142+ }
143+ run(
144+ arg0,
145+ arg1,
146+ )
147+ })
148+ return _c
149+}
150+
151+func (_c *SecretSealer_Open_Call) Return(bytes []byte, err error) *SecretSealer_Open_Call {
152+ _c.Call.Return(bytes, err)
153+ return _c
154+}
155+
156+func (_c *SecretSealer_Open_Call) RunAndReturn(run func(sealed []byte, aad []byte) ([]byte, error)) *SecretSealer_Open_Call {
157+ _c.Call.Return(run)
158+ return _c
159+}
160+
161+// Seal provides a mock function for the type SecretSealer
162+func (_mock *SecretSealer) Seal(plaintext []byte, aad []byte) []byte {
163+ ret := _mock.Called(plaintext, aad)
164+
165+ if len(ret) == 0 {
166+ panic("no return value specified for Seal")
167+ }
168+
169+ var r0 []byte
170+ if returnFunc, ok := ret.Get(0).(func([]byte, []byte) []byte); ok {
171+ r0 = returnFunc(plaintext, aad)
172+ } else {
173+ if ret.Get(0) != nil {
174+ r0 = ret.Get(0).([]byte)
175+ }
176+ }
177+ return r0
178+}
179+
180+// SecretSealer_Seal_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Seal'
181+type SecretSealer_Seal_Call struct {
182+ *mock.Call
183+}
184+
185+// Seal is a helper method to define mock.On call
186+// - plaintext []byte
187+// - aad []byte
188+func (_e *SecretSealer_Expecter) Seal(plaintext any, aad any) *SecretSealer_Seal_Call {
189+ return &SecretSealer_Seal_Call{Call: _e.mock.On("Seal", plaintext, aad)}
190+}
191+
192+func (_c *SecretSealer_Seal_Call) Run(run func(plaintext []byte, aad []byte)) *SecretSealer_Seal_Call {
193+ _c.Call.Run(func(args mock.Arguments) {
194+ var arg0 []byte
195+ if args[0] != nil {
196+ arg0 = args[0].([]byte)
197+ }
198+ var arg1 []byte
199+ if args[1] != nil {
200+ arg1 = args[1].([]byte)
201+ }
202+ run(
203+ arg0,
204+ arg1,
205+ )
206+ })
207+ return _c
208+}
209+
210+func (_c *SecretSealer_Seal_Call) Return(bytes []byte) *SecretSealer_Seal_Call {
211+ _c.Call.Return(bytes)
212+ return _c
213+}
214+
215+func (_c *SecretSealer_Seal_Call) RunAndReturn(run func(plaintext []byte, aad []byte) []byte) *SecretSealer_Seal_Call {
216+ _c.Call.Return(run)
217+ return _c
218+}
A internal/ports/mocks/SecretService.go +286 -0
@@ -0,0 +1,286 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewSecretService creates a new instance of SecretService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewSecretService(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *SecretService {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
25+ mock := &SecretService{}
26+ mock.Mock.Test(t)
27+
28+ t.Cleanup(func() {
29+ if helper, ok := t.(interface{ Helper() }); ok {
30+ helper.Helper()
31+ }
32+ mock.AssertExpectations(t)
33+ })
34+
35+ return mock
36+}
37+
38+// SecretService is an autogenerated mock type for the SecretService type
39+type SecretService struct {
40+ mock.Mock
41+}
42+
43+type SecretService_Expecter struct {
44+ mock *mock.Mock
45+}
46+
47+func (_m *SecretService) EXPECT() *SecretService_Expecter {
48+ return &SecretService_Expecter{mock: &_m.Mock}
49+}
50+
51+// DeleteSecret provides a mock function for the type SecretService
52+func (_mock *SecretService) DeleteSecret(ctx context.Context, p domain.Principal, scope domain.SettingsScope, name string) error {
53+ ret := _mock.Called(ctx, p, scope, name)
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for DeleteSecret")
57+ }
58+
59+ var r0 error
60+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.SettingsScope, string) error); ok {
61+ r0 = returnFunc(ctx, p, scope, name)
62+ } else {
63+ r0 = ret.Error(0)
64+ }
65+ return r0
66+}
67+
68+// SecretService_DeleteSecret_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteSecret'
69+type SecretService_DeleteSecret_Call struct {
70+ *mock.Call
71+}
72+
73+// DeleteSecret is a helper method to define mock.On call
74+// - ctx context.Context
75+// - p domain.Principal
76+// - scope domain.SettingsScope
77+// - name string
78+func (_e *SecretService_Expecter) DeleteSecret(ctx any, p any, scope any, name any) *SecretService_DeleteSecret_Call {
79+ return &SecretService_DeleteSecret_Call{Call: _e.mock.On("DeleteSecret", ctx, p, scope, name)}
80+}
81+
82+func (_c *SecretService_DeleteSecret_Call) Run(run func(ctx context.Context, p domain.Principal, scope domain.SettingsScope, name string)) *SecretService_DeleteSecret_Call {
83+ _c.Call.Run(func(args mock.Arguments) {
84+ var arg0 context.Context
85+ if args[0] != nil {
86+ arg0 = args[0].(context.Context)
87+ }
88+ var arg1 domain.Principal
89+ if args[1] != nil {
90+ arg1 = args[1].(domain.Principal)
91+ }
92+ var arg2 domain.SettingsScope
93+ if args[2] != nil {
94+ arg2 = args[2].(domain.SettingsScope)
95+ }
96+ var arg3 string
97+ if args[3] != nil {
98+ arg3 = args[3].(string)
99+ }
100+ run(
101+ arg0,
102+ arg1,
103+ arg2,
104+ arg3,
105+ )
106+ })
107+ return _c
108+}
109+
110+func (_c *SecretService_DeleteSecret_Call) Return(err error) *SecretService_DeleteSecret_Call {
111+ _c.Call.Return(err)
112+ return _c
113+}
114+
115+func (_c *SecretService_DeleteSecret_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, scope domain.SettingsScope, name string) error) *SecretService_DeleteSecret_Call {
116+ _c.Call.Return(run)
117+ return _c
118+}
119+
120+// ListSecrets provides a mock function for the type SecretService
121+func (_mock *SecretService) ListSecrets(ctx context.Context, p domain.Principal, scope domain.SettingsScope, size int, token string) (ports.SecretPage, error) {
122+ ret := _mock.Called(ctx, p, scope, size, token)
123+
124+ if len(ret) == 0 {
125+ panic("no return value specified for ListSecrets")
126+ }
127+
128+ var r0 ports.SecretPage
129+ var r1 error
130+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.SettingsScope, int, string) (ports.SecretPage, error)); ok {
131+ return returnFunc(ctx, p, scope, size, token)
132+ }
133+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.SettingsScope, int, string) ports.SecretPage); ok {
134+ r0 = returnFunc(ctx, p, scope, size, token)
135+ } else {
136+ r0 = ret.Get(0).(ports.SecretPage)
137+ }
138+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.SettingsScope, int, string) error); ok {
139+ r1 = returnFunc(ctx, p, scope, size, token)
140+ } else {
141+ r1 = ret.Error(1)
142+ }
143+ return r0, r1
144+}
145+
146+// SecretService_ListSecrets_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListSecrets'
147+type SecretService_ListSecrets_Call struct {
148+ *mock.Call
149+}
150+
151+// ListSecrets is a helper method to define mock.On call
152+// - ctx context.Context
153+// - p domain.Principal
154+// - scope domain.SettingsScope
155+// - size int
156+// - token string
157+func (_e *SecretService_Expecter) ListSecrets(ctx any, p any, scope any, size any, token any) *SecretService_ListSecrets_Call {
158+ return &SecretService_ListSecrets_Call{Call: _e.mock.On("ListSecrets", ctx, p, scope, size, token)}
159+}
160+
161+func (_c *SecretService_ListSecrets_Call) Run(run func(ctx context.Context, p domain.Principal, scope domain.SettingsScope, size int, token string)) *SecretService_ListSecrets_Call {
162+ _c.Call.Run(func(args mock.Arguments) {
163+ var arg0 context.Context
164+ if args[0] != nil {
165+ arg0 = args[0].(context.Context)
166+ }
167+ var arg1 domain.Principal
168+ if args[1] != nil {
169+ arg1 = args[1].(domain.Principal)
170+ }
171+ var arg2 domain.SettingsScope
172+ if args[2] != nil {
173+ arg2 = args[2].(domain.SettingsScope)
174+ }
175+ var arg3 int
176+ if args[3] != nil {
177+ arg3 = args[3].(int)
178+ }
179+ var arg4 string
180+ if args[4] != nil {
181+ arg4 = args[4].(string)
182+ }
183+ run(
184+ arg0,
185+ arg1,
186+ arg2,
187+ arg3,
188+ arg4,
189+ )
190+ })
191+ return _c
192+}
193+
194+func (_c *SecretService_ListSecrets_Call) Return(secretPage ports.SecretPage, err error) *SecretService_ListSecrets_Call {
195+ _c.Call.Return(secretPage, err)
196+ return _c
197+}
198+
199+func (_c *SecretService_ListSecrets_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, scope domain.SettingsScope, size int, token string) (ports.SecretPage, error)) *SecretService_ListSecrets_Call {
200+ _c.Call.Return(run)
201+ return _c
202+}
203+
204+// SetSecret provides a mock function for the type SecretService
205+func (_mock *SecretService) SetSecret(ctx context.Context, p domain.Principal, scope domain.SettingsScope, name string, value string) (domain.Secret, error) {
206+ ret := _mock.Called(ctx, p, scope, name, value)
207+
208+ if len(ret) == 0 {
209+ panic("no return value specified for SetSecret")
210+ }
211+
212+ var r0 domain.Secret
213+ var r1 error
214+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.SettingsScope, string, string) (domain.Secret, error)); ok {
215+ return returnFunc(ctx, p, scope, name, value)
216+ }
217+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.SettingsScope, string, string) domain.Secret); ok {
218+ r0 = returnFunc(ctx, p, scope, name, value)
219+ } else {
220+ r0 = ret.Get(0).(domain.Secret)
221+ }
222+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.SettingsScope, string, string) error); ok {
223+ r1 = returnFunc(ctx, p, scope, name, value)
224+ } else {
225+ r1 = ret.Error(1)
226+ }
227+ return r0, r1
228+}
229+
230+// SecretService_SetSecret_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetSecret'
231+type SecretService_SetSecret_Call struct {
232+ *mock.Call
233+}
234+
235+// SetSecret is a helper method to define mock.On call
236+// - ctx context.Context
237+// - p domain.Principal
238+// - scope domain.SettingsScope
239+// - name string
240+// - value string
241+func (_e *SecretService_Expecter) SetSecret(ctx any, p any, scope any, name any, value any) *SecretService_SetSecret_Call {
242+ return &SecretService_SetSecret_Call{Call: _e.mock.On("SetSecret", ctx, p, scope, name, value)}
243+}
244+
245+func (_c *SecretService_SetSecret_Call) Run(run func(ctx context.Context, p domain.Principal, scope domain.SettingsScope, name string, value string)) *SecretService_SetSecret_Call {
246+ _c.Call.Run(func(args mock.Arguments) {
247+ var arg0 context.Context
248+ if args[0] != nil {
249+ arg0 = args[0].(context.Context)
250+ }
251+ var arg1 domain.Principal
252+ if args[1] != nil {
253+ arg1 = args[1].(domain.Principal)
254+ }
255+ var arg2 domain.SettingsScope
256+ if args[2] != nil {
257+ arg2 = args[2].(domain.SettingsScope)
258+ }
259+ var arg3 string
260+ if args[3] != nil {
261+ arg3 = args[3].(string)
262+ }
263+ var arg4 string
264+ if args[4] != nil {
265+ arg4 = args[4].(string)
266+ }
267+ run(
268+ arg0,
269+ arg1,
270+ arg2,
271+ arg3,
272+ arg4,
273+ )
274+ })
275+ return _c
276+}
277+
278+func (_c *SecretService_SetSecret_Call) Return(secret domain.Secret, err error) *SecretService_SetSecret_Call {
279+ _c.Call.Return(secret, err)
280+ return _c
281+}
282+
283+func (_c *SecretService_SetSecret_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, scope domain.SettingsScope, name string, value string) (domain.Secret, error)) *SecretService_SetSecret_Call {
284+ _c.Call.Return(run)
285+ return _c
286+}
A internal/ports/mocks/VariableService.go +364 -0
@@ -0,0 +1,364 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewVariableService creates a new instance of VariableService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewVariableService(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *VariableService {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
25+ mock := &VariableService{}
26+ mock.Mock.Test(t)
27+
28+ t.Cleanup(func() {
29+ if helper, ok := t.(interface{ Helper() }); ok {
30+ helper.Helper()
31+ }
32+ mock.AssertExpectations(t)
33+ })
34+
35+ return mock
36+}
37+
38+// VariableService is an autogenerated mock type for the VariableService type
39+type VariableService struct {
40+ mock.Mock
41+}
42+
43+type VariableService_Expecter struct {
44+ mock *mock.Mock
45+}
46+
47+func (_m *VariableService) EXPECT() *VariableService_Expecter {
48+ return &VariableService_Expecter{mock: &_m.Mock}
49+}
50+
51+// DeleteVariable provides a mock function for the type VariableService
52+func (_mock *VariableService) DeleteVariable(ctx context.Context, p domain.Principal, scope domain.SettingsScope, name string) error {
53+ ret := _mock.Called(ctx, p, scope, name)
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for DeleteVariable")
57+ }
58+
59+ var r0 error
60+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.SettingsScope, string) error); ok {
61+ r0 = returnFunc(ctx, p, scope, name)
62+ } else {
63+ r0 = ret.Error(0)
64+ }
65+ return r0
66+}
67+
68+// VariableService_DeleteVariable_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteVariable'
69+type VariableService_DeleteVariable_Call struct {
70+ *mock.Call
71+}
72+
73+// DeleteVariable is a helper method to define mock.On call
74+// - ctx context.Context
75+// - p domain.Principal
76+// - scope domain.SettingsScope
77+// - name string
78+func (_e *VariableService_Expecter) DeleteVariable(ctx any, p any, scope any, name any) *VariableService_DeleteVariable_Call {
79+ return &VariableService_DeleteVariable_Call{Call: _e.mock.On("DeleteVariable", ctx, p, scope, name)}
80+}
81+
82+func (_c *VariableService_DeleteVariable_Call) Run(run func(ctx context.Context, p domain.Principal, scope domain.SettingsScope, name string)) *VariableService_DeleteVariable_Call {
83+ _c.Call.Run(func(args mock.Arguments) {
84+ var arg0 context.Context
85+ if args[0] != nil {
86+ arg0 = args[0].(context.Context)
87+ }
88+ var arg1 domain.Principal
89+ if args[1] != nil {
90+ arg1 = args[1].(domain.Principal)
91+ }
92+ var arg2 domain.SettingsScope
93+ if args[2] != nil {
94+ arg2 = args[2].(domain.SettingsScope)
95+ }
96+ var arg3 string
97+ if args[3] != nil {
98+ arg3 = args[3].(string)
99+ }
100+ run(
101+ arg0,
102+ arg1,
103+ arg2,
104+ arg3,
105+ )
106+ })
107+ return _c
108+}
109+
110+func (_c *VariableService_DeleteVariable_Call) Return(err error) *VariableService_DeleteVariable_Call {
111+ _c.Call.Return(err)
112+ return _c
113+}
114+
115+func (_c *VariableService_DeleteVariable_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, scope domain.SettingsScope, name string) error) *VariableService_DeleteVariable_Call {
116+ _c.Call.Return(run)
117+ return _c
118+}
119+
120+// GetVariable provides a mock function for the type VariableService
121+func (_mock *VariableService) GetVariable(ctx context.Context, p domain.Principal, scope domain.SettingsScope, name string) (domain.Variable, error) {
122+ ret := _mock.Called(ctx, p, scope, name)
123+
124+ if len(ret) == 0 {
125+ panic("no return value specified for GetVariable")
126+ }
127+
128+ var r0 domain.Variable
129+ var r1 error
130+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.SettingsScope, string) (domain.Variable, error)); ok {
131+ return returnFunc(ctx, p, scope, name)
132+ }
133+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.SettingsScope, string) domain.Variable); ok {
134+ r0 = returnFunc(ctx, p, scope, name)
135+ } else {
136+ r0 = ret.Get(0).(domain.Variable)
137+ }
138+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.SettingsScope, string) error); ok {
139+ r1 = returnFunc(ctx, p, scope, name)
140+ } else {
141+ r1 = ret.Error(1)
142+ }
143+ return r0, r1
144+}
145+
146+// VariableService_GetVariable_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetVariable'
147+type VariableService_GetVariable_Call struct {
148+ *mock.Call
149+}
150+
151+// GetVariable is a helper method to define mock.On call
152+// - ctx context.Context
153+// - p domain.Principal
154+// - scope domain.SettingsScope
155+// - name string
156+func (_e *VariableService_Expecter) GetVariable(ctx any, p any, scope any, name any) *VariableService_GetVariable_Call {
157+ return &VariableService_GetVariable_Call{Call: _e.mock.On("GetVariable", ctx, p, scope, name)}
158+}
159+
160+func (_c *VariableService_GetVariable_Call) Run(run func(ctx context.Context, p domain.Principal, scope domain.SettingsScope, name string)) *VariableService_GetVariable_Call {
161+ _c.Call.Run(func(args mock.Arguments) {
162+ var arg0 context.Context
163+ if args[0] != nil {
164+ arg0 = args[0].(context.Context)
165+ }
166+ var arg1 domain.Principal
167+ if args[1] != nil {
168+ arg1 = args[1].(domain.Principal)
169+ }
170+ var arg2 domain.SettingsScope
171+ if args[2] != nil {
172+ arg2 = args[2].(domain.SettingsScope)
173+ }
174+ var arg3 string
175+ if args[3] != nil {
176+ arg3 = args[3].(string)
177+ }
178+ run(
179+ arg0,
180+ arg1,
181+ arg2,
182+ arg3,
183+ )
184+ })
185+ return _c
186+}
187+
188+func (_c *VariableService_GetVariable_Call) Return(variable domain.Variable, err error) *VariableService_GetVariable_Call {
189+ _c.Call.Return(variable, err)
190+ return _c
191+}
192+
193+func (_c *VariableService_GetVariable_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, scope domain.SettingsScope, name string) (domain.Variable, error)) *VariableService_GetVariable_Call {
194+ _c.Call.Return(run)
195+ return _c
196+}
197+
198+// ListVariables provides a mock function for the type VariableService
199+func (_mock *VariableService) ListVariables(ctx context.Context, p domain.Principal, scope domain.SettingsScope, size int, token string) (ports.VariablePage, error) {
200+ ret := _mock.Called(ctx, p, scope, size, token)
201+
202+ if len(ret) == 0 {
203+ panic("no return value specified for ListVariables")
204+ }
205+
206+ var r0 ports.VariablePage
207+ var r1 error
208+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.SettingsScope, int, string) (ports.VariablePage, error)); ok {
209+ return returnFunc(ctx, p, scope, size, token)
210+ }
211+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.SettingsScope, int, string) ports.VariablePage); ok {
212+ r0 = returnFunc(ctx, p, scope, size, token)
213+ } else {
214+ r0 = ret.Get(0).(ports.VariablePage)
215+ }
216+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.SettingsScope, int, string) error); ok {
217+ r1 = returnFunc(ctx, p, scope, size, token)
218+ } else {
219+ r1 = ret.Error(1)
220+ }
221+ return r0, r1
222+}
223+
224+// VariableService_ListVariables_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListVariables'
225+type VariableService_ListVariables_Call struct {
226+ *mock.Call
227+}
228+
229+// ListVariables is a helper method to define mock.On call
230+// - ctx context.Context
231+// - p domain.Principal
232+// - scope domain.SettingsScope
233+// - size int
234+// - token string
235+func (_e *VariableService_Expecter) ListVariables(ctx any, p any, scope any, size any, token any) *VariableService_ListVariables_Call {
236+ return &VariableService_ListVariables_Call{Call: _e.mock.On("ListVariables", ctx, p, scope, size, token)}
237+}
238+
239+func (_c *VariableService_ListVariables_Call) Run(run func(ctx context.Context, p domain.Principal, scope domain.SettingsScope, size int, token string)) *VariableService_ListVariables_Call {
240+ _c.Call.Run(func(args mock.Arguments) {
241+ var arg0 context.Context
242+ if args[0] != nil {
243+ arg0 = args[0].(context.Context)
244+ }
245+ var arg1 domain.Principal
246+ if args[1] != nil {
247+ arg1 = args[1].(domain.Principal)
248+ }
249+ var arg2 domain.SettingsScope
250+ if args[2] != nil {
251+ arg2 = args[2].(domain.SettingsScope)
252+ }
253+ var arg3 int
254+ if args[3] != nil {
255+ arg3 = args[3].(int)
256+ }
257+ var arg4 string
258+ if args[4] != nil {
259+ arg4 = args[4].(string)
260+ }
261+ run(
262+ arg0,
263+ arg1,
264+ arg2,
265+ arg3,
266+ arg4,
267+ )
268+ })
269+ return _c
270+}
271+
272+func (_c *VariableService_ListVariables_Call) Return(variablePage ports.VariablePage, err error) *VariableService_ListVariables_Call {
273+ _c.Call.Return(variablePage, err)
274+ return _c
275+}
276+
277+func (_c *VariableService_ListVariables_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, scope domain.SettingsScope, size int, token string) (ports.VariablePage, error)) *VariableService_ListVariables_Call {
278+ _c.Call.Return(run)
279+ return _c
280+}
281+
282+// SetVariable provides a mock function for the type VariableService
283+func (_mock *VariableService) SetVariable(ctx context.Context, p domain.Principal, scope domain.SettingsScope, name string, value string) (domain.Variable, error) {
284+ ret := _mock.Called(ctx, p, scope, name, value)
285+
286+ if len(ret) == 0 {
287+ panic("no return value specified for SetVariable")
288+ }
289+
290+ var r0 domain.Variable
291+ var r1 error
292+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.SettingsScope, string, string) (domain.Variable, error)); ok {
293+ return returnFunc(ctx, p, scope, name, value)
294+ }
295+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.SettingsScope, string, string) domain.Variable); ok {
296+ r0 = returnFunc(ctx, p, scope, name, value)
297+ } else {
298+ r0 = ret.Get(0).(domain.Variable)
299+ }
300+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.SettingsScope, string, string) error); ok {
301+ r1 = returnFunc(ctx, p, scope, name, value)
302+ } else {
303+ r1 = ret.Error(1)
304+ }
305+ return r0, r1
306+}
307+
308+// VariableService_SetVariable_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetVariable'
309+type VariableService_SetVariable_Call struct {
310+ *mock.Call
311+}
312+
313+// SetVariable is a helper method to define mock.On call
314+// - ctx context.Context
315+// - p domain.Principal
316+// - scope domain.SettingsScope
317+// - name string
318+// - value string
319+func (_e *VariableService_Expecter) SetVariable(ctx any, p any, scope any, name any, value any) *VariableService_SetVariable_Call {
320+ return &VariableService_SetVariable_Call{Call: _e.mock.On("SetVariable", ctx, p, scope, name, value)}
321+}
322+
323+func (_c *VariableService_SetVariable_Call) Run(run func(ctx context.Context, p domain.Principal, scope domain.SettingsScope, name string, value string)) *VariableService_SetVariable_Call {
324+ _c.Call.Run(func(args mock.Arguments) {
325+ var arg0 context.Context
326+ if args[0] != nil {
327+ arg0 = args[0].(context.Context)
328+ }
329+ var arg1 domain.Principal
330+ if args[1] != nil {
331+ arg1 = args[1].(domain.Principal)
332+ }
333+ var arg2 domain.SettingsScope
334+ if args[2] != nil {
335+ arg2 = args[2].(domain.SettingsScope)
336+ }
337+ var arg3 string
338+ if args[3] != nil {
339+ arg3 = args[3].(string)
340+ }
341+ var arg4 string
342+ if args[4] != nil {
343+ arg4 = args[4].(string)
344+ }
345+ run(
346+ arg0,
347+ arg1,
348+ arg2,
349+ arg3,
350+ arg4,
351+ )
352+ })
353+ return _c
354+}
355+
356+func (_c *VariableService_SetVariable_Call) Return(variable domain.Variable, err error) *VariableService_SetVariable_Call {
357+ _c.Call.Return(variable, err)
358+ return _c
359+}
360+
361+func (_c *VariableService_SetVariable_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, scope domain.SettingsScope, name string, value string) (domain.Variable, error)) *VariableService_SetVariable_Call {
362+ _c.Call.Return(run)
363+ return _c
364+}
M internal/ports/services.go +44 -0
@@ -219,3 +219,47 @@ type FailureLimiter interface {
219219 Blocked(ctx context.Context, addr string) (bool, time.Duration)
220220 Fail(ctx context.Context, addr string)
221221 }
222+
223+// SecretPage is one page of secrets.
224+type SecretPage struct {
225+ Secrets []domain.Secret
226+ NextPageToken string
227+}
228+
229+// VariablePage is one page of variables.
230+type VariablePage struct {
231+ Variables []domain.Variable
232+ NextPageToken string
233+}
234+
235+// EnvironmentPage is one page of environments.
236+type EnvironmentPage struct {
237+ Environments []domain.Environment
238+ NextPageToken string
239+}
240+
241+// SecretService is the secret use cases the API serves. They need the
242+// secrets:write scope, never on a token limited to branches. Values are never
243+// returned. *usecase.CISettings implements it.
244+type SecretService interface {
245+ SetSecret(ctx context.Context, p domain.Principal, scope domain.SettingsScope, name, value string) (domain.Secret, error)
246+ ListSecrets(ctx context.Context, p domain.Principal, scope domain.SettingsScope, size int, token string) (SecretPage, error)
247+ DeleteSecret(ctx context.Context, p domain.Principal, scope domain.SettingsScope, name string) error
248+}
249+
250+// VariableService is the variable use cases the API serves, with the access
251+// rules of SecretService. *usecase.CISettings implements it.
252+type VariableService interface {
253+ SetVariable(ctx context.Context, p domain.Principal, scope domain.SettingsScope, name, value string) (domain.Variable, error)
254+ GetVariable(ctx context.Context, p domain.Principal, scope domain.SettingsScope, name string) (domain.Variable, error)
255+ ListVariables(ctx context.Context, p domain.Principal, scope domain.SettingsScope, size int, token string) (VariablePage, error)
256+ DeleteVariable(ctx context.Context, p domain.Principal, scope domain.SettingsScope, name string) error
257+}
258+
259+// EnvironmentService is the environment use cases the API serves, with the
260+// access rules of SecretService. *usecase.CISettings implements it.
261+type EnvironmentService interface {
262+ CreateEnvironment(ctx context.Context, p domain.Principal, repo domain.RepoName, name string) (domain.Environment, error)
263+ ListEnvironments(ctx context.Context, p domain.Principal, repo domain.RepoName, size int, token string) (EnvironmentPage, error)
264+ DeleteEnvironment(ctx context.Context, p domain.Principal, repo domain.RepoName, name string) error
265+}
M internal/usecase/ci.go +4 -0
@@ -72,6 +72,10 @@ type CIConfig struct {
7272 // read when an action of another repository is resolved for the run. Nil
7373 // reads nothing for a token (fail closed).
7474 Tokens ports.TokenStore
75+ // Settings resolves the variables and secrets of a job (CI-3). Nil gives
76+ // jobs none, and an empty "vars" context. Leave it unset rather than
77+ // assigning a typed nil pointer.
78+ Settings ports.CIJobSettings
7579 // RemoteConcurrency bounds the git ls-remote processes at once (default
7680 // DefaultCIRemoteConcurrency); ResolveTimeout bounds one ResolveActions call
7781 // (default DefaultCIResolveTimeout).
M internal/usecase/ci_plan.go +50 -8
@@ -28,9 +28,37 @@ type wfCache struct {
2828 mu sync.Mutex
2929 max int
3030 m map[int64]*workflow.Workflow
31+ // uses keeps which secrets each job of a run refers to (the names only,
32+ // never a value), per run then job key: the workflow file of a run does not
33+ // change, so the walk of its YAML is done once per job.
34+ uses map[int64]map[string]secretUse
3135 }
3236
33-func newWfCache(max int) *wfCache { return &wfCache{max: max, m: map[int64]*workflow.Workflow{}} }
37+func newWfCache(max int) *wfCache {
38+ return &wfCache{max: max, m: map[int64]*workflow.Workflow{}, uses: map[int64]map[string]secretUse{}}
39+}
40+
41+// secretUse returns the secrets the job refers to, walking the YAML once per
42+// run and job.
43+func (w *wfCache) secretUse(runID int64, yaml, jobKey string) secretUse {
44+ w.mu.Lock()
45+ if u, ok := w.uses[runID][jobKey]; ok {
46+ w.mu.Unlock()
47+ return u
48+ }
49+ w.mu.Unlock()
50+ u := secretsUsedBy(yaml, jobKey)
51+ w.mu.Lock()
52+ defer w.mu.Unlock()
53+ if w.uses[runID] == nil {
54+ if len(w.uses) >= w.max {
55+ clear(w.uses)
56+ }
57+ w.uses[runID] = map[string]secretUse{}
58+ }
59+ w.uses[runID][jobKey] = u
60+ return u
61+}
3462
3563 func (w *wfCache) get(runID int64, path, yaml string) (*workflow.Workflow, error) {
3664 w.mu.Lock()
@@ -55,13 +83,12 @@ func (w *wfCache) get(runID int64, path, yaml string) (*workflow.Workflow, error
5583 // unsupported names the feature of a job that ermite does not run yet, or "".
5684 // Such a job fails with "not supported yet: <feature>" instead of running
5785 // wrong. CI-2 runs steps with uses:, job container:, services: and step
58-// timeout-minutes (ADR 0025); what stays is CI-3.
86+// timeout-minutes (ADR 0025), CI-3 runs environment:; what stays is the rest of
87+// CI-3.
5988 func unsupported(j *workflow.Job) string {
6089 switch {
6190 case j.IsReusable():
6291 return "reusable workflows (uses at the job level)"
63- case j.Environment != nil:
64- return "environment"
6592 case j.Concurrency != nil:
6693 return "concurrency"
6794 }
@@ -366,8 +393,13 @@ func (rs *runState) planJob(ctx context.Context, wj *workflow.Job, idx int, need
366393 fail(err.Error())
367394 return
368395 }
396+ vars, err := rs.c.varsContext(ctx, rs.run.Repo)
397+ if err != nil {
398+ fail(err.Error())
399+ return
400+ }
369401 env := template.Env{Contexts: map[string]expr.Value{
370- "github": gh, "needs": needs, "inputs": expr.NewDictionary(), "vars": expr.NewDictionary(),
402+ "github": gh, "needs": needs, "inputs": expr.NewDictionary(), "vars": vars,
371403 }}
372404 run, err := wj.EvaluateIf(ctx, env, st)
373405 switch {
@@ -460,10 +492,20 @@ func (rs *runState) instance(ctx context.Context, wj *workflow.Job, plan *workfl
460492 if !plan.HasMatrix {
461493 matrixCtx = expr.Null{} // GitHub: "matrix": null for a job without a matrix
462494 }
463- contexts, err := contextsJSON(gh, needs, plan.Context(i), matrixCtx, rs.c.jobKeys(ctx, rs.run, base.Index, i))
495+ environment, err := environmentOf(ctx, wj, env)
496+ if err != nil {
497+ return j, err
498+ }
499+ // The vars of planning (see planJob), not read again for each leg.
500+ vars, _ := env.Contexts["vars"].(*expr.Dictionary)
501+ if vars == nil {
502+ vars = expr.NewDictionary()
503+ }
504+ contexts, err := contextsJSON(gh, needs, plan.Context(i), matrixCtx, vars, rs.c.jobKeys(ctx, rs.run, base.Index, i))
464505 if err != nil {
465506 return j, err
466507 }
508+ j.Environment = environment
467509 j.Name, j.Labels, j.TimeoutMinutes = name, labels, timeout
468510 j.MatrixJSON = compactJSON(expr.ToJSON(matrix))
469511 j.ContextsJSON, j.ComboTotal = contexts, len(plan.Combos)
@@ -496,11 +538,11 @@ func compactJSON(s string) string {
496538 // workflow_ref, workflow_sha, workflow_repository, workflow_file_path); the
497539 // runner adds status. The runner copies the keys of this "job" into its job
498540 // context (executor.State.initRest), whatever they are.
499-func contextsJSON(github, needs, strategy *expr.Dictionary, matrix expr.Value, job ordered) (string, error) {
541+func contextsJSON(github, needs, strategy *expr.Dictionary, matrix expr.Value, vars *expr.Dictionary, job ordered) (string, error) {
500542 out := map[string]json.RawMessage{}
501543 for k, v := range map[string]expr.Value{
502544 "github": github, "needs": needs, "strategy": strategy, "matrix": matrix,
503- "inputs": expr.NewDictionary(), "vars": expr.NewDictionary(),
545+ "inputs": expr.NewDictionary(), "vars": vars,
504546 } {
505547 out[k] = json.RawMessage(compactJSON(expr.ToJSON(v)))
506548 }
M internal/usecase/ci_runner.go +44 -9
@@ -123,7 +123,11 @@ func (c *CI) FetchJob(ctx context.Context, secret string, params ports.FetchJobP
123123 if err := checkPollID(params.PollID); err != nil {
124124 return ports.FetchJobResult{}, err
125125 }
126- if j, ok := c.repeatPoll(ctx, runner, params.PollID); ok {
126+ j, ok, err := c.repeatPoll(ctx, runner, params.PollID)
127+ if err != nil {
128+ return ports.FetchJobResult{}, err // the runner asks again with the same poll id
129+ }
130+ if ok {
127131 return ports.FetchJobResult{Job: j}, nil
128132 }
129133 }
@@ -188,18 +192,18 @@ func offeredLabels(r domain.CIRunner, asked []string) ([]string, error) {
188192
189193 // repeatPoll answers a retried poll with the assignment the first call made,
190194 // as long as it has not been acknowledged or cancelled.
191-func (c *CI) repeatPoll(ctx context.Context, runner domain.CIRunner, pollID string) (*ports.RunnerJob, bool) {
195+func (c *CI) repeatPoll(ctx context.Context, runner domain.CIRunner, pollID string) (*ports.RunnerJob, bool, error) {
192196 c.mu.Lock()
193197 rec, ok := c.polls[runner.ID]
194198 c.mu.Unlock()
195199 if !ok || rec.pollID != pollID {
196- return nil, false
200+ return nil, false, nil
197201 }
198202 var rid int64
199203 var ra int32
200204 var id string
201205 if _, err := fmt.Sscanf(rec.ref, "%d/%d/%s", &rid, &ra, &id); err != nil {
202- return nil, false
206+ return nil, false, nil
203207 }
204208 var out *ports.RunnerJob
205209 err := c.withRun(ctx, rid, ra, func(rs *runState) error {
@@ -207,17 +211,41 @@ func (c *CI) repeatPoll(ctx context.Context, runner domain.CIRunner, pollID stri
207211 if j == nil || j.Phase != domain.CIPhaseAssigned || j.TokenHash != rec.hash || j.RunnerID != runner.ID {
208212 return errNoJob
209213 }
210- out = c.runnerJob(rs, *j, rec.token, rec.ghToken)
214+ // The secrets are not kept with the poll: they are read again.
215+ settings, err := c.settingsOf(ctx, rs, *j)
216+ if err != nil {
217+ return err
218+ }
219+ out = c.runnerJob(rs, *j, rec.token, rec.ghToken, settings)
211220 return nil
212221 })
213- return out, err == nil && out != nil
222+ switch {
223+ case err == nil && out != nil:
224+ return out, true, nil
225+ case isSettingsError(err):
226+ return nil, false, err // checked first: it may wrap ErrConflict or ErrNotFound
227+ case err == nil, errors.Is(err, errNoJob), errors.Is(err, domain.ErrNotFound), errors.Is(err, domain.ErrConflict):
228+ return nil, false, nil // nothing to repeat: the poll goes on as a new one
229+ }
230+ return nil, false, err // the settings could not be read: not a lost assignment
214231 }
215232
216-// runnerJob is the job as the runner gets it.
217-func (c *CI) runnerJob(rs *runState, j domain.CIJob, token, ghToken string) *ports.RunnerJob {
233+// runnerJob is the job as the runner gets it. settings are read when the job
234+// is assigned and live in this value only: j is a copy, so the contexts it
235+// carries (with the variables as they are now) are not stored, and the secrets
236+// go nowhere but to the runner (never to the run record, the github context or
237+// a log; RunnerJob prints without them).
238+//
239+// TODO(ci3-c2): the runner writes the "Set up job" log; it states there that
240+// secrets were withheld when Trusted is false (the server adds no setup line).
241+func (c *CI) runnerJob(rs *runState, j domain.CIJob, token, ghToken string, settings ports.JobSettings) *ports.RunnerJob {
242+ if settings.Vars != nil {
243+ j.ContextsJSON = withVars(j.ContextsJSON, settings.Vars)
244+ }
218245 return &ports.RunnerJob{
219246 Job: j, Token: token, GithubToken: ghToken, Repo: rs.run.Repo, Sha: rs.run.Sha, Ref: rs.run.Ref,
220247 WorkflowPath: rs.run.WorkflowPath, WorkflowYAML: rs.src.WorkflowYAML,
248+ Environment: j.Environment, Vars: settings.Vars, Secrets: settings.Secrets, Trusted: rs.run.Trusted,
221249 }
222250 }
223251
@@ -240,6 +268,11 @@ func (c *CI) assignTo(ctx context.Context, runner domain.CIRunner, offered []str
240268 if j == nil || j.Phase != domain.CIPhaseReady || rs.run.CancelRequested {
241269 return errNoJob
242270 }
271+ // Before anything changes: a failure here leaves the job ready.
272+ settings, err := c.settingsOf(ctx, rs, *j)
273+ if err != nil {
274+ return err
275+ }
243276 token := newToken(domain.JobTokenPrefix)
244277 ghToken := rs.issueRepoToken(j)
245278 j.Phase, j.AssignedAt = domain.CIPhaseAssigned, c.now()
@@ -249,7 +282,7 @@ func (c *CI) assignTo(ctx context.Context, runner domain.CIRunner, offered []str
249282 if err := rs.flush(ctx); err != nil {
250283 return err
251284 }
252- out = c.runnerJob(rs, *j, token, ghToken)
285+ out = c.runnerJob(rs, *j, token, ghToken, settings)
253286 if pollID != "" {
254287 c.mu.Lock()
255288 c.polls[runner.ID] = pollRecord{pollID: pollID, ref: jobRef(*j), token: token, ghToken: ghToken, hash: j.TokenHash}
@@ -262,6 +295,8 @@ func (c *CI) assignTo(ctx context.Context, runner domain.CIRunner, offered []str
262295 log := zerowrap.FromCtx(ctx)
263296 log.Info().Str("runner", runner.Name).Str("job_id", out.Job.ID).Int64("run_id", out.Job.RunID).Msg("job assigned")
264297 return out, nil
298+ case isSettingsError(err):
299+ return nil, err // the job stays ready; the runner polls again
265300 case errors.Is(err, errNoJob), errors.Is(err, domain.ErrNotFound), errors.Is(err, domain.ErrConflict):
266301 continue // taken or gone meanwhile
267302 default:
M internal/usecase/ci_runs.go +4 -0
@@ -399,6 +399,9 @@ func (c *CI) RerunRun(ctx context.Context, p domain.Principal, name domain.RepoN
399399 run.Attempt, run.Status, run.Conclusion = next, domain.CIQueued, domain.CINoConclusion
400400 run.StartedAt, run.CompletedAt, run.CreatedAt = time.Time{}, time.Time{}, now
401401 run.CancelRequested, run.CancelRequestedAt = false, time.Time{}
402+ // Kept or lowered, never raised: the owner who runs an agent's run
403+ // again does not give it secrets (D1).
404+ run.Trusted = domain.RerunTrusted(rs.run.Trusted, p)
402405 w.Runs = []domain.CIRun{run}
403406 rs2 := &runState{c: c, run: run, attempt: next, jobs: fresh, src: rs.src, dirty: map[string]bool{}, checks: map[string]bool{}}
404407 for _, j := range fresh {
@@ -469,6 +472,7 @@ func rerunSet(rs *runState, failedOnly bool) (map[string]bool, error) {
469472 func (w *wfCache) drop(runID int64) {
470473 w.mu.Lock()
471474 delete(w.m, runID)
475+ delete(w.uses, runID)
472476 w.mu.Unlock()
473477 }
474478
A internal/usecase/ci_secrets.go +344 -0
@@ -0,0 +1,344 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "encoding/json"
8+ "errors"
9+ "fmt"
10+ "slices"
11+ "strings"
12+ "sync/atomic"
13+
14+ "go.yaml.in/yaml/v3"
15+
16+ "bnema.dev/ermite-actions/expr"
17+ "bnema.dev/ermite-actions/template"
18+ "bnema.dev/ermite-actions/workflow"
19+ "bnema.dev/ermite/internal/domain"
20+ "bnema.dev/ermite/internal/ports"
21+)
22+
23+// Which secrets a job receives (CI-3 plan D2, least disclosure).
24+//
25+// A job gets the secrets its workflow refers to and no others, so that a job
26+// that never reads a secret never holds it. ermite walks every expression of
27+// the YAML subtree of the job, and the workflow-level "env", with the
28+// expression parser of ermite-actions: `secrets.NAME` and `secrets['NAME']`
29+// name one secret; any other use of the context (a bare `secrets`,
30+// `toJSON(secrets)`, `secrets[matrix.x]`, `secrets.*`) names all of them.
31+// Missing one would only give the job an empty secret (a parity bug), never
32+// a leaked one. The walk reads the YAML rather than the parsed model so that a
33+// field the model does not list is still seen.
34+
35+// secretWalks counts the walks, for the tests of the cache.
36+var secretWalks atomic.Int64
37+
38+// secretUse is the result of the walk.
39+type secretUse struct {
40+ // All is set when the job uses the secrets context in a way that does not
41+ // name a secret.
42+ All bool
43+ // Names are the secrets named, upper-cased and sorted.
44+ Names []string
45+}
46+
47+// none reports whether the job refers to no secret.
48+func (u secretUse) none() bool { return !u.All && len(u.Names) == 0 }
49+
50+// maxSecretWalkDepth bounds the YAML nesting followed (aliases included); a
51+// workflow nested deeper is read as using every secret.
52+const maxSecretWalkDepth = 200
53+
54+// secretsUsedBy walks the job and the workflow-level env of a workflow file.
55+// A file that does not parse as YAML, or that nests too deep, uses all the
56+// secrets: the safe reading for the run, whose job will fail on it anyway.
57+func secretsUsedBy(workflowYAML, jobID string) secretUse {
58+ secretWalks.Add(1)
59+ var doc yaml.Node
60+ if err := yaml.Unmarshal([]byte(workflowYAML), &doc); err != nil || len(doc.Content) == 0 {
61+ return secretUse{All: true}
62+ }
63+ w := &secretWalk{names: map[string]bool{}}
64+ root := w.deref(doc.Content[0])
65+ if root.Kind != yaml.MappingNode {
66+ return secretUse{All: true}
67+ }
68+ for i := 0; i+1 < len(root.Content); i += 2 {
69+ key := root.Content[i].Value
70+ switch key {
71+ case "env":
72+ w.node(root.Content[i+1], 0)
73+ case "jobs":
74+ jobs := w.deref(root.Content[i+1])
75+ for j := 0; jobs.Kind == yaml.MappingNode && j+1 < len(jobs.Content); j += 2 {
76+ if jobs.Content[j].Value == jobID {
77+ w.node(jobs.Content[j+1], 0)
78+ }
79+ }
80+ }
81+ }
82+ use := secretUse{All: w.all || w.tooDeep}
83+ for n := range w.names {
84+ use.Names = append(use.Names, n)
85+ }
86+ slices.Sort(use.Names)
87+ return use
88+}
89+
90+type secretWalk struct {
91+ names map[string]bool
92+ all bool
93+ tooDeep bool
94+}
95+
96+func (w *secretWalk) deref(n *yaml.Node) *yaml.Node {
97+ for range 16 {
98+ if n.Kind != yaml.AliasNode || n.Alias == nil {
99+ return n
100+ }
101+ n = n.Alias
102+ }
103+ return n
104+}
105+
106+// node visits every key and value below n.
107+func (w *secretWalk) node(n *yaml.Node, depth int) {
108+ if depth > maxSecretWalkDepth {
109+ w.tooDeep = true
110+ return
111+ }
112+ n = w.deref(n)
113+ switch n.Kind {
114+ case yaml.ScalarNode:
115+ w.text(n.Value)
116+ case yaml.SequenceNode, yaml.MappingNode, yaml.DocumentNode:
117+ for _, c := range n.Content {
118+ w.node(c, depth+1)
119+ }
120+ }
121+}
122+
123+// text reads the ${{ }} expressions of a string.
124+func (w *secretWalk) text(s string) {
125+ for _, src := range expressionsIn(s) {
126+ w.expression(src)
127+ }
128+}
129+
130+// expressionsIn returns the text inside each ${{ }} of s. A quote opens a
131+// string literal inside the expression, where "}}" does not close it.
132+func expressionsIn(s string) []string {
133+ var out []string
134+ for {
135+ start := strings.Index(s, "${{")
136+ if start < 0 {
137+ return out
138+ }
139+ s = s[start+3:]
140+ inStr, end := false, -1
141+ for i := 0; i < len(s); i++ {
142+ switch {
143+ case s[i] == '\'':
144+ inStr = !inStr // '' inside a literal toggles twice
145+ case !inStr && s[i] == '}' && i+1 < len(s) && s[i+1] == '}':
146+ end = i
147+ }
148+ if end >= 0 {
149+ break
150+ }
151+ }
152+ if end < 0 {
153+ out = append(out, s) // unterminated: the workflow does not parse, read it all
154+ return out
155+ }
156+ out = append(out, s[:end])
157+ s = s[end+2:]
158+ }
159+}
160+
161+var (
162+ exprContexts = []string{"github", "needs", "strategy", "matrix", "job", "jobs", "runner", "env", "vars", "secrets", "steps", "inputs"}
163+ exprFuncs = []expr.FuncInfo{
164+ {Name: "hashFiles", MinArgs: 1, MaxArgs: -1}, {Name: "success"}, {Name: "failure"}, {Name: "always"}, {Name: "cancelled"},
165+ }
166+)
167+
168+func (w *secretWalk) expression(src string) {
169+ node, err := expr.Parse(src, exprContexts, exprFuncs)
170+ if err != nil || node == nil {
171+ // Does not parse: the job will fail on it, but never miss a use.
172+ if strings.Contains(strings.ToLower(src), "secrets") {
173+ w.all = true
174+ }
175+ return
176+ }
177+ w.visit(node)
178+}
179+
180+func isSecretsContext(n expr.Node) bool {
181+ c, ok := n.(*expr.ContextAccess)
182+ return ok && strings.EqualFold(c.Name.Lexeme, "secrets")
183+}
184+
185+func (w *secretWalk) visit(n expr.Node) {
186+ switch n := n.(type) {
187+ case *expr.ContextAccess:
188+ if isSecretsContext(n) {
189+ w.all = true // a bare use of the context
190+ }
191+ case *expr.IndexAccess:
192+ if isSecretsContext(n.Expr) {
193+ if lit, ok := n.Index.(*expr.Literal); ok {
194+ if s, ok := lit.Value.(expr.String); ok {
195+ w.names[strings.ToUpper(string(s))] = true
196+ return
197+ }
198+ }
199+ w.all = true // secrets[x], secrets.*: not a name
200+ w.visit(n.Index)
201+ return
202+ }
203+ w.visit(n.Expr)
204+ w.visit(n.Index)
205+ case *expr.Unary:
206+ w.visit(n.Expr)
207+ case *expr.Binary:
208+ w.visit(n.Left)
209+ w.visit(n.Right)
210+ case *expr.Logical:
211+ for _, a := range n.Args {
212+ w.visit(a)
213+ }
214+ case *expr.Grouping:
215+ w.visit(n.Group)
216+ case *expr.FunctionCall:
217+ for _, a := range n.Args {
218+ w.visit(a)
219+ }
220+ case *expr.Literal, *expr.Star, nil:
221+ default:
222+ panic(fmt.Sprintf("secretsUsedBy: unknown expression node %T", n)) // closed set of package expr
223+ }
224+}
225+
226+// --- variables, environments and secrets of a job ---
227+
228+// varsDictionary is the "vars" context for the merged variables.
229+func varsDictionary(vars map[string]string) *expr.Dictionary {
230+ d := expr.NewDictionary()
231+ for _, k := range sortedKeys(vars) {
232+ d.Add(k, expr.String(vars[k]))
233+ }
234+ return d
235+}
236+
237+// varsContext is the "vars" context for what runs before a job has an
238+// environment (run-name, matrix limits, if, strategy, runs-on, name): the
239+// variables of the instance overridden by the repository's. It is empty when
240+// the server resolves none. A failure is returned: running on partial
241+// variables would give wrong results silently.
242+func (c *CI) varsContext(ctx context.Context, repo domain.RepoName) (*expr.Dictionary, error) {
243+ if c.cfg.Settings == nil {
244+ return expr.NewDictionary(), nil
245+ }
246+ got, err := c.cfg.Settings.Resolve(ctx, ports.JobSettingsRequest{Repo: repo})
247+ if err != nil {
248+ return nil, fmt.Errorf("read the variables: %w", err)
249+ }
250+ return varsDictionary(got.Vars), nil
251+}
252+
253+// environmentOf evaluates the "environment:" of a job (a name, an expression or
254+// a mapping with a name and an url, which is ignored) and returns the name, ""
255+// for a job without one.
256+func environmentOf(ctx context.Context, wj *workflow.Job, env template.Env) (string, error) {
257+ if wj.Environment == nil {
258+ return "", nil
259+ }
260+ tok := wj.Environment
261+ if m, ok := tok.(*template.Mapping); ok {
262+ tok = m.Find("name")
263+ if tok == nil {
264+ return "", fmt.Errorf("job '%s': environment needs a name", wj.ID)
265+ }
266+ }
267+ name, err := template.EvaluateString(ctx, tok, env)
268+ if err != nil {
269+ return "", err
270+ }
271+ // Validated as evaluated, never trimmed: the name a workflow gives is the
272+ // name the API and the CLI accept.
273+ name, err = domain.ParseEnvironmentName(name)
274+ if err != nil {
275+ var te *domain.TextError
276+ if errors.As(err, &te) {
277+ return "", fmt.Errorf("job '%s': the environment name %q %s", wj.ID, name, te.Reason)
278+ }
279+ return "", fmt.Errorf("job '%s': invalid environment name %q", wj.ID, name)
280+ }
281+ return name, nil
282+}
283+
284+// settingsOf resolves the variables and secrets of a job at its assignment:
285+// the variables always, the secrets only for a trusted run and only the ones
286+// the job's workflow refers to. The result is for the runner and nowhere else.
287+func (c *CI) settingsOf(ctx context.Context, rs *runState, j domain.CIJob) (ports.JobSettings, error) {
288+ if c.cfg.Settings == nil {
289+ return ports.JobSettings{}, nil
290+ }
291+ req := ports.JobSettingsRequest{
292+ Repo: rs.run.Repo, Environment: j.Environment,
293+ // An environment is created on first use, by a run of the owner: a run
294+ // that is not trusted does not add environments to the repository.
295+ CreateEnvironment: j.Environment != "" && rs.run.Trusted,
296+ }
297+ if rs.run.Trusted {
298+ use := c.flows.secretUse(rs.run.ID, rs.src.WorkflowYAML, j.Key)
299+ req.WantSecrets, req.AllSecrets, req.SecretNames = !use.none(), use.All, use.Names
300+ }
301+ got, err := c.cfg.Settings.Resolve(ctx, req)
302+ if err != nil {
303+ return ports.JobSettings{}, &settingsError{err}
304+ }
305+ return got, nil
306+}
307+
308+// settingsError is a failure to read the variables and secrets of a job. It is
309+// its own type because the callers that skip a job gone or taken meanwhile
310+// (errNoJob, ErrNotFound, ErrConflict) must never skip on it: an error of the
311+// settings, even one that wraps ErrConflict (the creation of an environment
312+// ran out of retries), is the caller's to see, and changes nothing.
313+type settingsError struct{ err error }
314+
315+func (e *settingsError) Error() string {
316+ return "read the variables and secrets of the job: " + e.err.Error()
317+}
318+func (e *settingsError) Unwrap() error { return e.err }
319+
320+// isSettingsError reports whether err comes from reading the settings of a job.
321+func isSettingsError(err error) bool {
322+ var se *settingsError
323+ return errors.As(err, &se)
324+}
325+
326+// withVars returns the contexts JSON of a job with its "vars" replaced by the
327+// current variables, so that a variable changed while the job waited is seen.
328+// The contexts hold no secret.
329+func withVars(contexts string, vars map[string]string) string {
330+ var m map[string]json.RawMessage
331+ if err := json.Unmarshal([]byte(contexts), &m); err != nil {
332+ return contexts
333+ }
334+ b, err := json.Marshal(vars)
335+ if err != nil {
336+ return contexts
337+ }
338+ m["vars"] = b
339+ out, err := json.Marshal(m)
340+ if err != nil {
341+ return contexts
342+ }
343+ return string(out)
344+}
A internal/usecase/ci_secrets_test.go +157 -0
@@ -0,0 +1,157 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "strings"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+
11+ "bnema.dev/ermite/internal/usecase"
12+)
13+
14+// Which secrets a job refers to: a name by dot or bracket, anything else is all.
15+func TestSecretsUsedBy(t *testing.T) {
16+ t.Parallel()
17+ steps := func(run string) string {
18+ return "on: push\njobs:\n a:\n runs-on: x\n steps:\n - run: " + run + "\n"
19+ }
20+ cases := []struct {
21+ name string
22+ yaml string
23+ want usecase.SecretUse
24+ }{
25+ {"none", steps("echo hi"), usecase.SecretUse{}},
26+ {"dot", steps("echo ${{ secrets.Token }}"), usecase.SecretUse{Names: []string{"TOKEN"}}},
27+ {"bracket", steps(`echo "${{ secrets['my_Token'] }}"`), usecase.SecretUse{Names: []string{"MY_TOKEN"}}},
28+ {"bracket with double quotes is not an expression string", steps(`echo '${{ secrets["x"] }}'`), usecase.SecretUse{All: true}},
29+ {"several, sorted, deduplicated", steps("echo ${{ secrets.B }} ${{ secrets.a }} ${{ secrets.B }}"), usecase.SecretUse{Names: []string{"A", "B"}}},
30+ {"in a condition", steps("echo\n if: ${{ secrets.FLAG != '' }}"), usecase.SecretUse{Names: []string{"FLAG"}}},
31+ {"in a function", steps("echo ${{ format('{0}', secrets.X) }}"), usecase.SecretUse{Names: []string{"X"}}},
32+ {"the context name is case-insensitive", steps("echo ${{ SECRETS.x }}"), usecase.SecretUse{Names: []string{"X"}}},
33+ {"bare context", steps("echo ${{ secrets }}"), usecase.SecretUse{All: true}},
34+ {"toJSON(secrets)", steps("echo ${{ toJSON(secrets) }}"), usecase.SecretUse{All: true}},
35+ {"computed index", steps("echo ${{ secrets[matrix.name] }}"), usecase.SecretUse{All: true}},
36+ {"star", steps("echo ${{ secrets.* }}"), usecase.SecretUse{All: true}},
37+ {"a named secret next to a bare use", steps("echo ${{ secrets.A }} ${{ join(secrets) }}"), usecase.SecretUse{All: true, Names: []string{"A"}}},
38+ {"a string literal that says secrets is not a use", steps("echo ${{ 'secrets.NOT' }}"), usecase.SecretUse{}},
39+ {"text outside the braces is not a use", steps("echo secrets.NOT"), usecase.SecretUse{}},
40+ {"an expression with a quoted brace", steps("echo ${{ format('}}', secrets.Q) }}"), usecase.SecretUse{Names: []string{"Q"}}},
41+ {"does not parse but mentions secrets", steps("echo ${{ secrets.A == }}"), usecase.SecretUse{All: true}},
42+ {"does not parse and does not mention them", steps("echo ${{ == }}"), usecase.SecretUse{}},
43+ {"not YAML", "on: [", usecase.SecretUse{All: true}},
44+ {"not a mapping", "- a\n- b\n", usecase.SecretUse{All: true}},
45+ {"empty", "", usecase.SecretUse{All: true}},
46+ }
47+ for _, tc := range cases {
48+ assert.Equal(t, tc.want, usecase.SecretsUsedBy(tc.yaml, "a"), tc.name)
49+ }
50+}
51+
52+func TestSecretsUsedByCoversTheWholeSubtreeAndWorkflowEnvOnly(t *testing.T) {
53+ t.Parallel()
54+ src := `on: push
55+env:
56+ WF: ${{ secrets.WORKFLOW_LEVEL }}
57+defaults:
58+ run:
59+ shell: bash
60+jobs:
61+ a:
62+ runs-on: ${{ secrets.IN_RUNS_ON }}
63+ name: ${{ secrets.IN_NAME }}
64+ env:
65+ E: ${{ secrets.JOB_ENV }}
66+ container:
67+ image: x
68+ credentials:
69+ password: ${{ secrets.REGISTRY }}
70+ services:
71+ db:
72+ image: y
73+ env:
74+ P: ${{ secrets.SERVICE }}
75+ outputs:
76+ o: ${{ secrets.OUTPUT }}
77+ strategy:
78+ matrix:
79+ k: [ "${{ secrets.MATRIX }}" ]
80+ steps:
81+ - uses: some/action@v1
82+ with:
83+ token: ${{ secrets.WITH_INPUT }}
84+ - run: echo
85+ env: { K: "${{ secrets.FLOW_STYLE }}" }
86+ b:
87+ runs-on: x
88+ steps:
89+ - run: echo ${{ secrets.OTHER_JOB }}
90+`
91+ got := usecase.SecretsUsedBy(src, "a")
92+ assert.False(t, got.All)
93+ assert.Equal(t, []string{
94+ "FLOW_STYLE", "IN_NAME", "IN_RUNS_ON", "JOB_ENV", "MATRIX", "OUTPUT", "REGISTRY", "SERVICE", "WITH_INPUT", "WORKFLOW_LEVEL",
95+ }, got.Names, "every expression of the job and of the workflow env; not another job's")
96+ assert.Equal(t, []string{"OTHER_JOB", "WORKFLOW_LEVEL"}, usecase.SecretsUsedBy(src, "b").Names)
97+ assert.Equal(t, []string{"WORKFLOW_LEVEL"}, usecase.SecretsUsedBy(src, "missing").Names)
98+}
99+
100+// An anchor defined in another job (or reached through a chain of aliases) is
101+// followed: the job that aliases it refers to its secrets.
102+func TestSecretsUsedByFollowsYAMLAliases(t *testing.T) {
103+ t.Parallel()
104+ src := `on: push
105+jobs:
106+ b:
107+ runs-on: x
108+ env: &shared
109+ K: ${{ secrets.FROM_OTHER_JOB }}
110+ steps:
111+ - run: echo
112+ a:
113+ runs-on: x
114+ env: *shared
115+ steps:
116+ - run: echo
117+ env: &chain
118+ J: ${{ secrets.CHAIN_ROOT }}
119+ - run: echo
120+ env: *chain
121+ c:
122+ runs-on: x
123+ steps:
124+ - run: echo
125+ with: { list: [ *shared, *chain ] }
126+`
127+ assert.Equal(t, []string{"FROM_OTHER_JOB"}, usecase.SecretsUsedBy(src, "b").Names)
128+ assert.Equal(t, []string{"CHAIN_ROOT", "FROM_OTHER_JOB"}, usecase.SecretsUsedBy(src, "a").Names, "anchor from another job, and an anchor used twice")
129+ assert.Equal(t, []string{"CHAIN_ROOT", "FROM_OTHER_JOB"}, usecase.SecretsUsedBy(src, "c").Names, "aliases inside a flow sequence")
130+}
131+
132+// Deeply nested YAML is read as "all secrets" rather than followed forever.
133+func TestSecretsUsedByDeepNestingIsAll(t *testing.T) {
134+ t.Parallel()
135+ var b strings.Builder
136+ b.WriteString("on: push\njobs:\n a:\n runs-on: x\n steps:\n - run: echo\n env: ")
137+ b.WriteString(strings.Repeat("[", 400) + strings.Repeat("]", 400) + "\n")
138+ assert.True(t, usecase.SecretsUsedBy(b.String(), "a").All)
139+}
140+
141+// The analysis is made once per run and job, kept apart from the run it
142+// belongs to, and dropped with it. It holds names only.
143+func TestSecretUseIsCachedPerRunAndJob(t *testing.T) {
144+ src := "on: push\njobs:\n a:\n runs-on: x\n steps:\n - run: echo ${{ secrets.A }}\n b:\n runs-on: x\n steps:\n - run: echo ${{ secrets.B }}\n"
145+ c := usecase.NewSecretUseCache(4)
146+ before := usecase.SecretWalks()
147+ for range 5 {
148+ assert.Equal(t, []string{"A"}, c.Use(1, src, "a").Names)
149+ }
150+ assert.EqualValues(t, 1, usecase.SecretWalks()-before, "five legs, one walk")
151+ assert.Equal(t, []string{"B"}, c.Use(1, src, "b").Names, "another job of the run has its own")
152+ assert.Equal(t, []string{"A"}, c.Use(2, src, "a").Names, "another run too")
153+ assert.EqualValues(t, 3, usecase.SecretWalks()-before)
154+ c.Drop(1)
155+ c.Use(1, src, "a")
156+ assert.EqualValues(t, 4, usecase.SecretWalks()-before, "dropped with the run")
157+}
A internal/usecase/ci_settings.go +432 -0
@@ -0,0 +1,432 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "encoding/base64"
8+ "errors"
9+ "fmt"
10+ "slices"
11+ "strings"
12+
13+ "github.com/bnema/zerowrap"
14+
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+)
18+
19+// CISettings is the use cases of secrets, variables and environments (ADR 0019,
20+// CI-3): the owner side served by the API, and the resolution of what a job
21+// sees.
22+//
23+// Secrets are write-only: no method returns a value but Resolve, which gives
24+// them to the planner for the job of a trusted run. Values are sealed with the
25+// sealer before they are stored; without one (no ERMITE_SECRETS_KEY) secrets
26+// cannot be set and jobs get none, while names can still be listed and deleted.
27+// The values are never logged.
28+type CISettings struct {
29+ meta ports.RepoMetaStore
30+ store ports.CISettingsStore
31+ sealer ports.SecretSealer // nil: no key
32+ clock ports.Clock
33+}
34+
35+var (
36+ _ ports.SecretService = (*CISettings)(nil)
37+ _ ports.VariableService = (*CISettings)(nil)
38+ _ ports.EnvironmentService = (*CISettings)(nil)
39+ _ ports.CIJobSettings = (*CISettings)(nil)
40+)
41+
42+// NewCISettings builds the settings use cases. sealer is nil when the server
43+// has no key. When there is one, it records the key check beside the secrets or
44+// compares it with the recorded one: another key than the one the secrets were
45+// sealed with is domain.ErrSecretsKeyMismatch, and the server must not start.
46+func NewCISettings(ctx context.Context, meta ports.RepoMetaStore, store ports.CISettingsStore, sealer ports.SecretSealer, clock ports.Clock) (*CISettings, error) {
47+ log := zerowrap.FromCtx(ctx)
48+ if sealer == nil {
49+ log.Warn().Msg("ERMITE_SECRETS_KEY is not set: secrets cannot be set and jobs receive none")
50+ } else if err := store.BindSecretsKey(ctx, sealer.KeyCheck()); err != nil {
51+ return nil, err
52+ }
53+ return &CISettings{meta: meta, store: store, sealer: sealer, clock: clock}, nil
54+}
55+
56+// settingsAccess is the access rule of every call: secrets:write, never a token
57+// limited to branch prefixes (it never holds the owner's rights, ADR 0027; with
58+// secrets it could read back any value by pushing a workflow). The instance
59+// scope also needs a token that is not restricted to repositories. A repository
60+// that does not exist or that the caller cannot read is not found.
61+func (s *CISettings) settingsAccess(ctx context.Context, p domain.Principal, scope domain.SettingsScope) error {
62+ if !scope.Valid() {
63+ return &domain.InvalidFieldsError{Fields: []domain.FieldViolation{{Field: "scope", Reason: "is required and must name the instance, a repository or one of its environments"}}}
64+ }
65+ if p.IsAnonymous() {
66+ return domain.ErrUnauthenticated
67+ }
68+ if p.IsBranchLimited() {
69+ return fmt.Errorf("%w: a token limited to branch prefixes may not manage secrets and variables", domain.ErrForbidden)
70+ }
71+ if scope.Kind == domain.ScopeKindInstance {
72+ if !p.HasScope(domain.ScopeSecretsWrite) || len(p.Repos()) > 0 {
73+ return fmt.Errorf("%w: the instance scope needs the %s scope on a token that is not limited to some repositories", domain.ErrForbidden, domain.ScopeSecretsWrite)
74+ }
75+ return nil
76+ }
77+ return s.repoAccess(ctx, p, scope.Repo)
78+}
79+
80+func (s *CISettings) repoAccess(ctx context.Context, p domain.Principal, name domain.RepoName) error {
81+ if p.IsAnonymous() {
82+ return domain.ErrUnauthenticated
83+ }
84+ if p.IsBranchLimited() {
85+ return fmt.Errorf("%w: a token limited to branch prefixes may not manage secrets and variables", domain.ErrForbidden)
86+ }
87+ repo, err := lookupRepo(ctx, s.meta, name)
88+ if err != nil {
89+ return err
90+ }
91+ if p.Allows(domain.ScopeSecretsWrite, name) {
92+ return nil
93+ }
94+ if !canRead(p, repo) {
95+ return domain.ErrNotFound
96+ }
97+ return fmt.Errorf("%w: this needs the %s scope on %s", domain.ErrForbidden, domain.ScopeSecretsWrite, name)
98+}
99+
100+func logSetting(ctx context.Context, p domain.Principal, what string, scope domain.SettingsScope, name string) {
101+ ctxLog(ctx).Info().Str("scope", scope.Key()).Str("name", name).
102+ Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg(what) // never the value
103+}
104+
105+// SetSecret creates the secret or replaces its value.
106+func (s *CISettings) SetSecret(ctx context.Context, p domain.Principal, scope domain.SettingsScope, name, value string) (domain.Secret, error) {
107+ if err := s.settingsAccess(ctx, p, scope); err != nil {
108+ return domain.Secret{}, err
109+ }
110+ var v domain.Violations
111+ up, err := domain.ParseSettingName(name)
112+ if err != nil {
113+ v.AddErr("name", err)
114+ }
115+ if err := domain.CheckSecretValue(value); err != nil {
116+ v.AddErr("value", err)
117+ }
118+ if err := v.Err(); err != nil {
119+ return domain.Secret{}, err
120+ }
121+ if s.sealer == nil {
122+ return domain.Secret{}, fmt.Errorf("%w: the server has no ERMITE_SECRETS_KEY, so it cannot store secrets", domain.ErrPreconditionFailed)
123+ }
124+ sealed := s.sealer.Seal([]byte(value), domain.SecretAAD(scope, up))
125+ sec, err := s.store.PutSecret(ctx, scope, up, sealed, s.clock.Now().UTC())
126+ if err != nil {
127+ return domain.Secret{}, err
128+ }
129+ logSetting(ctx, p, "secret set", scope, up)
130+ return sec, nil
131+}
132+
133+// ListSecrets lists the names of the secrets of one scope, ordered by name.
134+func (s *CISettings) ListSecrets(ctx context.Context, p domain.Principal, scope domain.SettingsScope, size int, token string) (ports.SecretPage, error) {
135+ if err := s.settingsAccess(ctx, p, scope); err != nil {
136+ return ports.SecretPage{}, err
137+ }
138+ n, err := pageSize(size)
139+ if err != nil {
140+ return ports.SecretPage{}, err
141+ }
142+ after, err := decodeNameCursor(token, secretPageTokenPrefix)
143+ if err != nil {
144+ return ports.SecretPage{}, err
145+ }
146+ sealed, err := s.store.ListSealedSecrets(ctx, scope)
147+ if err != nil {
148+ return ports.SecretPage{}, err
149+ }
150+ all := make([]domain.Secret, 0, len(sealed))
151+ for _, x := range sealed {
152+ all = append(all, domain.Secret{Name: x.Name, UpdatedAt: x.UpdatedAt}) // the sealed value stops here
153+ }
154+ slices.SortFunc(all, func(a, b domain.Secret) int { return strings.Compare(a.Name, b.Name) })
155+ var page ports.SecretPage
156+ page.Secrets, page.NextPageToken = pageAfter(all, n,
157+ func(x domain.Secret) bool { return after == "" || x.Name > after },
158+ func(x domain.Secret) string { return encodeNameCursor(secretPageTokenPrefix, x.Name) })
159+ return page, nil
160+}
161+
162+// DeleteSecret deletes a secret.
163+func (s *CISettings) DeleteSecret(ctx context.Context, p domain.Principal, scope domain.SettingsScope, name string) error {
164+ if err := s.settingsAccess(ctx, p, scope); err != nil {
165+ return err
166+ }
167+ up, err := domain.ParseSettingName(name)
168+ if err != nil {
169+ return notFoundOrInvalid(err)
170+ }
171+ if err := s.store.DeleteSecret(ctx, scope, up); err != nil {
172+ return err
173+ }
174+ logSetting(ctx, p, "secret deleted", scope, up)
175+ return nil
176+}
177+
178+// notFoundOrInvalid maps a name that cannot exist to not found on the calls
179+// that name an existing thing (delete, get): a name that breaks the rules is a
180+// bad request on a write, but nothing is stored under it.
181+func notFoundOrInvalid(err error) error {
182+ var v domain.Violations
183+ v.AddErr("name", err)
184+ return v.Err()
185+}
186+
187+// SetVariable creates the variable or replaces its value.
188+func (s *CISettings) SetVariable(ctx context.Context, p domain.Principal, scope domain.SettingsScope, name, value string) (domain.Variable, error) {
189+ if err := s.settingsAccess(ctx, p, scope); err != nil {
190+ return domain.Variable{}, err
191+ }
192+ var v domain.Violations
193+ up, err := domain.ParseSettingName(name)
194+ if err != nil {
195+ v.AddErr("name", err)
196+ }
197+ if err := domain.CheckVariableValue(value); err != nil {
198+ v.AddErr("value", err)
199+ }
200+ if err := v.Err(); err != nil {
201+ return domain.Variable{}, err
202+ }
203+ out, err := s.store.PutVariable(ctx, scope, up, value, s.clock.Now().UTC())
204+ if err != nil {
205+ return domain.Variable{}, err
206+ }
207+ logSetting(ctx, p, "variable set", scope, up)
208+ return out, nil
209+}
210+
211+// GetVariable returns one variable of one scope.
212+func (s *CISettings) GetVariable(ctx context.Context, p domain.Principal, scope domain.SettingsScope, name string) (domain.Variable, error) {
213+ if err := s.settingsAccess(ctx, p, scope); err != nil {
214+ return domain.Variable{}, err
215+ }
216+ up, err := domain.ParseSettingName(name)
217+ if err != nil {
218+ return domain.Variable{}, notFoundOrInvalid(err)
219+ }
220+ return s.store.GetVariable(ctx, scope, up)
221+}
222+
223+// ListVariables lists the variables of one scope, ordered by name.
224+func (s *CISettings) ListVariables(ctx context.Context, p domain.Principal, scope domain.SettingsScope, size int, token string) (ports.VariablePage, error) {
225+ if err := s.settingsAccess(ctx, p, scope); err != nil {
226+ return ports.VariablePage{}, err
227+ }
228+ n, err := pageSize(size)
229+ if err != nil {
230+ return ports.VariablePage{}, err
231+ }
232+ after, err := decodeNameCursor(token, variablePageTokenPrefix)
233+ if err != nil {
234+ return ports.VariablePage{}, err
235+ }
236+ all, err := s.store.ListVariables(ctx, scope)
237+ if err != nil {
238+ return ports.VariablePage{}, err
239+ }
240+ slices.SortFunc(all, func(a, b domain.Variable) int { return strings.Compare(a.Name, b.Name) })
241+ var page ports.VariablePage
242+ page.Variables, page.NextPageToken = pageAfter(all, n,
243+ func(x domain.Variable) bool { return after == "" || x.Name > after },
244+ func(x domain.Variable) string { return encodeNameCursor(variablePageTokenPrefix, x.Name) })
245+ return page, nil
246+}
247+
248+// DeleteVariable deletes a variable.
249+func (s *CISettings) DeleteVariable(ctx context.Context, p domain.Principal, scope domain.SettingsScope, name string) error {
250+ if err := s.settingsAccess(ctx, p, scope); err != nil {
251+ return err
252+ }
253+ up, err := domain.ParseSettingName(name)
254+ if err != nil {
255+ return notFoundOrInvalid(err)
256+ }
257+ if err := s.store.DeleteVariable(ctx, scope, up); err != nil {
258+ return err
259+ }
260+ logSetting(ctx, p, "variable deleted", scope, up)
261+ return nil
262+}
263+
264+// CreateEnvironment creates an environment; one that exists (without regard to
265+// case) is returned unchanged.
266+func (s *CISettings) CreateEnvironment(ctx context.Context, p domain.Principal, repo domain.RepoName, name string) (domain.Environment, error) {
267+ if err := s.repoAccess(ctx, p, repo); err != nil {
268+ return domain.Environment{}, err
269+ }
270+ env, err := domain.ParseEnvironmentName(name)
271+ if err != nil {
272+ var v domain.Violations
273+ v.AddErr("name", err)
274+ return domain.Environment{}, v.Err()
275+ }
276+ out, err := s.store.CreateEnvironment(ctx, repo, env, s.clock.Now().UTC())
277+ if err != nil {
278+ return domain.Environment{}, err
279+ }
280+ logSetting(ctx, p, "environment created", domain.EnvironmentScope(repo, env), "")
281+ return out, nil
282+}
283+
284+// ListEnvironments lists the environments of a repository, ordered by name.
285+func (s *CISettings) ListEnvironments(ctx context.Context, p domain.Principal, repo domain.RepoName, size int, token string) (ports.EnvironmentPage, error) {
286+ if err := s.repoAccess(ctx, p, repo); err != nil {
287+ return ports.EnvironmentPage{}, err
288+ }
289+ n, err := pageSize(size)
290+ if err != nil {
291+ return ports.EnvironmentPage{}, err
292+ }
293+ after, err := decodeNameCursor(token, environmentPageTokenPrefix)
294+ if err != nil {
295+ return ports.EnvironmentPage{}, err
296+ }
297+ all, err := s.store.ListEnvironments(ctx, repo)
298+ if err != nil {
299+ return ports.EnvironmentPage{}, err
300+ }
301+ key := func(e domain.Environment) string { return domain.EnvironmentKey(e.Name) }
302+ slices.SortFunc(all, func(a, b domain.Environment) int { return strings.Compare(key(a), key(b)) })
303+ var page ports.EnvironmentPage
304+ page.Environments, page.NextPageToken = pageAfter(all, n,
305+ func(x domain.Environment) bool { return after == "" || key(x) > after },
306+ func(x domain.Environment) string { return encodeNameCursor(environmentPageTokenPrefix, key(x)) })
307+ return page, nil
308+}
309+
310+// DeleteEnvironment deletes an environment with its secrets and variables.
311+func (s *CISettings) DeleteEnvironment(ctx context.Context, p domain.Principal, repo domain.RepoName, name string) error {
312+ if err := s.repoAccess(ctx, p, repo); err != nil {
313+ return err
314+ }
315+ env, err := domain.ParseEnvironmentName(name)
316+ if err != nil {
317+ return domain.ErrNotFound
318+ }
319+ if err := s.store.DeleteEnvironment(ctx, repo, env); err != nil {
320+ return err
321+ }
322+ logSetting(ctx, p, "environment deleted", domain.EnvironmentScope(repo, env), "")
323+ return nil
324+}
325+
326+// Page tokens name the last item of the previous page.
327+const (
328+ secretPageTokenPrefix = "sn1:"
329+ variablePageTokenPrefix = "vn1:"
330+ environmentPageTokenPrefix = "en1:"
331+)
332+
333+func encodeNameCursor(prefix, last string) string {
334+ return base64.RawURLEncoding.EncodeToString([]byte(prefix + last))
335+}
336+
337+// decodeNameCursor returns "" for the empty token.
338+func decodeNameCursor(tok, prefix string) (string, error) {
339+ if tok == "" {
340+ return "", nil
341+ }
342+ raw, ok := decodeToken(tok, prefix)
343+ if !ok || raw == "" {
344+ return "", fmt.Errorf("%w: settings", domain.ErrInvalidPageToken)
345+ }
346+ return raw, nil
347+}
348+
349+// Resolve gives the variables, and the secrets when wanted, that a job sees:
350+// the instance's, overridden by the repository's, overridden by the
351+// environment's. A job that names an environment creates it on first use.
352+// Secrets are opened only for the names asked (or all of them), and a secret
353+// that does not open is left out and logged by name, never by value.
354+func (s *CISettings) Resolve(ctx context.Context, req ports.JobSettingsRequest) (ports.JobSettings, error) {
355+ var out ports.JobSettings
356+ scopes := []domain.SettingsScope{domain.InstanceScope(), domain.RepoScope(req.Repo)}
357+ if req.Environment != "" {
358+ if req.CreateEnvironment {
359+ if _, err := s.store.CreateEnvironment(ctx, req.Repo, req.Environment, s.clock.Now().UTC()); err != nil && !errors.Is(err, domain.ErrNotFound) {
360+ return out, err
361+ }
362+ }
363+ scopes = append(scopes, domain.EnvironmentScope(req.Repo, req.Environment))
364+ }
365+ out.Vars = map[string]string{}
366+ for _, scope := range scopes {
367+ vars, err := s.store.ListVariables(ctx, scope)
368+ if errors.Is(err, domain.ErrNotFound) {
369+ continue // no such repository or environment (yet): nothing to add
370+ }
371+ if err != nil {
372+ return ports.JobSettings{}, err
373+ }
374+ for _, v := range vars {
375+ out.Vars[v.Name] = v.Value
376+ }
377+ }
378+ if !req.WantSecrets || (!req.AllSecrets && len(req.SecretNames) == 0) {
379+ return out, nil
380+ }
381+ if s.sealer == nil {
382+ out.SecretsOff = true
383+ return out, nil
384+ }
385+ names := make([]string, 0, len(req.SecretNames))
386+ for _, n := range req.SecretNames {
387+ names = append(names, strings.ToUpper(n))
388+ }
389+ slices.Sort(names)
390+ names = slices.Compact(names)
391+ out.Secrets = map[string]string{}
392+ log := zerowrap.FromCtx(ctx)
393+ // Scopes go from the lowest precedence to the highest, so a later value
394+ // replaces an earlier one. A value that does not open is withheld, and
395+ // takes away what a lower scope gave: the override must never fall back to
396+ // the value it was meant to replace.
397+ admit := func(scope domain.SettingsScope, x ports.SealedSecret) {
398+ plain, err := s.sealer.Open(x.Sealed, domain.SecretAAD(scope, x.Name))
399+ if err != nil {
400+ delete(out.Secrets, x.Name)
401+ log.Error().Str("scope", scope.Key()).Str("name", x.Name).Msg("a stored secret does not open: it is left out of the job")
402+ return
403+ }
404+ out.Secrets[x.Name] = string(plain)
405+ }
406+ for _, scope := range scopes {
407+ if req.AllSecrets {
408+ sealed, err := s.store.ListSealedSecrets(ctx, scope)
409+ if errors.Is(err, domain.ErrNotFound) {
410+ continue
411+ }
412+ if err != nil {
413+ return ports.JobSettings{}, err
414+ }
415+ for _, x := range sealed {
416+ admit(scope, x)
417+ }
418+ continue
419+ }
420+ for _, n := range names { // point reads: only the secrets the job names
421+ x, err := s.store.GetSealedSecret(ctx, scope, n)
422+ if errors.Is(err, domain.ErrNotFound) {
423+ continue
424+ }
425+ if err != nil {
426+ return ports.JobSettings{}, err
427+ }
428+ admit(scope, x)
429+ }
430+ }
431+ return out, nil
432+}
A internal/usecase/ci_settings_test.go +257 -0
@@ -0,0 +1,257 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "bytes"
7+ "context"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/mock"
13+ "github.com/stretchr/testify/require"
14+
15+ "bnema.dev/ermite/internal/adapters/kvstore"
16+ "bnema.dev/ermite/internal/adapters/secretbox"
17+ "bnema.dev/ermite/internal/domain"
18+ "bnema.dev/ermite/internal/ports"
19+ "bnema.dev/ermite/internal/ports/mocks"
20+ "bnema.dev/ermite/internal/usecase"
21+)
22+
23+type settingsEnv struct {
24+ store *kvstore.Store
25+ s *usecase.CISettings
26+ repo domain.RepoName
27+}
28+
29+func newSettings(t *testing.T, key byte) *settingsEnv {
30+ t.Helper()
31+ ctx := context.Background()
32+ store, err := kvstore.Open(ctx, t.TempDir())
33+ require.NoError(t, err)
34+ t.Cleanup(func() { _ = store.Close(ctx) })
35+ repo := name(t, "proj")
36+ require.NoError(t, store.Create(ctx, domain.Repo{Name: repo, Visibility: domain.VisibilityPrivate, CreatedAt: now}))
37+ var sealer ports.SecretSealer
38+ if key != 0 {
39+ box, err := secretbox.New(bytes.Repeat([]byte{key}, secretbox.KeyLen))
40+ require.NoError(t, err)
41+ sealer = box
42+ }
43+ s, err := usecase.NewCISettings(ctx, store, store.Settings(), sealer, &fakeClock{t: now})
44+ require.NoError(t, err)
45+ return &settingsEnv{store: store, s: s, repo: repo}
46+}
47+
48+// Precedence: environment over repository over instance, for variables and
49+// secrets, and only the secrets that were asked for are opened.
50+func TestResolvePrecedenceAndSelection(t *testing.T) {
51+ ctx, root := context.Background(), domain.Root()
52+ e := newSettings(t, 1)
53+ inst, repo, env := domain.InstanceScope(), domain.RepoScope(e.repo), domain.EnvironmentScope(e.repo, "prod")
54+ _, err := e.s.CreateEnvironment(ctx, root, e.repo, "Prod")
55+ require.NoError(t, err)
56+
57+ for _, c := range []struct {
58+ sc domain.SettingsScope
59+ name, value string
60+ }{
61+ {inst, "A", "inst"}, {inst, "B", "inst"}, {inst, "C", "inst"},
62+ {repo, "B", "repo"}, {repo, "C", "repo"},
63+ {env, "C", "env"}, {env, "D", "env"},
64+ } {
65+ _, err := e.s.SetSecret(ctx, root, c.sc, c.name, c.value)
66+ require.NoError(t, err)
67+ _, err = e.s.SetVariable(ctx, root, c.sc, c.name, "v-"+c.value)
68+ require.NoError(t, err)
69+ }
70+
71+ got, err := e.s.Resolve(ctx, ports.JobSettingsRequest{Repo: e.repo, Environment: "PROD", WantSecrets: true, AllSecrets: true})
72+ require.NoError(t, err)
73+ assert.Equal(t, map[string]string{"A": "v-inst", "B": "v-repo", "C": "v-env", "D": "v-env"}, got.Vars)
74+ assert.Equal(t, map[string]string{"A": "inst", "B": "repo", "C": "env", "D": "env"}, got.Secrets)
75+
76+ // Without an environment: instance and repository only.
77+ got, err = e.s.Resolve(ctx, ports.JobSettingsRequest{Repo: e.repo, WantSecrets: true, AllSecrets: true})
78+ require.NoError(t, err)
79+ assert.Equal(t, map[string]string{"A": "v-inst", "B": "v-repo", "C": "v-repo"}, got.Vars)
80+ assert.Equal(t, map[string]string{"A": "inst", "B": "repo", "C": "repo"}, got.Secrets)
81+
82+ // Only the names asked for (any case) come out.
83+ got, err = e.s.Resolve(ctx, ports.JobSettingsRequest{Repo: e.repo, Environment: "prod", WantSecrets: true, SecretNames: []string{"d", "A"}})
84+ require.NoError(t, err)
85+ assert.Equal(t, map[string]string{"A": "inst", "D": "env"}, got.Secrets)
86+
87+ // Not wanted (an untrusted run), or nothing named: no secret at all.
88+ for _, req := range []ports.JobSettingsRequest{
89+ {Repo: e.repo, AllSecrets: true, SecretNames: []string{"A"}},
90+ {Repo: e.repo, WantSecrets: true},
91+ } {
92+ got, err = e.s.Resolve(ctx, req)
93+ require.NoError(t, err)
94+ assert.Empty(t, got.Secrets)
95+ assert.NotEmpty(t, got.Vars, "variables are not secret")
96+ }
97+
98+ // A missing environment adds nothing and is not created unless asked.
99+ got, err = e.s.Resolve(ctx, ports.JobSettingsRequest{Repo: e.repo, Environment: "new"})
100+ require.NoError(t, err)
101+ assert.Equal(t, "v-repo", got.Vars["C"])
102+ page, err := e.s.ListEnvironments(ctx, root, e.repo, 0, "")
103+ require.NoError(t, err)
104+ assert.Len(t, page.Environments, 1)
105+ _, err = e.s.Resolve(ctx, ports.JobSettingsRequest{Repo: e.repo, Environment: "New", CreateEnvironment: true})
106+ require.NoError(t, err)
107+ page, err = e.s.ListEnvironments(ctx, root, e.repo, 0, "")
108+ require.NoError(t, err)
109+ assert.Len(t, page.Environments, 2)
110+}
111+
112+// A sealed value moved to another secret is not given to a job: the ciphertext
113+// is bound to its scope and name.
114+func TestResolveLeavesOutASwappedCiphertext(t *testing.T) {
115+ ctx, root := context.Background(), domain.Root()
116+ e := newSettings(t, 1)
117+ sc := domain.RepoScope(e.repo)
118+ _, err := e.s.SetSecret(ctx, root, sc, "A", "value-of-a")
119+ require.NoError(t, err)
120+ _, err = e.s.SetSecret(ctx, root, sc, "B", "value-of-b")
121+ require.NoError(t, err)
122+
123+ st := e.store.Settings()
124+ sealed, err := st.ListSealedSecrets(ctx, sc)
125+ require.NoError(t, err)
126+ require.Len(t, sealed, 2)
127+ // Swap the two stored values behind the use case's back.
128+ _, err = st.PutSecret(ctx, sc, "A", sealed[1].Sealed, now)
129+ require.NoError(t, err)
130+ _, err = st.PutSecret(ctx, sc, "B", sealed[0].Sealed, now)
131+ require.NoError(t, err)
132+
133+ got, err := e.s.Resolve(ctx, ports.JobSettingsRequest{Repo: e.repo, WantSecrets: true, AllSecrets: true})
134+ require.NoError(t, err)
135+ assert.Empty(t, got.Secrets, "neither opens")
136+}
137+
138+func TestSetSecretWithoutAKeyIsAFailedPrecondition(t *testing.T) {
139+ ctx, root := context.Background(), domain.Root()
140+ e := newSettings(t, 0)
141+ _, err := e.s.SetSecret(ctx, root, domain.RepoScope(e.repo), "A", "v")
142+ assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
143+ // Checked after the arguments: a bad name is still invalid.
144+ _, err = e.s.SetSecret(ctx, root, domain.RepoScope(e.repo), "1", "v")
145+ assert.True(t, domain.IsInvalid(err))
146+ // Variables and environments need no key, and no secret is sent.
147+ _, err = e.s.SetVariable(ctx, root, domain.RepoScope(e.repo), "V", "v")
148+ require.NoError(t, err)
149+ got, err := e.s.Resolve(ctx, ports.JobSettingsRequest{Repo: e.repo, WantSecrets: true, AllSecrets: true})
150+ require.NoError(t, err)
151+ assert.True(t, got.SecretsOff)
152+ assert.Empty(t, got.Secrets)
153+ assert.Equal(t, "v", got.Vars["V"])
154+}
155+
156+func TestNewCISettingsRefusesAnotherKey(t *testing.T) {
157+ ctx := context.Background()
158+ dir := t.TempDir()
159+ open := func(key byte) error {
160+ store, err := kvstore.Open(ctx, dir)
161+ require.NoError(t, err)
162+ defer func() { _ = store.Close(ctx) }()
163+ box, err := secretbox.New(bytes.Repeat([]byte{key}, secretbox.KeyLen))
164+ require.NoError(t, err)
165+ _, err = usecase.NewCISettings(ctx, store, store.Settings(), box, &fakeClock{t: time.Now()})
166+ return err
167+ }
168+ require.NoError(t, open(1))
169+ require.NoError(t, open(1))
170+ assert.ErrorIs(t, open(2), domain.ErrSecretsKeyMismatch)
171+ assert.NoError(t, open(1), "the first key still starts")
172+}
173+
174+func TestSettingsAccess(t *testing.T) {
175+ ctx := context.Background()
176+ e := newSettings(t, 1)
177+ sc := domain.RepoScope(e.repo)
178+ tok := func(scopes []domain.Scope, repos []domain.RepoName, prefixes []string) domain.Principal {
179+ return domain.TokenPrincipal(domain.Token{ID: "t", Scopes: scopes, Repos: repos, BranchPrefixes: prefixes})
180+ }
181+ all := domain.AllScopes()
182+ cases := []struct {
183+ name string
184+ p domain.Principal
185+ sc domain.SettingsScope
186+ want error
187+ }{
188+ {"root", domain.Root(), sc, nil},
189+ {"owner session", domain.Owner(), sc, nil},
190+ {"token with the scope", tok([]domain.Scope{domain.ScopeSecretsWrite}, nil, nil), sc, nil},
191+ {"token on its repository", tok([]domain.Scope{domain.ScopeSecretsWrite}, []domain.RepoName{e.repo}, nil), sc, nil},
192+ {"every scope but branch-limited", tok(all, nil, []string{"agent/"}), sc, domain.ErrForbidden},
193+ {"branch-limited, instance scope", tok(all, nil, []string{"agent/"}), domain.InstanceScope(), domain.ErrForbidden},
194+ {"reads the repository but lacks the scope", tok([]domain.Scope{domain.ScopeRepoRead}, nil, nil), sc, domain.ErrForbidden},
195+ {"cannot read the private repository", tok([]domain.Scope{domain.ScopeMRWrite}, nil, nil), sc, domain.ErrNotFound},
196+ {"anonymous", domain.Anonymous(), sc, domain.ErrUnauthenticated},
197+ {"job token", domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "j", Repo: e.repo}), sc, domain.ErrForbidden},
198+ {"instance, repository-restricted token", tok([]domain.Scope{domain.ScopeSecretsWrite}, []domain.RepoName{e.repo}, nil), domain.InstanceScope(), domain.ErrForbidden},
199+ {"instance, unrestricted", tok([]domain.Scope{domain.ScopeSecretsWrite}, nil, nil), domain.InstanceScope(), nil},
200+ {"invalid scope", domain.Root(), domain.SettingsScope{}, domain.ErrInvalid},
201+ }
202+ for _, tc := range cases {
203+ _, err := e.s.ListSecrets(ctx, tc.p, tc.sc, 0, "")
204+ if tc.want == nil {
205+ assert.NoError(t, err, tc.name)
206+ continue
207+ }
208+ assert.ErrorIs(t, err, tc.want, tc.name)
209+ }
210+}
211+
212+// A higher scope whose ciphertext does not open withholds the secret: the job
213+// never gets the value of a lower scope that the override was meant to replace.
214+// Named and "all" resolution agree, and an unrelated secret is not affected.
215+func TestResolveWithholdsASecretWhoseOverrideDoesNotOpen(t *testing.T) {
216+ ctx, root := context.Background(), domain.Root()
217+ e := newSettings(t, 1)
218+ inst, repo := domain.InstanceScope(), domain.RepoScope(e.repo)
219+ for _, c := range []struct {
220+ sc domain.SettingsScope
221+ name, value string
222+ }{{inst, "A", "lower-a"}, {inst, "B", "lower-b"}, {repo, "A", "upper-a"}} {
223+ _, err := e.s.SetSecret(ctx, root, c.sc, c.name, c.value)
224+ require.NoError(t, err)
225+ }
226+ _, err := e.store.Settings().PutSecret(ctx, repo, "A", []byte("garbage that is not a sealed value at all"), now)
227+ require.NoError(t, err)
228+
229+ for name, req := range map[string]ports.JobSettingsRequest{
230+ "all": {Repo: e.repo, WantSecrets: true, AllSecrets: true},
231+ "named": {Repo: e.repo, WantSecrets: true, SecretNames: []string{"a", "B"}},
232+ } {
233+ got, err := e.s.Resolve(ctx, req)
234+ require.NoError(t, err, name)
235+ assert.Equal(t, map[string]string{"B": "lower-b"}, got.Secrets, name)
236+ }
237+}
238+
239+// Named resolution reads the names it needs, not whole scopes.
240+func TestResolveNamedSecretsUsePointReads(t *testing.T) {
241+ ctx := context.Background()
242+ store := mocks.NewCISettingsStore(t)
243+ sealer := mocks.NewSecretSealer(t)
244+ sealer.EXPECT().KeyCheck().Return([]byte("k"))
245+ store.EXPECT().BindSecretsKey(mock.Anything, mock.Anything).Return(nil)
246+ s, err := usecase.NewCISettings(ctx, mocks.NewRepoMetaStore(t), store, sealer, &fakeClock{t: now})
247+ require.NoError(t, err)
248+ repo := name(t, "proj")
249+ store.EXPECT().ListVariables(mock.Anything, mock.Anything).Return(nil, nil)
250+ // Two scopes (instance, repository), one name each: no ListSealedSecrets call.
251+ store.EXPECT().GetSealedSecret(mock.Anything, domain.InstanceScope(), "A").Return(ports.SealedSecret{}, domain.ErrNotFound)
252+ store.EXPECT().GetSealedSecret(mock.Anything, domain.RepoScope(repo), "A").Return(ports.SealedSecret{Name: "A", Sealed: []byte("s")}, nil)
253+ sealer.EXPECT().Open([]byte("s"), domain.SecretAAD(domain.RepoScope(repo), "A")).Return([]byte("v"), nil)
254+ got, err := s.Resolve(ctx, ports.JobSettingsRequest{Repo: repo, WantSecrets: true, SecretNames: []string{"a", "A"}})
255+ require.NoError(t, err)
256+ assert.Equal(t, map[string]string{"A": "v"}, got.Secrets)
257+}
M internal/usecase/ci_trigger.go +25 -11
@@ -37,6 +37,9 @@ type ciTrigger struct {
3737 updates []domain.RefUpdate
3838 actor string
3939 pusher domain.CIPusher
40+ // trusted: the principal that pushed or merged is the owner
41+ // (domain.TrustedActor), so the run gets secrets.
42+ trusted bool
4043 at time.Time
4144 log zerowrap.Logger
4245 }
@@ -45,19 +48,20 @@ type ciTrigger struct {
4548 // a worker plans them. A full queue drops the trigger, logged at error: a push
4649 // is never slowed down or failed by CI.
4750 func (c *CI) OnPush(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, pusher domain.Principal) {
48- c.enqueue(ctx, repo, updates, authorOf(pusher), domain.PusherOf(pusher))
51+ c.enqueue(ctx, repo, updates, authorOf(pusher), domain.PusherOf(pusher), domain.TrustedActor(pusher))
4952 }
5053
5154 // OnMerge queues the move of a target branch by a merge. The actor of the run is
5255 // the owner, as GitHub shows; the pusher recorded with it, whose rights the
5356 // actions of other repositories are resolved with, is the principal that merged:
5457 // a token limited to some repositories does not gain the owner's reach by
55-// merging.
58+// merging. For the same reason the run is trusted (gets secrets) only when the
59+// merger is: the displayed actor is not the one that decides.
5660 func (c *CI) OnMerge(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, merger domain.Principal) {
57- c.enqueue(ctx, repo, updates, authorOf(domain.Owner()), domain.PusherOf(merger))
61+ c.enqueue(ctx, repo, updates, authorOf(domain.Owner()), domain.PusherOf(merger), domain.TrustedActor(merger))
5862 }
5963
60-func (c *CI) enqueue(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, actor string, pusher domain.CIPusher) {
64+func (c *CI) enqueue(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, actor string, pusher domain.CIPusher, trusted bool) {
6165 var kept []domain.RefUpdate
6266 for _, u := range updates {
6367 if !u.IsDelete() && (strings.HasPrefix(u.Ref, "refs/heads/") || strings.HasPrefix(u.Ref, "refs/tags/")) {
@@ -67,7 +71,7 @@ func (c *CI) enqueue(ctx context.Context, repo domain.RepoName, updates []domain
6771 if len(kept) == 0 {
6872 return
6973 }
70- t := ciTrigger{repo: repo, updates: kept, actor: actor, pusher: pusher, at: c.now(), log: zerowrap.FromCtx(ctx)}
74+ t := ciTrigger{repo: repo, updates: kept, actor: actor, pusher: pusher, trusted: trusted, at: c.now(), log: zerowrap.FromCtx(ctx)}
7175 c.mu.Lock()
7276 closed := c.closed
7377 c.mu.Unlock()
@@ -394,7 +398,7 @@ func (c *CI) startInvalid(ctx context.Context, repo domain.Repo, t ciTrigger, f
394398 run := domain.CIRun{
395399 Repo: repo.Name, ID: id, Number: number, Attempt: 1, WorkflowPath: wfPath, WorkflowName: name, Name: name,
396400 Title: firstLine(f.head.Message), Event: "push", Ref: f.update.Ref, Sha: f.commit.String(), Actor: t.actor,
397- Status: domain.CICompleted, Conclusion: domain.CIFailure, Error: cutText(problem, maxRunError),
401+ Status: domain.CICompleted, Conclusion: domain.CIFailure, Error: cutText(problem, maxRunError), Trusted: t.trusted,
398402 CreatedAt: now, StartedAt: now, CompletedAt: now,
399403 }
400404 return ports.CIRunDraft{
@@ -439,14 +443,18 @@ func (c *CI) startRun(ctx context.Context, repo domain.Repo, t ciTrigger, f push
439443 if f.isTag {
440444 title = c.registeredName(ctx, repo.Name, wfPath, name)
441445 }
446+ vars, err := c.varsContext(ctx, repo.Name)
447+ if err != nil {
448+ return err
449+ }
442450 draft, err := c.cfg.Store.CreateRun(ctx, repo.Name, wfPath, func(id, number int64) ports.CIRunDraft {
443451 run := domain.CIRun{
444452 Repo: repo.Name, ID: id, Number: number, Attempt: 1, WorkflowPath: wfPath, WorkflowName: name, Name: name,
445453 Title: title, Event: "push", Ref: f.update.Ref, Sha: f.commit.String(), Actor: t.actor,
446- Status: domain.CIQueued, CreatedAt: now,
454+ Status: domain.CIQueued, Trusted: t.trusted, CreatedAt: now,
447455 }
448456 githubJSON := c.githubJSON(ctx, repo, run, f)
449- if runName, ok := c.evalRunName(ctx, wf, githubJSON); ok {
457+ if runName, ok := c.evalRunName(ctx, wf, githubJSON, vars); ok {
450458 run.Name, run.Title = runName, runName
451459 }
452460 d := ports.CIRunDraft{Run: run, Source: domain.CIRunSource{
@@ -550,7 +558,7 @@ func (c *CI) registeredNameOf(ctx context.Context, repo domain.RepoName, wfPath
550558 // to nothing: the run then has no name of its own, GitHub's display title falls
551559 // back to the commit message (a branch) or the registered name (a tag), and
552560 // the name of the run to the name of the workflow.
553-func (c *CI) evalRunName(ctx context.Context, wf *workflow.Workflow, githubJSON string) (string, bool) {
561+func (c *CI) evalRunName(ctx context.Context, wf *workflow.Workflow, githubJSON string, vars *expr.Dictionary) (string, bool) {
554562 if wf.RunName == nil {
555563 return "", false
556564 }
@@ -559,7 +567,7 @@ func (c *CI) evalRunName(ctx context.Context, wf *workflow.Workflow, githubJSON
559567 return "", false
560568 }
561569 env := template.Env{Contexts: map[string]expr.Value{
562- "github": gh, "inputs": expr.NewDictionary(), "vars": expr.NewDictionary(),
570+ "github": gh, "inputs": expr.NewDictionary(), "vars": vars,
563571 }}
564572 name, err := template.EvaluateString(ctx, wf.RunName, env)
565573 if err != nil {
@@ -595,8 +603,14 @@ func (c *CI) matrixProblem(ctx context.Context, repo domain.Repo, t ciTrigger, f
595603 if err != nil {
596604 return matrixIssue{}
597605 }
606+ vars, err := c.varsContext(ctx, repo.Name)
607+ if err != nil {
608+ // Only the sizes of the matrices are looked at here: the job fails on
609+ // the same error when it is planned.
610+ vars = expr.NewDictionary()
611+ }
598612 env := template.Env{Contexts: map[string]expr.Value{
599- "github": gh, "needs": expr.NewDictionary(), "inputs": expr.NewDictionary(), "vars": expr.NewDictionary(),
613+ "github": gh, "needs": expr.NewDictionary(), "inputs": expr.NewDictionary(), "vars": vars,
600614 }}
601615 for _, j := range wf.Jobs {
602616 _, err := j.EvaluateStrategy(ctx, env)
M internal/usecase/export_test.go +27 -1
@@ -5,6 +5,7 @@ package usecase
55 import (
66 "context"
77
8+ "bnema.dev/ermite-actions/expr"
89 "bnema.dev/ermite-actions/workflow"
910 "bnema.dev/ermite/internal/domain"
1011 )
@@ -18,7 +19,7 @@ func (c *CI) Sweep(ctx context.Context) { c.sweep(ctx) }
1819 // RunTitle gives the title of a run as startRun sets it: the evaluated
1920 // run-name, else commitTitle.
2021 func (c *CI) RunTitle(ctx context.Context, wf *workflow.Workflow, githubJSON, commitTitle string) string {
21- if name, ok := c.evalRunName(ctx, wf, githubJSON); ok {
22+ if name, ok := c.evalRunName(ctx, wf, githubJSON, expr.NewDictionary()); ok {
2223 return name
2324 }
2425 return commitTitle
@@ -28,3 +29,28 @@ func (c *CI) RunTitle(ctx context.Context, wf *workflow.Workflow, githubJSON, co
2829 func CheckRunID(ctx context.Context, run domain.CIRun, index, combo int) int64 {
2930 return checkRunID(ctx, run, index, combo)
3031 }
32+
33+// SecretUse is the result of SecretsUsedBy.
34+type SecretUse = secretUse
35+
36+// SecretsUsedBy exposes secretsUsedBy to the tests.
37+func SecretsUsedBy(workflowYAML, jobID string) SecretUse {
38+ return secretsUsedBy(workflowYAML, jobID)
39+}
40+
41+// SecretWalks is the number of YAML walks made so far.
42+func SecretWalks() int64 { return secretWalks.Load() }
43+
44+// SecretUseCache is a wfCache exposed to the tests.
45+type SecretUseCache struct{ c *wfCache }
46+
47+// NewSecretUseCache builds a cache for up to max runs.
48+func NewSecretUseCache(max int) *SecretUseCache { return &SecretUseCache{newWfCache(max)} }
49+
50+// Use is wfCache.secretUse.
51+func (s *SecretUseCache) Use(runID int64, yaml, jobKey string) SecretUse {
52+ return s.c.secretUse(runID, yaml, jobKey)
53+}
54+
55+// Drop is wfCache.drop.
56+func (s *SecretUseCache) Drop(runID int64) { s.c.drop(runID) }
M proto/ermite/storage/ci.proto +6 -0
@@ -99,6 +99,9 @@ message CIRun {
9999 // workflow name. Empty in runs stored before it existed (read as
100100 // workflow_name).
101101 string name = 22;
102+ // trusted: the actor of the run is the owner, so its jobs get secrets. False
103+ // in runs stored before it existed (the safe reading).
104+ bool trusted = 23;
102105 }
103106
104107 // CIRunSource is stored under "runsrc/<run id:020d>": what a run was planned
@@ -196,6 +199,9 @@ message CIJob {
196199 // of the job does not fail the run, does not cancel the siblings of its
197200 // matrix, and needs.<job>.result is success.
198201 bool continue_on_error = 40;
202+ // The evaluated environment: of the job, set when the job is planned; empty
203+ // for none. Its secrets and variables are read when the job is assigned.
204+ string environment = 41;
199205 }
200206
201207 // CICheck is stored under "check/<repo>/<sha>/<id>": the id of its job, or
A proto/ermite/storage/settings.proto +34 -0
@@ -0,0 +1,34 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Internal, unversioned storage schema of ermite: CI secrets, variables and
4+// environments (ADR 0019, CI-3).
5+
6+syntax = "proto3";
7+
8+package ermite.storage;
9+
10+import "google/protobuf/timestamp.proto";
11+
12+option go_package = "bnema.dev/ermite/gen/ermite/storage;storagepb";
13+
14+// SecretRecord is stored under "secret/<scope>/<NAME>": the value sealed with
15+// AES-256-GCM (version byte, nonce, ciphertext and tag), bound by its additional
16+// data to the scope and the name.
17+message SecretRecord {
18+ bytes sealed = 1;
19+ google.protobuf.Timestamp updated_at = 2;
20+}
21+
22+// VariableRecord is stored under "var/<scope>/<NAME>".
23+message VariableRecord {
24+ string value = 1;
25+ google.protobuf.Timestamp updated_at = 2;
26+}
27+
28+// EnvironmentRecord is stored under "env/<repo>/<lower-cased escaped name>";
29+// name keeps the case it was created with.
30+message EnvironmentRecord {
31+ string repo = 1;
32+ string name = 2;
33+ google.protobuf.Timestamp created_at = 3;
34+}

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 41

↑↓ moveenter openesc close