Repository navigation
Feat: Build cortex-gateway and the Cortex ingestion endpoint #1758
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a6a3b7c
0f02f49
c0c9290
6786867
5fe0cc0
431f6bb
a3e4e96
6e57c3b
2e9faa4
ab5c2ef
1a7cc74
3c5cfec
e226b89
0217b37
5961d03
43b93ba
67a4a31
b5bd6d8
6f4b6ea
60a4ca3
83244cc
5080682
3e37e8d
e11d6de
adbe7a6
455b9d4
b559a2e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| package main | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "strings" | ||
|
|
||
| "github.com/rossoctl/cortex/core/config" | ||
| "github.com/rossoctl/cortex/core/cost/pricing" | ||
| "github.com/rossoctl/cortex/core/sessionapi" | ||
| ) | ||
|
|
||
| // gatewayTokenEnv names the file that holds the bearer token cortex-gateway sends on | ||
| // POST /v1/ingest. The supervisor creates the file; Cortex only reads it. | ||
| const gatewayTokenEnv = "CORTEX_GATEWAY_TOKEN_FILE" | ||
|
|
||
| // ingestOptions turns on the ingestion endpoint in praxis mode. In direct mode it returns | ||
| // nil and reads neither the environment nor a file. | ||
| func ingestOptions(cfg *config.Config, rates pricing.Resolver, getenv func(string) string, readFile func(string) ([]byte, error)) ([]sessionapi.Option, error) { | ||
| if !cfg.PraxisEnabled() { | ||
| return nil, nil | ||
| } | ||
| path := getenv(gatewayTokenEnv) | ||
| if path == "" { | ||
| return nil, fmt.Errorf("praxis.enabled needs %s to name the gateway token file", gatewayTokenEnv) | ||
| } | ||
| b, err := readFile(path) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("read the gateway token file (%s): %w", gatewayTokenEnv, err) | ||
| } | ||
| token := strings.TrimSpace(string(b)) | ||
| if token == "" { | ||
| return nil, fmt.Errorf("the gateway token file %s (%s) is empty", path, gatewayTokenEnv) | ||
| } | ||
| return []sessionapi.Option{sessionapi.WithIngest(sessionapi.Ingest{Token: token, Rates: rates})}, nil | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,86 @@ | ||
| package main | ||
|
|
||
| import ( | ||
| "errors" | ||
| "net/http" | ||
| "net/http/httptest" | ||
| "strings" | ||
| "testing" | ||
|
|
||
| "github.com/rossoctl/cortex/core/config" | ||
| "github.com/rossoctl/cortex/core/session" | ||
| "github.com/rossoctl/cortex/core/sessionapi" | ||
| ) | ||
|
|
||
| func praxisCfg(enabled bool) *config.Config { | ||
| cfg := &config.Config{} | ||
| if enabled { | ||
| cfg.Praxis = &config.PraxisConfig{Enabled: true} | ||
| } | ||
| return cfg | ||
| } | ||
|
|
||
| func TestIngestOptions_DirectModeNone(t *testing.T) { | ||
| getenv := func(string) string { t.Fatal("getenv called in direct mode"); return "" } | ||
| readFile := func(string) ([]byte, error) { t.Fatal("readFile called in direct mode"); return nil, nil } | ||
| opts, err := ingestOptions(praxisCfg(false), nil, getenv, readFile) | ||
| if err != nil || opts != nil { | ||
| t.Fatalf("got %v, %v; want nil, nil", opts, err) | ||
| } | ||
| } | ||
|
|
||
| func TestIngestOptions_NeedsTokenFile(t *testing.T) { | ||
| _, err := ingestOptions(praxisCfg(true), nil, func(string) string { return "" }, | ||
| func(string) ([]byte, error) { return nil, nil }) | ||
| if err == nil || !strings.Contains(err.Error(), "CORTEX_GATEWAY_TOKEN_FILE") { | ||
| t.Fatalf("err = %v, want one that names CORTEX_GATEWAY_TOKEN_FILE", err) | ||
| } | ||
| _, err = ingestOptions(praxisCfg(true), nil, func(string) string { return "/x" }, | ||
| func(string) ([]byte, error) { return nil, errors.New("no such file") }) | ||
| if err == nil { | ||
| t.Fatal("an unreadable token file must be an error") | ||
| } | ||
| } | ||
|
|
||
| func TestIngestOptions_EmptyToken(t *testing.T) { | ||
| _, err := ingestOptions(praxisCfg(true), nil, func(string) string { return "/x" }, | ||
| func(string) ([]byte, error) { return []byte(" \n"), nil }) | ||
| if err == nil { | ||
| t.Fatal("an empty token must be an error") | ||
| } | ||
| } | ||
|
|
||
| func TestIngestOptions_ReadsToken(t *testing.T) { | ||
| opts, err := ingestOptions(praxisCfg(true), nil, | ||
| func(k string) string { | ||
| if k != "CORTEX_GATEWAY_TOKEN_FILE" { | ||
| t.Fatalf("getenv(%q)", k) | ||
| } | ||
| return "/tok" | ||
| }, | ||
| func(p string) ([]byte, error) { | ||
| if p != "/tok" { | ||
| t.Fatalf("readFile(%q)", p) | ||
| } | ||
| return []byte("abc\n"), nil | ||
| }) | ||
| if err != nil || len(opts) != 1 { | ||
| t.Fatalf("got %d options, %v; want 1, nil", len(opts), err) | ||
| } | ||
| srv := sessionapi.New(":0", session.New(0, 0, 0), opts...) | ||
| ts := httptest.NewServer(srv.Server().Handler) | ||
| defer ts.Close() | ||
| // The trailing newline is trimmed: "abc" is the token, "abc\n" from the file is not. | ||
| for token, want := range map[string]int{"abc": http.StatusNoContent, "abcd": http.StatusUnauthorized} { | ||
| req, _ := http.NewRequest(http.MethodPost, ts.URL+"/v1/ingest", strings.NewReader("")) | ||
| req.Header.Set("Authorization", "Bearer "+token) | ||
| resp, err := http.DefaultClient.Do(req) | ||
| if err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| resp.Body.Close() | ||
| if resp.StatusCode != want { | ||
| t.Errorf("Bearer for %q: status %d, want %d", token, resp.StatusCode, want) | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1191,23 +1191,30 @@ func main() { | |
|
|
||
| var sessionAPISrv *sessionapi.Server | ||
| if cfg.Listener.SessionAPIAddr != "" && sessions != nil { | ||
| // POST /v1/ingest, in praxis mode only: nil options in direct mode. | ||
| ingestOpts, err := ingestOptions(cfg, pricingRegistry, os.Getenv, os.ReadFile) | ||
| if err != nil { | ||
| fatalf("praxis ingestion: %v", err) | ||
| } | ||
|
Comment on lines
+1194
to
+1198
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fail at startup when
Add a check before the Proposed fix
|
||
| sessionAPISrv = sessionapi.New( | ||
| cfg.Listener.SessionAPIAddr, | ||
| sessions, | ||
| sessionapi.WithPipelines(inboundH, outboundH), | ||
| sessionapi.WithCatalog(sessionapi.PluginsCatalog), | ||
| sessionapi.WithUsage(usageAgg), | ||
| // nil when the ledger is off, which handleUsage reads as "serve the ring's | ||
| // maximum window and say which window that was". | ||
| sessionapi.WithCostLedger(costLedger), | ||
| // nil unless the archive runs; pages and ?archived=true then read history from disk. | ||
| sessionapi.WithArchive(sessArchive), | ||
| // DELETE /v1/sessions and DELETE /v1/sessions/{id} only where the API is this | ||
| // machine's alone: a laptop install binds loopback-only, and its one user owns the | ||
| // history. See sessionapi.clearRefusal. | ||
| sessionapi.WithClearAllowed(cfg.Listener.BindLoopbackOnly), | ||
| // Reads hold raw prompts: the same browser defenses as the erase, on every route. | ||
| sessionapi.WithLoopbackOnly(cfg.Listener.BindLoopbackOnly), | ||
| append([]sessionapi.Option{ | ||
| sessionapi.WithPipelines(inboundH, outboundH), | ||
| sessionapi.WithCatalog(sessionapi.PluginsCatalog), | ||
| sessionapi.WithUsage(usageAgg), | ||
| // nil when the ledger is off, which handleUsage reads as "serve the ring's | ||
| // maximum window and say which window that was". | ||
| sessionapi.WithCostLedger(costLedger), | ||
| // nil unless the archive runs; pages and ?archived=true then read history from disk. | ||
| sessionapi.WithArchive(sessArchive), | ||
| // DELETE /v1/sessions and DELETE /v1/sessions/{id} only where the API is this | ||
| // machine's alone: a laptop install binds loopback-only, and its one user owns the | ||
| // history. See sessionapi.clearRefusal. | ||
| sessionapi.WithClearAllowed(cfg.Listener.BindLoopbackOnly), | ||
| // Reads hold raw prompts: the same browser defenses as the erase, on every route. | ||
| sessionapi.WithLoopbackOnly(cfg.Listener.BindLoopbackOnly), | ||
| }, ingestOpts...)..., | ||
| ) | ||
| sessionLn := listeners.take("session-api") | ||
| go func() { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| package config | ||
|
|
||
| import ( | ||
| "strings" | ||
| "testing" | ||
|
|
||
| "gopkg.in/yaml.v3" | ||
| ) | ||
|
|
||
| func TestPraxis_DefaultOff(t *testing.T) { | ||
| var c Config | ||
| if err := yaml.Unmarshal([]byte("mode: proxy-sidecar\n"), &c); err != nil { | ||
| t.Fatalf("yaml: %v", err) | ||
| } | ||
| if c.PraxisEnabled() { | ||
| t.Fatal("PraxisEnabled() is true with no praxis block") | ||
| } | ||
| if err := yaml.Unmarshal([]byte("mode: proxy-sidecar\npraxis:\n enabled: true\n"), &c); err != nil { | ||
| t.Fatalf("yaml: %v", err) | ||
| } | ||
| if !c.PraxisEnabled() { | ||
| t.Fatal("praxis.enabled: true did not parse") | ||
| } | ||
| } | ||
|
|
||
| func TestPraxis_NeedsLoopbackOnly(t *testing.T) { | ||
| c := praxisConfig() | ||
| err := Validate(c) | ||
| if err == nil || !strings.Contains(err.Error(), "listener.bind_loopback_only") { | ||
| t.Fatalf("Validate = %v, want an error naming listener.bind_loopback_only", err) | ||
| } | ||
| c.Listener.BindLoopbackOnly = true | ||
| if err := Validate(c); err != nil { | ||
| t.Fatalf("Validate with bind_loopback_only: %v", err) | ||
| } | ||
| } | ||
|
|
||
| func TestPraxis_RefusedWithMTLS(t *testing.T) { | ||
| c := praxisConfig() | ||
| c.MTLS = &MTLSConfig{} | ||
| c.Listener.BindLoopbackOnly = true | ||
| err := Validate(c) | ||
| if err == nil || !strings.Contains(err.Error(), "mtls") { | ||
| t.Fatalf("Validate = %v, want an error naming mtls", err) | ||
| } | ||
| } | ||
|
|
||
| func TestPraxis_RefusedWithSessionsOff(t *testing.T) { | ||
| c := praxisConfig() | ||
| c.Listener.BindLoopbackOnly = true | ||
| off := false | ||
| c.Session.Enabled = &off | ||
| err := Validate(c) | ||
| if err == nil || !strings.Contains(err.Error(), "session.enabled") { | ||
| t.Fatalf("Validate = %v, want an error naming session.enabled", err) | ||
| } | ||
| } | ||
|
|
||
| // praxisConfig is a proxy-sidecar config that passes the listener checks, so | ||
| // only the praxis rules can refuse it. | ||
| func praxisConfig() *Config { | ||
| c := &Config{Mode: ModeProxySidecar, Praxis: &PraxisConfig{Enabled: true}} | ||
| c.Listener.ReverseProxyBackend = "127.0.0.1:8000" | ||
| return c | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -38,6 +38,9 @@ func Validate(cfg *Config) error { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if err := validateSessionArchive(cfg); err != nil { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return err | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if err := validatePraxis(cfg); err != nil { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return err | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return validatePricing(cfg) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -73,6 +76,26 @@ func validateCostLedger(cfg *Config) error { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "or drop cost_ledger.enabled") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // validatePraxis refuses praxis mode outside a loopback-only laptop install, and | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // beside mtls: the ingest endpoint and the hop to cortex-gateway are loopback | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // plaintext, so neither combination can work. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| func validatePraxis(cfg *Config) error { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if !cfg.PraxisEnabled() { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return nil | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if cfg.MTLS != nil { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return fmt.Errorf("praxis.enabled: true cannot be used with mtls: drop the mtls block or praxis.enabled") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if !cfg.Listener.BindLoopbackOnly { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return fmt.Errorf("praxis.enabled: true requires listener.bind_loopback_only: true") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // The rows reach Cortex only through POST /v1/ingest, which needs the session store. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if !cfg.Session.SessionEnabled() { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return fmt.Errorf("praxis.enabled: true cannot be used with session.enabled: false: Cortex ingests the gateway's rows into the session store") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return nil | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+82
to
+97
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Refuse
Proposed fix
Committable suggestion
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // validatePricing builds the rate table and discards it, so a fault in the | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // `pricing:` section fails at startup beside every other config error. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Use
http.NewRequestWithContextto fix thenoctxlint error.golangci-lint reports
net/http.NewRequest must not be calledon this line. Pass the test context so the request also stops when the test ends.Proposed fix
Committable suggestion
golangci-lint (2.15.0)
[error] 78-78: net/http.NewRequest must not be called. use net/http.NewRequestWithContext
(noctx)
Source: Linters/SAST tools