v0.33.0 cf26365

api.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3// Package rpc serves the ermite API contract (bnema.dev/ermite-api) with
4// ConnectRPC: handlers that map the messages of the contract to the use cases
5// of usecase and back, the interceptor that authenticates callers, and the error
6// mapping.
7package rpc
8
9import (
10	"context"
11	"net/http"
12	"net/netip"
13	"slices"
14
15	"connectrpc.com/connect"
16	"github.com/bnema/zerowrap"
17
18	"bnema.dev/ermite-api/gen/ermite/ermiteconnect"
19	"bnema.dev/ermite/internal/clientlimit"
20	"bnema.dev/ermite/internal/domain"
21	"bnema.dev/ermite/internal/ports"
22)
23
24// MaxRequestBytes bounds the size of a request message. Responses are not
25// bounded: a diff has its own limits and blobs are streamed.
26const MaxRequestBytes = 1 << 20
27
28// RunnerMaxRequestBytes bounds the size of a request message of RunnerService
29// and JobArtifactService: a job summary of exactly 1 MiB (UploadSummary) and a
30// block message with 1 MiB of data must be accepted, so the limit leaves room
31// for the envelope (job id, step number, block id, framing).
32const RunnerMaxRequestBytes = MaxRequestBytes + 64<<10
33
34// Features are the optional capabilities of the contract that this server
35// always implements, as GetVersion lists them. FeaturePasskeys comes on top
36// when Config.Passkeys is set, and FeatureCISecrets when the settings services
37// are, and FeatureCommitSignatures when Config.TrustedKeys is set.
38var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.workflows", "merge_requests.auto_merge", "merge_requests.ignore_checks", "merge_requests.ready"}
39
40// FeaturePasskeys is listed by GetVersion when passkeys are available.
41// FeatureCIArtifacts (artifact.go) when the artifact services are served.
42const FeaturePasskeys = "passkeys"
43
44// FeatureCIOIDC is listed by GetVersion when the server issues OIDC ID tokens
45// to CI jobs (Job.id_token_url, ServerService.RotateOIDCKey).
46const FeatureCIOIDC = "ci.oidc"
47
48// Config configures the API.
49type Config struct {
50	// Mirrors serves MirrorService when set; leave unset rather than a typed nil.
51	Mirrors ports.MirrorService
52	Auth    ports.AuthService
53	Repos   ports.RepoService
54	Tokens  ports.TokenService
55	Browse  ports.BrowseService
56	Events  ports.EventService
57	// MergeRequests serves MergeRequestService, SigningKey GetSigningKey.
58	MergeRequests ports.MergeRequestService
59	SigningKey    ports.SigningKeyService
60	// Issues serves IssueService.
61	Issues ports.IssueService
62	// Releases serves ReleaseService when set; leave unset rather than a typed nil.
63	Releases ports.ReleaseService
64	// IssueModeration serves the moderation calls of IssueService.
65	IssueModeration ports.IssueModerationService
66	Version         string
67	// TrustedProxies are the networks allowed to set X-Forwarded-For.
68	TrustedProxies []netip.Prefix
69	// Limiter counts failed authentications; nil builds a default one. Share
70	// it with the other adapters that authenticate, so that a client has one
71	// budget of failures. Leave it unset rather than assigning a typed nil
72	// pointer (a nil *T in the interface is not nil, and would be called).
73	Limiter ports.FailureLimiter
74	// Runner serves RunnerService and Runs serves RunService; without them the
75	// CI services are not served. Leave them unset rather than assigning a
76	// typed nil pointer.
77	Runner ports.RunnerService
78	Runs   ports.RunService
79	// Sessions are the owner's web sessions; nil serves no SessionService.
80	// Leave it unset rather than assigning a typed nil pointer (a nil *T in
81	// the interface is not nil, and the service would be served and called).
82	Sessions ports.SessionService
83	// Passkeys serves ListPasskeys and RemovePasskey; nil means passkeys are
84	// not available (the RPCs answer failed_precondition and GetVersion does
85	// not list "passkeys"). Same rule as Sessions about typed nil pointers.
86	Passkeys ports.PasskeyService
87	// Secrets, Variables and Environments serve SecretService, VariableService
88	// and EnvironmentService, and GetVersion lists "ci.secrets", when all three
89	// are set (they are one use case). Same rule as Sessions about typed nil
90	// pointers.
91	Secrets      ports.SecretService
92	Variables    ports.VariableService
93	Environments ports.EnvironmentService
94	// OIDCKeys manages ID-token signing keys, including when OIDC is disabled.
95	OIDCKeys ports.OIDCKeyService
96	// JobArtifacts and Artifacts serve JobArtifactService and ArtifactService,
97	// and GetVersion lists "ci.artifacts", when both are set and Runner is.
98	// Same rule as Sessions about typed nil pointers.
99	JobArtifacts ports.JobArtifactService
100	Artifacts    ports.ArtifactService
101	// TrustedKeys serves TrustedKeyService, and GetVersion lists
102	// "commit_signatures", when set (ADR 0030). Same rule as Sessions about
103	// typed nil pointers.
104	TrustedKeys ports.TrustedKeyService
105}
106
107// API is the set of Connect handlers of the contract.
108type API struct {
109	handler http.Handler
110}
111
112// New builds the API.
113func New(cfg Config) *API {
114	if cfg.Limiter == nil {
115		cfg.Limiter = clientlimit.NewLimiter(nil)
116	}
117	opts := []connect.HandlerOption{
118		connect.WithInterceptors(newInterceptor(cfg)),
119		connect.WithReadMaxBytes(MaxRequestBytes),
120		connect.WithRecover(func(ctx context.Context, spec connect.Spec, _ http.Header, panicValue any) error {
121			log := zerowrap.FromCtx(ctx)
122			log.Error().Interface("panic", panicValue).Str("procedure", spec.Procedure).Msg("api handler panicked")
123			return connect.NewError(connect.CodeInternal, errInternal)
124		}),
125	}
126	mux := http.NewServeMux()
127	mux.Handle(ermiteconnect.NewRepoServiceHandler(&repoService{cfg.Repos}, opts...))
128	mux.Handle(ermiteconnect.NewGitServiceHandler(&gitService{cfg.Browse}, opts...))
129	mux.Handle(ermiteconnect.NewTokenServiceHandler(&tokenService{cfg.Tokens}, opts...))
130	mux.Handle(ermiteconnect.NewEventServiceHandler(&eventService{cfg.Events}, opts...))
131	features := slices.Clone(Features)
132	if cfg.Mirrors != nil {
133		mux.Handle(ermiteconnect.NewMirrorServiceHandler(&mirrorService{cfg.Mirrors}, opts...))
134		features = append(features, FeatureMirror)
135	}
136	if cfg.Passkeys != nil {
137		features = append(features, FeaturePasskeys)
138	}
139	settings := cfg.Secrets != nil && cfg.Variables != nil && cfg.Environments != nil
140	if settings {
141		features = append(features, FeatureCISecrets)
142	}
143	artifacts := cfg.JobArtifacts != nil && cfg.Artifacts != nil && cfg.Runner != nil
144	if artifacts {
145		features = append(features, FeatureCIArtifacts)
146	}
147	if cfg.Releases != nil {
148		features = append(features, FeatureReleases)
149	}
150	if cfg.OIDCKeys != nil && cfg.OIDCKeys.Enabled() {
151		features = append(features, FeatureCIOIDC)
152	}
153	if cfg.TrustedKeys != nil {
154		features = append(features, FeatureCommitSignatures)
155	}
156	mux.Handle(ermiteconnect.NewServerServiceHandler(&serverService{version: cfg.Version, features: features, key: cfg.SigningKey, oidc: cfg.OIDCKeys}, opts...))
157	mux.Handle(ermiteconnect.NewMergeRequestServiceHandler(&mergeRequestService{mrs: cfg.MergeRequests}, opts...))
158	mux.Handle(ermiteconnect.NewIssueServiceHandler(&issueService{issues: cfg.Issues, moderation: cfg.IssueModeration}, opts...))
159	if cfg.Sessions != nil {
160		mux.Handle(ermiteconnect.NewSessionServiceHandler(&sessionService{sessions: cfg.Sessions, passkeys: cfg.Passkeys}, opts...))
161	}
162	if cfg.TrustedKeys != nil {
163		mux.Handle(ermiteconnect.NewTrustedKeyServiceHandler(&trustedKeyService{cfg.TrustedKeys}, opts...))
164	}
165	if settings {
166		mux.Handle(ermiteconnect.NewSecretServiceHandler(&secretService{cfg.Secrets}, opts...))
167		mux.Handle(ermiteconnect.NewVariableServiceHandler(&variableService{cfg.Variables}, opts...))
168		mux.Handle(ermiteconnect.NewEnvironmentServiceHandler(&environmentService{cfg.Environments}, opts...))
169	}
170	if cfg.Releases != nil {
171		releaseOpts := append(slices.Clone(opts), connect.WithReadMaxBytes(RunnerMaxRequestBytes))
172		mux.Handle(ermiteconnect.NewReleaseServiceHandler(&releaseService{cfg.Releases}, releaseOpts...))
173	}
174	if cfg.Runner != nil {
175		runnerOpts := append(slices.Clone(opts), connect.WithReadMaxBytes(RunnerMaxRequestBytes))
176		runners := &runnerService{runners: cfg.Runner, limiter: cfg.Limiter}
177		mux.Handle(ermiteconnect.NewRunnerServiceHandler(runners, runnerOpts...))
178		if artifacts {
179			// A block message carries up to 1 MiB of data, like a summary.
180			mux.Handle(ermiteconnect.NewJobArtifactServiceHandler(&jobArtifactService{artifacts: cfg.JobArtifacts, runner: runners}, runnerOpts...))
181			mux.Handle(ermiteconnect.NewArtifactServiceHandler(&artifactService{artifacts: cfg.Artifacts}, opts...))
182		}
183	}
184	if cfg.Runs != nil {
185		mux.Handle(ermiteconnect.NewRunServiceHandler(&runService{runs: cfg.Runs}, opts...))
186	}
187	return &API{handler: mux}
188}
189
190// Handler returns the handler of all services. Its paths are the procedure
191// paths of the contract ("/ermite.RepoService/ListRepos"); httpserver mounts
192// it under /-/api/ and strips the prefix.
193func (a *API) Handler() http.Handler { return a.handler }
194
195// caller returns the principal the interceptor put in ctx; anonymous when
196// there is none.
197func caller(ctx context.Context) domain.Principal {
198	p, _ := domain.PrincipalFromContext(ctx)
199	return p
200}

Keyboard shortcuts

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

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

Branches 2

Tags 45

↑↓ moveenter openesc close