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
29 | `ERMITE_COMMITTER_EMAIL` | `ermite@<public host>` | committer email of squash merges |29 | `ERMITE_COMMITTER_EMAIL` | `ermite@<public host>` | committer email of squash merges |
30 | `ERMITE_OWNER` | `owner` | owner login that CI shows in `github.repository` (`<owner>/<repo>`) and `github.repository_owner`; follows the rules of repository names |30 | `ERMITE_OWNER` | `owner` | owner login that CI shows in `github.repository` (`<owner>/<repo>`) and `github.repository_owner`; follows the rules of repository names |
31 | `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 |31 | `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 |
32 | `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) |33 | `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) |
33 34
34 `ermite version` prints the version (and the server's when a server URL is configured, see Remote CLI).35 `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 (
13 13
14 require (14 require (
15 bnema.dev/ermite-actions v0.2.015 bnema.dev/ermite-actions v0.2.0
16- bnema.dev/ermite-api v0.2.016+ bnema.dev/ermite-api v0.2.1-0.20261014200206-5c335b855824
17 bnema.dev/ermite-git v0.1.017 bnema.dev/ermite-git v0.1.0
18 bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c318 bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3
19 connectrpc.com/connect v1.21.019 connectrpc.com/connect v1.21.0
@@ -27,6 +27,7 @@ require (
27 github.com/spf13/cobra v1.10.227 github.com/spf13/cobra v1.10.2
28 github.com/stretchr/testify v1.12.128 github.com/stretchr/testify v1.12.1
29 github.com/yuin/goldmark v1.8.629 github.com/yuin/goldmark v1.8.6
30+ go.yaml.in/yaml/v3 v3.0.5
30 golang.org/x/crypto v0.57.031 golang.org/x/crypto v0.57.0
31 golang.org/x/sys v0.49.032 golang.org/x/sys v0.49.0
32 golang.org/x/term v0.46.033 golang.org/x/term v0.46.0
@@ -157,7 +158,6 @@ require (
157 go.opentelemetry.io/otel/trace v1.46.0 // indirect158 go.opentelemetry.io/otel/trace v1.46.0 // indirect
158 go.uber.org/multierr v1.11.0 // indirect159 go.uber.org/multierr v1.11.0 // indirect
159 go.uber.org/zap v1.28.0 // indirect160 go.uber.org/zap v1.28.0 // indirect
160- go.yaml.in/yaml/v3 v3.0.5 // indirect
161 golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa // indirect161 golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa // indirect
162 golang.org/x/mod v0.41.0 // indirect162 golang.org/x/mod v0.41.0 // indirect
163 golang.org/x/net v0.59.0 // indirect163 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 {
90 return ermiteconnect.NewMergeRequestServiceClient(c.http, c.base, c.opts...)90 return ermiteconnect.NewMergeRequestServiceClient(c.http, c.base, c.opts...)
91 }91 }
92 92
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+
93 // Server returns the ServerService client.108 // Server returns the ServerService client.
94 func (c *Client) Server() ermiteconnect.ServerServiceClient {109 func (c *Client) Server() ermiteconnect.ServerServiceClient {
95 return ermiteconnect.NewServerServiceClient(c.http, c.base, c.opts...)110 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) {
139 func TestMissingFeatures(t *testing.T) {139 func TestMissingFeatures(t *testing.T) {
140 assert.Empty(t, client.MissingFeatures(client.UsedFeatures()))140 assert.Empty(t, client.MissingFeatures(client.UsedFeatures()))
141 assert.Equal(t,141 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},
143 client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, client.FeaturePasskeys, "other"}))143 client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, client.FeaturePasskeys, "other"}))
144 assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{144 assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{
145 client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSession,145 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,
147 client.FeaturePasskeys,147 client.FeaturePasskeys,
148 }))148 }))
149 assert.Equal(t, []string{client.FeaturePasskeys}, client.MissingFeatures([]string{149 assert.Equal(t, []string{client.FeaturePasskeys}, client.MissingFeatures([]string{
150 client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureSession,150 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,
152 }))152 }))
153 assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil))153 assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil))
154 }154 }
M internal/adapters/client/features.go +4 -1
@@ -37,11 +37,14 @@ const (
37 // calls (`ermite runner`).37 // calls (`ermite runner`).
38 FeatureCIRuns = "ci.runs"38 FeatureCIRuns = "ci.runs"
39 FeatureCIRunners = "ci.runners"39 FeatureCIRunners = "ci.runners"
40+ // FeatureCISecrets is SecretService, VariableService and EnvironmentService
41+ // (`ermite secret`, `variable`, `environment`).
42+ FeatureCISecrets = "ci.secrets"
40 )43 )
41 44
42 // UsedFeatures lists the features the CLI uses, in the order they are reported.45 // UsedFeatures lists the features the CLI uses, in the order they are reported.
43 func UsedFeatures() []string {46 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}
45 }48 }
46 49
47 // MissingFeatures returns the features the CLI uses that have is lacking.50 // 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) {
275 Event: r.Event, Ref: r.Ref, Sha: r.Sha, Actor: r.Actor,275 Event: r.Event, Ref: r.Ref, Sha: r.Sha, Actor: r.Actor,
276 Status: encodeStatus(r.Status), Conclusion: encodeConclusion(r.Conclusion), Error: r.Error,276 Status: encodeStatus(r.Status), Conclusion: encodeConclusion(r.Conclusion), Error: r.Error,
277 CreatedAt: encodeTime(r.CreatedAt), StartedAt: encodeTime(r.StartedAt), CompletedAt: encodeTime(r.CompletedAt),277 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,
279 }), nil279 }), nil
280 }280 }
281 281
@@ -299,7 +299,7 @@ func decodeRun(item kv.Item) (domain.CIRun, error) {
299 Status: decodeStatus(pb.GetStatus()), Conclusion: decodeConclusion(pb.GetConclusion()), Error: pb.GetError(),299 Status: decodeStatus(pb.GetStatus()), Conclusion: decodeConclusion(pb.GetConclusion()), Error: pb.GetError(),
300 CreatedAt: decodeTime(pb.GetCreatedAt()), StartedAt: decodeTime(pb.GetStartedAt()), CompletedAt: decodeTime(pb.GetCompletedAt()),300 CreatedAt: decodeTime(pb.GetCreatedAt()), StartedAt: decodeTime(pb.GetStartedAt()), CompletedAt: decodeTime(pb.GetCompletedAt()),
301 CancelRequested: pb.GetCancelRequested(), CancelRequestedAt: decodeTime(pb.GetCancelRequestedAt()),301 CancelRequested: pb.GetCancelRequested(), CancelRequestedAt: decodeTime(pb.GetCancelRequestedAt()),
302- Version: item.Version,302+ Trusted: pb.GetTrusted(), Version: item.Version,
303 }, nil303 }, nil
304 }304 }
305 305
@@ -330,7 +330,7 @@ func encodeJob(j domain.CIJob) ([]byte, error) {
330 Outputs: j.Outputs, ReadyAt: encodeTime(j.ReadyAt), AssignedAt: encodeTime(j.AssignedAt),330 Outputs: j.Outputs, ReadyAt: encodeTime(j.ReadyAt), AssignedAt: encodeTime(j.AssignedAt),
331 AckedAt: encodeTime(j.AckedAt), AssignCancels: int32(j.AssignCancels), //nolint:gosec // at most 3331 AckedAt: encodeTime(j.AckedAt), AssignCancels: int32(j.AssignCancels), //nolint:gosec // at most 3
332 TokenValidUntil: encodeTime(j.TokenValidUntil), CancelReason: encodeCancelReason(j.CancelReason), CancelMessage: j.CancelMessage,332 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,
334 }334 }
335 if j.HasToken() {335 if j.HasToken() {
336 pb.TokenHash = j.TokenHash[:]336 pb.TokenHash = j.TokenHash[:]
@@ -360,7 +360,7 @@ func decodeJob(item kv.Item) (domain.CIJob, error) {
360 TokenValidUntil: decodeTime(pb.GetTokenValidUntil()), ReadyAt: decodeTime(pb.GetReadyAt()),360 TokenValidUntil: decodeTime(pb.GetTokenValidUntil()), ReadyAt: decodeTime(pb.GetReadyAt()),
361 AssignedAt: decodeTime(pb.GetAssignedAt()), AckedAt: decodeTime(pb.GetAckedAt()), AssignCancels: int(pb.GetAssignCancels()),361 AssignedAt: decodeTime(pb.GetAssignedAt()), AckedAt: decodeTime(pb.GetAckedAt()), AssignCancels: int(pb.GetAssignCancels()),
362 CancelMessage: pb.GetCancelMessage(), CancelRequestedAt: decodeTime(pb.GetCancelRequestedAt()),362 CancelMessage: pb.GetCancelMessage(), CancelRequestedAt: decodeTime(pb.GetCancelRequestedAt()),
363- Version: item.Version,363+ Environment: pb.GetEnvironment(), Version: item.Version,
364 }364 }
365 j.CancelReason = decodeCancelReason(pb.GetCancelReason())365 j.CancelReason = decodeCancelReason(pb.GetCancelReason())
366 copy(j.TokenHash[:], pb.GetTokenHash())366 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) {
403 assert.Equal(t, "run push on branch by someone", got.Name)403 assert.Equal(t, "run push on branch by someone", got.Name)
404 assert.Equal(t, "ci", got.WorkflowName, "the workflow name is unchanged")404 assert.Equal(t, "ci", got.WorkflowName, "the workflow name is unchanged")
405 }405 }
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
206 }206 }
207 return keys207 return keys
208 }208 }
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 {
144 snap := s.db.Snapshot(ctx)144 snap := s.db.Snapshot(ctx)
145 repoItem, repoOK := snap.Get(ctx, repoKey(name))145 repoItem, repoOK := snap.Get(ctx, repoKey(name))
146 keys := append(mergeRequestKeys(ctx, snap, name), ciKeys(ctx, snap, name)...)146 keys := append(mergeRequestKeys(ctx, snap, name), ciKeys(ctx, snap, name)...)
147+ keys = append(keys, settingsKeys(ctx, snap, name)...)
147 for len(keys) > deleteChunk {148 for len(keys) > deleteChunk {
148 var b kv.Batch149 var b kv.Batch
149 for _, k := range keys[:deleteChunk] {150 for _, k := range keys[:deleteChunk] {
M internal/adapters/rpc/api.go +19 -2
@@ -32,7 +32,8 @@ const RunnerMaxRequestBytes = MaxRequestBytes + 64<<10
32 32
33 // Features are the optional capabilities of the contract that this server33 // Features are the optional capabilities of the contract that this server
34 // always implements, as GetVersion lists them. FeaturePasskeys comes on top34 // 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.
36 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"}37 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"}
37 38
38 // FeaturePasskeys is listed by GetVersion when passkeys are available.39 // FeaturePasskeys is listed by GetVersion when passkeys are available.
@@ -69,6 +70,13 @@ type Config struct {
69 // not available (the RPCs answer failed_precondition and GetVersion does70 // not available (the RPCs answer failed_precondition and GetVersion does
70 // not list "passkeys"). Same rule as Sessions about typed nil pointers.71 // not list "passkeys"). Same rule as Sessions about typed nil pointers.
71 Passkeys ports.PasskeyService72 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
72 }80 }
73 81
74 // API is the set of Connect handlers of the contract.82 // API is the set of Connect handlers of the contract.
@@ -99,17 +107,26 @@ func New(cfg Config) *API {
99 if cfg.Passkeys != nil {107 if cfg.Passkeys != nil {
100 features = append(features, FeaturePasskeys)108 features = append(features, FeaturePasskeys)
101 }109 }
110+ settings := cfg.Secrets != nil && cfg.Variables != nil && cfg.Environments != nil
111+ if settings {
112+ features = append(features, FeatureCISecrets)
113+ }
102 mux.Handle(ermiteconnect.NewServerServiceHandler(&serverService{version: cfg.Version, features: features, key: cfg.SigningKey}, opts...))114 mux.Handle(ermiteconnect.NewServerServiceHandler(&serverService{version: cfg.Version, features: features, key: cfg.SigningKey}, opts...))
103 mux.Handle(ermiteconnect.NewMergeRequestServiceHandler(&mergeRequestService{cfg.MergeRequests}, opts...))115 mux.Handle(ermiteconnect.NewMergeRequestServiceHandler(&mergeRequestService{cfg.MergeRequests}, opts...))
104 if cfg.Sessions != nil {116 if cfg.Sessions != nil {
105 mux.Handle(ermiteconnect.NewSessionServiceHandler(&sessionService{sessions: cfg.Sessions, passkeys: cfg.Passkeys}, opts...))117 mux.Handle(ermiteconnect.NewSessionServiceHandler(&sessionService{sessions: cfg.Sessions, passkeys: cfg.Passkeys}, opts...))
106 }118 }
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+ }
107 if cfg.Runner != nil {124 if cfg.Runner != nil {
108 runnerOpts := append(slices.Clone(opts), connect.WithReadMaxBytes(RunnerMaxRequestBytes))125 runnerOpts := append(slices.Clone(opts), connect.WithReadMaxBytes(RunnerMaxRequestBytes))
109 mux.Handle(ermiteconnect.NewRunnerServiceHandler(&runnerService{runners: cfg.Runner, limiter: cfg.Limiter}, runnerOpts...))126 mux.Handle(ermiteconnect.NewRunnerServiceHandler(&runnerService{runners: cfg.Runner, limiter: cfg.Limiter}, runnerOpts...))
110 }127 }
111 if cfg.Runs != nil {128 if cfg.Runs != nil {
112- mux.Handle(ermiteconnect.NewRunServiceHandler(&runService{cfg.Runs}, opts...))129+ mux.Handle(ermiteconnect.NewRunServiceHandler(&runService{runs: cfg.Runs}, opts...))
113 }130 }
114 return &API{handler: mux}131 return &API{handler: mux}
115 }132 }
M internal/adapters/rpc/ci.go +6 -1
@@ -207,7 +207,12 @@ func (s *runnerService) UploadSummary(ctx context.Context, req *connect.Request[
207 }207 }
208 208
209 // runService implements ermiteconnect.RunServiceHandler.209 // 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+}
211 216
212 func repoOf(s string) (domain.RepoName, error) { return domain.ParseRepoName(s) }217 func repoOf(s string) (domain.RepoName, error) { return domain.ParseRepoName(s) }
213 218
M internal/adapters/rpc/ci_convert.go +2 -0
@@ -145,6 +145,7 @@ func runToPB(r domain.CIRun) *pb.Run {
145 WorkflowPath: r.WorkflowPath, WorkflowName: r.WorkflowName, Name: runName(r), Title: r.Title, Event: r.Event,145 WorkflowPath: r.WorkflowPath, WorkflowName: r.WorkflowName, Name: runName(r), Title: r.Title, Event: r.Event,
146 Ref: r.Ref, Sha: r.Sha, Actor: r.Actor, Status: statusToPB(r.Status), Conclusion: conclusionToPB(r.Conclusion),146 Ref: r.Ref, Sha: r.Sha, Actor: r.Actor, Status: statusToPB(r.Status), Conclusion: conclusionToPB(r.Conclusion),
147 Error: r.Error, CreatedAt: timestamp(r.CreatedAt), StartedAt: timestamp(r.StartedAt), CompletedAt: timestamp(r.CompletedAt),147 Error: r.Error, CreatedAt: timestamp(r.CreatedAt), StartedAt: timestamp(r.StartedAt), CompletedAt: timestamp(r.CompletedAt),
148+ Trusted: r.Trusted,
148 Version: int64(r.Version), //nolint:gosec // a store sequence, far below 2^63149 Version: int64(r.Version), //nolint:gosec // a store sequence, far below 2^63
149 }150 }
150 }151 }
@@ -219,6 +220,7 @@ func runnerJobToPB(j *ports.RunnerJob) *pb.Job {
219 WorkflowPath: j.WorkflowPath, WorkflowYaml: j.WorkflowYAML, JobKey: j.Job.Key, Matrix: j.Job.MatrixJSON,220 WorkflowPath: j.WorkflowPath, WorkflowYaml: j.WorkflowYAML, JobKey: j.Job.Key, Matrix: j.Job.MatrixJSON,
220 Contexts: j.Job.ContextsJSON, Labels: j.Job.Labels, TimeoutMinutes: j.Job.TimeoutMinutes,221 Contexts: j.Job.ContextsJSON, Labels: j.Job.Labels, TimeoutMinutes: j.Job.TimeoutMinutes,
221 Repo: j.Repo.String(), Sha: j.Sha, Ref: j.Ref, GithubToken: j.GithubToken,222 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,
222 }224 }
223 }225 }
224 226
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 {
35 session ermiteconnect.SessionServiceClient35 session ermiteconnect.SessionServiceClient
36 runner ermiteconnect.RunnerServiceClient36 runner ermiteconnect.RunnerServiceClient
37 run ermiteconnect.RunServiceClient37 run ermiteconnect.RunServiceClient
38+
39+ secret ermiteconnect.SecretServiceClient
40+ vars ermiteconnect.VariableServiceClient
41+ env ermiteconnect.EnvironmentServiceClient
38 }42 }
39 43
40 // bearerTransport adds the Authorization header of a secret, if any.44 // bearerTransport adds the Authorization header of a secret, if any.
@@ -85,6 +89,10 @@ func newClientsOn(hc connect.HTTPClient, base string, opts ...connect.ClientOpti
85 session: ermiteconnect.NewSessionServiceClient(hc, base, opts...),89 session: ermiteconnect.NewSessionServiceClient(hc, base, opts...),
86 runner: ermiteconnect.NewRunnerServiceClient(hc, base, opts...),90 runner: ermiteconnect.NewRunnerServiceClient(hc, base, opts...),
87 run: ermiteconnect.NewRunServiceClient(hc, base, opts...),91 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...),
88 }96 }
89 }97 }
90 98
M internal/app/api_test.go +1 -1
@@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) {
196 for _, who := range allPrincipals {196 for _, who := range allPrincipals {
197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
198 require.NoError(t, err, who)198 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)
200 assert.NotEmpty(t, resp.Msg.GetVersion())200 assert.NotEmpty(t, resp.Msg.GetVersion())
201 }201 }
202 })202 })
M internal/app/ci_flow_test.go +2 -2
@@ -324,14 +324,14 @@ func TestCIUnsupportedFeatureFailsTheJob(t *testing.T) {
324 jobs:324 jobs:
325 j:325 j:
326 runs-on: ubuntu-latest326 runs-on: ubuntu-latest
327- environment: production327+ concurrency: group
328 steps:328 steps:
329 - run: echo329 - run: echo
330 `}, "")330 `}, "")
331 c.waitFor("the run fails", func() bool { return len(c.runs()) == 1 && c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })331 c.waitFor("the run fails", func() bool { return len(c.runs()) == 1 && c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
332 j := c.getRun(c.runs()[0].GetId()).GetJobs()[0]332 j := c.getRun(c.runs()[0].GetId()).GetJobs()[0]
333 assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, j.GetConclusion())333 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())
335 }335 }
336 336
337 // A job for a label no runner has is "waiting for a runner" and fails after the337 // 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:
308 }308 }
309 309
310 // CI-2: planning runs what CI-1 refused (uses, container, services, a step310 // 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.
312 func TestCIPlanningAcceptsCI2Features(t *testing.T) {313 func TestCIPlanningAcceptsCI2Features(t *testing.T) {
313 c := newCIHarness(t, nil)314 c := newCIHarness(t, nil)
314 runner := c.registerRunner("alpha", "ubuntu-latest")315 runner := c.registerRunner("alpha", "ubuntu-latest")
@@ -330,7 +331,6 @@ jobs:
330 331
331 for _, tc := range []struct{ name, job, want string }{332 for _, tc := range []struct{ name, job, want string }{
332 {"reusable", "uses: ./.github/workflows/x.yml", "not supported yet: reusable workflows (uses at the job level)"},333 {"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"},
334 {"concurrency", "runs-on: ubuntu-latest\n concurrency: g\n steps:\n - run: echo", "not supported yet: concurrency"},334 {"concurrency", "runs-on: ubuntu-latest\n concurrency: g\n steps:\n - run: echo", "not supported yet: concurrency"},
335 } {335 } {
336 t.Run(tc.name, func(t *testing.T) {336 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) {
326 ver := decode[versionOut](t, res.stdout)326 ver := decode[versionOut](t, res.stdout)
327 assert.Equal(t, c.url, ver.Server.URL)327 assert.Equal(t, c.url, ver.Server.URL)
328 assert.NotEmpty(t, ver.Server.Version)328 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)
330 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)330 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)
331 stdout, stderr, code = c.bin(t, secret, "version")331 stdout, stderr, code = c.bin(t, secret, "version")
332 require.Equal(t, 0, code, stderr)332 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{
118 client.FeatureTokenBranchPrefixes: "`ermite token create --branch-prefix` needs: the command is refused, and `token list` does not show the limits",118 client.FeatureTokenBranchPrefixes: "`ermite token create --branch-prefix` needs: the command is refused, and `token list` does not show the limits",
119 client.FeatureCIRuns: "`ermite run` needs: the commands will fail on this server",119 client.FeatureCIRuns: "`ermite run` needs: the commands will fail on this server",
120 client.FeatureCIRunners: "`ermite runner` needs: the commands will fail on this server",120 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",
121 client.FeaturePasskeys: "`ermite web passkey` needs: the commands will fail on this server (passkeys need an https public URL with a DNS host name)",122 client.FeaturePasskeys: "`ermite web passkey` needs: the commands will fail on this server (passkeys need an https public URL with a DNS host name)",
122 }123 }
M internal/app/cli_version_test.go +5 -5
@@ -17,7 +17,7 @@ import (
17 )17 )
18 18
19 // allFeatures are the features this client uses.19 // 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"}
21 21
22 // Every feature the client uses needs a sentence for the version warning.22 // Every feature the client uses needs a sentence for the version warning.
23 func TestEveryUsedFeatureExplainsWhatBreaks(t *testing.T) {23 func TestEveryUsedFeatureExplainsWhatBreaks(t *testing.T) {
@@ -45,15 +45,15 @@ func TestVersionWithServer(t *testing.T) {
45 f := versionFake(allFeatures...)45 f := versionFake(allFeatures...)
46 url := f.serve(t)46 url := f.serve(t)
47 res := runCLI(t, envFor(url), "", "version").ok(t)47 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)
49 assert.Empty(t, res.stderr, "no warning when every feature is there")49 assert.Empty(t, res.stderr, "no warning when every feature is there")
50 50
51 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)51 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)
53 }53 }
54 54
55 func TestVersionWarnsAboutMissingFeatures(t *testing.T) {55 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")
57 url := f.serve(t)57 url := f.serve(t)
58 res := runCLI(t, envFor(url), "", "version").ok(t)58 res := runCLI(t, envFor(url), "", "version").ok(t)
59 assert.Contains(t, res.stdout, "v0.4.0")59 assert.Contains(t, res.stdout, "v0.4.0")
@@ -61,7 +61,7 @@ func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
61 assert.NotContains(t, res.stderr, "repo.update")61 assert.NotContains(t, res.stderr, "repo.update")
62 62
63 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)63 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)
65 assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json")65 assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json")
66 66
67 f = versionFake()67 f = versionFake()
M internal/app/e2e_helpers_test.go +5 -0
@@ -50,6 +50,8 @@ type harnessOpts struct {
50 revalidateEvery time.Duration50 revalidateEvery time.Duration
51 // publicURL overrides ERMITE_PUBLIC_URL (http://example.test) when set.51 // publicURL overrides ERMITE_PUBLIC_URL (http://example.test) when set.
52 publicURL string52 publicURL string
53+ // env adds or overrides variables of the server's environment.
54+ env map[string]string
53 }55 }
54 56
55 // newHarness starts a forge. The data directory is short: the hook socket57 // 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
77 if o.publicURL != "" {79 if o.publicURL != "" {
78 env[config.EnvPublicURL] = o.publicURL80 env[config.EnvPublicURL] = o.publicURL
79 }81 }
82+ for k, v := range o.env {
83+ env[k] = v
84+ }
80 if o.trustedProxies != "" {85 if o.trustedProxies != "" {
81 env[config.EnvTrustedProxies] = o.trustedProxies86 env[config.EnvTrustedProxies] = o.trustedProxies
82 }87 }
M internal/app/forge.go +28 -4
@@ -26,6 +26,7 @@ import (
26 "bnema.dev/ermite/internal/adapters/httpserver"26 "bnema.dev/ermite/internal/adapters/httpserver"
27 "bnema.dev/ermite/internal/adapters/kvstore"27 "bnema.dev/ermite/internal/adapters/kvstore"
28 "bnema.dev/ermite/internal/adapters/rpc"28 "bnema.dev/ermite/internal/adapters/rpc"
29+ "bnema.dev/ermite/internal/adapters/secretbox"
29 "bnema.dev/ermite/internal/adapters/sshsig"30 "bnema.dev/ermite/internal/adapters/sshsig"
30 "bnema.dev/ermite/internal/adapters/web"31 "bnema.dev/ermite/internal/adapters/web"
31 "bnema.dev/ermite/internal/adapters/webauthn"32 "bnema.dev/ermite/internal/adapters/webauthn"
@@ -100,9 +101,11 @@ type forge struct {
100 EventLog *usecase.Events101 EventLog *usecase.Events
101 // CI is the CI engine: workflow trigger, planner, runner protocol and the102 // CI is the CI engine: workflow trigger, planner, runner protocol and the
102 // owner side. It starts with the server and stops with it.103 // owner side. It starts with the server and stops with it.
103- CI *usecase.CI104+ CI *usecase.CI
104- API *rpc.API105+ // Settings are the secrets, variables and environments of CI.
105- Bus ports.EventBus106+ Settings *usecase.CISettings
107+ API *rpc.API
108+ Bus ports.EventBus
106 }109 }
107 110
108 // newSigningSeed makes the seed of a new ed25519 signing key.111 // 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
210 log.Info().Str("reader", cfg.GitReader).Msg("git reader selected")213 log.Info().Str("reader", cfg.GitReader).Msg("git reader selected")
211 f.Browse = usecase.NewBrowse(store, reader)214 f.Browse = usecase.NewBrowse(store, reader)
212 215
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+
213 // CI (ADR 0025): the engine reads workflows through the reader, and plans233 // CI (ADR 0025): the engine reads workflows through the reader, and plans
214 // them in the background; a push only queues its refs.234 // them in the background; a push only queues its refs.
215 ciCfg := usecase.CIConfig{235 ciCfg := usecase.CIConfig{
@@ -221,7 +241,8 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
221 Bin: cfg.Git, Path: path, Home: filepath.Join(cfg.DataDir, "run", "git-home"),241 Bin: cfg.Git, Path: path, Home: filepath.Join(cfg.DataDir, "run", "git-home"),
222 Proxy: gitremote.ProxyEnv(os.Getenv),242 Proxy: gitremote.ProxyEnv(os.Getenv),
223 })),243 })),
224- Tokens: store.Tokens(),244+ Tokens: store.Tokens(),
245+ Settings: f.Settings,
225 }246 }
226 if opts.ci != nil {247 if opts.ci != nil {
227 opts.ci(&ciCfg)248 opts.ci(&ciCfg)
@@ -291,6 +312,9 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
291 Passkeys: apiPasskeys,312 Passkeys: apiPasskeys,
292 Runner: f.CI,313 Runner: f.CI,
293 Runs: f.CI,314 Runs: f.CI,
315+ Secrets: f.Settings,
316+ Variables: f.Settings,
317+ Environments: f.Settings,
294 Version: serverVersion(),318 Version: serverVersion(),
295 TrustedProxies: cfg.TrustedProxies,319 TrustedProxies: cfg.TrustedProxies,
296 Limiter: limiter,320 Limiter: limiter,
M internal/app/root.go +1 -1
@@ -32,7 +32,7 @@ func newRootCmdWith(env *cliEnv) *cobra.Command {
32 newServeCmd(), newHookCmd(),32 newServeCmd(), newHookCmd(),
33 newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env),33 newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env),
34 newMRCmd(env), newSigningKeyCmd(env), newWebCmd(env), newConfigCmd(env),34 newMRCmd(env), newSigningKeyCmd(env), newWebCmd(env), newConfigCmd(env),
35- newRunCmd(env), newRunnerCmd(env),35+ newRunCmd(env), newRunnerCmd(env), newSecretCmd(env), newVariableCmd(env), newEnvironmentCmd(env),
36 )36 )
37 return root37 return root
38 }38 }
M internal/config/config.go +32 -0
@@ -7,6 +7,7 @@ package config
7 7
8 import (8 import (
9 "crypto/sha256"9 "crypto/sha256"
10+ "encoding/base64"
10 "fmt"11 "fmt"
11 "net"12 "net"
12 "net/netip"13 "net/netip"
@@ -42,8 +43,15 @@ const (
42 // EnvCIPollTimeout is how long FetchJob holds a runner's call. Lower it43 // EnvCIPollTimeout is how long FetchJob holds a runner's call. Lower it
43 // when a reverse proxy cuts idle responses sooner than the default 50 s.44 // when a reverse proxy cuts idle responses sooner than the default 50 s.
44 EnvCIPollTimeout = "ERMITE_CI_POLL_TIMEOUT"45 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"
45 )50 )
46 51
52+// SecretsKeyLen is the length of the secrets key in bytes (AES-256).
53+const SecretsKeyLen = 32
54+
47 // Defaults.55 // Defaults.
48 const (56 const (
49 DefaultAddr = ":7341"57 DefaultAddr = ":7341"
@@ -112,6 +120,10 @@ type Config struct {
112 // CIPollTimeout is how long FetchJob holds a call; 0 (variable unset)120 // CIPollTimeout is how long FetchJob holds a call; 0 (variable unset)
113 // leaves the default to the CI use case.121 // leaves the default to the CI use case.
114 CIPollTimeout time.Duration122 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
115 }127 }
116 128
117 // Error lists every configuration problem found, in variable order.129 // Error lists every configuration problem found, in variable order.
@@ -273,12 +285,32 @@ func Load(get func(string) string) (Config, error) {
273 }285 }
274 }286 }
275 287
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+
276 if len(problems) > 0 {297 if len(problems) > 0 {
277 return Config{}, &Error{Problems: problems}298 return Config{}, &Error{Problems: problems}
278 }299 }
279 return c, nil300 return c, nil
280 }301 }
281 302
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+
282 // parseRetention reads a duration in days ("90d") or in Go's syntax ("720h"),314 // parseRetention reads a duration in days ("90d") or in Go's syntax ("720h"),
283 // at least one hour.315 // at least one hour.
284 func parseRetention(raw string) (time.Duration, error) {316 func parseRetention(raw string) (time.Duration, error) {
M internal/config/config_test.go +34 -0
@@ -4,6 +4,7 @@ package config_test
4 4
5 import (5 import (
6 "crypto/sha256"6 "crypto/sha256"
7+ "encoding/base64"
7 "errors"8 "errors"
8 "net/netip"9 "net/netip"
9 "strings"10 "strings"
@@ -285,3 +286,36 @@ func TestCIPollTimeout(t *testing.T) {
285 assert.Contains(t, ce.Problems[0], config.EnvCIPollTimeout, in)286 assert.Contains(t, ce.Problems[0], config.EnvCIPollTimeout, in)
286 }287 }
287 }288 }
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 {
178 CompletedAt time.Time178 CompletedAt time.Time
179 CancelRequested bool179 CancelRequested bool
180 CancelRequestedAt time.Time180 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
181 // Version is the version of the stored record, which grows at every186 // Version is the version of the stored record, which grows at every
182 // change and never goes back.187 // change and never goes back.
183 Version uint64188 Version uint64
@@ -261,6 +266,10 @@ type CIJob struct {
261 // need it unaffected (needs.<job>.result is success). The conclusion of266 // need it unaffected (needs.<job>.result is success). The conclusion of
262 // the job itself stays failure.267 // the job itself stays failure.
263 ContinueOnError bool268 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
264 // MaxParallel is the max-parallel of the strategy, 0 for no limit.273 // MaxParallel is the max-parallel of the strategy, 0 for no limit.
265 MaxParallel int274 MaxParallel int
266 Outputs map[string]string275 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
5 import (5 import (
6 "context"6 "context"
7 "errors"7 "errors"
8+ "fmt"
8 "strconv"9 "strconv"
9 "time"10 "time"
10 11
@@ -286,8 +287,29 @@ type RunnerJob struct {
286 Sha, Ref string287 Sha, Ref string
287 WorkflowPath string288 WorkflowPath string
288 WorkflowYAML string289 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
289 }302 }
290 303
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+
291 // FetchJobParams selects the job a runner asks for.313 // FetchJobParams selects the job a runner asks for.
292 type FetchJobParams struct {314 type FetchJobParams struct {
293 Labels []string315 Labels []string
@@ -542,3 +564,94 @@ type RunService interface {
542 ListRunners(ctx context.Context, p domain.Principal, size int, token string) (RunnerPage, error)564 ListRunners(ctx context.Context, p domain.Principal, size int, token string) (RunnerPage, error)
543 RemoveRunner(ctx context.Context, p domain.Principal, id string) error565 RemoveRunner(ctx context.Context, p domain.Principal, id string) error
544 }566 }
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 {
219 Blocked(ctx context.Context, addr string) (bool, time.Duration)219 Blocked(ctx context.Context, addr string) (bool, time.Duration)
220 Fail(ctx context.Context, addr string)220 Fail(ctx context.Context, addr string)
221 }221 }
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 {
72 // read when an action of another repository is resolved for the run. Nil72 // read when an action of another repository is resolved for the run. Nil
73 // reads nothing for a token (fail closed).73 // reads nothing for a token (fail closed).
74 Tokens ports.TokenStore74 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
75 // RemoteConcurrency bounds the git ls-remote processes at once (default79 // RemoteConcurrency bounds the git ls-remote processes at once (default
76 // DefaultCIRemoteConcurrency); ResolveTimeout bounds one ResolveActions call80 // DefaultCIRemoteConcurrency); ResolveTimeout bounds one ResolveActions call
77 // (default DefaultCIResolveTimeout).81 // (default DefaultCIResolveTimeout).
M internal/usecase/ci_plan.go +50 -8
@@ -28,9 +28,37 @@ type wfCache struct {
28 mu sync.Mutex28 mu sync.Mutex
29 max int29 max int
30 m map[int64]*workflow.Workflow30 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
31 }35 }
32 36
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+}
34 62
35 func (w *wfCache) get(runID int64, path, yaml string) (*workflow.Workflow, error) {63 func (w *wfCache) get(runID int64, path, yaml string) (*workflow.Workflow, error) {
36 w.mu.Lock()64 w.mu.Lock()
@@ -55,13 +83,12 @@ func (w *wfCache) get(runID int64, path, yaml string) (*workflow.Workflow, error
55 // unsupported names the feature of a job that ermite does not run yet, or "".83 // unsupported names the feature of a job that ermite does not run yet, or "".
56 // Such a job fails with "not supported yet: <feature>" instead of running84 // Such a job fails with "not supported yet: <feature>" instead of running
57 // wrong. CI-2 runs steps with uses:, job container:, services: and step85 // 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.
59 func unsupported(j *workflow.Job) string {88 func unsupported(j *workflow.Job) string {
60 switch {89 switch {
61 case j.IsReusable():90 case j.IsReusable():
62 return "reusable workflows (uses at the job level)"91 return "reusable workflows (uses at the job level)"
63- case j.Environment != nil:
64- return "environment"
65 case j.Concurrency != nil:92 case j.Concurrency != nil:
66 return "concurrency"93 return "concurrency"
67 }94 }
@@ -366,8 +393,13 @@ func (rs *runState) planJob(ctx context.Context, wj *workflow.Job, idx int, need
366 fail(err.Error())393 fail(err.Error())
367 return394 return
368 }395 }
396+ vars, err := rs.c.varsContext(ctx, rs.run.Repo)
397+ if err != nil {
398+ fail(err.Error())
399+ return
400+ }
369 env := template.Env{Contexts: map[string]expr.Value{401 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,
371 }}403 }}
372 run, err := wj.EvaluateIf(ctx, env, st)404 run, err := wj.EvaluateIf(ctx, env, st)
373 switch {405 switch {
@@ -460,10 +492,20 @@ func (rs *runState) instance(ctx context.Context, wj *workflow.Job, plan *workfl
460 if !plan.HasMatrix {492 if !plan.HasMatrix {
461 matrixCtx = expr.Null{} // GitHub: "matrix": null for a job without a matrix493 matrixCtx = expr.Null{} // GitHub: "matrix": null for a job without a matrix
462 }494 }
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))
464 if err != nil {505 if err != nil {
465 return j, err506 return j, err
466 }507 }
508+ j.Environment = environment
467 j.Name, j.Labels, j.TimeoutMinutes = name, labels, timeout509 j.Name, j.Labels, j.TimeoutMinutes = name, labels, timeout
468 j.MatrixJSON = compactJSON(expr.ToJSON(matrix))510 j.MatrixJSON = compactJSON(expr.ToJSON(matrix))
469 j.ContextsJSON, j.ComboTotal = contexts, len(plan.Combos)511 j.ContextsJSON, j.ComboTotal = contexts, len(plan.Combos)
@@ -496,11 +538,11 @@ func compactJSON(s string) string {
496 // workflow_ref, workflow_sha, workflow_repository, workflow_file_path); the538 // workflow_ref, workflow_sha, workflow_repository, workflow_file_path); the
497 // runner adds status. The runner copies the keys of this "job" into its job539 // runner adds status. The runner copies the keys of this "job" into its job
498 // context (executor.State.initRest), whatever they are.540 // 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) {
500 out := map[string]json.RawMessage{}542 out := map[string]json.RawMessage{}
501 for k, v := range map[string]expr.Value{543 for k, v := range map[string]expr.Value{
502 "github": github, "needs": needs, "strategy": strategy, "matrix": matrix,544 "github": github, "needs": needs, "strategy": strategy, "matrix": matrix,
503- "inputs": expr.NewDictionary(), "vars": expr.NewDictionary(),545+ "inputs": expr.NewDictionary(), "vars": vars,
504 } {546 } {
505 out[k] = json.RawMessage(compactJSON(expr.ToJSON(v)))547 out[k] = json.RawMessage(compactJSON(expr.ToJSON(v)))
506 }548 }
M internal/usecase/ci_runner.go +44 -9
@@ -123,7 +123,11 @@ func (c *CI) FetchJob(ctx context.Context, secret string, params ports.FetchJobP
123 if err := checkPollID(params.PollID); err != nil {123 if err := checkPollID(params.PollID); err != nil {
124 return ports.FetchJobResult{}, err124 return ports.FetchJobResult{}, err
125 }125 }
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 {
127 return ports.FetchJobResult{Job: j}, nil131 return ports.FetchJobResult{Job: j}, nil
128 }132 }
129 }133 }
@@ -188,18 +192,18 @@ func offeredLabels(r domain.CIRunner, asked []string) ([]string, error) {
188 192
189 // repeatPoll answers a retried poll with the assignment the first call made,193 // repeatPoll answers a retried poll with the assignment the first call made,
190 // as long as it has not been acknowledged or cancelled.194 // 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) {
192 c.mu.Lock()196 c.mu.Lock()
193 rec, ok := c.polls[runner.ID]197 rec, ok := c.polls[runner.ID]
194 c.mu.Unlock()198 c.mu.Unlock()
195 if !ok || rec.pollID != pollID {199 if !ok || rec.pollID != pollID {
196- return nil, false200+ return nil, false, nil
197 }201 }
198 var rid int64202 var rid int64
199 var ra int32203 var ra int32
200 var id string204 var id string
201 if _, err := fmt.Sscanf(rec.ref, "%d/%d/%s", &rid, &ra, &id); err != nil {205 if _, err := fmt.Sscanf(rec.ref, "%d/%d/%s", &rid, &ra, &id); err != nil {
202- return nil, false206+ return nil, false, nil
203 }207 }
204 var out *ports.RunnerJob208 var out *ports.RunnerJob
205 err := c.withRun(ctx, rid, ra, func(rs *runState) error {209 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
207 if j == nil || j.Phase != domain.CIPhaseAssigned || j.TokenHash != rec.hash || j.RunnerID != runner.ID {211 if j == nil || j.Phase != domain.CIPhaseAssigned || j.TokenHash != rec.hash || j.RunnerID != runner.ID {
208 return errNoJob212 return errNoJob
209 }213 }
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)
211 return nil220 return nil
212 })221 })
213- return out, err == nil && out != nil222+ 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
214 }231 }
215 232
216-// runnerJob is the job as the runner gets it.233+// runnerJob is the job as the runner gets it. settings are read when the job
217-func (c *CI) runnerJob(rs *runState, j domain.CIJob, token, ghToken string) *ports.RunnerJob {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+ }
218 return &ports.RunnerJob{245 return &ports.RunnerJob{
219 Job: j, Token: token, GithubToken: ghToken, Repo: rs.run.Repo, Sha: rs.run.Sha, Ref: rs.run.Ref,246 Job: j, Token: token, GithubToken: ghToken, Repo: rs.run.Repo, Sha: rs.run.Sha, Ref: rs.run.Ref,
220 WorkflowPath: rs.run.WorkflowPath, WorkflowYAML: rs.src.WorkflowYAML,247 WorkflowPath: rs.run.WorkflowPath, WorkflowYAML: rs.src.WorkflowYAML,
248+ Environment: j.Environment, Vars: settings.Vars, Secrets: settings.Secrets, Trusted: rs.run.Trusted,
221 }249 }
222 }250 }
223 251
@@ -240,6 +268,11 @@ func (c *CI) assignTo(ctx context.Context, runner domain.CIRunner, offered []str
240 if j == nil || j.Phase != domain.CIPhaseReady || rs.run.CancelRequested {268 if j == nil || j.Phase != domain.CIPhaseReady || rs.run.CancelRequested {
241 return errNoJob269 return errNoJob
242 }270 }
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+ }
243 token := newToken(domain.JobTokenPrefix)276 token := newToken(domain.JobTokenPrefix)
244 ghToken := rs.issueRepoToken(j)277 ghToken := rs.issueRepoToken(j)
245 j.Phase, j.AssignedAt = domain.CIPhaseAssigned, c.now()278 j.Phase, j.AssignedAt = domain.CIPhaseAssigned, c.now()
@@ -249,7 +282,7 @@ func (c *CI) assignTo(ctx context.Context, runner domain.CIRunner, offered []str
249 if err := rs.flush(ctx); err != nil {282 if err := rs.flush(ctx); err != nil {
250 return err283 return err
251 }284 }
252- out = c.runnerJob(rs, *j, token, ghToken)285+ out = c.runnerJob(rs, *j, token, ghToken, settings)
253 if pollID != "" {286 if pollID != "" {
254 c.mu.Lock()287 c.mu.Lock()
255 c.polls[runner.ID] = pollRecord{pollID: pollID, ref: jobRef(*j), token: token, ghToken: ghToken, hash: j.TokenHash}288 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
262 log := zerowrap.FromCtx(ctx)295 log := zerowrap.FromCtx(ctx)
263 log.Info().Str("runner", runner.Name).Str("job_id", out.Job.ID).Int64("run_id", out.Job.RunID).Msg("job assigned")296 log.Info().Str("runner", runner.Name).Str("job_id", out.Job.ID).Int64("run_id", out.Job.RunID).Msg("job assigned")
264 return out, nil297 return out, nil
298+ case isSettingsError(err):
299+ return nil, err // the job stays ready; the runner polls again
265 case errors.Is(err, errNoJob), errors.Is(err, domain.ErrNotFound), errors.Is(err, domain.ErrConflict):300 case errors.Is(err, errNoJob), errors.Is(err, domain.ErrNotFound), errors.Is(err, domain.ErrConflict):
266 continue // taken or gone meanwhile301 continue // taken or gone meanwhile
267 default:302 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
399 run.Attempt, run.Status, run.Conclusion = next, domain.CIQueued, domain.CINoConclusion399 run.Attempt, run.Status, run.Conclusion = next, domain.CIQueued, domain.CINoConclusion
400 run.StartedAt, run.CompletedAt, run.CreatedAt = time.Time{}, time.Time{}, now400 run.StartedAt, run.CompletedAt, run.CreatedAt = time.Time{}, time.Time{}, now
401 run.CancelRequested, run.CancelRequestedAt = false, time.Time{}401 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)
402 w.Runs = []domain.CIRun{run}405 w.Runs = []domain.CIRun{run}
403 rs2 := &runState{c: c, run: run, attempt: next, jobs: fresh, src: rs.src, dirty: map[string]bool{}, checks: map[string]bool{}}406 rs2 := &runState{c: c, run: run, attempt: next, jobs: fresh, src: rs.src, dirty: map[string]bool{}, checks: map[string]bool{}}
404 for _, j := range fresh {407 for _, j := range fresh {
@@ -469,6 +472,7 @@ func rerunSet(rs *runState, failedOnly bool) (map[string]bool, error) {
469 func (w *wfCache) drop(runID int64) {472 func (w *wfCache) drop(runID int64) {
470 w.mu.Lock()473 w.mu.Lock()
471 delete(w.m, runID)474 delete(w.m, runID)
475+ delete(w.uses, runID)
472 w.mu.Unlock()476 w.mu.Unlock()
473 }477 }
474 478
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 {
37 updates []domain.RefUpdate37 updates []domain.RefUpdate
38 actor string38 actor string
39 pusher domain.CIPusher39 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
40 at time.Time43 at time.Time
41 log zerowrap.Logger44 log zerowrap.Logger
42 }45 }
@@ -45,19 +48,20 @@ type ciTrigger struct {
45 // a worker plans them. A full queue drops the trigger, logged at error: a push48 // a worker plans them. A full queue drops the trigger, logged at error: a push
46 // is never slowed down or failed by CI.49 // is never slowed down or failed by CI.
47 func (c *CI) OnPush(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, pusher domain.Principal) {50 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))
49 }52 }
50 53
51 // OnMerge queues the move of a target branch by a merge. The actor of the run is54 // OnMerge queues the move of a target branch by a merge. The actor of the run is
52 // the owner, as GitHub shows; the pusher recorded with it, whose rights the55 // the owner, as GitHub shows; the pusher recorded with it, whose rights the
53 // actions of other repositories are resolved with, is the principal that merged:56 // actions of other repositories are resolved with, is the principal that merged:
54 // a token limited to some repositories does not gain the owner's reach by57 // 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.
56 func (c *CI) OnMerge(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, merger domain.Principal) {60 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))
58 }62 }
59 63
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) {
61 var kept []domain.RefUpdate65 var kept []domain.RefUpdate
62 for _, u := range updates {66 for _, u := range updates {
63 if !u.IsDelete() && (strings.HasPrefix(u.Ref, "refs/heads/") || strings.HasPrefix(u.Ref, "refs/tags/")) {67 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
67 if len(kept) == 0 {71 if len(kept) == 0 {
68 return72 return
69 }73 }
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)}
71 c.mu.Lock()75 c.mu.Lock()
72 closed := c.closed76 closed := c.closed
73 c.mu.Unlock()77 c.mu.Unlock()
@@ -394,7 +398,7 @@ func (c *CI) startInvalid(ctx context.Context, repo domain.Repo, t ciTrigger, f
394 run := domain.CIRun{398 run := domain.CIRun{
395 Repo: repo.Name, ID: id, Number: number, Attempt: 1, WorkflowPath: wfPath, WorkflowName: name, Name: name,399 Repo: repo.Name, ID: id, Number: number, Attempt: 1, WorkflowPath: wfPath, WorkflowName: name, Name: name,
396 Title: firstLine(f.head.Message), Event: "push", Ref: f.update.Ref, Sha: f.commit.String(), Actor: t.actor,400 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,
398 CreatedAt: now, StartedAt: now, CompletedAt: now,402 CreatedAt: now, StartedAt: now, CompletedAt: now,
399 }403 }
400 return ports.CIRunDraft{404 return ports.CIRunDraft{
@@ -439,14 +443,18 @@ func (c *CI) startRun(ctx context.Context, repo domain.Repo, t ciTrigger, f push
439 if f.isTag {443 if f.isTag {
440 title = c.registeredName(ctx, repo.Name, wfPath, name)444 title = c.registeredName(ctx, repo.Name, wfPath, name)
441 }445 }
446+ vars, err := c.varsContext(ctx, repo.Name)
447+ if err != nil {
448+ return err
449+ }
442 draft, err := c.cfg.Store.CreateRun(ctx, repo.Name, wfPath, func(id, number int64) ports.CIRunDraft {450 draft, err := c.cfg.Store.CreateRun(ctx, repo.Name, wfPath, func(id, number int64) ports.CIRunDraft {
443 run := domain.CIRun{451 run := domain.CIRun{
444 Repo: repo.Name, ID: id, Number: number, Attempt: 1, WorkflowPath: wfPath, WorkflowName: name, Name: name,452 Repo: repo.Name, ID: id, Number: number, Attempt: 1, WorkflowPath: wfPath, WorkflowName: name, Name: name,
445 Title: title, Event: "push", Ref: f.update.Ref, Sha: f.commit.String(), Actor: t.actor,453 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,
447 }455 }
448 githubJSON := c.githubJSON(ctx, repo, run, f)456 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 {
450 run.Name, run.Title = runName, runName458 run.Name, run.Title = runName, runName
451 }459 }
452 d := ports.CIRunDraft{Run: run, Source: domain.CIRunSource{460 d := ports.CIRunDraft{Run: run, Source: domain.CIRunSource{
@@ -550,7 +558,7 @@ func (c *CI) registeredNameOf(ctx context.Context, repo domain.RepoName, wfPath
550 // to nothing: the run then has no name of its own, GitHub's display title falls558 // to nothing: the run then has no name of its own, GitHub's display title falls
551 // back to the commit message (a branch) or the registered name (a tag), and559 // back to the commit message (a branch) or the registered name (a tag), and
552 // the name of the run to the name of the workflow.560 // 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) {
554 if wf.RunName == nil {562 if wf.RunName == nil {
555 return "", false563 return "", false
556 }564 }
@@ -559,7 +567,7 @@ func (c *CI) evalRunName(ctx context.Context, wf *workflow.Workflow, githubJSON
559 return "", false567 return "", false
560 }568 }
561 env := template.Env{Contexts: map[string]expr.Value{569 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,
563 }}571 }}
564 name, err := template.EvaluateString(ctx, wf.RunName, env)572 name, err := template.EvaluateString(ctx, wf.RunName, env)
565 if err != nil {573 if err != nil {
@@ -595,8 +603,14 @@ func (c *CI) matrixProblem(ctx context.Context, repo domain.Repo, t ciTrigger, f
595 if err != nil {603 if err != nil {
596 return matrixIssue{}604 return matrixIssue{}
597 }605 }
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+ }
598 env := template.Env{Contexts: map[string]expr.Value{612 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,
600 }}614 }}
601 for _, j := range wf.Jobs {615 for _, j := range wf.Jobs {
602 _, err := j.EvaluateStrategy(ctx, env)616 _, err := j.EvaluateStrategy(ctx, env)
M internal/usecase/export_test.go +27 -1
@@ -5,6 +5,7 @@ package usecase
5 import (5 import (
6 "context"6 "context"
7 7
8+ "bnema.dev/ermite-actions/expr"
8 "bnema.dev/ermite-actions/workflow"9 "bnema.dev/ermite-actions/workflow"
9 "bnema.dev/ermite/internal/domain"10 "bnema.dev/ermite/internal/domain"
10 )11 )
@@ -18,7 +19,7 @@ func (c *CI) Sweep(ctx context.Context) { c.sweep(ctx) }
18 // RunTitle gives the title of a run as startRun sets it: the evaluated19 // RunTitle gives the title of a run as startRun sets it: the evaluated
19 // run-name, else commitTitle.20 // run-name, else commitTitle.
20 func (c *CI) RunTitle(ctx context.Context, wf *workflow.Workflow, githubJSON, commitTitle string) string {21 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 {
22 return name23 return name
23 }24 }
24 return commitTitle25 return commitTitle
@@ -28,3 +29,28 @@ func (c *CI) RunTitle(ctx context.Context, wf *workflow.Workflow, githubJSON, co
28 func CheckRunID(ctx context.Context, run domain.CIRun, index, combo int) int64 {29 func CheckRunID(ctx context.Context, run domain.CIRun, index, combo int) int64 {
29 return checkRunID(ctx, run, index, combo)30 return checkRunID(ctx, run, index, combo)
30 }31 }
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 {
99 // workflow name. Empty in runs stored before it existed (read as99 // workflow name. Empty in runs stored before it existed (read as
100 // workflow_name).100 // workflow_name).
101 string name = 22;101 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;
102 }105 }
103 106
104 // CIRunSource is stored under "runsrc/<run id:020d>": what a run was planned107 // CIRunSource is stored under "runsrc/<run id:020d>": what a run was planned
@@ -196,6 +199,9 @@ message CIJob {
196 // of the job does not fail the run, does not cancel the siblings of its199 // of the job does not fail the run, does not cancel the siblings of its
197 // matrix, and needs.<job>.result is success.200 // matrix, and needs.<job>.result is success.
198 bool continue_on_error = 40;201 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;
199 }205 }
200 206
201 // CICheck is stored under "check/<repo>/<sha>/<id>": the id of its job, or207 // 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 3

Tags 43

↑↓ moveenter openesc close