diff --git a/AGENTS.md b/AGENTS.md index 37c29f9aaa..7574d5e34a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,7 @@ Top‑level packages worth knowing: - `cmd/alertmanager/` — main binary entry point (`main.go`); thin wrapper that parses flags and calls `app`. - `app/` — embeddable Alertmanager runtime extracted from `cmd/alertmanager`. Owns the process lifecycle (`New`/`Start`/`Stop`/`Reload`/`Run`), subsystem wiring (`setup`), config-reload subgraph (`reloader`), listeners and `Options`. Lets tests and other binaries run Alertmanager in‑process. See https://github.com/prometheus/alertmanager/issues/406. - `cmd/amtool/` — CLI for interacting with the Alertmanager API. -- `api/` — HTTP API. `api/v2/` is the active API; `api/v1_deprecation_router.go` only returns deprecation responses. +- `api/` — HTTP API. `api/v2/` is the active REST API; `api/connect/` is the experimental ConnectRPC API mounted under `/api/`; `api/v1_deprecation_router.go` only returns deprecation responses. - `cli/` — `amtool` command implementations. - `cluster/` — HA gossip clustering (memberlist-based). - `config/` — YAML config parsing, validation, secrets, coordinator. @@ -98,6 +98,7 @@ goreman start - Errors: wrap with `fmt.Errorf("...: %w", err)` and check with `errors.Is`/`errors.As` (`errorlint`). - Keep package‑level documentation up to date (`revive: package-comments`). - Tests live next to the code as `*_test.go`. Larger integration tests live under `test/`. The `notify/test` package provides shared testing helpers for notifier integrations. +- Use Ginkgo/Gomega for new Connect API tests under `api/connect/` and `test/e2e/`. Existing shared API and v2 tests retain their current testing style. ## When changing the API diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bb89f90f9..3d7210e3c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ## main / (unreleased) * [CHANGE] eventrecorder: The output data format now uses the new `events/v2` schema. This is a breaking change: alert labels, alert annotations, group labels, silence annotations, and muted-alert labels are JSON maps, and protobuf consumers must use the new schema. +* [FEATURE] api: Add an experimental ConnectRPC API served alongside `/api/v2/` under the version-neutral `/api/` prefix, exposing the Connect, gRPC, and gRPC-Web protocols plus the gRPC Health Checking Protocol and server reflection. The first service is `status.v3alpha.StatusService`. ## 0.34.0 / 2026-08-16 diff --git a/api/api.go b/api/api.go index d7c10f6662..4c6af610ec 100644 --- a/api/api.go +++ b/api/api.go @@ -18,6 +18,7 @@ import ( "errors" "fmt" "log/slog" + "mime" "net/http" "runtime" "strings" @@ -30,6 +31,7 @@ import ( "github.com/prometheus/common/route" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" + apiconnect "github.com/prometheus/alertmanager/api/connect" apiv2 "github.com/prometheus/alertmanager/api/v2" "github.com/prometheus/alertmanager/cluster" "github.com/prometheus/alertmanager/config" @@ -42,6 +44,7 @@ import ( // API represents all APIs of Alertmanager. type API struct { v2 *apiv2.API + connect *apiconnect.API deprecationRouter *V1DeprecationRouter requestDuration *prometheus.HistogramVec @@ -64,13 +67,14 @@ type Options struct { GroupMutedFunc func(routeID, groupKey string) ([]string, bool) // Peer from the gossip cluster. If nil, no clustering will be used. Peer cluster.ClusterPeer - // Timeout for all HTTP connections. The zero value (and negative - // values) result in no timeout. + // Timeout for HTTP requests and Connect unary RPCs. The zero value (and + // negative values) result in no timeout. Timeout time.Duration - // Concurrency limit for GET requests. The zero value (and negative - // values) result in a limit of GOMAXPROCS or 8, whichever is - // larger. Status code 503 is served for GET requests that would exceed - // the concurrency limit. + // Concurrency limit for GET requests and, independently, Connect unary + // RPCs and streams. The zero value (and negative values) result in a + // limit of GOMAXPROCS or 8, whichever is larger. Status code 503 is served + // for GET requests that would exceed the concurrency limit; Connect calls + // receive ResourceExhausted. Concurrency int // Logger is used for logging, if nil, no logging will happen. Logger *slog.Logger @@ -116,6 +120,7 @@ func New(opts Options) (*API, error) { concurrency = max(runtime.GOMAXPROCS(0), 8) } + // The Connect API is always mounted alongside API v2. v2, err := apiv2.NewAPI( opts.Alerts, opts.GroupFunc, @@ -128,6 +133,12 @@ func New(opts Options) (*API, error) { if err != nil { return nil, err } + connect := apiconnect.NewAPI(apiconnect.Options{ + Peer: opts.Peer, + UnaryConcurrency: concurrency, + StreamConcurrency: concurrency, + UnaryTimeout: opts.Timeout, + }) requestsInFlight := prometheus.NewGauge(prometheus.GaugeOpts{ Name: "alertmanager_http_requests_in_flight", @@ -151,6 +162,7 @@ func New(opts Options) (*API, error) { return &API{ deprecationRouter: NewV1DeprecationRouter(l.With("version", "v1")), v2: v2, + connect: connect, requestDuration: opts.RequestDuration, requestsInFlight: requestsInFlight, concurrencyLimitExceeded: concurrencyLimitExceeded, @@ -162,21 +174,41 @@ func New(opts Options) (*API, error) { // Register API. As APIv2 works on the http.Handler level, this method also creates a new // http.ServeMux and then uses it to register both the provided router (to // handle "/") and APIv2 (to handle "/api/v2"). The method returns -// the newly created http.ServeMux. If a timeout has been set on construction of -// API, it is enforced for all HTTP request going through this mux. The same is -// true for the concurrency limit, with the exception that it is only applied to -// GET requests. +// the newly created http.ServeMux. Configured timeouts apply to regular HTTP +// requests and Connect unary RPCs. Regular HTTP GETs and Connect RPCs use +// independent concurrency limits; streams also have a separate limit. func (api *API) Register(r *route.Router, routePrefix string) *http.ServeMux { // TODO(gotjosh) API V1 was removed as of version 0.27, when we reach 1.0.0 we should removed these deprecation warnings. api.deprecationRouter.Register(r.WithPrefix("/api/v1")) mux := http.NewServeMux() - mux.Handle("/", api.limitHandler(r)) + connectHandler := api.connect.Handler() + // ConnectRPC procedure paths are the only bounded label values on the + // Connect/gRPC surface; any other path yields a 404 and must not be + // recorded verbatim, or a client could inflate metric/trace cardinality. + servicePrefixes := api.connect.ServicePrefixes() + // Native gRPC is served at the server root, so match against the raw + // request path (no mount prefix). + grpcHandler := api.instrumentConnectHandler("", servicePrefixes, connectHandler) + webHandler := api.limitHandler(r) + mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if isGRPCRequest(r) { + grpcHandler.ServeHTTP(w, r) + return + } + webHandler.ServeHTTP(w, r) + })) apiPrefix := "" if routePrefix != "/" { apiPrefix = routePrefix } + + // The v1 deprecation routes live on the base router r (mounted at "/"). + // Re-register them under the more specific /api/v1/ prefix so the + // version-neutral Connect /api/ catch-all below does not shadow them. + mux.Handle(apiPrefix+"/api/v1/", api.limitHandler(r)) + mux.Handle( apiPrefix+"/api/v2/", api.instrumentHandler( @@ -190,17 +222,60 @@ func (api *API) Register(r *route.Router, routePrefix string) *http.ServeMux { ), ) + // Connect and gRPC-Web procedures are fully-qualified and carry their own + // service version (e.g. /status.v3alpha.StatusService/GetStatus), so mount them + // behind a version-neutral /api/ prefix. Native gRPC remains at the root so + // standard clients do not need path-prefix support. The more specific + // /api/v1/ and /api/v2/ patterns above win via longest-prefix matching. + mux.Handle( + apiPrefix+"/api/", + api.instrumentConnectHandler( + apiPrefix+"/api", + servicePrefixes, + http.StripPrefix(apiPrefix+"/api", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if isGRPCRequest(r) { + http.NotFound(w, r) + return + } + connectHandler.ServeHTTP(w, r) + })), + ), + ) + return mux } +func isGRPCRequest(r *http.Request) bool { + if r.ProtoMajor != 2 { + return false + } + mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + return err == nil && (mediaType == "application/grpc" || strings.HasPrefix(mediaType, "application/grpc+")) +} + // Update config and resolve timeout of each API. APIv2 also needs // setAlertStatus to be updated. func (api *API) Update(cfg *config.Config, setAlertStatus func(ctx context.Context, labels model.LabelSet)) { - api.v2.Update(cfg, setAlertStatus) + if api.v2 != nil { + api.v2.Update(cfg, setAlertStatus) + } + if api.connect != nil { + api.connect.Update(cfg) + } } func (api *API) limitHandler(h http.Handler) http.Handler { - concLimiter := http.HandlerFunc(func(rsp http.ResponseWriter, req *http.Request) { + limited := api.concurrencyLimitHandler(h) + if api.timeout <= 0 { + return limited + } + return http.TimeoutHandler(limited, api.timeout, fmt.Sprintf( + "Exceeded configured timeout of %v.\n", api.timeout, + )) +} + +func (api *API) concurrencyLimitHandler(h http.Handler) http.Handler { + return http.HandlerFunc(func(rsp http.ResponseWriter, req *http.Request) { if req.Method == http.MethodGet { // Only limit concurrency of GETs. select { case api.inFlightSem <- struct{}{}: // All good, carry on. @@ -219,12 +294,6 @@ func (api *API) limitHandler(h http.Handler) http.Handler { } h.ServeHTTP(rsp, req) }) - if api.timeout <= 0 { - return concLimiter - } - return http.TimeoutHandler(concLimiter, api.timeout, fmt.Sprintf( - "Exceeded configured timeout of %v.\n", api.timeout, - )) } func (api *API) instrumentHandler(prefix string, h http.Handler) http.Handler { @@ -240,3 +309,32 @@ func (api *API) instrumentHandler(prefix string, h http.Handler) http.Handler { ).ServeHTTP(w, r) }) } + +// unmatchedRPCLabel is the placeholder handler label and trace span name used +// for Connect/gRPC requests whose path does not correspond to a registered +// service. Collapsing these to a single value keeps clients from inflating +// metric and trace cardinality by hitting arbitrary paths. +const unmatchedRPCLabel = "unmatched" + +// instrumentConnectHandler is like instrumentHandler but bounds label and +// span cardinality for the Connect/gRPC surface. Requests whose path (after +// stripping mountPrefix) matches a registered service are recorded under that +// service's prefix; the trailing method segment is intentionally dropped so a +// client cannot inflate cardinality by appending arbitrary (and 404-ing) +// method names. Everything else collapses to unmatchedRPCLabel. +func (api *API) instrumentConnectHandler(mountPrefix string, servicePrefixes []string, h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + procedure, _ := strings.CutPrefix(r.URL.Path, mountPrefix) + label := unmatchedRPCLabel + for _, p := range servicePrefixes { + if strings.HasPrefix(procedure, p) { + label = p + break + } + } + promhttp.InstrumentHandlerDuration( + api.requestDuration.MustCurryWith(prometheus.Labels{"handler": label}), + otelhttp.NewHandler(h, label), + ).ServeHTTP(w, r) + }) +} diff --git a/api/api_test.go b/api/api_test.go new file mode 100644 index 0000000000..183c621543 --- /dev/null +++ b/api/api_test.go @@ -0,0 +1,150 @@ +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import ( + "net/http" + "net/http/httptest" + "sync" + "testing" + "testing/synctest" + + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/require" +) + +func TestConcurrencyLimitHandler(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + entered := make(chan struct{}) + release := make(chan struct{}) + var enteredOnce sync.Once + unblock := sync.OnceFunc(func() { close(release) }) + defer unblock() + + dst := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + enteredOnce.Do(func() { close(entered) }) + <-release + } + w.WriteHeader(http.StatusNoContent) + }) + api := &API{ + requestsInFlight: prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "test_requests_in_flight", + }), + concurrencyLimitExceeded: prometheus.NewCounter(prometheus.CounterOpts{ + Name: "test_concurrency_limit_exceeded_total", + }), + inFlightSem: make(chan struct{}, 1), + } + handler := api.concurrencyLimitHandler(dst) + + firstDone := make(chan *httptest.ResponseRecorder, 1) + go func() { + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/", nil)) + firstDone <- recorder + }() + <-entered + + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/", nil)) + require.Equal(t, http.StatusServiceUnavailable, recorder.Code) + + recorder = httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/", nil)) + require.Equal(t, http.StatusNoContent, recorder.Code) + + unblock() + first := <-firstDone + require.Equal(t, http.StatusNoContent, first.Code) + + recorder = httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/", nil)) + require.Equal(t, http.StatusNoContent, recorder.Code) + }) +} + +// TestInstrumentConnectHandlerBoundsCardinality ensures that Connect/gRPC +// requests to unregistered paths collapse to a single placeholder handler +// label instead of being recorded verbatim, which would let a client inflate +// metric cardinality by hitting arbitrary paths. +func TestInstrumentConnectHandlerBoundsCardinality(t *testing.T) { + const servicePrefix = "/status.v3alpha.StatusService/" + + for _, mountPrefix := range []string{"", "/alertmanager/api"} { + t.Run(mountPrefix, func(t *testing.T) { + reg := prometheus.NewRegistry() + requestDuration := prometheus.NewHistogramVec( + prometheus.HistogramOpts{Name: "test_http_request_duration_seconds"}, + []string{"handler", "method", "code"}, + ) + reg.MustRegister(requestDuration) + + api := &API{requestDuration: requestDuration} + + // The inner handler mimics the ConnectRPC mux: 200 for the registered + // procedure, 404 for everything else (including unknown methods on a + // known service). + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == servicePrefix+"GetStatus" { + w.WriteHeader(http.StatusOK) + return + } + http.NotFound(w, r) + }) + h := api.instrumentConnectHandler( + mountPrefix, + []string{servicePrefix}, + http.StripPrefix(mountPrefix, inner), + ) + + for _, procedure := range []string{ + servicePrefix + "GetStatus", + // Unknown methods on a known service must not each get their own + // label; they collapse onto the service prefix. + servicePrefix + "Evil1", + servicePrefix + "Evil2", + // Entirely unregistered paths collapse onto the placeholder. + "/attacker/controlled/1", + "/attacker/controlled/2", + } { + recorder := httptest.NewRecorder() + h.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, mountPrefix+procedure, nil)) + } + + families, err := reg.Gather() + require.NoError(t, err) + + handlers := map[string]struct{}{} + for _, fam := range families { + if fam.GetName() != "test_http_request_duration_seconds" { + continue + } + for _, m := range fam.GetMetric() { + for _, lp := range m.GetLabel() { + if lp.GetName() == "handler" { + handlers[lp.GetValue()] = struct{}{} + } + } + } + } + + require.Equal(t, map[string]struct{}{ + servicePrefix: {}, + unmatchedRPCLabel: {}, + }, handlers) + }) + } +} diff --git a/api/connect/connect.go b/api/connect/connect.go new file mode 100644 index 0000000000..293bdf9860 --- /dev/null +++ b/api/connect/connect.go @@ -0,0 +1,185 @@ +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package apiconnect implements the experimental ConnectRPC-based +// Alertmanager API. It is always mounted alongside API v2. Connect and +// gRPC-Web use the version-neutral /api/ prefix, while native gRPC uses the +// server root. Each service is independently versioned (e.g. status.v3alpha); the +// package itself carries no umbrella version. +package apiconnect + +import ( + "context" + "errors" + "net/http" + "runtime" + "sync/atomic" + "time" + + "connectrpc.com/connect" + "connectrpc.com/grpchealth" + "connectrpc.com/grpcreflect" + + "github.com/prometheus/alertmanager/api/status/v3alpha/statusv3alphaconnect" + "github.com/prometheus/alertmanager/cluster" + "github.com/prometheus/alertmanager/config" +) + +// Options configures the Connect API. +type Options struct { + Peer cluster.ClusterPeer + UnaryConcurrency int + StreamConcurrency int + UnaryTimeout time.Duration +} + +// API implements the ConnectRPC service handlers for the Connect API. +type API struct { + peer cluster.ClusterPeer + uptime time.Time + admission *admissionInterceptor + peerSnapshotSem chan struct{} + + configSnapshot atomic.Pointer[string] +} + +// NewAPI returns a new Connect API handler. Peer may be nil when clustering +// is disabled. +func NewAPI(opts Options) *API { + unaryConcurrency := defaultConcurrency(opts.UnaryConcurrency) + streamConcurrency := defaultConcurrency(opts.StreamConcurrency) + return &API{ + peer: opts.Peer, + uptime: time.Now(), + peerSnapshotSem: make(chan struct{}, 1), + admission: &admissionInterceptor{ + unary: make(chan struct{}, unaryConcurrency), + streams: make(chan struct{}, streamConcurrency), + unaryTimeout: opts.UnaryTimeout, + }, + } +} + +func defaultConcurrency(concurrency int) int { + if concurrency < 1 { + return max(runtime.GOMAXPROCS(0), 8) + } + return concurrency +} + +// admissionInterceptor gives unary RPCs and streams independent capacity so +// slow Connect clients cannot consume the API v2 GET request allowance. +type admissionInterceptor struct { + unary chan struct{} + streams chan struct{} + unaryTimeout time.Duration +} + +func (i *admissionInterceptor) WrapUnary(next connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + select { + case i.unary <- struct{}{}: + defer func() { <-i.unary }() + default: + return nil, connect.NewError(connect.CodeResourceExhausted, errors.New("maximum concurrent unary RPCs reached")) + } + if i.unaryTimeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, i.unaryTimeout) + defer cancel() + } + response, err := next(ctx, req) + if err == nil || connect.CodeOf(err) != connect.CodeUnknown { + return response, err + } + switch { + case errors.Is(err, context.DeadlineExceeded): + return nil, connect.NewError(connect.CodeDeadlineExceeded, err) + case errors.Is(err, context.Canceled): + return nil, connect.NewError(connect.CodeCanceled, err) + default: + return response, err + } + } +} + +func (i *admissionInterceptor) WrapStreamingClient(next connect.StreamingClientFunc) connect.StreamingClientFunc { + return next +} + +func (i *admissionInterceptor) WrapStreamingHandler(next connect.StreamingHandlerFunc) connect.StreamingHandlerFunc { + return func(ctx context.Context, conn connect.StreamingHandlerConn) error { + select { + case i.streams <- struct{}{}: + defer func() { <-i.streams }() + default: + return connect.NewError(connect.CodeResourceExhausted, errors.New("maximum concurrent streams reached")) + } + return next(ctx, conn) + } +} + +// Update swaps in the currently loaded configuration. It is safe for +// concurrent use with the RPC handlers. +func (api *API) Update(cfg *config.Config) { + if cfg == nil { + api.configSnapshot.Store(nil) + return + } + original := cfg.String() + api.configSnapshot.Store(&original) +} + +// Handler returns an http.Handler serving every ConnectRPC service exposed +// by the Connect API: the versioned application services, the gRPC Health +// Checking Protocol (grpc.health.v1.Health), and gRPC server reflection +// (v1 and v1alpha, for tools such as grpcurl). Procedures are +// fully-qualified, so the returned handler is mounted at a single prefix. +func (api *API) Handler(opts ...connect.HandlerOption) http.Handler { + return api.buildHandler(opts...) +} + +// ServicePrefixes returns the URL path prefixes ("//") +// for every service registered by Handler. Callers use it to bound the +// cardinality of metric and trace labels: any request path that does not +// match one of these prefixes yields a 404 and should not be recorded +// verbatim. +func (*API) ServicePrefixes() []string { + return []string{ + "/status.v3alpha.StatusService/", + "/grpc.health.v1.Health/", + "/grpc.reflection.v1.ServerReflection/", + "/grpc.reflection.v1alpha.ServerReflection/", + } +} + +// buildHandler registers every ConnectRPC service on a fresh mux. +func (api *API) buildHandler(opts ...connect.HandlerOption) http.Handler { + opts = append([]connect.HandlerOption{connect.WithInterceptors(api.admission)}, opts...) + + // serviceNames lists the fully-qualified service names advertised via + // health checking and reflection. + serviceNames := []string{ + statusv3alphaconnect.StatusServiceName, + } + + mux := http.NewServeMux() + mux.Handle(statusv3alphaconnect.NewStatusServiceHandler(api, opts...)) + mux.Handle(grpchealth.NewHandler(grpchealth.NewStaticChecker(serviceNames...), opts...)) + + reflector := grpcreflect.NewStaticReflector(serviceNames...) + mux.Handle(grpcreflect.NewHandlerV1(reflector, opts...)) + mux.Handle(grpcreflect.NewHandlerV1Alpha(reflector, opts...)) + + return mux +} diff --git a/api/connect/connect_suite_test.go b/api/connect/connect_suite_test.go new file mode 100644 index 0000000000..b12041a4b0 --- /dev/null +++ b/api/connect/connect_suite_test.go @@ -0,0 +1,26 @@ +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package apiconnect + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestConnectAPI(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Connect API Suite") +} diff --git a/api/connect/health_test.go b/api/connect/health_test.go new file mode 100644 index 0000000000..bd0090912b --- /dev/null +++ b/api/connect/health_test.go @@ -0,0 +1,64 @@ +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package apiconnect + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/prometheus/alertmanager/api/status/v3alpha/statusv3alphaconnect" + "github.com/prometheus/alertmanager/config" +) + +// TestGRPCHealth verifies the gRPC Health Checking Protocol handler is +// mounted and reports SERVING for both the overall server ("") and the +// registered StatusService. The Health service is queried over the Connect +// protocol with JSON, which needs only an HTTP/1.1 client. +var _ = Describe("gRPC health", func() { + It("reports serving for the server and StatusService", func() { + api := NewAPI(Options{}) + api.Update(&config.Config{}) + + srv := httptest.NewServer(api.Handler()) + DeferCleanup(srv.Close) + client := srv.Client() + client.Timeout = 5 * time.Second + + for _, service := range []string{"", statusv3alphaconnect.StatusServiceName} { + reqBody, err := json.Marshal(map[string]string{"service": service}) + Expect(err).NotTo(HaveOccurred()) + + resp, err := client.Post( + srv.URL+"/grpc.health.v1.Health/Check", + "application/json", + bytes.NewReader(reqBody), + ) + Expect(err).NotTo(HaveOccurred()) + + var out struct { + Status string `json:"status"` + } + Expect(json.NewDecoder(resp.Body).Decode(&out)).To(Succeed()) + Expect(resp.Body.Close()).To(Succeed()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + Expect(out.Status).To(Equal("SERVING_STATUS_SERVING")) + } + }) +}) diff --git a/api/connect/status.go b/api/connect/status.go new file mode 100644 index 0000000000..d23bccf5be --- /dev/null +++ b/api/connect/status.go @@ -0,0 +1,122 @@ +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package apiconnect + +import ( + "context" + "sort" + + "connectrpc.com/connect" + "github.com/prometheus/common/version" + "google.golang.org/protobuf/types/known/timestamppb" + + statusv3alpha "github.com/prometheus/alertmanager/api/status/v3alpha" + "github.com/prometheus/alertmanager/api/status/v3alpha/statusv3alphaconnect" +) + +// Ensure API satisfies the generated StatusService handler interface. +var _ statusv3alphaconnect.StatusServiceHandler = (*API)(nil) + +// GetStatus returns the Alertmanager instance and cluster status. +func (api *API) GetStatus(ctx context.Context, _ *connect.Request[statusv3alpha.GetStatusRequest]) (*connect.Response[statusv3alpha.GetStatusResponse], error) { + var original string + if snapshot := api.configSnapshot.Load(); snapshot != nil { + original = *snapshot + } + + status := &statusv3alpha.AlertmanagerStatus{ + StartTime: timestamppb.New(api.uptime), + VersionInfo: &statusv3alpha.VersionInfo{ + Version: version.Version, + Revision: version.Revision, + Branch: version.Branch, + BuildUser: version.BuildUser, + BuildDate: version.BuildDate, + GoVersion: version.GoVersion, + }, + Config: &statusv3alpha.AlertmanagerConfig{ + Original: original, + }, + Cluster: &statusv3alpha.ClusterStatus{ + State: statusv3alpha.ClusterStatus_STATE_DISABLED, + Peers: []*statusv3alpha.PeerStatus{}, + }, + } + + // If clustering is disabled, api.peer is nil and the cluster is + // reported as disabled. + if api.peer != nil { + clusterStatus, err := api.snapshotClusterStatus(ctx) + if err != nil { + return nil, err + } + status.Cluster = clusterStatus + } + + resp := connect.NewResponse(&statusv3alpha.GetStatusResponse{Status: status}) + resp.Header().Set("Cache-Control", "no-store") + return resp, nil +} + +func (api *API) snapshotClusterStatus(ctx context.Context) (*statusv3alpha.ClusterStatus, error) { + select { + case api.peerSnapshotSem <- struct{}{}: + case <-ctx.Done(): + return nil, ctx.Err() + } + if err := ctx.Err(); err != nil { + <-api.peerSnapshotSem + return nil, err + } + + result := make(chan *statusv3alpha.ClusterStatus, 1) + go func() { + defer func() { <-api.peerSnapshotSem }() + members := api.peer.Peers() + peers := make([]*statusv3alpha.PeerStatus, 0, len(members)) + for _, member := range members { + peers = append(peers, &statusv3alpha.PeerStatus{ + Name: member.Name(), + Address: member.Address(), + }) + } + sort.Slice(peers, func(i, j int) bool { + return peers[i].Name < peers[j].Name + }) + result <- &statusv3alpha.ClusterStatus{ + Name: api.peer.Name(), + State: clusterState(api.peer.Status()), + Peers: peers, + } + }() + + select { + case clusterStatus := <-result: + return clusterStatus, nil + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +// clusterState maps a cluster.ClusterPeer status string onto the proto enum. +func clusterState(s string) statusv3alpha.ClusterStatus_State { + switch s { + case "ready": + return statusv3alpha.ClusterStatus_STATE_READY + case "settling": + return statusv3alpha.ClusterStatus_STATE_SETTLING + default: + return statusv3alpha.ClusterStatus_STATE_UNSPECIFIED + } +} diff --git a/api/connect/status_test.go b/api/connect/status_test.go new file mode 100644 index 0000000000..cea65c127c --- /dev/null +++ b/api/connect/status_test.go @@ -0,0 +1,339 @@ +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package apiconnect + +import ( + "context" + "net/http" + "net/http/httptest" + "sync" + "sync/atomic" + "time" + + "connectrpc.com/connect" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/prometheus/common/version" + + statusv3alpha "github.com/prometheus/alertmanager/api/status/v3alpha" + "github.com/prometheus/alertmanager/api/status/v3alpha/statusv3alphaconnect" + "github.com/prometheus/alertmanager/cluster" + "github.com/prometheus/alertmanager/config" +) + +// fakeMember is a test double for cluster.ClusterMember. +type fakeMember struct { + name string + address string +} + +func (m fakeMember) Name() string { return m.name } +func (m fakeMember) Address() string { return m.address } + +// fakePeer is a test double for cluster.ClusterPeer. +type fakePeer struct { + name string + status string + peers []cluster.ClusterMember +} + +func (p fakePeer) Name() string { return p.name } +func (p fakePeer) Status() string { return p.status } +func (p fakePeer) Peers() []cluster.ClusterMember { return p.peers } + +type blockingPeer struct { + enteredOnce sync.Once + releaseOnce sync.Once + calls atomic.Int64 + entered chan struct{} + release chan struct{} +} + +func (p *blockingPeer) Name() string { return "self" } +func (p *blockingPeer) Status() string { return "ready" } +func (p *blockingPeer) Peers() []cluster.ClusterMember { + p.calls.Add(1) + p.enteredOnce.Do(func() { close(p.entered) }) + <-p.release + return nil +} +func (p *blockingPeer) unblock() { p.releaseOnce.Do(func() { close(p.release) }) } + +var _ = Describe("StatusService", func() { + It("returns status when clustering is disabled", func() { + api := NewAPI(Options{}) + api.Update(&config.Config{}) + + resp, err := api.GetStatus(context.Background(), connect.NewRequest(&statusv3alpha.GetStatusRequest{})) + Expect(err).NotTo(HaveOccurred()) + + got := resp.Msg.GetStatus() + Expect(got).NotTo(BeNil()) + Expect(got.GetVersionInfo().GetVersion()).To(Equal(version.Version)) + Expect(got.GetVersionInfo().GetRevision()).To(Equal(version.Revision)) + Expect(got.GetVersionInfo().GetBranch()).To(Equal(version.Branch)) + Expect(got.GetVersionInfo().GetGoVersion()).To(Equal(version.GoVersion)) + Expect(got.GetConfig().GetOriginal()).NotTo(BeEmpty()) + Expect(got.GetStartTime()).NotTo(BeNil()) + Expect(got.GetCluster().GetState()).To(Equal(statusv3alpha.ClusterStatus_STATE_DISABLED)) + Expect(got.GetCluster().GetName()).To(BeEmpty()) + Expect(got.GetCluster().GetPeers()).To(BeEmpty()) + }) + + It("returns sorted peers when clustering is enabled", func() { + peer := fakePeer{ + name: "self", + status: "ready", + peers: []cluster.ClusterMember{ + fakeMember{name: "c-node", address: "10.0.0.3:9094"}, + fakeMember{name: "a-node", address: "10.0.0.1:9094"}, + fakeMember{name: "b-node", address: "10.0.0.2:9094"}, + }, + } + + api := NewAPI(Options{Peer: peer}) + api.Update(&config.Config{}) + + resp, err := api.GetStatus(context.Background(), connect.NewRequest(&statusv3alpha.GetStatusRequest{})) + Expect(err).NotTo(HaveOccurred()) + + clusterStatus := resp.Msg.GetStatus().GetCluster() + Expect(clusterStatus.GetName()).To(Equal("self")) + Expect(clusterStatus.GetState()).To(Equal(statusv3alpha.ClusterStatus_STATE_READY)) + + names := make([]string, 0, len(clusterStatus.GetPeers())) + for _, peer := range clusterStatus.GetPeers() { + names = append(names, peer.GetName()) + } + Expect(names).To(Equal([]string{"a-node", "b-node", "c-node"})) + }) + + It("does not block updates behind GetStatus", func() { + peer := &blockingPeer{ + entered: make(chan struct{}), + release: make(chan struct{}), + } + DeferCleanup(peer.unblock) + + api := NewAPI(Options{Peer: peer}) + api.Update(&config.Config{}) + + statusDone := make(chan error, 1) + go func() { + _, err := api.GetStatus(context.Background(), connect.NewRequest(&statusv3alpha.GetStatusRequest{})) + statusDone <- err + }() + Eventually(peer.entered, 5*time.Second).Should(BeClosed()) + + updateDone := make(chan struct{}) + go func() { + api.Update(&config.Config{}) + close(updateDone) + }() + Eventually(updateDone, 5*time.Second).Should(BeClosed()) + + peer.unblock() + var statusErr error + Eventually(statusDone, 5*time.Second).Should(Receive(&statusErr)) + Expect(statusErr).NotTo(HaveOccurred()) + }) + + It("bounds peer snapshots when the unary deadline expires", func() { + peer := &blockingPeer{ + entered: make(chan struct{}), + release: make(chan struct{}), + } + api := NewAPI(Options{Peer: peer, UnaryTimeout: 20 * time.Millisecond}) + api.Update(&config.Config{}) + + srv := httptest.NewServer(api.Handler()) + DeferCleanup(srv.Close) + DeferCleanup(peer.unblock) + client := statusv3alphaconnect.NewStatusServiceClient(&http.Client{Timeout: time.Second}, srv.URL) + + for range 2 { + started := time.Now() + _, err := client.GetStatus(context.Background(), connect.NewRequest(&statusv3alpha.GetStatusRequest{})) + Expect(connect.CodeOf(err)).To(Equal(connect.CodeDeadlineExceeded)) + Expect(time.Since(started)).To(BeNumerically("<", 500*time.Millisecond)) + } + Eventually(peer.calls.Load, time.Second).Should(Equal(int64(1))) + }) + + DescribeTable("maps cluster states", + func(input string, expected statusv3alpha.ClusterStatus_State) { + Expect(clusterState(input)).To(Equal(expected)) + }, + Entry("ready", "ready", statusv3alpha.ClusterStatus_STATE_READY), + Entry("settling", "settling", statusv3alpha.ClusterStatus_STATE_SETTLING), + Entry("empty", "", statusv3alpha.ClusterStatus_STATE_UNSPECIFIED), + Entry("unknown", "bogus", statusv3alpha.ClusterStatus_STATE_UNSPECIFIED), + ) + + // TestGetStatus_OverHTTP exercises the full ConnectRPC wiring over HTTP, + // using both the Connect and gRPC protocols to prove the handler works on + // both transports. The gRPC protocol requires HTTP/2, so both the server + // and client are configured for unencrypted HTTP/2 (cleartext h2c) via the + // standard library's http.Protocols. + DescribeTable("serves status over HTTP", + func(wantMethod string, opts []connect.ClientOption) { + api := NewAPI(Options{}) + api.Update(&config.Config{}) + + methods := make(chan string, 1) + handler := api.Handler() + srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + methods <- r.Method + handler.ServeHTTP(w, r) + })) + serverProtocols := new(http.Protocols) + serverProtocols.SetHTTP1(true) + serverProtocols.SetUnencryptedHTTP2(true) + srv.Config.Protocols = serverProtocols + srv.Start() + DeferCleanup(srv.Close) + + // The Connect protocol works over HTTP/1.1 too, but the native gRPC + // protocol requires HTTP/2. A single cleartext-HTTP/2 (h2c) client + // therefore serves all subtests below. + clientProtocols := new(http.Protocols) + clientProtocols.SetUnencryptedHTTP2(true) + transport := &http.Transport{Protocols: clientProtocols} + h2cClient := &http.Client{Transport: transport, Timeout: 5 * time.Second} + DeferCleanup(transport.CloseIdleConnections) + + client := statusv3alphaconnect.NewStatusServiceClient(h2cClient, srv.URL, opts...) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + resp, err := client.GetStatus(ctx, connect.NewRequest(&statusv3alpha.GetStatusRequest{})) + Expect(err).NotTo(HaveOccurred()) + Eventually(methods, 5*time.Second).Should(Receive(Equal(wantMethod))) + Expect(resp.Header().Get("Cache-Control")).To(Equal("no-store")) + Expect(resp.Msg.GetStatus().GetVersionInfo().GetVersion()).To(Equal(version.Version)) + Expect(resp.Msg.GetStatus().GetCluster().GetState()).To(Equal(statusv3alpha.ClusterStatus_STATE_DISABLED)) + }, + Entry("Connect POST", http.MethodPost, []connect.ClientOption{}), + Entry("Connect HTTP GET", http.MethodGet, []connect.ClientOption{connect.WithHTTPGet()}), + Entry("gRPC-Web", http.MethodPost, []connect.ClientOption{connect.WithGRPCWeb()}), + Entry("gRPC", http.MethodPost, []connect.ClientOption{connect.WithGRPC()}), + ) +}) + +var _ = Describe("Connect API", func() { + It("pins registered service prefixes", func() { + Expect(NewAPI(Options{}).ServicePrefixes()).To(Equal([]string{ + "/status.v3alpha.StatusService/", + "/grpc.health.v1.Health/", + "/grpc.reflection.v1.ServerReflection/", + "/grpc.reflection.v1alpha.ServerReflection/", + })) + }) +}) + +var _ = Describe("RPC admission", func() { + It("defaults unary and stream concurrency independently", func() { + api := NewAPI(Options{UnaryConcurrency: 1}) + Expect(cap(api.admission.unary)).To(Equal(1)) + Expect(cap(api.admission.streams)).To(BeNumerically(">=", 8)) + + api = NewAPI(Options{StreamConcurrency: 1}) + Expect(cap(api.admission.unary)).To(BeNumerically(">=", 8)) + Expect(cap(api.admission.streams)).To(Equal(1)) + }) + + It("limits unary RPCs independently", func() { + admission := &admissionInterceptor{unary: make(chan struct{}, 1), streams: make(chan struct{}, 1)} + entered := make(chan struct{}) + release := make(chan struct{}) + var enteredOnce sync.Once + DeferCleanup(func() { + select { + case <-release: + default: + close(release) + } + }) + + wrapped := admission.WrapUnary(func(context.Context, connect.AnyRequest) (connect.AnyResponse, error) { + enteredOnce.Do(func() { close(entered) }) + <-release + return nil, nil + }) + firstDone := make(chan error, 1) + go func() { + _, err := wrapped(context.Background(), nil) + firstDone <- err + }() + Eventually(entered, 5*time.Second).Should(BeClosed()) + + _, err := wrapped(context.Background(), nil) + Expect(connect.CodeOf(err)).To(Equal(connect.CodeResourceExhausted)) + + close(release) + var firstErr error + Eventually(firstDone, 5*time.Second).Should(Receive(&firstErr)) + Expect(firstErr).NotTo(HaveOccurred()) + + _, err = wrapped(context.Background(), nil) + Expect(err).NotTo(HaveOccurred()) + }) + + It("limits streams independently", func() { + admission := &admissionInterceptor{unary: make(chan struct{}, 1), streams: make(chan struct{}, 1)} + entered := make(chan struct{}) + release := make(chan struct{}) + var enteredOnce sync.Once + DeferCleanup(func() { + select { + case <-release: + default: + close(release) + } + }) + + wrapped := admission.WrapStreamingHandler(func(context.Context, connect.StreamingHandlerConn) error { + enteredOnce.Do(func() { close(entered) }) + <-release + return nil + }) + firstDone := make(chan error, 1) + go func() { firstDone <- wrapped(context.Background(), nil) }() + Eventually(entered, 5*time.Second).Should(BeClosed()) + + err := wrapped(context.Background(), nil) + Expect(connect.CodeOf(err)).To(Equal(connect.CodeResourceExhausted)) + + close(release) + var firstErr error + Eventually(firstDone, 5*time.Second).Should(Receive(&firstErr)) + Expect(firstErr).NotTo(HaveOccurred()) + + Expect(wrapped(context.Background(), nil)).To(Succeed()) + }) + + It("sets configured unary deadlines", func() { + admission := &admissionInterceptor{ + unary: make(chan struct{}, 1), + streams: make(chan struct{}, 1), + unaryTimeout: 10 * time.Millisecond, + } + wrapped := admission.WrapUnary(func(ctx context.Context, _ connect.AnyRequest) (connect.AnyResponse, error) { + <-ctx.Done() + return nil, ctx.Err() + }) + + _, err := wrapped(context.Background(), nil) + Expect(connect.CodeOf(err)).To(Equal(connect.CodeDeadlineExceeded)) + }) +}) diff --git a/api/status/v3/status.pb.go b/api/status/v3alpha/status.pb.go similarity index 71% rename from api/status/v3/status.pb.go rename to api/status/v3alpha/status.pb.go index 1023e894cc..83b2dcea89 100644 --- a/api/status/v3/status.pb.go +++ b/api/status/v3alpha/status.pb.go @@ -15,9 +15,9 @@ // versions: // protoc-gen-go v1.36.12 // protoc (unknown) -// source: status/v3/status.proto +// source: status/v3alpha/status.proto -package statusv3 +package statusv3alpha import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" @@ -71,11 +71,11 @@ func (x ClusterStatus_State) String() string { } func (ClusterStatus_State) Descriptor() protoreflect.EnumDescriptor { - return file_status_v3_status_proto_enumTypes[0].Descriptor() + return file_status_v3alpha_status_proto_enumTypes[0].Descriptor() } func (ClusterStatus_State) Type() protoreflect.EnumType { - return &file_status_v3_status_proto_enumTypes[0] + return &file_status_v3alpha_status_proto_enumTypes[0] } func (x ClusterStatus_State) Number() protoreflect.EnumNumber { @@ -84,7 +84,7 @@ func (x ClusterStatus_State) Number() protoreflect.EnumNumber { // Deprecated: Use ClusterStatus_State.Descriptor instead. func (ClusterStatus_State) EnumDescriptor() ([]byte, []int) { - return file_status_v3_status_proto_rawDescGZIP(), []int{3, 0} + return file_status_v3alpha_status_proto_rawDescGZIP(), []int{3, 0} } // AlertmanagerStatus is the top-level status payload. @@ -100,7 +100,7 @@ type AlertmanagerStatus struct { func (x *AlertmanagerStatus) Reset() { *x = AlertmanagerStatus{} - mi := &file_status_v3_status_proto_msgTypes[0] + mi := &file_status_v3alpha_status_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -112,7 +112,7 @@ func (x *AlertmanagerStatus) String() string { func (*AlertmanagerStatus) ProtoMessage() {} func (x *AlertmanagerStatus) ProtoReflect() protoreflect.Message { - mi := &file_status_v3_status_proto_msgTypes[0] + mi := &file_status_v3alpha_status_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -125,7 +125,7 @@ func (x *AlertmanagerStatus) ProtoReflect() protoreflect.Message { // Deprecated: Use AlertmanagerStatus.ProtoReflect.Descriptor instead. func (*AlertmanagerStatus) Descriptor() ([]byte, []int) { - return file_status_v3_status_proto_rawDescGZIP(), []int{0} + return file_status_v3alpha_status_proto_rawDescGZIP(), []int{0} } func (x *AlertmanagerStatus) GetVersionInfo() *VersionInfo { @@ -171,7 +171,7 @@ type VersionInfo struct { func (x *VersionInfo) Reset() { *x = VersionInfo{} - mi := &file_status_v3_status_proto_msgTypes[1] + mi := &file_status_v3alpha_status_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -183,7 +183,7 @@ func (x *VersionInfo) String() string { func (*VersionInfo) ProtoMessage() {} func (x *VersionInfo) ProtoReflect() protoreflect.Message { - mi := &file_status_v3_status_proto_msgTypes[1] + mi := &file_status_v3alpha_status_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -196,7 +196,7 @@ func (x *VersionInfo) ProtoReflect() protoreflect.Message { // Deprecated: Use VersionInfo.ProtoReflect.Descriptor instead. func (*VersionInfo) Descriptor() ([]byte, []int) { - return file_status_v3_status_proto_rawDescGZIP(), []int{1} + return file_status_v3alpha_status_proto_rawDescGZIP(), []int{1} } func (x *VersionInfo) GetVersion() string { @@ -252,7 +252,7 @@ type AlertmanagerConfig struct { func (x *AlertmanagerConfig) Reset() { *x = AlertmanagerConfig{} - mi := &file_status_v3_status_proto_msgTypes[2] + mi := &file_status_v3alpha_status_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -264,7 +264,7 @@ func (x *AlertmanagerConfig) String() string { func (*AlertmanagerConfig) ProtoMessage() {} func (x *AlertmanagerConfig) ProtoReflect() protoreflect.Message { - mi := &file_status_v3_status_proto_msgTypes[2] + mi := &file_status_v3alpha_status_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -277,7 +277,7 @@ func (x *AlertmanagerConfig) ProtoReflect() protoreflect.Message { // Deprecated: Use AlertmanagerConfig.ProtoReflect.Descriptor instead. func (*AlertmanagerConfig) Descriptor() ([]byte, []int) { - return file_status_v3_status_proto_rawDescGZIP(), []int{2} + return file_status_v3alpha_status_proto_rawDescGZIP(), []int{2} } func (x *AlertmanagerConfig) GetOriginal() string { @@ -292,7 +292,7 @@ type ClusterStatus struct { state protoimpl.MessageState `protogen:"open.v1"` // Cluster name, when configured. Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` - State ClusterStatus_State `protobuf:"varint,2,opt,name=state,proto3,enum=status.v3.ClusterStatus_State" json:"state,omitempty"` + State ClusterStatus_State `protobuf:"varint,2,opt,name=state,proto3,enum=status.v3alpha.ClusterStatus_State" json:"state,omitempty"` Peers []*PeerStatus `protobuf:"bytes,3,rep,name=peers,proto3" json:"peers,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -300,7 +300,7 @@ type ClusterStatus struct { func (x *ClusterStatus) Reset() { *x = ClusterStatus{} - mi := &file_status_v3_status_proto_msgTypes[3] + mi := &file_status_v3alpha_status_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -312,7 +312,7 @@ func (x *ClusterStatus) String() string { func (*ClusterStatus) ProtoMessage() {} func (x *ClusterStatus) ProtoReflect() protoreflect.Message { - mi := &file_status_v3_status_proto_msgTypes[3] + mi := &file_status_v3alpha_status_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -325,7 +325,7 @@ func (x *ClusterStatus) ProtoReflect() protoreflect.Message { // Deprecated: Use ClusterStatus.ProtoReflect.Descriptor instead. func (*ClusterStatus) Descriptor() ([]byte, []int) { - return file_status_v3_status_proto_rawDescGZIP(), []int{3} + return file_status_v3alpha_status_proto_rawDescGZIP(), []int{3} } func (x *ClusterStatus) GetName() string { @@ -360,7 +360,7 @@ type PeerStatus struct { func (x *PeerStatus) Reset() { *x = PeerStatus{} - mi := &file_status_v3_status_proto_msgTypes[4] + mi := &file_status_v3alpha_status_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -372,7 +372,7 @@ func (x *PeerStatus) String() string { func (*PeerStatus) ProtoMessage() {} func (x *PeerStatus) ProtoReflect() protoreflect.Message { - mi := &file_status_v3_status_proto_msgTypes[4] + mi := &file_status_v3alpha_status_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -385,7 +385,7 @@ func (x *PeerStatus) ProtoReflect() protoreflect.Message { // Deprecated: Use PeerStatus.ProtoReflect.Descriptor instead. func (*PeerStatus) Descriptor() ([]byte, []int) { - return file_status_v3_status_proto_rawDescGZIP(), []int{4} + return file_status_v3alpha_status_proto_rawDescGZIP(), []int{4} } func (x *PeerStatus) GetName() string { @@ -410,7 +410,7 @@ type GetStatusRequest struct { func (x *GetStatusRequest) Reset() { *x = GetStatusRequest{} - mi := &file_status_v3_status_proto_msgTypes[5] + mi := &file_status_v3alpha_status_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -422,7 +422,7 @@ func (x *GetStatusRequest) String() string { func (*GetStatusRequest) ProtoMessage() {} func (x *GetStatusRequest) ProtoReflect() protoreflect.Message { - mi := &file_status_v3_status_proto_msgTypes[5] + mi := &file_status_v3alpha_status_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -435,7 +435,7 @@ func (x *GetStatusRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetStatusRequest.ProtoReflect.Descriptor instead. func (*GetStatusRequest) Descriptor() ([]byte, []int) { - return file_status_v3_status_proto_rawDescGZIP(), []int{5} + return file_status_v3alpha_status_proto_rawDescGZIP(), []int{5} } type GetStatusResponse struct { @@ -447,7 +447,7 @@ type GetStatusResponse struct { func (x *GetStatusResponse) Reset() { *x = GetStatusResponse{} - mi := &file_status_v3_status_proto_msgTypes[6] + mi := &file_status_v3alpha_status_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -459,7 +459,7 @@ func (x *GetStatusResponse) String() string { func (*GetStatusResponse) ProtoMessage() {} func (x *GetStatusResponse) ProtoReflect() protoreflect.Message { - mi := &file_status_v3_status_proto_msgTypes[6] + mi := &file_status_v3alpha_status_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -472,7 +472,7 @@ func (x *GetStatusResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetStatusResponse.ProtoReflect.Descriptor instead. func (*GetStatusResponse) Descriptor() ([]byte, []int) { - return file_status_v3_status_proto_rawDescGZIP(), []int{6} + return file_status_v3alpha_status_proto_rawDescGZIP(), []int{6} } func (x *GetStatusResponse) GetStatus() *AlertmanagerStatus { @@ -482,15 +482,15 @@ func (x *GetStatusResponse) GetStatus() *AlertmanagerStatus { return nil } -var File_status_v3_status_proto protoreflect.FileDescriptor +var File_status_v3alpha_status_proto protoreflect.FileDescriptor -const file_status_v3_status_proto_rawDesc = "" + +const file_status_v3alpha_status_proto_rawDesc = "" + "\n" + - "\x16status/v3/status.proto\x12\tstatus.v3\x1a\x1fgoogle/protobuf/timestamp.proto\"\xf5\x01\n" + - "\x12AlertmanagerStatus\x129\n" + - "\fversion_info\x18\x01 \x01(\v2\x16.status.v3.VersionInfoR\vversionInfo\x125\n" + - "\x06config\x18\x02 \x01(\v2\x1d.status.v3.AlertmanagerConfigR\x06config\x122\n" + - "\acluster\x18\x03 \x01(\v2\x18.status.v3.ClusterStatusR\acluster\x129\n" + + "\x1bstatus/v3alpha/status.proto\x12\x0estatus.v3alpha\x1a\x1fgoogle/protobuf/timestamp.proto\"\x84\x02\n" + + "\x12AlertmanagerStatus\x12>\n" + + "\fversion_info\x18\x01 \x01(\v2\x1b.status.v3alpha.VersionInfoR\vversionInfo\x12:\n" + + "\x06config\x18\x02 \x01(\v2\".status.v3alpha.AlertmanagerConfigR\x06config\x127\n" + + "\acluster\x18\x03 \x01(\v2\x1d.status.v3alpha.ClusterStatusR\acluster\x129\n" + "\n" + "start_time\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\tstartTime\"\xb8\x01\n" + "\vVersionInfo\x12\x18\n" + @@ -504,11 +504,11 @@ const file_status_v3_status_proto_rawDesc = "" + "\n" + "go_version\x18\x06 \x01(\tR\tgoVersion\"0\n" + "\x12AlertmanagerConfig\x12\x1a\n" + - "\boriginal\x18\x01 \x01(\tR\boriginal\"\xdf\x01\n" + + "\boriginal\x18\x01 \x01(\tR\boriginal\"\xe9\x01\n" + "\rClusterStatus\x12\x12\n" + - "\x04name\x18\x01 \x01(\tR\x04name\x124\n" + - "\x05state\x18\x02 \x01(\x0e2\x1e.status.v3.ClusterStatus.StateR\x05state\x12+\n" + - "\x05peers\x18\x03 \x03(\v2\x15.status.v3.PeerStatusR\x05peers\"W\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x129\n" + + "\x05state\x18\x02 \x01(\x0e2#.status.v3alpha.ClusterStatus.StateR\x05state\x120\n" + + "\x05peers\x18\x03 \x03(\v2\x1a.status.v3alpha.PeerStatusR\x05peers\"W\n" + "\x05State\x12\x15\n" + "\x11STATE_UNSPECIFIED\x10\x00\x12\x12\n" + "\x0eSTATE_DISABLED\x10\x01\x12\x12\n" + @@ -518,47 +518,47 @@ const file_status_v3_status_proto_rawDesc = "" + "PeerStatus\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x18\n" + "\aaddress\x18\x02 \x01(\tR\aaddress\"\x12\n" + - "\x10GetStatusRequest\"J\n" + - "\x11GetStatusResponse\x125\n" + - "\x06status\x18\x01 \x01(\v2\x1d.status.v3.AlertmanagerStatusR\x06status2Y\n" + - "\rStatusService\x12H\n" + - "\tGetStatus\x12\x1b.status.v3.GetStatusRequest\x1a\x1c.status.v3.GetStatusResponse\"\x00B;Z9github.com/prometheus/alertmanager/api/status/v3;statusv3b\x06proto3" + "\x10GetStatusRequest\"O\n" + + "\x11GetStatusResponse\x12:\n" + + "\x06status\x18\x01 \x01(\v2\".status.v3alpha.AlertmanagerStatusR\x06status2f\n" + + "\rStatusService\x12U\n" + + "\tGetStatus\x12 .status.v3alpha.GetStatusRequest\x1a!.status.v3alpha.GetStatusResponse\"\x03\x90\x02\x01BEZCgithub.com/prometheus/alertmanager/api/status/v3alpha;statusv3alphab\x06proto3" var ( - file_status_v3_status_proto_rawDescOnce sync.Once - file_status_v3_status_proto_rawDescData []byte + file_status_v3alpha_status_proto_rawDescOnce sync.Once + file_status_v3alpha_status_proto_rawDescData []byte ) -func file_status_v3_status_proto_rawDescGZIP() []byte { - file_status_v3_status_proto_rawDescOnce.Do(func() { - file_status_v3_status_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_status_v3_status_proto_rawDesc), len(file_status_v3_status_proto_rawDesc))) +func file_status_v3alpha_status_proto_rawDescGZIP() []byte { + file_status_v3alpha_status_proto_rawDescOnce.Do(func() { + file_status_v3alpha_status_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_status_v3alpha_status_proto_rawDesc), len(file_status_v3alpha_status_proto_rawDesc))) }) - return file_status_v3_status_proto_rawDescData -} - -var file_status_v3_status_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_status_v3_status_proto_msgTypes = make([]protoimpl.MessageInfo, 7) -var file_status_v3_status_proto_goTypes = []any{ - (ClusterStatus_State)(0), // 0: status.v3.ClusterStatus.State - (*AlertmanagerStatus)(nil), // 1: status.v3.AlertmanagerStatus - (*VersionInfo)(nil), // 2: status.v3.VersionInfo - (*AlertmanagerConfig)(nil), // 3: status.v3.AlertmanagerConfig - (*ClusterStatus)(nil), // 4: status.v3.ClusterStatus - (*PeerStatus)(nil), // 5: status.v3.PeerStatus - (*GetStatusRequest)(nil), // 6: status.v3.GetStatusRequest - (*GetStatusResponse)(nil), // 7: status.v3.GetStatusResponse + return file_status_v3alpha_status_proto_rawDescData +} + +var file_status_v3alpha_status_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_status_v3alpha_status_proto_msgTypes = make([]protoimpl.MessageInfo, 7) +var file_status_v3alpha_status_proto_goTypes = []any{ + (ClusterStatus_State)(0), // 0: status.v3alpha.ClusterStatus.State + (*AlertmanagerStatus)(nil), // 1: status.v3alpha.AlertmanagerStatus + (*VersionInfo)(nil), // 2: status.v3alpha.VersionInfo + (*AlertmanagerConfig)(nil), // 3: status.v3alpha.AlertmanagerConfig + (*ClusterStatus)(nil), // 4: status.v3alpha.ClusterStatus + (*PeerStatus)(nil), // 5: status.v3alpha.PeerStatus + (*GetStatusRequest)(nil), // 6: status.v3alpha.GetStatusRequest + (*GetStatusResponse)(nil), // 7: status.v3alpha.GetStatusResponse (*timestamppb.Timestamp)(nil), // 8: google.protobuf.Timestamp } -var file_status_v3_status_proto_depIdxs = []int32{ - 2, // 0: status.v3.AlertmanagerStatus.version_info:type_name -> status.v3.VersionInfo - 3, // 1: status.v3.AlertmanagerStatus.config:type_name -> status.v3.AlertmanagerConfig - 4, // 2: status.v3.AlertmanagerStatus.cluster:type_name -> status.v3.ClusterStatus - 8, // 3: status.v3.AlertmanagerStatus.start_time:type_name -> google.protobuf.Timestamp - 0, // 4: status.v3.ClusterStatus.state:type_name -> status.v3.ClusterStatus.State - 5, // 5: status.v3.ClusterStatus.peers:type_name -> status.v3.PeerStatus - 1, // 6: status.v3.GetStatusResponse.status:type_name -> status.v3.AlertmanagerStatus - 6, // 7: status.v3.StatusService.GetStatus:input_type -> status.v3.GetStatusRequest - 7, // 8: status.v3.StatusService.GetStatus:output_type -> status.v3.GetStatusResponse +var file_status_v3alpha_status_proto_depIdxs = []int32{ + 2, // 0: status.v3alpha.AlertmanagerStatus.version_info:type_name -> status.v3alpha.VersionInfo + 3, // 1: status.v3alpha.AlertmanagerStatus.config:type_name -> status.v3alpha.AlertmanagerConfig + 4, // 2: status.v3alpha.AlertmanagerStatus.cluster:type_name -> status.v3alpha.ClusterStatus + 8, // 3: status.v3alpha.AlertmanagerStatus.start_time:type_name -> google.protobuf.Timestamp + 0, // 4: status.v3alpha.ClusterStatus.state:type_name -> status.v3alpha.ClusterStatus.State + 5, // 5: status.v3alpha.ClusterStatus.peers:type_name -> status.v3alpha.PeerStatus + 1, // 6: status.v3alpha.GetStatusResponse.status:type_name -> status.v3alpha.AlertmanagerStatus + 6, // 7: status.v3alpha.StatusService.GetStatus:input_type -> status.v3alpha.GetStatusRequest + 7, // 8: status.v3alpha.StatusService.GetStatus:output_type -> status.v3alpha.GetStatusResponse 8, // [8:9] is the sub-list for method output_type 7, // [7:8] is the sub-list for method input_type 7, // [7:7] is the sub-list for extension type_name @@ -566,27 +566,27 @@ var file_status_v3_status_proto_depIdxs = []int32{ 0, // [0:7] is the sub-list for field type_name } -func init() { file_status_v3_status_proto_init() } -func file_status_v3_status_proto_init() { - if File_status_v3_status_proto != nil { +func init() { file_status_v3alpha_status_proto_init() } +func file_status_v3alpha_status_proto_init() { + if File_status_v3alpha_status_proto != nil { return } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_status_v3_status_proto_rawDesc), len(file_status_v3_status_proto_rawDesc)), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_status_v3alpha_status_proto_rawDesc), len(file_status_v3alpha_status_proto_rawDesc)), NumEnums: 1, NumMessages: 7, NumExtensions: 0, NumServices: 1, }, - GoTypes: file_status_v3_status_proto_goTypes, - DependencyIndexes: file_status_v3_status_proto_depIdxs, - EnumInfos: file_status_v3_status_proto_enumTypes, - MessageInfos: file_status_v3_status_proto_msgTypes, + GoTypes: file_status_v3alpha_status_proto_goTypes, + DependencyIndexes: file_status_v3alpha_status_proto_depIdxs, + EnumInfos: file_status_v3alpha_status_proto_enumTypes, + MessageInfos: file_status_v3alpha_status_proto_msgTypes, }.Build() - File_status_v3_status_proto = out.File - file_status_v3_status_proto_goTypes = nil - file_status_v3_status_proto_depIdxs = nil + File_status_v3alpha_status_proto = out.File + file_status_v3alpha_status_proto_goTypes = nil + file_status_v3alpha_status_proto_depIdxs = nil } diff --git a/api/status/v3/statusv3connect/status.connect.go b/api/status/v3alpha/statusv3alphaconnect/status.connect.go similarity index 66% rename from api/status/v3/statusv3connect/status.connect.go rename to api/status/v3alpha/statusv3alphaconnect/status.connect.go index bee0a5cfbc..7dddb1f9ec 100644 --- a/api/status/v3/statusv3connect/status.connect.go +++ b/api/status/v3alpha/statusv3alphaconnect/status.connect.go @@ -13,15 +13,15 @@ // Code generated by protoc-gen-connect-go. DO NOT EDIT. // -// Source: status/v3/status.proto +// Source: status/v3alpha/status.proto -package statusv3connect +package statusv3alphaconnect import ( connect "connectrpc.com/connect" context "context" errors "errors" - v3 "github.com/prometheus/alertmanager/api/status/v3" + v3alpha "github.com/prometheus/alertmanager/api/status/v3alpha" http "net/http" strings "strings" ) @@ -35,7 +35,7 @@ const _ = connect.IsAtLeastVersion1_13_0 const ( // StatusServiceName is the fully-qualified name of the StatusService service. - StatusServiceName = "status.v3.StatusService" + StatusServiceName = "status.v3alpha.StatusService" ) // These constants are the fully-qualified names of the RPCs defined in this package. They're @@ -47,30 +47,31 @@ const ( // period. const ( // StatusServiceGetStatusProcedure is the fully-qualified name of the StatusService's GetStatus RPC. - StatusServiceGetStatusProcedure = "/status.v3.StatusService/GetStatus" + StatusServiceGetStatusProcedure = "/status.v3alpha.StatusService/GetStatus" ) -// StatusServiceClient is a client for the status.v3.StatusService service. +// StatusServiceClient is a client for the status.v3alpha.StatusService service. type StatusServiceClient interface { // GetStatus returns the Alertmanager instance and cluster status. - GetStatus(context.Context, *connect.Request[v3.GetStatusRequest]) (*connect.Response[v3.GetStatusResponse], error) + GetStatus(context.Context, *connect.Request[v3alpha.GetStatusRequest]) (*connect.Response[v3alpha.GetStatusResponse], error) } -// NewStatusServiceClient constructs a client for the status.v3.StatusService service. By default, -// it uses the Connect protocol with the binary Protobuf Codec, asks for gzipped responses, and -// sends uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the connect.WithGRPC() -// or connect.WithGRPCWeb() options. +// NewStatusServiceClient constructs a client for the status.v3alpha.StatusService service. By +// default, it uses the Connect protocol with the binary Protobuf Codec, asks for gzipped responses, +// and sends uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the +// connect.WithGRPC() or connect.WithGRPCWeb() options. // // The URL supplied here should be the base URL for the Connect or gRPC server (for example, // http://api.acme.com or https://acme.com/grpc). func NewStatusServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) StatusServiceClient { baseURL = strings.TrimRight(baseURL, "/") - statusServiceMethods := v3.File_status_v3_status_proto.Services().ByName("StatusService").Methods() + statusServiceMethods := v3alpha.File_status_v3alpha_status_proto.Services().ByName("StatusService").Methods() return &statusServiceClient{ - getStatus: connect.NewClient[v3.GetStatusRequest, v3.GetStatusResponse]( + getStatus: connect.NewClient[v3alpha.GetStatusRequest, v3alpha.GetStatusResponse]( httpClient, baseURL+StatusServiceGetStatusProcedure, connect.WithSchema(statusServiceMethods.ByName("GetStatus")), + connect.WithIdempotency(connect.IdempotencyNoSideEffects), connect.WithClientOptions(opts...), ), } @@ -78,18 +79,18 @@ func NewStatusServiceClient(httpClient connect.HTTPClient, baseURL string, opts // statusServiceClient implements StatusServiceClient. type statusServiceClient struct { - getStatus *connect.Client[v3.GetStatusRequest, v3.GetStatusResponse] + getStatus *connect.Client[v3alpha.GetStatusRequest, v3alpha.GetStatusResponse] } -// GetStatus calls status.v3.StatusService.GetStatus. -func (c *statusServiceClient) GetStatus(ctx context.Context, req *connect.Request[v3.GetStatusRequest]) (*connect.Response[v3.GetStatusResponse], error) { +// GetStatus calls status.v3alpha.StatusService.GetStatus. +func (c *statusServiceClient) GetStatus(ctx context.Context, req *connect.Request[v3alpha.GetStatusRequest]) (*connect.Response[v3alpha.GetStatusResponse], error) { return c.getStatus.CallUnary(ctx, req) } -// StatusServiceHandler is an implementation of the status.v3.StatusService service. +// StatusServiceHandler is an implementation of the status.v3alpha.StatusService service. type StatusServiceHandler interface { // GetStatus returns the Alertmanager instance and cluster status. - GetStatus(context.Context, *connect.Request[v3.GetStatusRequest]) (*connect.Response[v3.GetStatusResponse], error) + GetStatus(context.Context, *connect.Request[v3alpha.GetStatusRequest]) (*connect.Response[v3alpha.GetStatusResponse], error) } // NewStatusServiceHandler builds an HTTP handler from the service implementation. It returns the @@ -98,14 +99,15 @@ type StatusServiceHandler interface { // By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf // and JSON codecs. They also support gzip compression. func NewStatusServiceHandler(svc StatusServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { - statusServiceMethods := v3.File_status_v3_status_proto.Services().ByName("StatusService").Methods() + statusServiceMethods := v3alpha.File_status_v3alpha_status_proto.Services().ByName("StatusService").Methods() statusServiceGetStatusHandler := connect.NewUnaryHandler( StatusServiceGetStatusProcedure, svc.GetStatus, connect.WithSchema(statusServiceMethods.ByName("GetStatus")), + connect.WithIdempotency(connect.IdempotencyNoSideEffects), connect.WithHandlerOptions(opts...), ) - return "/status.v3.StatusService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + return "/status.v3alpha.StatusService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case StatusServiceGetStatusProcedure: statusServiceGetStatusHandler.ServeHTTP(w, r) @@ -118,6 +120,6 @@ func NewStatusServiceHandler(svc StatusServiceHandler, opts ...connect.HandlerOp // UnimplementedStatusServiceHandler returns CodeUnimplemented from all methods. type UnimplementedStatusServiceHandler struct{} -func (UnimplementedStatusServiceHandler) GetStatus(context.Context, *connect.Request[v3.GetStatusRequest]) (*connect.Response[v3.GetStatusResponse], error) { - return nil, connect.NewError(connect.CodeUnimplemented, errors.New("status.v3.StatusService.GetStatus is not implemented")) +func (UnimplementedStatusServiceHandler) GetStatus(context.Context, *connect.Request[v3alpha.GetStatusRequest]) (*connect.Response[v3alpha.GetStatusResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("status.v3alpha.StatusService.GetStatus is not implemented")) } diff --git a/app/app.go b/app/app.go index 68382fb01f..67e3ec80aa 100644 --- a/app/app.go +++ b/app/app.go @@ -515,9 +515,16 @@ func (a *App) setup() error { mux := apih.Register(router, routePrefix) + protocols := new(http.Protocols) + protocols.SetHTTP1(true) + protocols.SetHTTP2(true) + protocols.SetUnencryptedHTTP2(true) a.server = &http.Server{ // Instrument all handlers with tracing. - Handler: tracing.Middleware(mux), + Handler: tracing.Middleware(mux), + Protocols: protocols, + ReadHeaderTimeout: 10 * time.Second, + IdleTimeout: 90 * time.Second, } return nil diff --git a/app/lifecycle_test.go b/app/lifecycle_test.go index 441c1be9f7..f957aab8ad 100644 --- a/app/lifecycle_test.go +++ b/app/lifecycle_test.go @@ -102,6 +102,8 @@ func waitHealthy(t *testing.T, addr string) { func TestApp_StartStop(t *testing.T) { a, err := New(testOptions(t)) require.NoError(t, err) + require.Equal(t, 10*time.Second, a.server.ReadHeaderTimeout) + require.Equal(t, 90*time.Second, a.server.IdleTimeout) require.NoError(t, a.Start()) addr := a.Addr() diff --git a/go.mod b/go.mod index 4f5c1b3f3d..c36a9f3a34 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,8 @@ go 1.26.0 require ( connectrpc.com/connect v1.20.0 + connectrpc.com/grpchealth v1.5.0 + connectrpc.com/grpcreflect v1.3.0 github.com/KimMachineGun/automemlimit v0.7.5 github.com/alecthomas/kingpin/v2 v2.4.0 github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b @@ -35,6 +37,8 @@ require ( github.com/mdlayher/vsock v1.3.0 github.com/oklog/run v1.2.0 github.com/oklog/ulid/v2 v2.1.2 + github.com/onsi/ginkgo/v2 v2.32.0 + github.com/onsi/gomega v1.42.1 github.com/prometheus/client_golang v1.24.1 github.com/prometheus/client_model v0.6.2 github.com/prometheus/common v0.70.1 @@ -64,6 +68,7 @@ require ( ) require ( + github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/armon/go-metrics v0.4.1 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.33 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.33 // indirect @@ -94,9 +99,12 @@ require ( github.com/go-openapi/swag/stringutils v0.28.0 // indirect github.com/go-openapi/swag/typeutils v0.28.0 // indirect github.com/go-openapi/swag/yamlutils v0.28.0 // indirect + github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/google/btree v1.1.3 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-immutable-radix v1.3.1 // indirect diff --git a/go.sum b/go.sum index cf7ca08aea..5e0acd933a 100644 --- a/go.sum +++ b/go.sum @@ -55,12 +55,18 @@ cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9 cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo= connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ= connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4= +connectrpc.com/grpchealth v1.5.0 h1:nHEVTwz9WYKxW2JTYUFD337q76oAZMvot9jX0WjVCQo= +connectrpc.com/grpchealth v1.5.0/go.mod h1:fC9WGwKmDruKCNh8wj2rThiaxxoiXxvsCVIu2Ex2voA= +connectrpc.com/grpcreflect v1.3.0 h1:Y4V+ACf8/vOb1XOc251Qun7jMB75gCUNw6llvB9csXc= +connectrpc.com/grpcreflect v1.3.0/go.mod h1:nfloOtCS8VUQOQ1+GTdFzVg2CJo4ZGaat8JIovCtDYs= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= github.com/KimMachineGun/automemlimit v0.7.5 h1:RkbaC0MwhjL1ZuBKunGDjE/ggwAX43DwZrJqVwyveTk= github.com/KimMachineGun/automemlimit v0.7.5/go.mod h1:QZxpHaGOQoYvFhv/r4u3U0JTC2ZcOwbSr11UZF46UBM= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/alecthomas/kingpin/v2 v2.4.0 h1:f48lwail6p8zpO1bC4TxtqACaGqHYA22qkHjHpqDjYY= github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE= @@ -172,6 +178,12 @@ github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmV github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= +github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= +github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= +github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= +github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= +github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= @@ -241,9 +253,13 @@ github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTM github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-yaml v1.9.5/go.mod h1:U/jl18uSupI5rdI2jmuCswEA2htH9eXfferR3KfscvA= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= @@ -323,6 +339,8 @@ github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLe github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg= +github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -388,6 +406,8 @@ github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1: github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4= github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPGBnNLMooyc= +github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= +github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= @@ -421,6 +441,8 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= +github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= +github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= @@ -438,6 +460,8 @@ github.com/mdlayher/socket v0.6.0 h1:ScZPaAGyO1icQnbFrhPM8mnXyMu9qukC1K4ZoM2IQKU github.com/mdlayher/socket v0.6.0/go.mod h1:q7vozUAnxSqnjHc12Fik5yUKIzfZ8ITCfMkhOtE9z18= github.com/mdlayher/vsock v1.3.0 h1:bqQfZ1OznI03y6YiXp2sze05RVdzLn/zsfjnjd4+ivI= github.com/mdlayher/vsock v1.3.0/go.mod h1:WsuksavOvwCnV5UqGHUkvAvCy+Dqy81y4goKQTzxxNY= +github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= +github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI= github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= @@ -463,6 +487,10 @@ github.com/oklog/run v1.2.0 h1:O8x3yXwah4A73hJdlrwo/2X6J62gE5qTMusH0dvz60E= github.com/oklog/run v1.2.0/go.mod h1:mgDbKRSwPhJfesJ4PntqFUbKQRZ50NgmZTSPlFA0YFk= github.com/oklog/ulid/v2 v2.1.2 h1:IEclFb9JNvzYA6MW2SCxbLzcHTVsfqm3PrqGQJH5zec= github.com/oklog/ulid/v2 v2.1.2/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= +github.com/onsi/ginkgo/v2 v2.32.0 h1:Hw7s2pVrQo/8Yz5N77qdnpHaoc+c6cC9WIV1Jce+J6E= +github.com/onsi/ginkgo/v2 v2.32.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= +github.com/onsi/gomega v1.42.1 h1:iN1rCUX+44NZ1Dc97MPoeFYbFR0vh8zxoxMFwKdyZ6I= +github.com/onsi/gomega v1.42.1/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY= github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= @@ -550,6 +578,14 @@ github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8 github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.4.1/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= github.com/twmb/franz-go v1.21.5 h1:cVYI2+JTTKSvohhy8bCOleYrS7G79ZBrLVFIJsoHm8M= github.com/twmb/franz-go v1.21.5/go.mod h1:rfoMTnVk7107fhTGxfEKIHP/e7tPe6oyij/ywzO0czk= diff --git a/proto/api/status/v3/status.proto b/proto/api/status/v3alpha/status.proto similarity index 92% rename from proto/api/status/v3/status.proto rename to proto/api/status/v3alpha/status.proto index d3c3ff6662..db30dad119 100644 --- a/proto/api/status/v3/status.proto +++ b/proto/api/status/v3alpha/status.proto @@ -13,16 +13,18 @@ syntax = "proto3"; -package status.v3; +package status.v3alpha; import "google/protobuf/timestamp.proto"; -option go_package = "github.com/prometheus/alertmanager/api/status/v3;statusv3"; +option go_package = "github.com/prometheus/alertmanager/api/status/v3alpha;statusv3alpha"; // StatusService exposes Alertmanager instance and cluster status. service StatusService { // GetStatus returns the Alertmanager instance and cluster status. - rpc GetStatus(GetStatusRequest) returns (GetStatusResponse) {} + rpc GetStatus(GetStatusRequest) returns (GetStatusResponse) { + option idempotency_level = NO_SIDE_EFFECTS; + } } // AlertmanagerStatus is the top-level status payload. diff --git a/test/e2e/e2e_suite_test.go b/test/e2e/e2e_suite_test.go new file mode 100644 index 0000000000..88a035ae6c --- /dev/null +++ b/test/e2e/e2e_suite_test.go @@ -0,0 +1,29 @@ +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package e2e contains Ginkgo-based end-to-end tests that boot Alertmanager +// in-process via the app package and exercise the experimental Connect API +// services through their generated ConnectRPC clients. +package e2e + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestE2E(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "ConnectRPC API E2E Suite") +} diff --git a/test/e2e/harness_test.go b/test/e2e/harness_test.go new file mode 100644 index 0000000000..a0fe5d0ed2 --- /dev/null +++ b/test/e2e/harness_test.go @@ -0,0 +1,139 @@ +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package e2e + +import ( + "context" + "net/http" + "os" + "path/filepath" + "time" + + "connectrpc.com/connect" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/common/promslog" + "github.com/prometheus/exporter-toolkit/web" + + "github.com/prometheus/alertmanager/api/status/v3alpha/statusv3alphaconnect" + "github.com/prometheus/alertmanager/app" + "github.com/prometheus/alertmanager/featurecontrol" + "github.com/prometheus/alertmanager/matcher/compat" +) + +const minimalConfig = `route: + receiver: default +receivers: + - name: default +` + +// instance is a running in-process Alertmanager bound to an ephemeral port +// with clustering disabled. Both API v2 and the Connect API are served. +type instance struct { + app *app.App + baseURL string + routePrefix string + httpClient *http.Client + h2cClient *http.Client +} + +// startInstance boots an Alertmanager and registers its teardown (and +// temp-dir removal) via Ginkgo's DeferCleanup. +func startInstance(routePrefix string) *instance { + GinkgoHelper() + + dir, err := os.MkdirTemp("", "am-e2e-") + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { _ = os.RemoveAll(dir) }) + + configPath := filepath.Join(dir, "alertmanager.yml") + Expect(os.WriteFile(configPath, []byte(minimalConfig), 0o600)).To(Succeed()) + + logger := promslog.NewNopLogger() + ff, err := featurecontrol.NewFlags(logger, "") + Expect(err).NotTo(HaveOccurred()) + // compat.InitFromFlags mutates package-global matcher state; the e2e + // suite always uses the same feature set, so this is safe. + compat.InitFromFlags(logger, ff) + + addrs := []string{"127.0.0.1:0"} + systemd := false + webCfg := "" + + opts := app.DefaultOptions() + opts.ConfigFile = configPath + opts.DataDir = dir + opts.RoutePrefix = routePrefix + opts.WebConfig = &web.FlagConfig{ + WebListenAddresses: &addrs, + WebSystemdSocket: &systemd, + WebConfigFile: &webCfg, + } + opts.Logger = logger + opts.Registerer = prometheus.NewRegistry() + opts.Flagger = ff + + a, err := app.New(opts) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + Expect(a.Stop(ctx)).To(Succeed()) + }) + Expect(a.Start()).To(Succeed()) + + client := &http.Client{Timeout: 5 * time.Second} + DeferCleanup(client.CloseIdleConnections) + protocols := new(http.Protocols) + protocols.SetUnencryptedHTTP2(true) + h2cTransport := &http.Transport{Protocols: protocols} + h2cClient := &http.Client{Transport: h2cTransport, Timeout: 5 * time.Second} + DeferCleanup(h2cTransport.CloseIdleConnections) + + inst := &instance{ + app: a, + baseURL: "http://" + a.Addr(), + routePrefix: routePrefix, + httpClient: client, + h2cClient: h2cClient, + } + inst.waitHealthy() + return inst +} + +// waitHealthy blocks until the instance serves /-/healthy with a 200. +func (i *instance) waitHealthy() { + GinkgoHelper() + Eventually(func() int { + resp, err := i.httpClient.Get(i.webURL("/-/healthy")) + if err != nil { + return 0 + } + _ = resp.Body.Close() + return resp.StatusCode + }, 5*time.Second, 50*time.Millisecond).Should(Equal(http.StatusOK)) +} + +func (i *instance) webURL(path string) string { + return i.baseURL + i.routePrefix + path +} + +func (i *instance) apiPath() string { + return i.routePrefix + "/api" +} + +func (i *instance) statusClient(httpClient connect.HTTPClient, basePath string, opts ...connect.ClientOption) statusv3alphaconnect.StatusServiceClient { + return statusv3alphaconnect.NewStatusServiceClient(httpClient, i.baseURL+basePath, opts...) +} diff --git a/test/e2e/routing_test.go b/test/e2e/routing_test.go new file mode 100644 index 0000000000..ffa476cf53 --- /dev/null +++ b/test/e2e/routing_test.go @@ -0,0 +1,37 @@ +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package e2e + +import ( + "net/http" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("API routing", func() { + DescribeTable("serves v1 and v2 alongside the Connect API", + func(routePrefix, path string, expectedStatus int) { + inst := startInstance(routePrefix) + resp, err := inst.httpClient.Get(inst.webURL(path)) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(resp.Body.Close) + Expect(resp.StatusCode).To(Equal(expectedStatus)) + }, + Entry("v2 at the root", "", "/api/v2/status", http.StatusOK), + Entry("v1 at the root", "", "/api/v1/status", http.StatusGone), + Entry("v2 under a route prefix", "/alertmanager", "/api/v2/status", http.StatusOK), + Entry("v1 under a route prefix", "/alertmanager", "/api/v1/status", http.StatusGone), + ) +}) diff --git a/test/e2e/status_test.go b/test/e2e/status_test.go new file mode 100644 index 0000000000..a0d148631f --- /dev/null +++ b/test/e2e/status_test.go @@ -0,0 +1,124 @@ +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package e2e + +import ( + "context" + "strings" + "time" + + "connectrpc.com/connect" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/prometheus/common/version" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + healthv1 "google.golang.org/grpc/health/grpc_health_v1" + reflectionv1 "google.golang.org/grpc/reflection/grpc_reflection_v1" + + statusv3alpha "github.com/prometheus/alertmanager/api/status/v3alpha" + "github.com/prometheus/alertmanager/api/status/v3alpha/statusv3alphaconnect" +) + +var _ = Describe("StatusService", func() { + DescribeTable("GetStatus succeeds over supported transports", + func(routePrefix string, nativeGRPC bool, opts []connect.ClientOption) { + inst := startInstance(routePrefix) + httpClient := connect.HTTPClient(inst.httpClient) + basePath := inst.apiPath() + if nativeGRPC { + httpClient = inst.h2cClient + basePath = "" + } + client := inst.statusClient(httpClient, basePath, opts...) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + resp, err := client.GetStatus(ctx, connect.NewRequest(&statusv3alpha.GetStatusRequest{})) + Expect(err).NotTo(HaveOccurred()) + + status := resp.Msg.GetStatus() + Expect(status.GetVersionInfo().GetVersion()).To(Equal(version.Version)) + Expect(status.GetConfig().GetOriginal()).NotTo(BeEmpty()) + Expect(status.GetStartTime().AsTime()).NotTo(BeZero()) + Expect(status.GetCluster().GetState()).To(Equal(statusv3alpha.ClusterStatus_STATE_DISABLED)) + }, + Entry("Connect POST at the root prefix", "", false, []connect.ClientOption{}), + Entry("Connect HTTP GET at the root prefix", "", false, []connect.ClientOption{connect.WithHTTPGet()}), + Entry("gRPC-Web at the root prefix", "", false, []connect.ClientOption{connect.WithGRPCWeb()}), + Entry("native gRPC at the server root", "", true, []connect.ClientOption{connect.WithGRPC()}), + Entry("Connect POST under a route prefix", "/alertmanager", false, []connect.ClientOption{}), + Entry("Connect HTTP GET under a route prefix", "/alertmanager", false, []connect.ClientOption{connect.WithHTTPGet()}), + Entry("gRPC-Web under a route prefix", "/alertmanager", false, []connect.ClientOption{connect.WithGRPCWeb()}), + Entry("native gRPC with a route prefix configured", "/alertmanager", true, []connect.ClientOption{connect.WithGRPC()}), + ) + + DescribeTable("rejects transports outside their configured prefix", + func(routePrefix, basePath string, nativeGRPC bool, opts []connect.ClientOption) { + inst := startInstance(routePrefix) + httpClient := connect.HTTPClient(inst.httpClient) + if basePath == "api" { + basePath = inst.apiPath() + } + if nativeGRPC { + httpClient = inst.h2cClient + } + client := inst.statusClient(httpClient, basePath, opts...) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _, err := client.GetStatus(ctx, connect.NewRequest(&statusv3alpha.GetStatusRequest{})) + Expect(err).To(HaveOccurred()) + }, + Entry("Connect HTTP GET at the server root", "", "", false, []connect.ClientOption{connect.WithHTTPGet()}), + Entry("gRPC-Web at the server root", "", "", false, []connect.ClientOption{connect.WithGRPCWeb()}), + Entry("native gRPC under /api", "", "api", true, []connect.ClientOption{connect.WithGRPC()}), + Entry("Connect HTTP GET outside a route prefix", "/alertmanager", "/api", false, []connect.ClientOption{connect.WithHTTPGet()}), + Entry("gRPC-Web outside a route prefix", "/alertmanager", "/api", false, []connect.ClientOption{connect.WithGRPCWeb()}), + Entry("native gRPC under a prefixed /api", "/alertmanager", "api", true, []connect.ClientOption{connect.WithGRPC()}), + ) + + DescribeTable("exposes native health and reflection at the server root", + func(routePrefix string) { + inst := startInstance(routePrefix) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + conn, err := grpc.NewClient(strings.TrimPrefix(inst.baseURL, "http://"), grpc.WithTransportCredentials(insecure.NewCredentials())) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(conn.Close) + + health, err := healthv1.NewHealthClient(conn).Check(ctx, &healthv1.HealthCheckRequest{}) + Expect(err).NotTo(HaveOccurred()) + Expect(health.GetStatus()).To(Equal(healthv1.HealthCheckResponse_SERVING)) + + stream, err := reflectionv1.NewServerReflectionClient(conn).ServerReflectionInfo(ctx) + Expect(err).NotTo(HaveOccurred()) + Expect(stream.Send(&reflectionv1.ServerReflectionRequest{ + MessageRequest: &reflectionv1.ServerReflectionRequest_ListServices{}, + })).To(Succeed()) + response, err := stream.Recv() + Expect(err).NotTo(HaveOccurred()) + + services := response.GetListServicesResponse().GetService() + names := make([]string, 0, len(services)) + for _, service := range services { + names = append(names, service.GetName()) + } + Expect(names).To(ContainElement(statusv3alphaconnect.StatusServiceName)) + }, + Entry("without a route prefix", ""), + Entry("with a route prefix", "/alertmanager"), + ) +})