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 Unverified
SHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivAThe 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.0 | 15 | bnema.dev/ermite-actions v0.2.0 | ||
| 16 | - | bnema.dev/ermite-api v0.2.0 | 16 | + | bnema.dev/ermite-api v0.2.1-0.20261014200206-5c335b855824 |
| 17 | bnema.dev/ermite-git v0.1.0 | 17 | bnema.dev/ermite-git v0.1.0 | ||
| 18 | bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3 | 18 | bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3 | ||
| 19 | connectrpc.com/connect v1.21.0 | 19 | connectrpc.com/connect v1.21.0 | ||
| @@ -27,6 +27,7 @@ require ( | |||||
| 27 | github.com/spf13/cobra v1.10.2 | 27 | github.com/spf13/cobra v1.10.2 | ||
| 28 | github.com/stretchr/testify v1.12.1 | 28 | github.com/stretchr/testify v1.12.1 | ||
| 29 | github.com/yuin/goldmark v1.8.6 | 29 | github.com/yuin/goldmark v1.8.6 | ||
| 30 | + | go.yaml.in/yaml/v3 v3.0.5 | |||
| 30 | golang.org/x/crypto v0.57.0 | 31 | golang.org/x/crypto v0.57.0 | ||
| 31 | golang.org/x/sys v0.49.0 | 32 | golang.org/x/sys v0.49.0 | ||
| 32 | golang.org/x/term v0.46.0 | 33 | golang.org/x/term v0.46.0 | ||
| @@ -157,7 +158,6 @@ require ( | |||||
| 157 | go.opentelemetry.io/otel/trace v1.46.0 // indirect | 158 | go.opentelemetry.io/otel/trace v1.46.0 // indirect | ||
| 158 | go.uber.org/multierr v1.11.0 // indirect | 159 | go.uber.org/multierr v1.11.0 // indirect | ||
| 159 | go.uber.org/zap v1.28.0 // indirect | 160 | 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 // indirect | 161 | golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa // indirect | ||
| 162 | golang.org/x/mod v0.41.0 // indirect | 162 | golang.org/x/mod v0.41.0 // indirect | ||
| 163 | golang.org/x/net v0.59.0 // indirect | 163 | 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 | }), nil | 279 | }), 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 | }, nil | 303 | }, 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 3 | 331 | 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 keys | 207 | 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.Batch | 149 | 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 server | 33 | // Features are the optional capabilities of the contract that this server | ||
| 34 | // always implements, as GetVersion lists them. FeaturePasskeys comes on top | 34 | // 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 does | 70 | // 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.PasskeyService | 72 | 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^63 | 149 | 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.SessionServiceClient | 35 | session ermiteconnect.SessionServiceClient | ||
| 36 | runner ermiteconnect.RunnerServiceClient | 36 | runner ermiteconnect.RunnerServiceClient | ||
| 37 | run ermiteconnect.RunServiceClient | 37 | 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-latest | 326 | runs-on: ubuntu-latest | ||
| 327 | - | environment: production | 327 | + | concurrency: group |
| 328 | steps: | 328 | steps: | ||
| 329 | - run: echo | 329 | - 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 the | 337 | // 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 step | 310 | // 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.Duration | 50 | 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 string | 52 | 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 socket | 57 | // 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.publicURL | 80 | 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.trustedProxies | 86 | 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.Events | 101 | EventLog *usecase.Events | ||
| 101 | // CI is the CI engine: workflow trigger, planner, runner protocol and the | 102 | // 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.CI | 104 | + | CI *usecase.CI |
| 104 | - | API *rpc.API | 105 | + | // Settings are the secrets, variables and environments of CI. |
| 105 | - | Bus ports.EventBus | 106 | + | 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 plans | 233 | // 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 root | 37 | 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 it | 43 | // 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.Duration | 122 | 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, nil | 300 | 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.Time | 178 | CompletedAt time.Time | ||
| 179 | CancelRequested bool | 179 | CancelRequested bool | ||
| 180 | CancelRequestedAt time.Time | 180 | 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 every | 186 | // 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 uint64 | 188 | Version uint64 | ||
| @@ -261,6 +266,10 @@ type CIJob struct { | |||||
| 261 | // need it unaffected (needs.<job>.result is success). The conclusion of | 266 | // 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 bool | 268 | 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 int | 274 | MaxParallel int | ||
| 266 | Outputs map[string]string | 275 | 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 string | 287 | Sha, Ref string | ||
| 287 | WorkflowPath string | 288 | WorkflowPath string | ||
| 288 | WorkflowYAML string | 289 | 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 []string | 315 | 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) error | 565 | 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. Nil | 72 | // 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.TokenStore | 74 | 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 (default | 79 | // RemoteConcurrency bounds the git ls-remote processes at once (default | ||
| 76 | // DefaultCIRemoteConcurrency); ResolveTimeout bounds one ResolveActions call | 80 | // 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.Mutex | 28 | mu sync.Mutex | ||
| 29 | max int | 29 | max int | ||
| 30 | m map[int64]*workflow.Workflow | 30 | 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 running | 84 | // Such a job fails with "not supported yet: <feature>" instead of running | ||
| 57 | // wrong. CI-2 runs steps with uses:, job container:, services: and step | 85 | // 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 | return | 394 | 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 matrix | 493 | 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, err | 506 | return j, err | ||
| 466 | } | 507 | } | ||
| 508 | + | j.Environment = environment | |||
| 467 | j.Name, j.Labels, j.TimeoutMinutes = name, labels, timeout | 509 | 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); the | 538 | // workflow_ref, workflow_sha, workflow_repository, workflow_file_path); the | ||
| 497 | // runner adds status. The runner copies the keys of this "job" into its job | 539 | // 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{}, err | 124 | 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}, nil | 131 | 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, false | 200 | + | return nil, false, nil |
| 197 | } | 201 | } | ||
| 198 | var rid int64 | 202 | var rid int64 | ||
| 199 | var ra int32 | 203 | var ra int32 | ||
| 200 | var id string | 204 | 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, false | 206 | + | return nil, false, nil |
| 203 | } | 207 | } | ||
| 204 | var out *ports.RunnerJob | 208 | 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 errNoJob | 212 | 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 nil | 220 | return nil | ||
| 212 | }) | 221 | }) | ||
| 213 | - | return out, err == nil && out != nil | 222 | + | switch { |
| 223 | + | case err == nil && out != nil: | |||
| 224 | + | return out, true, nil | |||
| 225 | + | case isSettingsError(err): | |||
| 226 | + | return nil, false, err // checked first: it may wrap ErrConflict or ErrNotFound | |||
| 227 | + | case err == nil, errors.Is(err, errNoJob), errors.Is(err, domain.ErrNotFound), errors.Is(err, domain.ErrConflict): | |||
| 228 | + | return nil, false, nil // nothing to repeat: the poll goes on as a new one | |||
| 229 | + | } | |||
| 230 | + | return nil, false, err // the settings could not be read: not a lost assignment | |||
| 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 errNoJob | 269 | 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 err | 283 | 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, nil | 297 | 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 meanwhile | 301 | 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.CINoConclusion | 399 | run.Attempt, run.Status, run.Conclusion = next, domain.CIQueued, domain.CINoConclusion | ||
| 400 | run.StartedAt, run.CompletedAt, run.CreatedAt = time.Time{}, time.Time{}, now | 400 | 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.RefUpdate | 37 | updates []domain.RefUpdate | ||
| 38 | actor string | 38 | actor string | ||
| 39 | pusher domain.CIPusher | 39 | 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.Time | 43 | at time.Time | ||
| 41 | log zerowrap.Logger | 44 | 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 push | 48 | // 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 is | 54 | // 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 the | 55 | // 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 by | 57 | // 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.RefUpdate | 65 | 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 | return | 72 | 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.closed | 76 | 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, runName | 458 | 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 falls | 558 | // 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), and | 559 | // 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 "", false | 563 | return "", false | ||
| 556 | } | 564 | } | ||
| @@ -559,7 +567,7 @@ func (c *CI) evalRunName(ctx context.Context, wf *workflow.Workflow, githubJSON | |||||
| 559 | return "", false | 567 | 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 evaluated | 19 | // 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 name | 23 | return name | ||
| 23 | } | 24 | } | ||
| 24 | return commitTitle | 25 | 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 as | 99 | // 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 planned | 107 | // 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 its | 199 | // 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, or | 207 | // 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 | + | } | |||