diff --git a/CHANGELOG.md b/CHANGELOG.md index b6283d57ed..407ec971ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ ## [unreleased] +### Features + +1. [#6139](https://github.com/influxdata/chronograf/pull/6139): Add support for InfluxDB V3 + ### Other 1. [#6150](https://github.com/influxdata/chronograf/pull/6150): Upgrade golang to 1.25.3 diff --git a/V3TODO.md b/V3TODO.md new file mode 100644 index 0000000000..da60dbd2c7 --- /dev/null +++ b/V3TODO.md @@ -0,0 +1,31 @@ +# InfluxDB v3 support TODOs + +## Features + +- [X] Support InfluxDB 3 Serverless +- [X] UI should have old UI look for default + - [X] Enable new UI look from settings + +## Issues + +- [X] List databases for Core in Explorer shows fewer dbs than with `show databases` manually +- [X] Command line help print-out wrongly formated due to new v3 option: +``` +/influxdb-type:choice[influx|influx-enterprise|influx-relay|influx-v2|influx-v3-core|influx-v3-enterprise|influx-v3-cloud-dedicated] +``` + +## Tests + +- [X] Unit test for Update Source for cloud dedicated fields +- [X] Unit test for New Source for cloud dedicate fields +- [X] Unit test for Client cloud dedicated fields +- [ ] Unit test for query specific cloud dedicated fields +- [ ] After finalizing UI, fix Cypress tests + +## Polishing +- [ ] Handle TODOs in code +- [ ] Once all 5 v3 influxdb types are supported reorganize/refactor the code (cloud_dedicated.go, influx.go) to group similar server types + +## Enhancements +- [ ] UI: Better form validation to show errored field(s) +- [ ] UI: distinguish optional fields diff --git a/chronograf.go b/chronograf.go index e55baf0f99..64ae53446d 100644 --- a/chronograf.go +++ b/chronograf.go @@ -9,6 +9,7 @@ import ( "strconv" "time" + "github.com/influxdata/influxdb/influxql" "github.com/influxdata/kapacitor/client/v1" ) @@ -27,7 +28,6 @@ const ( ErrSourceInvalid = Error("source is invalid") ErrServerInvalid = Error("server is invalid") ErrAlertNotFound = Error("alert not found") - ErrAuthentication = Error("user not authenticated") ErrUninitialized = Error("client uninitialized. Call Open() method") ErrInvalidAxis = Error("Unexpected axis in cell. Valid axes are 'x', 'y', and 'y2'") ErrInvalidColorType = Error("Invalid color type. Valid color types are 'min', 'max', 'threshold', 'text', and 'background'") @@ -42,9 +42,6 @@ const ( ErrCannotDeleteDefaultOrganization = Error("cannot delete default organization") ErrConfigNotFound = Error("cannot find configuration") ErrAnnotationNotFound = Error("annotation not found") - ErrInvalidCellOptionsText = Error("invalid text wrapping option. Valid wrappings are 'truncate', 'wrap', and 'single line'") - ErrInvalidCellOptionsSort = Error("cell options sortby cannot be empty'") - ErrInvalidCellOptionsColumns = Error("cell options columns cannot be empty'") ErrOrganizationConfigNotFound = Error("could not find organization config") ErrInvalidCellQueryType = Error("invalid cell query type: must be 'flux' or 'influxql'") ) @@ -91,16 +88,43 @@ type Assets interface { // Supported time-series databases const ( - // InfluxDB is the open-source time-series database - InfluxDB = "influx" - // InfluxEnteprise is the clustered HA time-series database - InfluxEnterprise = "influx-enterprise" - // InfluxRelay is the basic HA layer over InfluxDB - InfluxRelay = "influx-relay" + // InfluxDBv1 is InfluxDB OSS v1 + InfluxDBv1 = "influx" + // InfluxDBv1Enterprise is InfluxDB v1 Enterprise (the clustered HA time-series database) + InfluxDBv1Enterprise = "influx-enterprise" + // InfluxDBv1Relay is the basic HA layer over InfluxDB v1 + InfluxDBv1Relay = "influx-relay" + // InfluxDBv2 is Influx DB 2.x with Token authentication InfluxDBv2 = "influx-v2" + + // InfluxDBv3Core is InfluxDB 3 Core (self-managed) + InfluxDBv3Core = "influx-v3-core" + // InfluxDBv3Enterprise is InfluxDB 3 Enterprise (self-managed) + InfluxDBv3Enterprise = "influx-v3-enterprise" + // InfluxDBv3Clustered is InfluxDB Clustered (self-managed) + InfluxDBv3Clustered = "influx-v3-clustered" + // InfluxDBv3CloudDedicated is InfluxDB Cloud Dedicated (fully-managed) + InfluxDBv3CloudDedicated = "influx-v3-cloud-dedicated" + // InfluxDBv3Serverless is InfluxDB Cloud Serverless (fully-managed) + InfluxDBv3Serverless = "influx-v3-serverless" ) +func IsV3SrcType(srcType string) bool { + return srcType == InfluxDBv3Core || + srcType == InfluxDBv3Enterprise || + srcType == InfluxDBv3Clustered || + srcType == InfluxDBv3CloudDedicated || + srcType == InfluxDBv3Serverless +} + +type V3Config struct { + CloudDedicatedManagementURL string + ClusteredAccountID string + ClusteredClusterID string + TimeConditionExpr influxql.Expr // Parsed time condition for SHOW TAG VALUES queries +} + // TSDBStatus represents the current status of a time series database type TSDBStatus interface { // Connect will connect to the time series using the information in `Source`. @@ -243,6 +267,11 @@ type Source struct { Username string `json:"username,omitempty"` // Username is the username to connect to the source Password string `json:"password,omitempty"` // Password is in CLEARTEXT SharedSecret string `json:"sharedSecret,omitempty"` // ShareSecret is the optional signing secret for Influx JWT authorization + ClusterID string `json:"clusterId,omitempty"` // ClusterID is the cluster ID for InfluxDB Cloud Dedicated sources + AccountID string `json:"accountId,omitempty"` // AccountID is the account ID for InfluxDB Cloud Dedicated sources + ManagementToken string `json:"managementToken,omitempty"` // ManagementToken is the management token for InfluxDB Cloud Dedicated sources + DatabaseToken string `json:"databaseToken,omitempty"` // DatabaseToken is the database token for InfluxDB Cloud Dedicated or other InfluxDB 3 sources + TagsCSVPath string `json:"tagsCSVPath,omitempty"` // TagsCSVPath is the path to a directory containing CSV files (per db) with tags for InfluxDB Cloud Dedicated sources URL string `json:"url"` // URL are the connections to the source MetaURL string `json:"metaUrl,omitempty"` // MetaURL is the url for the meta node InsecureSkipVerify bool `json:"insecureSkipVerify,omitempty"` // InsecureSkipVerify as true means any certificate presented by the source is accepted. @@ -251,6 +280,7 @@ type Source struct { Organization string `json:"organization"` // Organization is the organization ID that resource belongs to Role string `json:"role,omitempty"` // Not Currently Used. Role is the name of the minimum role that a user must possess to access the resource. DefaultRP string `json:"defaultRP"` // DefaultRP is the default retention policy used in database queries to this source + DefaultDB string `json:"defaultDB,omitempty"` // DefaultDB is the default database used in queries for InfluxDB Cloud Dedicated when database list is not available Version string `json:"version,omitempty"` // Version of influxdb } @@ -971,6 +1001,7 @@ type Environment struct { TelegrafSystemInterval time.Duration `json:"telegrafSystemInterval"` HostPageDisabled bool `json:"HostPageDisabled"` CustomAutoRefresh string `json:"customAutoRefresh,omitempty"` + V3SupportEnabled bool `json:"v3SupportEnabled"` } // KVClient defines what each kv store should be capable of. diff --git a/cmd/chronograf/main.go b/cmd/chronograf/main.go index de06dc3a09..06a3caebda 100644 --- a/cmd/chronograf/main.go +++ b/cmd/chronograf/main.go @@ -43,10 +43,11 @@ func main() { if _, err := parser.Parse(); err != nil { code := 1 - if fe, ok := err.(*flags.Error); ok { - if fe.Type == flags.ErrHelp { - code = 0 - } + if fe, ok := err.(*flags.Error); ok && fe.Type == flags.ErrHelp { + code = 0 + } + if code != 0 { + fmt.Printf("Error: %s\n", err) } os.Exit(code) } diff --git a/influx/authorization.go b/influx/authorization.go index de03a995e8..ad852f2981 100644 --- a/influx/authorization.go +++ b/influx/authorization.go @@ -23,6 +23,18 @@ func (n *NoAuthorization) Set(req *http.Request) error { return nil } // DefaultAuthorization creates either a shared JWT builder, basic auth or Noop or Token authentication func DefaultAuthorization(src *chronograf.Source) Authorizer { + // Use Token authentication for InfluxDB v3 Serverless + if src.Type == chronograf.InfluxDBv3Serverless { + return &TokenAuth{ + Token: src.DatabaseToken, + } + } + // Use Bearer Token authentication for all other InfluxDB 3 types + if chronograf.IsV3SrcType(src.Type) { + return &BearerToken{ + Token: src.DatabaseToken, + } + } // Use Token authentication for InfluxDB v2 if src.Type == chronograf.InfluxDBv2 { return &TokenAuth{ @@ -71,6 +83,16 @@ func (a *TokenAuth) Set(r *http.Request) error { return nil } +// BearerToken adds `Authorization: Bearer ` to the request header, where the token is in non-JWT format. +type BearerToken struct { + Token string +} + +func (a *BearerToken) Set(r *http.Request) error { + r.Header.Set("Authorization", "Bearer "+a.Token) + return nil +} + // BearerJWT is the default Bearer for InfluxDB type BearerJWT struct { Username string diff --git a/influx/cloud_dedicated.go b/influx/cloud_dedicated.go new file mode 100644 index 0000000000..5e63f0b979 --- /dev/null +++ b/influx/cloud_dedicated.go @@ -0,0 +1,464 @@ +package influx + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + + "github.com/influxdata/chronograf" + "github.com/influxdata/chronograf/util" + "github.com/influxdata/influxdb/influxql" +) + +type influxResult struct { + StatementID int `json:"statement_id"` + Series []series `json:"series"` +} +type fakeInfluxResponse []influxResult + +type series struct { + Name string `json:"name"` + Columns []string `json:"columns"` + Values [][]interface{} `json:"values"` +} + +type cdDatabase struct { + Name string `json:"name,omitempty"` +} + +type cdListDatabasesError struct { + Code string `json:"code,omitempty"` + Message string `json:"message,omitempty"` +} + +// validateClusteredOrCloudDedicatedAuth checks both the management endpoint and the database endpoint to validate authentication. +// Used for InfluxDB Clustered and InfluxDB Cloud Dedicated. +func (c *Client) validateClusteredOrCloudDedicatedAuth(ctx context.Context) error { + var req *http.Request + var err error + + if c.MgmtURL != nil { + // Call list databases on management api. + if req, _, err = c.newListDatabasesRequestViaMgmtApi(ctx); err != nil { + return fmt.Errorf("management authentication failed: %w", err) + } + if err = c.executeRequest(err, req); err != nil { + return fmt.Errorf("management authentication failed: %w", err) + } + } + // Call dummy query on query api. + if _, err := c.Query(ctx, chronograf.Query{Command: "SELECT * FROM dummy WHERE time > now()"}); err != nil { + return fmt.Errorf("database authentication failed: %w", err) + } + + return nil +} + +// showDatabasesViaMgmtApi lists databases using the management api and wraps the results into chronograf.Response structure. +// Used for InfluxDB Clustered and InfluxDB Cloud Dedicated. +func (c *Client) showDatabasesViaMgmtApi(ctx context.Context) (chronograf.Response, error) { + var dbNames []string + if c.DefaultDB != "" { + dbNames = []string{c.DefaultDB} + } else { + // Prepare request. + req, logs, err := c.newListDatabasesRequestViaMgmtApi(ctx) + if err != nil { + return nil, err + } + + // Do request. + hc := &http.Client{} + hc.Transport = SharedTransport(c.InsecureSkipVerify) + resp, err := hc.Do(req) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { + return nil, chronograf.ErrUpstreamTimeout + } + return nil, err + } + defer func(Body io.ReadCloser) { + _ = Body.Close() + }(resp.Body) + + // Handle non-OK status. + if resp.StatusCode != http.StatusOK { + var errorResponse cdListDatabasesError + dec := json.NewDecoder(resp.Body) + _ = dec.Decode(&errorResponse) + return nil, fmt.Errorf("received status code %d from server: err: %s", resp.StatusCode, errorResponse.Message) + } + + // Decode response. + var databases []cdDatabase + dec := json.NewDecoder(resp.Body) + decErr := dec.Decode(&databases) + if decErr != nil { + logs.WithField("influx_status", resp.StatusCode). + Error("Error parsing results from influxdb: err:", decErr) + return nil, decErr + } + + // Convert response. + dbNames = make([]string, len(databases)) + for i, db := range databases { + dbNames[i] = db.Name + } + } + return constructShowDatabasesResponse(dbNames), nil +} + +// newListDatabasesRequestViaMgmtApi constructs a new http.Request for listing databases via Management API. +// Used for InfluxDB Clustered and InfluxDB Cloud Dedicated. +func (c *Client) newListDatabasesRequestViaMgmtApi(ctx context.Context) (*http.Request, chronograf.Logger, error) { + req, err := http.NewRequest("GET", util.AppendPath(c.MgmtURL, "/databases").String(), nil) + if err != nil { + return nil, nil, err + } + req = req.WithContext(ctx) + logs := c.Logger. + WithField("component", "proxy"). + WithField("host", req.Host) + logs.Debug(req.URL.Path) + + if c.MgmtAuthorizer != nil { + if err := c.MgmtAuthorizer.Set(req); err != nil { + logs.Error("Error setting authorization header ", err) + return nil, nil, err + } + } + + return req, logs, err +} + +// constructShowDatabasesResponse constructs a chronograf.Response containing database names formatted as query result data. +func constructShowDatabasesResponse(dbNames []string) chronograf.Response { + values := make([][]interface{}, len(dbNames)) + for i, dbName := range dbNames { + values[i] = []interface{}{dbName} + } + + response := fakeInfluxResponse{ + { + StatementID: 0, + Series: []series{ + { + Name: "databases", + Columns: []string{"name"}, + Values: values, + }, + }, + }, + } + + data, _ := json.Marshal(response) + return &responseType{ + Results: data, + Err: "", + V2Err: "", + } +} + +func (c *Client) handleShowMeasurements(q chronograf.Query, logs chronograf.Logger) (chronograf.Response, error) { + if c.csvTagsStore == nil { + return nil, nil + } + logs.Info("Returning measurements from CSV") + return createShowMeasurementsResponseFromCSV(c.csvTagsStore, q.DB), nil +} + +func (c *Client) handleShowTagKeys(q chronograf.Query, logs chronograf.Logger) (chronograf.Response, error) { + if c.csvTagsStore == nil { + return nil, nil + } + stmt, err := parseShowTagKeysStatement(q.Command) + if err != nil { + logs.Debug("Could not parse SHOW TAG KEYS statement: ", err) + return nil, err + } + tables := extractTables(stmt) + logs.Info("Returning tag keys from CSV") + return createShowTagKeysResponseFromCSV(c.csvTagsStore, q.DB, tables), nil +} + +func (c *Client) handleShowTagValues(q *chronograf.Query, logs chronograf.Logger) (chronograf.Response, error) { + stmt, err := parseShowTagValuesStatement(q.Command) + if err != nil { + logs.Debug("Could not parse SHOW TAG VALUES statement: ", err) + return nil, err + } + + if c.csvTagsStore != nil { + // Use CSV + tables, tags := extractTablesAndTags(stmt) + logs.Info("Returning tag values from CSV") + resp := createShowTagValuesResponseFromCSV(c.csvTagsStore, q.DB, tables, tags) + if resp != nil { + return resp, nil + } + } else { + // Call InfluxDB + logs.Info("Returning tag values from InfluxDB with time condition applied") + appendTimeCondition(stmt, c.V3Config.TimeConditionExpr) + q.Command = stmt.String() + } + return nil, nil +} + +// parseShowTagValuesStatement parses a SHOW TAG VALUES query string into an instance of ShowTagValuesStatement. +func parseShowTagValuesStatement(query string) (*influxql.ShowTagValuesStatement, error) { + stmt, err := influxql.ParseStatement(query) + if err != nil { + return nil, fmt.Errorf("parsing error: %w", err) + } + showStmt, ok := stmt.(*influxql.ShowTagValuesStatement) + if !ok { + return nil, fmt.Errorf("not a SHOW TAG VALUES statement") + } + return showStmt, nil +} + +// parseShowTagKeysStatement parses a SHOW TAG KEYS query string into an instance of ShowTagKeysStatement. +func parseShowTagKeysStatement(query string) (*influxql.ShowTagKeysStatement, error) { + stmt, err := influxql.ParseStatement(query) + if err != nil { + return nil, fmt.Errorf("parsing error: %w", err) + } + showStmt, ok := stmt.(*influxql.ShowTagKeysStatement) + if !ok { + return nil, fmt.Errorf("not a SHOW TAG KEYS statement") + } + return showStmt, nil +} + +// appendTimeCondition appends a time condition clause to the provided SHOW TAG VALUES statement if no time condition exists. +// Returns true if the statement was modified. +func appendTimeCondition(showStmt *influxql.ShowTagValuesStatement, timeExpr influxql.Expr) bool { + // If no time expression provided, do nothing + if timeExpr == nil { + return false + } + + // Check if there's already a time condition in the WHERE clause + if showStmt.Condition != nil && hasTimeCondition(showStmt.Condition) { + // Already has a time condition, do nothing + return false + } + + // Add or modify the WHERE clause + if showStmt.Condition == nil { + // No existing WHERE clause, add our time condition + showStmt.Condition = timeExpr + } else { + // Existing WHERE clause without time condition, combine with AND + showStmt.Condition = &influxql.BinaryExpr{ + Op: influxql.AND, + LHS: showStmt.Condition, + RHS: timeExpr, + } + } + return true +} + +// hasTimeCondition recursively checks if an InfluxQL expression contains a reference to the "time" field. +func hasTimeCondition(expr influxql.Expr) bool { + if expr == nil { + return false + } + + switch e := expr.(type) { + case *influxql.VarRef: + return strings.EqualFold(e.Val, "time") + case *influxql.BinaryExpr: + return hasTimeCondition(e.LHS) || hasTimeCondition(e.RHS) + case *influxql.ParenExpr: + return hasTimeCondition(e.Expr) + case *influxql.Call: + for _, arg := range e.Args { + if hasTimeCondition(arg) { + return true + } + } + return false + default: + return false + } +} + +func createShowMeasurementsResponseFromCSV(csvTagsStore *CSVTagsStore, db string) chronograf.Response { + if csvTagsStore == nil { + return nil + } + tablesMap := csvTagsStore.GetMeasurementsMap(db) + if tablesMap == nil { + return nil + } + data := make([][]interface{}, 0, len(tablesMap)) + for table := range tablesMap { + data = append(data, []interface{}{table}) + } + if len(data) == 0 { + return nil + } + response := fakeInfluxResponse{ + influxResult{ + StatementID: 0, + Series: []series{ + { + Name: "measurements", + Columns: []string{"name"}, + Values: data, + }, + }, + }, + } + bytes, _ := json.Marshal(response) + return &responseType{ + Results: bytes, + Err: "", + V2Err: "", + } +} + +func createShowTagKeysResponseFromCSV(csvTagsStore *CSVTagsStore, db string, tables []string) chronograf.Response { + if csvTagsStore == nil { + return nil + } + tablesMap := csvTagsStore.GetMeasurementsMap(db) + if tablesMap == nil { + return nil + } + if len(tables) == 0 { + tables = make([]string, 0, len(tablesMap)) + for k := range tablesMap { + tables = append(tables, k) + } + } + response := make(fakeInfluxResponse, 0, len(tables)) + for i, table := range tables { + tableMap := tablesMap[table] + data := make([][]interface{}, 0, len(tableMap)) + for tag := range tableMap { + data = append(data, []interface{}{tag}) + } + if len(data) == 0 { + continue + } + response = append(response, influxResult{ + StatementID: i, + Series: []series{ + { + Name: table, + Columns: []string{"tagKey"}, + Values: data, + }, + }, + }) + } + if len(response) == 0 { + return nil + } + bytes, _ := json.Marshal(response) + return &responseType{ + Results: bytes, + Err: "", + V2Err: "", + } +} + +func createShowTagValuesResponseFromCSV(csvTagsStore *CSVTagsStore, db string, tables, tags []string) chronograf.Response { + if csvTagsStore == nil { + return nil + } + tablesMap := csvTagsStore.GetMeasurementsMap(db) + if tablesMap == nil { + return nil + } + if len(tables) == 0 { + tables = make([]string, 0, len(tablesMap)) + for k := range tablesMap { + tables = append(tables, k) + } + } + response := make(fakeInfluxResponse, 0, len(tables)) + for i, table := range tables { + tableMap := tablesMap[table] + data := make([][]interface{}, 0, len(tableMap)*2) //arbitrary length + for tag, val := range tableMap { + if len(tags) == 0 || contains(tags, tag) { + for _, v := range val { + data = append(data, []interface{}{tag, v}) + } + } + } + if len(data) == 0 { + continue + } + response = append(response, influxResult{ + StatementID: i, + Series: []series{ + { + Name: table, + Columns: []string{"key", "value"}, + Values: data, + }, + }, + }) + } + if len(response) == 0 { + return nil + } + bytes, _ := json.Marshal(response) + return &responseType{ + Results: bytes, + Err: "", + V2Err: "", + } +} + +// contains checks if a string is present in a slice of strings. +func contains(slice []string, str string) bool { + for _, item := range slice { + if item == str { + return true + } + } + return false +} + +// extractTablesAndTags extracts all table names and relevant tag keys from a parsed SHOW TAG VALUES statement. +func extractTablesAndTags(showStmt *influxql.ShowTagValuesStatement) (tables []string, tags []string) { + // Extract table names + if showStmt.Sources != nil { + tables = showStmt.Sources.Names() + } + + // Extract tag keys + if showStmt.TagKeyExpr != nil { + switch expr := showStmt.TagKeyExpr.(type) { + case *influxql.ListLiteral: + // Handle WITH KEY IN ("tag1", "tag2") + for _, val := range expr.Vals { + tags = append(tags, val) + } + case *influxql.StringLiteral: + // Handle WITH KEY = "tag" + tags = append(tags, expr.Val) + } + } + + return tables, tags +} + +// extractTables extracts all table names from a parsed SHOW TAG KEYS statement. +func extractTables(showStmt *influxql.ShowTagKeysStatement) []string { + // Extract table names + if showStmt.Sources != nil { + return showStmt.Sources.Names() + } + return nil +} diff --git a/influx/cloud_dedicated_test.go b/influx/cloud_dedicated_test.go new file mode 100644 index 0000000000..37eac484d1 --- /dev/null +++ b/influx/cloud_dedicated_test.go @@ -0,0 +1,1002 @@ +package influx + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "os" + "sort" + "strings" + "testing" + + "github.com/influxdata/chronograf" + "github.com/influxdata/chronograf/log" + "github.com/influxdata/influxdb/influxql" +) + +func TestAppendTimeCondition(t *testing.T) { + // Define test time condition + testTimeCondition := "time > 0" + testTimeExpr, err := influxql.ParseExpr(testTimeCondition) + if err != nil { + t.Fatalf("Failed to parse test time condition: %v", err) + } + + tests := []struct { + name string + input string + expected string + }{ + { + name: "basic query without WHERE", + input: `SHOW TAG VALUES FROM machine_data WITH KEY = "tagkey"`, + expected: `SHOW TAG VALUES FROM machine_data WITH KEY = tagkey WHERE ` + testTimeCondition, + }, + { + name: "query without FROM clause", + input: `SHOW TAG VALUES WITH KEY = "tagkey"`, + expected: `SHOW TAG VALUES WITH KEY = tagkey WHERE ` + testTimeCondition, + }, + { + name: "query with tag name with where in it", + input: `SHOW TAG VALUES WITH KEY = "tag name with where in it"`, + expected: `SHOW TAG VALUES WITH KEY = "tag name with where in it" WHERE ` + testTimeCondition, + }, + { + name: "query with quoted table name and retention policy", + input: `SHOW TAG VALUES FROM "autogen"."machine_data" WITH KEY IN ("t1", "t2")`, + expected: `SHOW TAG VALUES FROM autogen.machine_data WITH KEY IN (t1, t2) WHERE ` + testTimeCondition, + }, + { + name: "query with trailing semicolon", + input: `SHOW TAG VALUES FROM machine_data WITH KEY = "tagkey";`, + expected: `SHOW TAG VALUES FROM machine_data WITH KEY = tagkey WHERE ` + testTimeCondition, + }, + { + name: "query with existing WHERE clause", + input: `SHOW TAG VALUES FROM machine_data WITH KEY = "tagkey" WHERE ` + testTimeCondition, + expected: `SHOW TAG VALUES FROM machine_data WITH KEY = tagkey WHERE ` + testTimeCondition, + }, + { + name: "query with existing WHERE clause (case insensitive)", + input: `SHOW TAG VALUES FROM machine_data WITH KEY = "tagkey" where ` + testTimeCondition, + expected: `SHOW TAG VALUES FROM machine_data WITH KEY = tagkey WHERE ` + testTimeCondition, + }, + { + name: "query with LIMIT clause", + input: `SHOW TAG VALUES FROM machine_data WITH KEY = "tagkey" LIMIT 10`, + expected: `SHOW TAG VALUES FROM machine_data WITH KEY = tagkey WHERE ` + testTimeCondition + ` LIMIT 10`, + }, + { + name: "query with OFFSET clause", + input: `SHOW TAG VALUES FROM machine_data WITH KEY = "tagkey" OFFSET 5`, + expected: `SHOW TAG VALUES FROM machine_data WITH KEY = tagkey WHERE ` + testTimeCondition + ` OFFSET 5`, + }, + { + name: "query with LIMIT and OFFSET", + input: `SHOW TAG VALUES FROM machine_data WITH KEY = "tagkey" LIMIT 10 OFFSET 5`, + expected: `SHOW TAG VALUES FROM machine_data WITH KEY = tagkey WHERE ` + testTimeCondition + ` LIMIT 10 OFFSET 5`, + }, + { + name: "query with OFFSET and LIMIT (reverse order)", + input: `SHOW TAG VALUES FROM machine_data WITH KEY = "tagkey" OFFSET 5 LIMIT 10`, + expected: `SHOW TAG VALUES FROM machine_data WITH KEY = tagkey WHERE ` + testTimeCondition + ` OFFSET 5`, + }, + { + name: "query with LIMIT and semicolon", + input: `SHOW TAG VALUES FROM machine_data WITH KEY = "tagkey" LIMIT 10;`, + expected: `SHOW TAG VALUES FROM machine_data WITH KEY = tagkey WHERE ` + testTimeCondition + ` LIMIT 10`, + }, + { + name: "query with WHERE and LIMIT", + input: `SHOW TAG VALUES FROM machine_data WITH KEY = "tagkey" WHERE ` + testTimeCondition + ` LIMIT 10`, + expected: `SHOW TAG VALUES FROM machine_data WITH KEY = tagkey WHERE ` + testTimeCondition + ` LIMIT 10`, + }, + { + name: "query with WHERE and semicolon", + input: `SHOW TAG VALUES FROM machine_data WITH KEY = "tagkey" WHERE ` + testTimeCondition + `;`, + expected: `SHOW TAG VALUES FROM machine_data WITH KEY = tagkey WHERE ` + testTimeCondition, + }, + { + name: "query with existing WHERE clause but no time condition", + input: `SHOW TAG VALUES FROM machine_data WITH KEY = "tagkey" WHERE "host" = 'server1'`, + expected: `SHOW TAG VALUES FROM machine_data WITH KEY = tagkey WHERE host = 'server1' AND ` + testTimeCondition, + }, + { + name: "query with complex WHERE clause containing time", + input: `SHOW TAG VALUES FROM machine_data WITH KEY = "tagkey" WHERE time > now() - 1h AND "host" = 'server1'`, + expected: `SHOW TAG VALUES FROM machine_data WITH KEY = tagkey WHERE time > now() - 1h AND host = 'server1'`, + }, + { + name: "query with WHERE clause containing time in OR condition", + input: `SHOW TAG VALUES FROM machine_data WITH KEY = "tagkey" WHERE "host" = 'server1' OR time > now() - 2h`, + expected: `SHOW TAG VALUES FROM machine_data WITH KEY = tagkey WHERE host = 'server1' OR time > now() - 2h`, + }, + { + name: "query with WHERE clause containing time in nested expression", + input: `SHOW TAG VALUES FROM machine_data WITH KEY = "tagkey" WHERE ("host" = 'server1' AND time > now() - 1h)`, + expected: `SHOW TAG VALUES FROM machine_data WITH KEY = tagkey WHERE (host = 'server1' AND time > now() - 1h)`, + }, + { + name: "query with WHERE clause containing time function", + input: `SHOW TAG VALUES FROM machine_data WITH KEY = "tagkey" WHERE time < now()`, + expected: `SHOW TAG VALUES FROM machine_data WITH KEY = tagkey WHERE time < now()`, + }, + { + name: "query with tag name containing spaces", + input: `SHOW TAG VALUES FROM machine_data WITH KEY = "tag with spaces"`, + expected: `SHOW TAG VALUES FROM machine_data WITH KEY = "tag with spaces" WHERE ` + testTimeCondition, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Parse the input string into a ShowTagValuesStatement + showStmt, parseErr := parseShowTagValuesStatement(tt.input) + + if parseErr != nil { + t.Errorf("parseShowTagValuesStatement(%q) unexpected error: %v", tt.input, parseErr) + return + } + + // Apply appendTimeCondition to the parsed statement + appendTimeCondition(showStmt, testTimeExpr) + + // Convert back to string and compare + result := showStmt.String() + if result != tt.expected { + t.Errorf("appendTimeCondition(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestParseShowTagValuesStatement(t *testing.T) { + tests := []struct { + name string + input string + expectedError string + }{ + { + name: "valid query with retention policy", + input: `SHOW TAG VALUES FROM "myrp"."mytable" WITH KEY IN ("tag1", "tag2", "tag3") WHERE time > now() - 24h`, + expectedError: "", + }, + { + name: "valid query without retention policy", + input: `SHOW TAG VALUES FROM "mytable" WITH KEY IN ("tag1", "tag2")`, + expectedError: "", + }, + { + name: "valid query with single tag", + input: `SHOW TAG VALUES FROM mytable WITH KEY = "tag1"`, + expectedError: "", + }, + { + name: "SHOW TAG VALUES without WITH", + input: `SHOW TAG VALUES`, + expectedError: "parsing error: found EOF, expected WITH", + }, + { + name: "query of other type", + input: `SELECT * FROM mytable`, + expectedError: "not a SHOW TAG VALUES statement", + }, + { + name: "empty string", + input: ``, + expectedError: "parsing error: found EOF", + }, + { + name: "malformed query", + input: `SHOW TAG VALUES FROM machine_data WITH KEY = "tagkey INVALID`, + expectedError: "parsing error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, parseErr := parseShowTagValuesStatement(tt.input) + + if tt.expectedError != "" { + if parseErr == nil { + t.Errorf("parseShowTagValuesStatement(%q) expected error containing %q but got none", tt.input, tt.expectedError) + return + } + if !strings.Contains(parseErr.Error(), tt.expectedError) { + t.Errorf("parseShowTagValuesStatement(%q) expected error containing %q but got %v", tt.input, tt.expectedError, parseErr) + } + return + } + + if parseErr != nil { + t.Errorf("parseShowTagValuesStatement(%q) unexpected error: %v", tt.input, parseErr) + return + } + }) + } +} + +func TestExtractTablesAndTags(t *testing.T) { + tests := []struct { + name string + input string + expectedTables []string + expectedTags []string + }{ + { + name: "full query with retention policy", + input: `SHOW TAG VALUES FROM "myrp"."mytable" WITH KEY IN ("tag1", "tag2", "tag3") WHERE time > now() - 24h`, + expectedTables: []string{"mytable"}, + expectedTags: []string{"tag1", "tag2", "tag3"}, + }, + { + name: "table only without retention policy", + input: `SHOW TAG VALUES FROM "mytable" WITH KEY IN ("tag1", "tag2")`, + expectedTables: []string{"mytable"}, + expectedTags: []string{"tag1", "tag2"}, + }, + { + name: "table without quotes", + input: `SHOW TAG VALUES FROM mytable WITH KEY IN ("tag1", "tag2")`, + expectedTables: []string{"mytable"}, + expectedTags: []string{"tag1", "tag2"}, + }, + { + name: "single tag with KEY equals", + input: `SHOW TAG VALUES FROM mytable WITH KEY = "tag1"`, + expectedTables: []string{"mytable"}, + expectedTags: []string{"tag1"}, + }, + { + name: "without WHERE clause", + input: `SHOW TAG VALUES FROM mytable WITH KEY IN ("tag1", "tag2", "tag3")`, + expectedTables: []string{"mytable"}, + expectedTags: []string{"tag1", "tag2", "tag3"}, + }, + { + name: "with LIMIT clause", + input: `SHOW TAG VALUES FROM mytable WITH KEY = "tag1" LIMIT 10`, + expectedTables: []string{"mytable"}, + expectedTags: []string{"tag1"}, + }, + { + name: "with OFFSET clause", + input: `SHOW TAG VALUES FROM mytable WITH KEY = "tag1" OFFSET 5`, + expectedTables: []string{"mytable"}, + expectedTags: []string{"tag1"}, + }, + { + name: "complex WHERE clause", + input: `SHOW TAG VALUES FROM mytable WITH KEY = "tag1" WHERE "tag2" = 'value'`, + expectedTables: []string{"mytable"}, + expectedTags: []string{"tag1"}, + }, + { + name: "multiple measurements", + input: `SHOW TAG VALUES FROM table1, table2 WITH KEY = "tag1"`, + expectedTables: []string{"table1", "table2"}, + expectedTags: []string{"tag1"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Parse the input string into a ShowTagValuesStatement + showStmt, parseErr := parseShowTagValuesStatement(tt.input) + + if parseErr != nil { + t.Errorf("parseShowTagValuesStatement(%q) unexpected error: %v", tt.input, parseErr) + return + } + + // Extract tables and tags from the parsed statement + tables, tags := extractTablesAndTags(showStmt) + + if len(tables) != len(tt.expectedTables) { + t.Errorf("extractTablesAndTags(%q) tables length = %d, want %d", tt.input, len(tables), len(tt.expectedTables)) + return + } + + for i, table := range tables { + if table != tt.expectedTables[i] { + t.Errorf("extractTablesAndTags(%q) tables[%d] = %q, want %q", tt.input, i, table, tt.expectedTables[i]) + } + } + + if len(tags) != len(tt.expectedTags) { + t.Errorf("extractTablesAndTags(%q) tags length = %d, want %d", tt.input, len(tags), len(tt.expectedTags)) + return + } + + for i, tag := range tags { + if tag != tt.expectedTags[i] { + t.Errorf("extractTablesAndTags(%q) tags[%d] = %q, want %q", tt.input, i, tag, tt.expectedTags[i]) + } + } + }) + } +} + +func TestParseShowTagKeysAndExtractTables(t *testing.T) { + tests := []struct { + name string + input string + expectedTables []string + expectsError bool + }{ + { + name: "full query with retention policy", + input: `SHOW TAG KEYS FROM "myrp"."mytable"`, + expectedTables: []string{"mytable"}, + }, + { + name: "table only without retention policy", + input: `SHOW TAG KEYS FROM "mytable"`, + expectedTables: []string{"mytable"}, + }, + { + name: "table without quotes", + input: `SHOW TAG KEYS FROM mytable`, + expectedTables: []string{"mytable"}, + }, + { + name: "single tag with KEY equals", + input: `SHOW TAG KEYS FROM mytable`, + expectedTables: []string{"mytable"}, + }, + { + name: "without WHERE clause", + input: `SHOW TAG KEYS FROM mytable`, + expectedTables: []string{"mytable"}, + }, + { + name: "with LIMIT clause", + input: `SHOW TAG KEYS FROM mytable WITH KEY = "tag1" LIMIT 10`, + expectedTables: []string{"mytable"}, + }, + { + name: "with OFFSET clause", + input: `SHOW TAG KEYS FROM mytable WITH KEY = "tag1" OFFSET 5`, + expectedTables: []string{"mytable"}, + }, + { + name: "multiple measurements", + input: `SHOW TAG KEYS FROM table1, table2`, + expectedTables: []string{"table1", "table2"}, + }, + { + name: "no tables specified", + input: `SHOW TAG KEYS`, + expectedTables: nil, + }, + { + name: "Error in statement", + input: `SHOW TAG KEY FROM table1`, + expectsError: true, + }, + { + name: "Diferent statement type", + input: `SELECT * FROM mytable`, + expectsError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + showStmt, parseErr := parseShowTagKeysStatement(tt.input) + + if !tt.expectsError && parseErr != nil { + t.Errorf("parseShowTagKeysStatement(%q) unexpected error: %v", tt.input, parseErr) + return + } + if tt.expectsError { + if parseErr == nil { + t.Errorf("parseShowTagKeysStatement(%q) expected error but got none", tt.input) + } + return + } + + // Extract tables and tags from the parsed statement + tables := extractTables(showStmt) + + if len(tables) != len(tt.expectedTables) { + t.Errorf("extractTablesAndTags(%q) tables length = %d, want %d", tt.input, len(tables), len(tt.expectedTables)) + return + } + + for i, table := range tables { + if table != tt.expectedTables[i] { + t.Errorf("extractTablesAndTags(%q) tables[%d] = %q, want %q", tt.input, i, table, tt.expectedTables[i]) + } + } + }) + } +} + +func TestCreateShowMeasurementsResponse(t *testing.T) { + tests := []struct { + name string + csvContent string + wantNil bool + wantContains []string + }{ + { + name: "valid db with two measurements", + csvContent: `meas1;tag1;val1 +meas2;tag2;val2 +`, + wantNil: false, + wantContains: []string{"meas1", "meas2"}, + }, + { + name: "empty csv file", + csvContent: "", + wantNil: true, + }, + } + + const testDB = "testdb" + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir, err := setupCSVTestDirWithContent(testDB, tt.csvContent) + if err != nil { + t.Fatalf("could not setup CSV test dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + csvStore, err := NewCSVTagsStore(tmpDir, log.New(log.DebugLevel)) + if err != nil { + t.Fatalf("could not create CSV tags store: %v", err) + } + + resp := createShowMeasurementsResponseFromCSV(csvStore, testDB) + + if tt.wantNil { + if resp != nil { + t.Errorf("Expected nil response, got: %#v", resp) + } + return + } + + if resp == nil { + t.Fatal("Expected non-nil response") + } + + // Decode JSON result + var decoded []struct { + StatementID int `json:"statement_id"` + Series []struct { + Name string `json:"name"` + Columns []string `json:"columns"` + Values [][]interface{} `json:"values"` + } `json:"series"` + } + rt := resp.(*responseType) // type assertion + if err := json.Unmarshal(rt.Results, &decoded); err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + var gotMeasurements []string + for _, row := range decoded[0].Series[0].Values { + if len(row) > 0 { + if name, ok := row[0].(string); ok { + gotMeasurements = append(gotMeasurements, name) + } + } + } + + if len(gotMeasurements) != len(tt.wantContains) { + t.Errorf("Expected %d measurements, got %d", len(tt.wantContains), len(gotMeasurements)) + } + + // Check each expected measurement is present + for _, want := range tt.wantContains { + found := false + for _, got := range gotMeasurements { + if got == want { + found = true + break + } + } + if !found { + t.Errorf("Missing expected measurement: %s", want) + } + } + }) + } +} + +func TestCreateShowTagKeysResponse(t *testing.T) { + const testDB = "testdb" + + tests := []struct { + name string + csvContent string + db string + tables []string + wantNil bool + wantResponse map[string][]string // table -> tag keys + }{ + { + name: "single table with two tags", + csvContent: `meas1;tagA;val1 +meas1;tagB;val2 +`, + db: testDB, + tables: []string{"meas1"}, + wantNil: false, + wantResponse: map[string][]string{ + "meas1": {"tagA", "tagB"}, + }, + }, + { + name: "all tables auto-filled", + csvContent: `meas1;t1;v1 +meas2;t2;v2 +`, + db: testDB, + tables: nil, // will trigger table listing + wantNil: false, + wantResponse: map[string][]string{ + "meas1": {"t1"}, + "meas2": {"t2"}, + }, + }, + { + name: "nonexistent table returns nil", + csvContent: `meas1;t1;v1 +meas1;t1;v2 +meas1;t2;v3 +meas2;t3;v4 +`, + db: testDB, + tables: []string{"missing-meas"}, + wantNil: true, + }, + { + name: "nonexistent db returns nil", + csvContent: "", + db: "missing-db", + tables: []string{"meas1"}, + wantNil: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir, err := setupCSVTestDirWithContent(testDB, tt.csvContent) + if err != nil { + t.Fatalf("could not setup CSV test dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + csvStore, err := NewCSVTagsStore(tmpDir, log.New(log.DebugLevel)) + if err != nil { + t.Fatalf("could not create CSV tags store: %v", err) + } + + resp := createShowTagKeysResponseFromCSV(csvStore, tt.db, tt.tables) + + if tt.wantNil { + if resp != nil { + t.Errorf("Expected nil response, got: %#v", resp) + } + return + } + + if resp == nil { + t.Fatal("Expected non-nil response") + } + + rt := resp.(*responseType) + + var decoded []struct { + StatementID int `json:"statement_id"` + Series []struct { + Name string `json:"name"` + Columns []string `json:"columns"` + Values [][]interface{} `json:"values"` + } `json:"series"` + } + if err := json.Unmarshal(rt.Results, &decoded); err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + got := map[string][]string{} + for _, result := range decoded { + for _, s := range result.Series { + var keys []string + for _, val := range s.Values { + if len(val) > 0 { + if key, ok := val[0].(string); ok { + keys = append(keys, key) + } + } + } + got[s.Name] = keys + } + } + + if len(got) != len(tt.wantResponse) { + t.Errorf("Expected %d tables, got %d", len(tt.wantResponse), len(got)) + } + + for table, expectedTags := range tt.wantResponse { + gotTags, ok := got[table] + if !ok { + t.Errorf("Missing table %s in response", table) + continue + } + if len(gotTags) != len(expectedTags) { + t.Errorf("For table %s: expected %v, got %v", table, expectedTags, gotTags) + } + tagSet := map[string]struct{}{} + for _, tag := range gotTags { + tagSet[tag] = struct{}{} + } + for _, tag := range expectedTags { + if _, ok := tagSet[tag]; !ok { + t.Errorf("Missing tag %q in table %s", tag, table) + } + } + } + }) + } +} + +func TestCreateShowTagValuesResponse(t *testing.T) { + const testDB = "testdb" + + tests := []struct { + name string + csvContent string + db string + tables []string + tags []string + wantNil bool + wantResponse map[string]map[string][]string // table -> tag -> []values + }{ + { + name: "all tables and all tags", + csvContent: `meas1;tag1;a +meas1;tag1;b +meas1;tag2;x +meas2;tag1;y +`, + db: testDB, + wantResponse: map[string]map[string][]string{ + "meas1": { + "tag1": {"a", "b"}, + "tag2": {"x"}, + }, + "meas2": { + "tag1": {"y"}, + }, + }, + }, + { + name: "filter table and tags", + csvContent: `meas1;t1;v1 +meas1;t1;v2 +meas1;t2;v3 +meas2;t3;v4 +`, + db: testDB, + tables: []string{"meas1"}, + tags: []string{"t1"}, + wantResponse: map[string]map[string][]string{ + "meas1": { + "t1": {"v1", "v2"}, + }, + }, + }, + { + name: "nonexistent table returns nil", + csvContent: `meas1;t1;v1 +meas1;t1;v2 +meas1;t2;v3 +meas2;t3;v4 +`, + db: testDB, + tables: []string{"missing-meas"}, + tags: []string{"t1"}, + wantNil: true, + }, + { + name: "nonexistent db returns nil", + csvContent: "", + db: "missing-db", + wantNil: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir, err := setupCSVTestDirWithContent(tt.db, tt.csvContent) + if err != nil { + t.Fatalf("could not setup CSV test dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + csvStore, err := NewCSVTagsStore(tmpDir, log.New(log.DebugLevel)) + if err != nil { + t.Fatalf("could not create CSV tags store: %v", err) + } + + resp := createShowTagValuesResponseFromCSV(csvStore, tt.db, tt.tables, tt.tags) + + if tt.wantNil { + if resp != nil { + t.Errorf("Expected nil response, got: %#v", resp) + } + return + } + if resp == nil { + t.Fatal("Expected non-nil response") + } + + rt := resp.(*responseType) + + var decoded []struct { + StatementID int `json:"statement_id"` + Series []struct { + Name string `json:"name"` + Columns []string `json:"columns"` + Values [][]interface{} `json:"values"` + } `json:"series"` + } + if err := json.Unmarshal(rt.Results, &decoded); err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + got := map[string]map[string][]string{} + for _, result := range decoded { + for _, s := range result.Series { + if got[s.Name] == nil { + got[s.Name] = map[string][]string{} + } + for _, row := range s.Values { + if len(row) == 2 { + key, _ := row[0].(string) + val, _ := row[1].(string) + got[s.Name][key] = append(got[s.Name][key], val) + } + } + } + } + + if len(got) != len(tt.wantResponse) { + t.Errorf("Expected %d tables, got %d", len(tt.wantResponse), len(got)) + } + for table, expectedTags := range tt.wantResponse { + gotTags, ok := got[table] + if !ok { + t.Errorf("Missing table %s", table) + continue + } + for tag, wantVals := range expectedTags { + gotVals, ok := gotTags[tag] + if !ok { + t.Errorf("Missing tag %q in table %q", tag, table) + continue + } + sort.Strings(wantVals) + sort.Strings(gotVals) + if len(gotVals) != len(wantVals) { + t.Errorf("Mismatch value count for %s/%s: got %v, want %v", table, tag, gotVals, wantVals) + continue + } + for i := range gotVals { + if gotVals[i] != wantVals[i] { + t.Errorf("Mismatch values for %s/%s: got %v, want %v", table, tag, gotVals, wantVals) + break + } + } + } + } + }) + } +} + +func TestClient_Query(t *testing.T) { + type testCase struct { + name string + query chronograf.Query + expectedBody [][]interface{} + csvContent string + mockMgmtReply []cdDatabase + expectBackend bool + } + + cases := []testCase{ + { + name: "SHOW DATABASES from MgmtURL", + query: chronograf.Query{Command: "SHOW DATABASES"}, + mockMgmtReply: []cdDatabase{{"db1"}, {"db2"}}, + expectedBody: [][]interface{}{ + {"db1"}, {"db2"}, + }, + }, + { + name: "SHOW MEASUREMENTS from tagValues", + query: chronograf.Query{Command: "SHOW MEASUREMENTS", DB: "db1"}, + csvContent: `meas1;tag1;v1 +meas2;tag2;v2 +`, + expectedBody: [][]interface{}{ + {"meas1"}, {"meas2"}, + }, + }, + { + name: "SHOW TAG KEYS FROM meas1", + query: chronograf.Query{Command: "SHOW TAG KEYS FROM meas1", DB: "db1"}, + csvContent: `meas1;tag1;v1 +meas1;tag2;v2 +`, + expectedBody: [][]interface{}{ + {"tag1"}, {"tag2"}, + }, + }, + { + name: "SHOW TAG VALUES FROM meas1 WITH KEY = tag1", + query: chronograf.Query{Command: "SHOW TAG VALUES FROM meas1 WITH KEY = tag1", DB: "db1"}, + csvContent: `meas1;tag1;v1 +meas1;tag1;v2 +`, + expectedBody: [][]interface{}{ + {"tag1", "v1"}, + {"tag1", "v2"}, + }, + }, + { + name: "SHOW TAG VALUES fallback to backend", + query: chronograf.Query{Command: "SHOW TAG VALUES FROM unknown WITH KEY in (tag1)", DB: "db1"}, + csvContent: `meas1;tag1;v1 +`, + expectBackend: true, + expectedBody: [][]interface{}{ + {"backend", "ok"}, + }, + }, + { + name: "SELECT query fallback", + query: chronograf.Query{Command: "SELECT * FROM x", DB: "db1"}, + expectBackend: true, + expectedBody: [][]interface{}{ + {"backend", "ok"}, + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + backendCalled := false + + backendSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/query") { + backendCalled = true + writeFakeInflux(w, [][]interface{}{{"backend", "ok"}}) + } else if strings.HasSuffix(r.URL.Path, "/databases") { + writeFakeDatabases(w, tc.mockMgmtReply) + } + })) + defer backendSrv.Close() + + client := &Client{ + Logger: log.New(log.DebugLevel), + SrcType: chronograf.InfluxDBv3CloudDedicated, + } + if tc.csvContent != "" { + tmpDir, err := setupCSVTestDirWithContent("db1", tc.csvContent) + if err != nil { + t.Fatalf("could not setup CSV test dir: %v", err) + } + defer os.RemoveAll(tmpDir) + csvStore, err := NewCSVTagsStore(tmpDir, log.New(log.DebugLevel)) + if err != nil { + t.Fatalf("could not create CSV tags store: %v", err) + } + client.csvTagsStore = csvStore + } + + client.URL, _ = url.Parse(backendSrv.URL) + // Manually override MgmtURL for testing + client.MgmtURL = client.URL + + resp, err := client.Query(context.Background(), tc.query) + if err != nil { + t.Fatal("Query failed: ", err) + } + + if tc.expectBackend && !backendCalled { + t.Errorf("Expected fallback to backend, but backend wasn't called") + } + if !tc.expectBackend && backendCalled { + t.Errorf("Unexpected backend call") + } + + values, err := decodeResponseJSON(resp) + if err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + if !equalMatrix(values, tc.expectedBody) { + t.Errorf("Unexpected result\nGot: %#v\nWant: %#v", values, tc.expectedBody) + } + }) + } +} + +func writeFakeInflux(w http.ResponseWriter, rows [][]interface{}) { + res := []influxResult{{ + StatementID: 0, + Series: []series{{ + Name: "mock", + Columns: make([]string, len(rows[0])), + Values: rows, + }}, + }} + raw, _ := json.Marshal(res) + resp := &struct { + Results json.RawMessage + Err string `json:"error,omitempty"` + }{ + Results: raw, + Err: "", + } + bytes, _ := json.Marshal(resp) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(bytes) +} + +func writeFakeDatabases(w http.ResponseWriter, rows []cdDatabase) { + bytes, _ := json.Marshal(rows) + w.Header().Set("Content-Type", "application/json") + w.Write(bytes) +} + +func decodeResponseJSON(resp chronograf.Response) ([][]interface{}, error) { + rt := resp.(*responseType) + var decoded []influxResult + if err := json.Unmarshal(rt.Results, &decoded); err != nil { + return nil, err + } + if len(decoded) == 0 || len(decoded[0].Series) == 0 { + return nil, nil + } + return decoded[0].Series[0].Values, nil +} + +func equalMatrix(a, b [][]interface{}) bool { + if len(a) != len(b) { + return false + } + + // Convert to string tuples for consistent sorting and comparison + toStrings := func(m [][]interface{}) []string { + s := make([]string, len(m)) + for i, row := range m { + strRow := make([]string, len(row)) + for j, col := range row { + strRow[j] = fmt.Sprintf("%v", col) + } + s[i] = strings.Join(strRow, "|") + } + sort.Strings(s) + return s + } + + as := toStrings(a) + bs := toStrings(b) + + for i := range as { + if as[i] != bs[i] { + return false + } + } + return true +} diff --git a/influx/csv_tags_store.go b/influx/csv_tags_store.go new file mode 100644 index 0000000000..2243e0e8e9 --- /dev/null +++ b/influx/csv_tags_store.go @@ -0,0 +1,189 @@ +package influx + +import ( + "encoding/csv" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/influxdata/chronograf" +) + +const csvFileExtension = ".csv" + +// TagValues represents a set of tag values for a specific tag key +type TagValues []string + +// TagKeysMap represents a map of tag keys to their possible values +type TagKeysMap map[string]TagValues + +// MeasurementsMap represents a map of measurement names to their tag structure +type MeasurementsMap map[string]TagKeysMap + +// CSVTagsStore manages tags loaded from CSV files for InfluxDB Cloud Dedicated. +// It uses a directory containing per-database CSV files named `.csv` +// and caches tags for the last accessed database. +type CSVTagsStore struct { + csvDirPath string + logs chronograf.Logger + + cachedDatabase string // Currently cached database name + cachedTags MeasurementsMap // Tags for the currently cached database + + lock sync.Mutex +} + +// NewCSVTagsStore creates a new CSVTagsStore instance with the given CSV directory path. +// Returns an error if csvDirPath is empty, doesn't exist, or is not a directory. +func NewCSVTagsStore(csvDirPath string, logger chronograf.Logger) (*CSVTagsStore, error) { + if csvDirPath == "" { + return nil, fmt.Errorf("CSV directory path cannot be empty") + } + + fileInfo, err := os.Stat(csvDirPath) + if os.IsNotExist(err) { + return nil, fmt.Errorf("CSV directory path does not exist: %s", csvDirPath) + } + if err != nil { + return nil, fmt.Errorf("failed to stat CSV directory path: %w", err) + } + if !fileInfo.IsDir() { + return nil, fmt.Errorf("CSV path is not a directory: %s", csvDirPath) + } + + return &CSVTagsStore{ + csvDirPath: csvDirPath, + logs: logger.WithField("component", "csv-tags-store").WithField("dir", csvDirPath), + cachedDatabase: "", + cachedTags: make(MeasurementsMap), + }, nil +} + +// ensureDatabaseLoaded ensures the specified database is loaded in cache. +// If a different database is already cached, it drops the old cache and loads the new one. +func (c *CSVTagsStore) ensureDatabaseLoaded(database string) error { + // If this database is already cached, nothing to do + if c.cachedDatabase == database { + return nil + } + + // Load the new database + measurements, err := c.readDatabaseCSV(database) + if err != nil { + if c.logs != nil { + c.logs.WithField("database", database).Error("Could not read database CSV: ", err) + } + return err + } + + // Replace the cache + c.cachedDatabase = database + c.cachedTags = measurements + + if c.logs != nil { + c.logs.WithField("database", database).Info("Loaded database tags from CSV") + } + + return nil +} + +// readDatabaseCSV reads tags for a specific database from its CSV file +func (c *CSVTagsStore) readDatabaseCSV(database string) (MeasurementsMap, error) { + csvFileName := database + csvFileExtension + csvFilePath := filepath.Join(c.csvDirPath, csvFileName) + + startTime := time.Now() + recordCount := 0 + + // Check if the file exists + if _, err := os.Stat(csvFilePath); os.IsNotExist(err) { + return nil, fmt.Errorf("CSV file for database %s does not exist: %s", database, csvFilePath) + } + + // Open the CSV file + file, err := os.Open(csvFilePath) + if err != nil { + return nil, fmt.Errorf("failed to open CSV file %s: %w", csvFilePath, err) + } + defer func() { _ = file.Close() }() + + // Create a CSV reader + reader := csv.NewReader(file) + reader.Comma = ';' + reader.FieldsPerRecord = -1 + + measurements := make(MeasurementsMap) + isFirstLine := true + for { + record, err := reader.Read() + if err != nil { + if errors.Is(err, io.EOF) { + break + } + return nil, fmt.Errorf("failed to read CSV record from %s: %w", csvFileName, err) + } + + // Skip the header line if it matches the expected format (`measurement;tag-key;tag-value`) + if isFirstLine { + isFirstLine = false + if len(record) == 3 && + strings.TrimSpace(record[0]) == "measurement" && + strings.TrimSpace(record[1]) == "tag-key" && + strings.TrimSpace(record[2]) == "tag-value" { + continue + } + } + + recordCount++ + + if len(record) < 3 { + continue // Skip incomplete records - we expect: measurement, tag, value + } + + meas, tag, val := strings.TrimSpace(record[0]), strings.TrimSpace(record[1]), strings.TrimSpace(record[2]) + if _, ok := measurements[meas]; !ok { + measurements[meas] = make(TagKeysMap) + } + measurements[meas][tag] = append(measurements[meas][tag], val) + } + + // Log performance metrics + if c.logs != nil { + c.logs.WithField("database", database). + WithField("records", recordCount). + WithField("duration", time.Since(startTime)). + Debug("CSV file loaded successfully") + } + + return measurements, nil +} + +// GetMeasurementsMap returns the measurements map for a given database. +// IMPORTANT: The returned map is read-only and must not be modified by the caller! +// Modifying the returned map or its nested structures may cause data corruption +// and race conditions! +func (c *CSVTagsStore) GetMeasurementsMap(database string) MeasurementsMap { + c.lock.Lock() + defer c.lock.Unlock() + + if err := c.ensureDatabaseLoaded(database); err != nil { + return nil + } + + return c.cachedTags +} + +// HasDatabase checks if a database exists by checking the existence of the csv file +func (c *CSVTagsStore) HasDatabase(database string) bool { + c.lock.Lock() + defer c.lock.Unlock() + + csvFilePath := filepath.Join(c.csvDirPath, database+csvFileExtension) + _, err := os.Stat(csvFilePath) + return err == nil +} diff --git a/influx/csv_tags_store_test.go b/influx/csv_tags_store_test.go new file mode 100644 index 0000000000..a9910309e9 --- /dev/null +++ b/influx/csv_tags_store_test.go @@ -0,0 +1,296 @@ +package influx + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/influxdata/chronograf/log" +) + +func TestCSVTagsStore_GetMeasurementsMap(t *testing.T) { + tests := []struct { + name string + content string + expected MeasurementsMap + wantErr bool + }{ + { + name: "simple valid input", + content: ` +meas1;tag1;val1 +meas1;tag1;val2 +meas1;tag2;val3 +`, + expected: MeasurementsMap{ + "meas1": { + "tag1": {"val1", "val2"}, + "tag2": {"val3"}, + }, + }, + wantErr: false, + }, + { + name: "empty input", + content: ``, + expected: MeasurementsMap{}, + wantErr: false, + }, + { + name: "incomplete lines are skipped", + content: ` +meas1;tag1;val1 +meas1 +meas1;tag2;val2 +`, + expected: MeasurementsMap{ + "meas1": { + "tag1": {"val1"}, + "tag2": {"val2"}, + }, + }, + wantErr: false, + }, + { + name: "header line is skipped", + content: `measurement;tag-key;tag-value +meas1;tag1;val1 +meas1;tag2;val2 +`, + expected: MeasurementsMap{ + "meas1": { + "tag1": {"val1"}, + "tag2": {"val2"}, + }, + }, + wantErr: false, + }, + { + name: "header with extra spaces is skipped", + content: ` measurement ; tag-key ; tag-value +meas1;tag1;val1 +meas1;tag2;val2 +`, + expected: MeasurementsMap{ + "meas1": { + "tag1": {"val1"}, + "tag2": {"val2"}, + }, + }, + wantErr: false, + }, + { + name: "non-header first line is not skipped", + content: `measurement;tag-key;different-value +meas1;tag1;val1 +`, + expected: MeasurementsMap{ + "measurement": { + "tag-key": {"different-value"}, + }, + "meas1": { + "tag1": {"val1"}, + }, + }, + wantErr: false, + }, + } + + const testDB = "testdb" + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir, err := setupCSVTestDirWithContent(testDB, tt.content) + if err != nil { + t.Fatalf("could not setup CSV test dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + store, err := NewCSVTagsStore(tmpDir, log.New(log.DebugLevel)) + if err != nil { + t.Fatalf("could not create CSV tags store: %v", err) + } + + // Test the directory-based approach using GetMeasurementsMap + if tt.content != "" { + // Test that we can load the measurements for testDB + measurements := store.GetMeasurementsMap(testDB) + if measurements == nil { + t.Errorf("expected measurements for %s, got nil", testDB) + return + } + + // Compare with expected measurements + if !equalMeasurementsMap(measurements, tt.expected) { + t.Errorf("unexpected measurements:\nGot: %#v\nWant: %#v", measurements, tt.expected) + } + } else { + // For empty content, test that GetMeasurementsMap returns nil for testDB + measurements := store.GetMeasurementsMap(testDB) + if measurements != nil { + t.Errorf("expected nil measurements for empty content, got: %#v", measurements) + } + } + }) + } +} + +// equalMeasurementsMap compares two MeasurementsMap values deeply +func equalMeasurementsMap(a, b MeasurementsMap) bool { + if len(a) != len(b) { + return false + } + for meas, tags := range a { + bTags, ok := b[meas] + if !ok || len(tags) != len(bTags) { + return false + } + for tag, values := range tags { + bValues, ok := bTags[tag] + if !ok || len(values) != len(bValues) { + return false + } + for i := range values { + if values[i] != bValues[i] { + return false + } + } + } + } + return true +} + +// setupCSVTestDirWithContent creates a temporary directory with a CSV file containing raw content +func setupCSVTestDirWithContent(dbName string, csvContent string) (string, error) { + tmpDir, err := os.MkdirTemp("", "csv-test-") + if err != nil { + return "", err + } + + if csvContent != "" { + csvFile := filepath.Join(tmpDir, dbName+".csv") + if err := os.WriteFile(csvFile, []byte(csvContent), 0644); err != nil { + os.RemoveAll(tmpDir) + return "", err + } + } + + return tmpDir, nil +} + +func TestCSVTagsStore_HasDatabase(t *testing.T) { + const testDB = "testdb" + + tests := []struct { + name string + csvContent string + queryDatabase string + expected bool + }{ + { + name: "existing database", + csvContent: "meas1;tag1;val1\n", + queryDatabase: testDB, + expected: true, + }, + { + name: "non-existing database", + csvContent: "meas1;tag1;val1\n", + queryDatabase: "nonexistent", + expected: false, + }, + { + name: "empty csv directory", + csvContent: "", + queryDatabase: testDB, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir, err := setupCSVTestDirWithContent(testDB, tt.csvContent) + if err != nil { + t.Fatalf("could not setup CSV test dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + store, err := NewCSVTagsStore(tmpDir, log.New(log.DebugLevel)) + if err != nil { + t.Fatalf("could not create CSV tags store: %v", err) + } + result := store.HasDatabase(tt.queryDatabase) + + if result != tt.expected { + t.Errorf("HasDatabase(%q) = %v, want %v", tt.queryDatabase, result, tt.expected) + } + }) + } +} + +func TestCSVTagsStore_NewCSVTagsStore_InvalidDirectory(t *testing.T) { + t.Run("non-existent directory", func(t *testing.T) { + _, err := NewCSVTagsStore("/path/that/does/not/exist", log.New(log.DebugLevel)) + if err == nil { + t.Error("Expected error for non-existent directory, got nil") + } + if !strings.Contains(err.Error(), "does not exist") { + t.Errorf("Expected error message to contain 'does not exist', got: %v", err) + } + }) + + t.Run("path is a file not a directory", func(t *testing.T) { + // Create a temporary file + tmpFile, err := os.CreateTemp("", "test-file-*.txt") + if err != nil { + t.Fatalf("could not create temp file: %v", err) + } + tmpFilePath := tmpFile.Name() + tmpFile.Close() + defer os.Remove(tmpFilePath) + + // Try to create CSVTagsStore with a file path instead of directory + _, err = NewCSVTagsStore(tmpFilePath, log.New(log.DebugLevel)) + if err == nil { + t.Error("Expected error for file path instead of directory, got nil") + } + if !strings.Contains(err.Error(), "not a directory") { + t.Errorf("Expected error message to contain 'not a directory', got: %v", err) + } + }) + + t.Run("empty path is rejected", func(t *testing.T) { + _, err := NewCSVTagsStore("", log.New(log.DebugLevel)) + if err == nil { + t.Error("Expected error for empty path, got nil") + } + if !strings.Contains(err.Error(), "cannot be empty") { + t.Errorf("Expected error message to contain 'cannot be empty', got: %v", err) + } + }) +} + +func TestCSVTagsStore_ErrorHandling(t *testing.T) { + const testDB = "testdb" + + t.Run("invalid CSV content", func(t *testing.T) { + // Create a directory but no CSV file + tmpDir, err := os.MkdirTemp("", "csv-test-") + if err != nil { + t.Fatalf("could not create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + store, err := NewCSVTagsStore(tmpDir, log.New(log.DebugLevel)) + if err != nil { + t.Fatalf("could not create CSV tags store: %v", err) + } + measurements := store.GetMeasurementsMap(testDB) + + // Should return nil when CSV file doesn't exist + if measurements != nil { + t.Errorf("Expected nil measurements for non-existent CSV, got: %#v", measurements) + } + }) +} diff --git a/influx/databases.go b/influx/databases.go index 6560e55e69..a05703238d 100644 --- a/influx/databases.go +++ b/influx/databases.go @@ -61,6 +61,10 @@ func (c *Client) getRP(ctx context.Context, db, rp string) (chronograf.Retention // CreateRP creates a retention policy for a specific database func (c *Client) CreateRP(ctx context.Context, db string, rp *chronograf.RetentionPolicy) (*chronograf.RetentionPolicy, error) { + if c.isV3SrcType() { + // Data retention in InfluxDB 3 is configured differently, on database level. + return nil, fmt.Errorf("retention policies not supported in InfluxDB 3") + } query := fmt.Sprintf(`CREATE RETENTION POLICY "%s" ON "%s" DURATION %s REPLICATION %d`, rp.Name, db, rp.Duration, rp.Replication) if len(rp.ShardDuration) != 0 { query = fmt.Sprintf(`%s SHARD DURATION %s`, query, rp.ShardDuration) @@ -88,6 +92,10 @@ func (c *Client) CreateRP(ctx context.Context, db string, rp *chronograf.Retenti // UpdateRP updates a specific retention policy for a specific database func (c *Client) UpdateRP(ctx context.Context, db string, rp string, upd *chronograf.RetentionPolicy) (*chronograf.RetentionPolicy, error) { + if c.isV3SrcType() { + // Data retention in InfluxDB 3 is configured differently, on database level. + return nil, fmt.Errorf("retention policies not supported in InfluxDB 3") + } var buffer bytes.Buffer buffer.WriteString(fmt.Sprintf(`ALTER RETENTION POLICY "%s" ON "%s"`, rp, db)) if len(upd.Duration) > 0 { @@ -140,6 +148,10 @@ func (c *Client) UpdateRP(ctx context.Context, db string, rp string, upd *chrono // DropRP removes a specific retention policy for a specific database func (c *Client) DropRP(ctx context.Context, db string, rp string) error { + if c.isV3SrcType() { + // Data retention in InfluxDB 3 is configured differently, on database level. + return fmt.Errorf("retention policies not supported in InfluxDB 3") + } _, err := c.Query(ctx, chronograf.Query{ Command: fmt.Sprintf(`DROP RETENTION POLICY "%s" ON "%s"`, rp, db), DB: db, diff --git a/influx/influx.go b/influx/influx.go index bd9d6c246f..9fbc53f88d 100644 --- a/influx/influx.go +++ b/influx/influx.go @@ -1,10 +1,12 @@ package influx import ( + "bytes" "context" "encoding/json" "errors" "fmt" + "io" "io/ioutil" "net/http" "net/url" @@ -40,8 +42,15 @@ func init() { type Client struct { URL *url.URL Authorizer Authorizer + MgmtURL *url.URL // (optional) URL for management API + MgmtAuthorizer Authorizer // (optional) Authorizer for management API InsecureSkipVerify bool + SrcType string Logger chronograf.Logger + DefaultDB string + V3Config chronograf.V3Config + + csvTagsStore *CSVTagsStore // (optional) Store to load CSV tag files from source.TagsCSVPath directory } // Response is a partial JSON decoded InfluxQL response used @@ -107,7 +116,9 @@ func (c *Client) query(u *url.URL, q chronograf.Query) (chronograf.Response, err defer resp.Body.Close() var response responseType - dec := json.NewDecoder(resp.Body) + b, _ := io.ReadAll(resp.Body) + logs.Debug("JSON response from InfluxDB: ", string(b)) + dec := json.NewDecoder(bytes.NewReader(b)) decErr := dec.Decode(&response) if resp.StatusCode != http.StatusOK { @@ -126,16 +137,6 @@ func (c *Client) query(u *url.URL, q chronograf.Query) (chronograf.Response, err return nil, decErr } - // If we don't have an error in our json response, and didn't get statusOK - // then send back an error - if resp.StatusCode != http.StatusOK && response.Err != "" { - logs. - WithField("influx_status", resp.StatusCode). - Error("Received non-200 response from influxdb") - - return &response, fmt.Errorf("received status code %d from server", - resp.StatusCode) - } return &response, nil } @@ -149,9 +150,43 @@ type result struct { // include both the database and retention policy. In-flight requests can be // cancelled using the provided context. func (c *Client) Query(ctx context.Context, q chronograf.Query) (chronograf.Response, error) { + if c.SrcType == chronograf.InfluxDBv3Clustered || c.SrcType == chronograf.InfluxDBv3CloudDedicated { + logs := c.Logger. + WithField("component", "proxy"). + WithField("command", q.Command) + + cmdUpper := strings.ToUpper(q.Command) + switch { + case cmdUpper == "SHOW DATABASES": + return c.showDatabasesViaMgmtApi(ctx) + + case strings.Contains(cmdUpper, "SHOW MEASUREMENTS"): + if resp, err := c.handleShowMeasurements(q, logs); resp != nil || err != nil { + return resp, err + } + + case strings.Contains(cmdUpper, "SHOW TAG KEYS"): + if resp, err := c.handleShowTagKeys(q, logs); resp != nil || err != nil { + return resp, err + } + case strings.Contains(cmdUpper, "SHOW TAG VALUES"): + if resp, err := c.handleShowTagValues(&q, logs); resp != nil || err != nil { + return resp, err + } + } + } + resps := make(chan (result)) go func() { - resp, err := c.query(c.URL, q) + var resp chronograf.Response + var err error + if c.SrcType == chronograf.InfluxDBv3Core || c.SrcType == chronograf.InfluxDBv3Enterprise { + // v3 Core, v3 Enterprise + resp, err = c.queryV3(c.URL, q) + } else { + // v1, v2, v3 Clustered, v3 Cloud Dedicated + resp, err = c.query(c.URL, q) + } resps <- result{resp, err} }() @@ -168,18 +203,23 @@ func (c *Client) ValidateAuth(ctx context.Context, src *chronograf.Source) error ctx, cancel := context.WithTimeout(ctx, 15*time.Second) defer cancel() + // v3 Clustered, v3 Cloud Dedicated: + if src.Type == chronograf.InfluxDBv3Clustered || src.Type == chronograf.InfluxDBv3CloudDedicated { + return c.validateClusteredOrCloudDedicatedAuth(ctx) + } + // v2: use flux query if src.Type == chronograf.InfluxDBv2 { - return c.validateAuthFlux(ctx, src) + return c.validateV2Auth(ctx, src) } - // v1: use InfluxQL + // v1, v3 Core, v3 Enterprise: use InfluxQL if _, err := c.Query(ctx, chronograf.Query{Command: "SHOW DATABASES"}); err != nil { return err } return nil } -// validateAuthFlux uses Flux query to validate token authentication -func (c *Client) validateAuthFlux(ctx context.Context, src *chronograf.Source) error { +// validateV2Auth uses Flux query to validate token authentication +func (c *Client) validateV2Auth(ctx context.Context, src *chronograf.Source) error { u, err := url.Parse(c.URL.String()) if err != nil { return err @@ -210,11 +250,15 @@ func (c *Client) validateAuthFlux(ctx context.Context, src *chronograf.Source) e } } + return c.executeRequest(err, req) +} + +func (c *Client) executeRequest(err error, req *http.Request) error { hc := &http.Client{} hc.Transport = SharedTransport(c.InsecureSkipVerify) resp, err := hc.Do(req) if err != nil { - if err == context.DeadlineExceeded || err == context.Canceled { + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { return chronograf.ErrUpstreamTimeout } return err @@ -245,6 +289,48 @@ func (c *Client) Connect(ctx context.Context, src *chronograf.Source) error { } c.URL = u + + if src.Type == chronograf.InfluxDBv3Clustered { + // InfluxDB Clustered also provides a management API. + if len(src.ManagementToken) > 0 { + accountID := c.V3Config.ClusteredAccountID + clusterID := c.V3Config.ClusteredClusterID + baseURL := *c.URL + baseURL.Path = "" + baseURL.RawQuery = "" + mgmtUrl := fmt.Sprintf("%s/api/v0/accounts/%s/clusters/%s", baseURL.String(), accountID, clusterID) + if u, err = url.Parse(mgmtUrl); err != nil { + return err + } + c.MgmtURL = u + c.MgmtAuthorizer = &BearerToken{ + Token: src.ManagementToken, + } + } + c.DefaultDB = src.DefaultDB + } + + if src.Type == chronograf.InfluxDBv3CloudDedicated { + if len(src.AccountID) > 0 { + // InfluxDB Cloud Dedicated also provides a management API. + mgmtUrl := fmt.Sprintf("%s/api/v0/accounts/%s/clusters/%s", c.V3Config.CloudDedicatedManagementURL, src.AccountID, src.ClusterID) + if u, err = url.Parse(mgmtUrl); err != nil { + return err + } + + c.MgmtURL = u + c.MgmtAuthorizer = &BearerToken{ + Token: src.ManagementToken, + } + } + c.DefaultDB = src.DefaultDB + if src.TagsCSVPath != "" { + if c.csvTagsStore, err = NewCSVTagsStore(src.TagsCSVPath, c.Logger); err != nil { + return err + } + } + } + c.SrcType = src.Type return nil } @@ -291,6 +377,10 @@ func (c *Client) pingTimeout(ctx context.Context) (string, string, error) { } } +type v3PingRespBody struct { + Version string `json:"version"` +} + type pingResult struct { Version string Type string @@ -326,28 +416,54 @@ func (c *Client) ping(u *url.URL) (string, string, error) { return "", "", err } - if resp.StatusCode != http.StatusNoContent { - var err = fmt.Errorf("%s", string(body)) + if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { + var err = errors.New(string(body)) return "", "", err } - version := resp.Header.Get("X-Influxdb-Build") - if version == "ENT" { - return version, chronograf.InfluxEnterprise, nil + if c.SrcType == chronograf.InfluxDBv3Core || c.SrcType == chronograf.InfluxDBv3Enterprise { + // Read the version from the body + if len(body) == 0 { + return "", "", fmt.Errorf("empty ping response body") + } + var b v3PingRespBody + err := json.Unmarshal(body, &b) + if err != nil { + return "", "", fmt.Errorf("failed to parse ping response body: %w", err) + } + if b.Version == "" { + return "", "", fmt.Errorf("missing version in ping response body") + } + return b.Version, c.SrcType, nil } - version = resp.Header.Get("X-Influxdb-Version") - if strings.Contains(version, "-c") { - return version, chronograf.InfluxEnterprise, nil - } else if strings.Contains(version, "relay") { - return version, chronograf.InfluxRelay, nil + + if !c.isV3SrcType() { + // Check the `X-Influxdb-Build` header + builds := resp.Header.Values("X-Influxdb-Build") + for _, build := range builds { + if build == "ENT" { + return build, chronograf.InfluxDBv1Enterprise, nil + } + } } + + // Read the version from the `X-Influxdb-Version` header + version := resp.Header.Get("X-Influxdb-Version") + if !c.isV3SrcType() && version != "" { + if strings.Contains(version, "-c") { + return version, chronograf.InfluxDBv1Enterprise, nil + } else if strings.Contains(version, "relay") { + return version, chronograf.InfluxDBv1Relay, nil + } + } + // Strip v prefix from version, some older '1.x' versions and also // InfluxDB 2.2.0 return version in format vx.x.x if strings.HasPrefix(version, "v") { version = version[1:] } - return version, chronograf.InfluxDB, nil + return version, c.SrcType, nil } // Write POSTs line protocol to a database and retention policy diff --git a/influx/influx_test.go b/influx/influx_test.go index b64a5dd683..d8b1b13e2f 100644 --- a/influx/influx_test.go +++ b/influx/influx_test.go @@ -761,3 +761,437 @@ func Test_Query(t *testing.T) { } } } + +func Test_Influx_ValidateAuth_V3Core(t *testing.T) { + t.Parallel() + calledPath := "" + ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + calledPath = r.URL.Path + // V3 Core uses /api/v3/query_influxql for SHOW DATABASES + if strings.HasSuffix(r.URL.Path, "/api/v3/query_influxql") { + rw.WriteHeader(http.StatusUnauthorized) + rw.Write([]byte(`{"error":"v3coreauthfailed"}`)) + if auth := r.Header.Get("Authorization"); auth != "Bearer my-db-token" { + t.Errorf("Expected Authorization 'Bearer my-db-token' but was: %v", auth) + } + } + })) + defer ts.Close() + for _, urlContext := range []string{"", "/ctx"} { + calledPath = "" + client, err := NewClient(ts.URL+urlContext, log.New(log.DebugLevel)) + if err != nil { + t.Fatal("Unexpected error initializing client: err:", err) + } + source := &chronograf.Source{ + URL: ts.URL + urlContext, + Type: chronograf.InfluxDBv3Core, + DatabaseToken: "my-db-token", + } + + client.Connect(context.Background(), source) + err = client.ValidateAuth(context.Background(), source) + if err == nil { + t.Fatal("Expected error but nil") + } + if !strings.Contains(err.Error(), "v3coreauthfailed") { + t.Errorf("Expected client error '%v' to contain server-sent error message", err) + } + expectedPath := urlContext + "/api/v3/query_influxql" + if calledPath != expectedPath { + t.Errorf("Path received: %v, want: %v ", calledPath, expectedPath) + } + } +} + +func Test_Influx_ValidateAuth_V3Enterprise(t *testing.T) { + t.Parallel() + calledPath := "" + ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + calledPath = r.URL.Path + // V3 Enterprise uses /api/v3/query_influxql for SHOW DATABASES + if strings.HasSuffix(r.URL.Path, "/api/v3/query_influxql") { + rw.WriteHeader(http.StatusUnauthorized) + rw.Write([]byte(`{"error":"v3enterpriseauthfailed"}`)) + if auth := r.Header.Get("Authorization"); auth != "Bearer my-db-token" { + t.Errorf("Expected Authorization 'Bearer my-db-token' but was: %v", auth) + } + } + })) + defer ts.Close() + for _, urlContext := range []string{"", "/ctx"} { + calledPath = "" + client, err := NewClient(ts.URL+urlContext, log.New(log.DebugLevel)) + if err != nil { + t.Fatal("Unexpected error initializing client: err:", err) + } + source := &chronograf.Source{ + URL: ts.URL + urlContext, + Type: chronograf.InfluxDBv3Enterprise, + DatabaseToken: "my-db-token", + } + + client.Connect(context.Background(), source) + err = client.ValidateAuth(context.Background(), source) + if err == nil { + t.Fatal("Expected error but nil") + } + if !strings.Contains(err.Error(), "v3enterpriseauthfailed") { + t.Errorf("Expected client error '%v' to contain server-sent error message", err) + } + expectedPath := urlContext + "/api/v3/query_influxql" + if calledPath != expectedPath { + t.Errorf("Path received: %v, want: %v ", calledPath, expectedPath) + } + } +} + +func Test_Influx_ValidateAuth_V3Clustered(t *testing.T) { + t.Parallel() + mgmtAuthCalled := false + ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + // V3 Clustered validates both management and database auth + if strings.Contains(r.URL.Path, "/api/v0/accounts/") { + // Management API call + mgmtAuthCalled = true + auth := r.Header.Get("Authorization") + if auth != "Bearer my-mgmt-token" { + t.Errorf("Expected Authorization 'Bearer my-mgmt-token' but was: %v", auth) + } + rw.WriteHeader(http.StatusUnauthorized) + rw.Write([]byte(`{"error":"mgmt auth failed"}`)) + } else if strings.HasSuffix(r.URL.Path, "/query") { + // Database query endpoint + auth := r.Header.Get("Authorization") + if auth != "Bearer my-db-token" { + t.Errorf("Expected Authorization 'Bearer my-db-token' but was: %v", auth) + } + rw.WriteHeader(http.StatusUnauthorized) + rw.Write([]byte(`{"error":"db auth failed"}`)) + } + })) + defer ts.Close() + + client, err := NewClient(ts.URL, log.New(log.DebugLevel)) + if err != nil { + t.Fatal("Unexpected error initializing client: err:", err) + } + source := &chronograf.Source{ + URL: ts.URL, + Type: chronograf.InfluxDBv3Clustered, + DatabaseToken: "my-db-token", + ManagementToken: "my-mgmt-token", + } + + err = client.Connect(context.Background(), source) + if err != nil { + t.Fatal("Unexpected error connecting client: err:", err) + } + err = client.ValidateAuth(context.Background(), source) + if err == nil { + t.Fatal("Expected error but nil") + } + if !strings.Contains(err.Error(), "management authentication failed") { + t.Errorf("Expected error to contain 'management authentication failed' but was: %v", err) + } + if !mgmtAuthCalled { + t.Error("Expected management API to be called") + } + + mgmtAuthCalled = false + client, err = NewClient(ts.URL, log.New(log.DebugLevel)) + if err != nil { + t.Fatal("Unexpected error connecting client: err:", err) + } + source = &chronograf.Source{ + URL: ts.URL, + Type: chronograf.InfluxDBv3Clustered, + DatabaseToken: "my-db-token", + DefaultDB: "defaultdb", + } + + err = client.Connect(context.Background(), source) + if err != nil { + t.Fatal("Unexpected error connecting client: err:", err) + } + if client.DefaultDB != "defaultdb" { + t.Errorf("Expected default DB to be 'defaultdb' but was: %v", client.DefaultDB) + } + + err = client.ValidateAuth(context.Background(), source) + if err == nil { + t.Fatal("Expected error but nil") + } + + if mgmtAuthCalled { + t.Error("Expected management API called") + } +} + +func Test_Influx_ValidateAuth_V3CloudDedicated(t *testing.T) { + t.Parallel() + mgmtAuthCalled := false + ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + // V3 Cloud Dedicated validates both management and database auth + if strings.Contains(r.URL.Path, "/api/v0/accounts/") { + // Management API call + mgmtAuthCalled = true + auth := r.Header.Get("Authorization") + if auth != "Bearer my-mgmt-token" { + t.Errorf("Expected Authorization 'Bearer my-mgmt-token' but was: %v", auth) + } + rw.WriteHeader(http.StatusUnauthorized) + rw.Write([]byte(`{"error":"mgmt auth failed"}`)) + } else if strings.HasSuffix(r.URL.Path, "/query") { + // Database query endpoint + auth := r.Header.Get("Authorization") + if auth != "Bearer my-db-token" { + t.Errorf("Expected Authorization 'Bearer my-db-token' but was: %v", auth) + } + rw.WriteHeader(http.StatusUnauthorized) + rw.Write([]byte(`{"error":"db auth failed"}`)) + } + })) + defer ts.Close() + + client, err := NewClient(ts.URL, log.New(log.DebugLevel)) + if err != nil { + t.Fatal("Unexpected error connecting client: err:", err) + } + client.V3Config = chronograf.V3Config{ + CloudDedicatedManagementURL: ts.URL, + ClusteredAccountID: "test-account-id", + ClusteredClusterID: "test-cluster-id", + } + if err != nil { + t.Fatal("Unexpected error initializing client: err:", err) + } + source := &chronograf.Source{ + URL: ts.URL, + Type: chronograf.InfluxDBv3CloudDedicated, + DatabaseToken: "my-db-token", + ManagementToken: "my-mgmt-token", + AccountID: "test-account-id", + ClusterID: "test-cluster-id", + } + + err = client.Connect(context.Background(), source) + if err != nil { + t.Fatal("Unexpected error connecting client: err:", err) + } + err = client.ValidateAuth(context.Background(), source) + if err == nil { + t.Fatal("Expected error but nil") + } + // Cloud Dedicated validates management auth first + if !strings.Contains(err.Error(), "management authentication failed") { + t.Errorf("Expected error to contain 'management authentication failed' but was: %v", err) + } + if !mgmtAuthCalled { + t.Error("Expected management API to be called") + } + + mgmtAuthCalled = false + client, err = NewClient(ts.URL, log.New(log.DebugLevel)) + if err != nil { + t.Fatal("Unexpected error connecting client: err:", err) + } + client.V3Config = chronograf.V3Config{ + CloudDedicatedManagementURL: ts.URL, + } + source = &chronograf.Source{ + URL: ts.URL, + Type: chronograf.InfluxDBv3CloudDedicated, + DatabaseToken: "my-db-token", + DefaultDB: "defaultdb", + } + + err = client.Connect(context.Background(), source) + if err != nil { + t.Fatal("Unexpected error connecting client: err:", err) + } + if client.DefaultDB != "defaultdb" { + t.Errorf("Expected default DB to be 'defaultdb' but was: %v", client.DefaultDB) + } + + err = client.ValidateAuth(context.Background(), source) + if err == nil { + t.Fatal("Expected error but nil") + } + + if mgmtAuthCalled { + t.Error("Expected management API called") + } +} + +func Test_Influx_Authorization_V3Core(t *testing.T) { + t.Parallel() + ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + if auth != "Bearer test-token-core" { + t.Errorf("Expected Authorization 'Bearer test-token-core' but was: %v", auth) + } + rw.WriteHeader(http.StatusOK) + // V3 Core returns different response format + if strings.HasSuffix(r.URL.Path, "/api/v3/query_influxql") { + rw.Write([]byte(`[{"iox::database":"mydb","deleted":false}]`)) + } else { + rw.Write([]byte(`{"results":[{}]}`)) + } + })) + defer ts.Close() + + client, err := NewClient(ts.URL, log.New(log.DebugLevel)) + if err != nil { + t.Fatal("Unexpected error initializing client: err:", err) + } + source := &chronograf.Source{ + URL: ts.URL, + Type: chronograf.InfluxDBv3Core, + DatabaseToken: "test-token-core", + } + + client.Connect(context.Background(), source) + query := chronograf.Query{ + Command: "SHOW DATABASES", + } + _, err = client.Query(context.Background(), query) + if err != nil { + t.Fatal("Expected no error but was", err) + } +} + +func Test_Influx_Authorization_V3Enterprise(t *testing.T) { + t.Parallel() + ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + if auth != "Bearer test-token-enterprise" { + t.Errorf("Expected Authorization 'Bearer test-token-enterprise' but was: %v", auth) + } + rw.WriteHeader(http.StatusOK) + // V3 Enterprise returns different response format + if strings.HasSuffix(r.URL.Path, "/api/v3/query_influxql") { + rw.Write([]byte(`[{"iox::database":"mydb","deleted":false}]`)) + } else { + rw.Write([]byte(`{"results":[{}]}`)) + } + })) + defer ts.Close() + + client, err := NewClient(ts.URL, log.New(log.DebugLevel)) + if err != nil { + t.Fatal("Unexpected error initializing client: err:", err) + } + source := &chronograf.Source{ + URL: ts.URL, + Type: chronograf.InfluxDBv3Enterprise, + DatabaseToken: "test-token-enterprise", + } + + client.Connect(context.Background(), source) + query := chronograf.Query{ + Command: "SHOW DATABASES", + } + _, err = client.Query(context.Background(), query) + if err != nil { + t.Fatal("Expected no error but was", err) + } +} + +func Test_Influx_Authorization_V3Clustered(t *testing.T) { + t.Parallel() + ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + // V3 Clustered intercepts SHOW DATABASES and calls management API + if strings.Contains(r.URL.Path, "/api/v0/accounts/") { + // Management API - expects management token + auth := r.Header.Get("Authorization") + if auth != "Bearer test-mgmt-token" { + t.Errorf("Expected Authorization 'Bearer test-mgmt-token' but was: %v", auth) + } + rw.WriteHeader(http.StatusOK) + rw.Write([]byte(`[{"name":"mydb","maxTables":500,"maxColumnsPerTable":200,"retentionPeriod":0}]`)) + } else { + // Database query - expects database token + auth := r.Header.Get("Authorization") + if auth != "Bearer test-token-clustered" { + t.Errorf("Expected Authorization 'Bearer test-token-clustered' but was: %v", auth) + } + rw.WriteHeader(http.StatusOK) + rw.Write([]byte(`{"results":[{}]}`)) + } + })) + defer ts.Close() + + client, err := NewClient(ts.URL, log.New(log.DebugLevel)) + if err != nil { + t.Fatal("Unexpected error initializing client: err:", err) + } + source := &chronograf.Source{ + URL: ts.URL, + Type: chronograf.InfluxDBv3Clustered, + DatabaseToken: "test-token-clustered", + ManagementToken: "test-mgmt-token", + } + client.V3Config = chronograf.V3Config{ + CloudDedicatedManagementURL: ts.URL, + ClusteredAccountID: "test-account-id", + ClusteredClusterID: "test-cluster-id", + } + client.Connect(context.Background(), source) + query := chronograf.Query{ + Command: "SHOW DATABASES", + } + _, err = client.Query(context.Background(), query) + if err != nil { + t.Fatal("Expected no error but was", err) + } +} + +func Test_Influx_Authorization_V3CloudDedicated(t *testing.T) { + t.Parallel() + ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + // V3 Cloud Dedicated intercepts SHOW DATABASES and calls management API + if strings.Contains(r.URL.Path, "/api/v0/accounts/") { + // Management API - expects management token + auth := r.Header.Get("Authorization") + if auth != "Bearer test-mgmt-token" { + t.Errorf("Expected Authorization 'Bearer test-mgmt-token' but was: %v", auth) + } + rw.WriteHeader(http.StatusOK) + rw.Write([]byte(`[{"name":"mydb","maxTables":500,"maxColumnsPerTable":200,"retentionPeriod":0}]`)) + } else { + // Database query - expects database token + auth := r.Header.Get("Authorization") + if auth != "Bearer test-token-cloud-dedicated" { + t.Errorf("Expected Authorization 'Bearer test-token-cloud-dedicated' but was: %v", auth) + } + rw.WriteHeader(http.StatusOK) + rw.Write([]byte(`{"results":[{}]}`)) + } + })) + defer ts.Close() + + client, err := NewClient(ts.URL, log.New(log.DebugLevel)) + if err != nil { + t.Fatal("Unexpected error initializing client: err:", err) + } + source := &chronograf.Source{ + URL: ts.URL, + Type: chronograf.InfluxDBv3CloudDedicated, + DatabaseToken: "test-token-cloud-dedicated", + ManagementToken: "test-mgmt-token", + AccountID: "test-account-id", + ClusterID: "test-cluster-id", + } + client.V3Config = chronograf.V3Config{ + CloudDedicatedManagementURL: ts.URL, + } + client.Connect(context.Background(), source) + query := chronograf.Query{ + Command: "SHOW DATABASES", + } + _, err = client.Query(context.Background(), query) + if err != nil { + t.Fatal("Expected no error but was", err) + } +} diff --git a/influx/influx_v3.go b/influx/influx_v3.go new file mode 100644 index 0000000000..9c6d8e09cb --- /dev/null +++ b/influx/influx_v3.go @@ -0,0 +1,297 @@ +package influx + +import ( + "encoding/json" + "fmt" + "io" + "math/rand" + "net/http" + "net/url" + "strings" + + "github.com/influxdata/chronograf" + "github.com/influxdata/chronograf/util" + "github.com/influxdata/influxdb/influxql" +) + +// v3Database represents a database entry from InfluxDB v3 API response to `SHOW DATABASES` +type v3Database struct { + Database string `json:"iox::database"` + Deleted bool `json:"deleted"` +} + +// v3RetentionPolicy represents a retention policy entry from InfluxDB v3 API response to `SHOW RETENTION POLICIES` +type v3RetentionPolicy struct { + Database string `json:"iox::database"` + Name string `json:"name"` +} + +func (c *Client) isV3SrcType() bool { + return chronograf.IsV3SrcType(c.SrcType) +} + +// queryV3 executes InfluxQL queries against InfluxDB v3 server +func (c *Client) queryV3(u *url.URL, q chronograf.Query) (chronograf.Response, error) { + // Parse query + cmd := q.Command + stmt, err := influxql.ParseStatement(cmd) + if err != nil { + return nil, fmt.Errorf("parsing error: %w", err) + } + + // Select query endpoint + var path string + switch stmt.(type) { + case *influxql.ShowDatabasesStatement, + *influxql.ShowRetentionPoliciesStatement: + // `SHOW DATABASES` and `SHOW RETENTION POLICIES` are queried on the v3 query endpoint + path = "/api/v3/query_influxql" + default: + // v1 compatibility query endpoint + path = "/query" + } + + // Prepare request + u = util.AppendPath(u, path) + req, err := http.NewRequest("GET", u.String(), nil) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + logs := c.Logger. + WithField("queryID", rand.Uint32()). + WithField("component", "proxy") + logs. + WithField("host", req.Host). + WithField("path", path). + WithField("command", cmd). + WithField("db", q.DB). + Debug("query") + + // If the database is not specified, then get it from the query + if q.DB == "" { + if db := parseDatabaseNameFromStatement(stmt); db != "" { + q.DB = db + logs.WithField("db", q.DB).Debug("database parsed from query") + } + } + + // Clear retention policies from queries since they're not supported in v3 queries + if clearRetentionPolicies(stmt) { + cmd = stmt.String() + logs.WithField("command", cmd).Debug("retention policies cleared from query") + } + + switch s := stmt.(type) { + case *influxql.ShowTagValuesStatement: + // Ensure time condition is added to `SHOW TAG VALUES` queries + if appendTimeCondition(s, c.V3Config.TimeConditionExpr) { + cmd = stmt.String() + logs.WithField("command", cmd).Debug("time condition added to SHOW TAG VALUES query") + } + case *influxql.ShowRetentionPoliciesStatement: + // Clear ON modifier from `SHOW RETENTION POLICIES` since not supported in v3 + if clearOnModifier(s) { + cmd = stmt.String() + logs.WithField("command", cmd).Debug("ON modifier cleared from SHOW RETENTION POLICIES query") + } + } + + // Query parameters + params := req.URL.Query() + params.Set("q", cmd) + params.Set("db", q.DB) + params.Set("epoch", "ms") + if q.Epoch != "" { + params.Set("epoch", q.Epoch) + } + req.URL.RawQuery = params.Encode() + + // Authorization + if c.Authorizer != nil { + if err := c.Authorizer.Set(req); err != nil { + logs.Error("Error setting authorization header ", err) + return nil, err + } + } + + // Do the request + hc := &http.Client{} + hc.Transport = SharedTransport(c.InsecureSkipVerify) + resp, err := hc.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + b, _ := io.ReadAll(resp.Body) + bodyString := string(b) + logs.Debug("JSON response from InfluxDB: ", bodyString) + + if resp.StatusCode != http.StatusOK { + if resp.StatusCode == http.StatusInternalServerError { + // Shorten the error message to the point + errPrefix := "error in InfluxQL statement:" + if strings.HasPrefix(bodyString, errPrefix) { + return nil, fmt.Errorf("%s", bodyString[len(errPrefix):]) + } + } + return nil, fmt.Errorf("received status code %d: %s", resp.StatusCode, bodyString) + } + + switch stmt.(type) { + case *influxql.ShowDatabasesStatement: + // Handle `SHOW DATABASES` + return processShowDatabasesV3Response(b) + + case *influxql.ShowRetentionPoliciesStatement: + // Handle `SHOW RETENTION POLICIES` + return processShowRetentionPoliciesV3Response(b) + + default: + // Handle response form v1 compatibility endpoint + return processV1Response(b) + } +} + +// processShowDatabasesV3Response parses InfluxDB v3 SHOW DATABASES response and returns non-deleted database names aS InfluxQL response +func processShowDatabasesV3Response(responseBody []byte) (chronograf.Response, error) { + var databases []v3Database + if err := json.Unmarshal(responseBody, &databases); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + // Convert response, filtering out deleted databases. + dbNames := make([]string, 0) + for _, db := range databases { + if !db.Deleted { + dbNames = append(dbNames, db.Database) + } + } + return constructShowDatabasesResponse(dbNames), nil +} + +// processShowRetentionPoliciesV3Response parses InfluxDB v3 SHOW RETENTION POLICIES response and returns it as InfluxQL response +func processShowRetentionPoliciesV3Response(responseBody []byte) (chronograf.Response, error) { + var policies []v3RetentionPolicy + if err := json.Unmarshal(responseBody, &policies); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + // Convert to InfluxQL response format with only "name" column + values := make([][]interface{}, len(policies)) + for i, policy := range policies { + values[i] = []interface{}{policy.Name} + } + + return buildSingleSeriesResponse("", []string{"name"}, values) +} + +// processV1Response parses a v1 compatibility API response and returns it as chronograf.Response +func processV1Response(responseBody []byte) (chronograf.Response, error) { + var response responseType + if err := json.Unmarshal(responseBody, &response); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + return &response, nil +} + +// clearMeasurementRP clears the retention policy from a measurement if it exists. +// Returns true if the retention policy was cleared. +func clearMeasurementRP(source influxql.Source) bool { + if mm, ok := source.(*influxql.Measurement); ok && mm.RetentionPolicy != "" { + mm.RetentionPolicy = "" + mm.Database = "" + return true + } + return false +} + +// clearRetentionPolicies removes retention policy references from InfluxQL statements +// since they're not supported in InfluxDB v3. Returns true if the statement was modified. +func clearRetentionPolicies(stmt influxql.Statement) bool { + modified := false + + var sources influxql.Sources + switch s := stmt.(type) { + case *influxql.ShowMeasurementsStatement: + return clearMeasurementRP(s.Source) + case *influxql.ShowTagKeysStatement: + sources = s.Sources + case *influxql.ShowTagValuesStatement: + sources = s.Sources + case *influxql.ShowFieldKeysStatement: + sources = s.Sources + case *influxql.SelectStatement: + sources = s.Sources + default: + return false + } + + if sources != nil { + for _, source := range sources { + if clearMeasurementRP(source) { + modified = true + } + } + } + + return modified +} + +// parseDatabaseNameFromStatement extracts the database name from InfluxQL query statement if present. +// Returns empty string if not found. +func parseDatabaseNameFromStatement(stmt influxql.Statement) string { + switch s := stmt.(type) { + case *influxql.SelectStatement: + if len(s.Sources) > 0 { + for _, source := range s.Sources { + if measurement, ok := source.(*influxql.Measurement); ok { + return measurement.Database + } + } + } + } + + return "" +} + +// clearOnModifier clears ON modifiers from show retention policies in the source. +// Returns true if the retention policy was cleared. +func clearOnModifier(stmt influxql.Statement) bool { + if mm, ok := stmt.(*influxql.ShowRetentionPoliciesStatement); ok && mm.Database != "" { + mm.Database = "" + return true + } + return false +} + +// buildSeriesResponse creates a standardized InfluxQL response with multiple series +func buildSeriesResponse(seriesResults []series) (chronograf.Response, error) { + response := fakeInfluxResponse{ + { + StatementID: 0, + Series: seriesResults, + }, + } + + data, err := json.Marshal(response) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return &responseType{ + Results: data, + }, nil +} + +// buildSingleSeriesResponse creates a standardized InfluxQL response with a single series +func buildSingleSeriesResponse(name string, columns []string, values [][]interface{}) (chronograf.Response, error) { + seriesResults := []series{ + { + Name: name, + Columns: columns, + Values: values, + }, + } + return buildSeriesResponse(seriesResults) +} diff --git a/influx/permissions.go b/influx/permissions.go index 90ef01402a..4e007968ad 100644 --- a/influx/permissions.go +++ b/influx/permissions.go @@ -123,6 +123,22 @@ func (r *showResults) RetentionPolicies(logger chronograf.Logger) []chronograf.R // parseRetentionPolicy validates and parses a retention policy row func parseRetentionPolicy(v []interface{}) (chronograf.RetentionPolicy, error) { columns := len(v) + + if columns == 1 { + // 1-column format: [name] -- returned by InfluxDB 3 + if name, ok := v[0].(string); !ok { + return chronograf.RetentionPolicy{}, fmt.Errorf("column 0 (name) is not a string") + } else { + return chronograf.RetentionPolicy{ + Name: name, + Duration: "0s", + ShardDuration: "0s", + Replication: 1, + Default: false, + }, nil + } + } + if columns < 5 { return chronograf.RetentionPolicy{}, fmt.Errorf("insufficient columns: expected at least 5, got %d", columns) } else if name, ok := v[0].(string); !ok { diff --git a/kv/bolt/client.go b/kv/bolt/client.go index 2acced1f56..fe22c63389 100644 --- a/kv/bolt/client.go +++ b/kv/bolt/client.go @@ -5,7 +5,7 @@ import ( "fmt" "io" "os" - "path" + "path/filepath" "time" "github.com/influxdata/chronograf" @@ -239,7 +239,7 @@ func (c *client) backup(ctx context.Context, lastBuild, build chronograf.BuildIn // copy creates a copy of the database in toFile func (c *client) copy(ctx context.Context, version string) error { - backupDir := path.Join(path.Dir(c.path), "backup") + backupDir := filepath.Join(filepath.Dir(c.path), "backup") if _, err := os.Stat(backupDir); os.IsNotExist(err) { if err = os.Mkdir(backupDir, 0700); err != nil { return err @@ -254,8 +254,8 @@ func (c *client) copy(ctx context.Context, version string) error { } defer fromFile.Close() - toName := fmt.Sprintf("%s.%s", path.Base(c.path), version) - toPath := path.Join(backupDir, toName) + toName := fmt.Sprintf("%s.%s", filepath.Base(c.path), version) + toPath := filepath.Join(backupDir, toName) toFile, err := os.OpenFile(toPath, os.O_RDWR|os.O_CREATE, 0600) if err != nil { return err diff --git a/kv/etcd/client_test.go b/kv/etcd/client_test.go index 2e1e865096..42354c5885 100644 --- a/kv/etcd/client_test.go +++ b/kv/etcd/client_test.go @@ -2,7 +2,6 @@ package etcd import ( "context" - "fmt" "io/ioutil" "net/url" "os" @@ -12,6 +11,7 @@ import ( "github.com/influxdata/chronograf" "github.com/influxdata/chronograf/kv" "github.com/influxdata/chronograf/mocks" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" clientv3 "go.etcd.io/etcd/client/v3" "go.etcd.io/etcd/server/v3/embed" @@ -119,7 +119,7 @@ func Test_WithURL(t *testing.T) { }, { url: parse("etcd://u:p@127.0.0.1:2379?cert=a&key=b&ca=c"), - err: "no such file or directory", + err: ".*file.*", }, { url: parse("etcd://a:b@1.2.3.4:5555?ca=test.crt&key=test.key&cert=test.crt"), @@ -157,7 +157,8 @@ func Test_WithURL(t *testing.T) { } else { require.NotNil(t, err) // Contains is used, because nested exceptions can evolve with go versions - require.Contains(t, fmt.Sprintf("%v", err), test.err) + //require.Contains(t, fmt.Sprintf("%v", err), test.err) + assert.Regexp(t, test.err, err.Error()) } }) } diff --git a/kv/internal/internal.go b/kv/internal/internal.go index 70cc068f79..59850bf048 100644 --- a/kv/internal/internal.go +++ b/kv/internal/internal.go @@ -48,6 +48,12 @@ func MarshalSource(s chronograf.Source) ([]byte, error) { Role: s.Role, DefaultRP: s.DefaultRP, Version: s.Version, + ClusterID: s.ClusterID, + AccountID: s.AccountID, + ManagementToken: s.ManagementToken, + DatabaseToken: s.DatabaseToken, + TagsCSVPath: s.TagsCSVPath, + DefaultDatabase: s.DefaultDB, }) } @@ -73,6 +79,12 @@ func UnmarshalSource(data []byte, s *chronograf.Source) error { s.Role = pb.Role s.DefaultRP = pb.DefaultRP s.Version = pb.Version + s.ClusterID = pb.ClusterID + s.AccountID = pb.AccountID + s.ManagementToken = pb.ManagementToken + s.DatabaseToken = pb.DatabaseToken + s.TagsCSVPath = pb.TagsCSVPath + s.DefaultDB = pb.DefaultDatabase return nil } diff --git a/kv/internal/internal.pb.go b/kv/internal/internal.pb.go index 15ce31b5ee..4afb75ee5f 100644 --- a/kv/internal/internal.pb.go +++ b/kv/internal/internal.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.27.1 -// protoc v3.17.3 +// protoc-gen-go v1.34.2 +// protoc v5.28.0 // source: internal.proto package internal @@ -40,6 +40,12 @@ type Source struct { Role string `protobuf:"bytes,13,opt,name=Role,proto3" json:"Role,omitempty"` // Role is the name of the miniumum role that a user must possess to access the resource DefaultRP string `protobuf:"bytes,14,opt,name=DefaultRP,proto3" json:"DefaultRP,omitempty"` // DefaultRP is the default retention policy used in database queries to this source Version string `protobuf:"bytes,15,opt,name=Version,proto3" json:"Version,omitempty"` // Version of the InfluxDB or Unknown + ClusterID string `protobuf:"bytes,16,opt,name=ClusterID,proto3" json:"ClusterID,omitempty"` // Cluster ID of an InfluxDB Cloud Dedicated source + AccountID string `protobuf:"bytes,17,opt,name=AccountID,proto3" json:"AccountID,omitempty"` // Account ID of an InfluxDB Cloud Dedicated source + ManagementToken string `protobuf:"bytes,18,opt,name=ManagementToken,proto3" json:"ManagementToken,omitempty"` // Management token of an InfluxDB Cloud Dedicated source + DatabaseToken string `protobuf:"bytes,19,opt,name=DatabaseToken,proto3" json:"DatabaseToken,omitempty"` // Database token of an InfluxDB Cloud Dedicated or other InfluxDB 3 source + TagsCSVPath string `protobuf:"bytes,20,opt,name=TagsCSVPath,proto3" json:"TagsCSVPath,omitempty"` // TagsCSVPath is the path to a directory containing CSV files (per db) with tags for the source + DefaultDatabase string `protobuf:"bytes,21,opt,name=DefaultDatabase,proto3" json:"DefaultDatabase,omitempty"` // DefaultDatabase is the default database used in queries for InfluxDB Cloud Dedicated when database list is not available } func (x *Source) Reset() { @@ -179,6 +185,48 @@ func (x *Source) GetVersion() string { return "" } +func (x *Source) GetClusterID() string { + if x != nil { + return x.ClusterID + } + return "" +} + +func (x *Source) GetAccountID() string { + if x != nil { + return x.AccountID + } + return "" +} + +func (x *Source) GetManagementToken() string { + if x != nil { + return x.ManagementToken + } + return "" +} + +func (x *Source) GetDatabaseToken() string { + if x != nil { + return x.DatabaseToken + } + return "" +} + +func (x *Source) GetTagsCSVPath() string { + if x != nil { + return x.TagsCSVPath + } + return "" +} + +func (x *Source) GetDefaultDatabase() string { + if x != nil { + return x.DefaultDatabase + } + return "" +} + type Dashboard struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -2418,7 +2466,7 @@ var File_internal_proto protoreflect.FileDescriptor var file_internal_proto_rawDesc = []byte{ 0x0a, 0x0e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x12, 0x08, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x22, 0x9e, 0x03, 0x0a, 0x06, 0x53, + 0x12, 0x08, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x22, 0xf6, 0x04, 0x0a, 0x06, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x02, 0x49, 0x44, 0x12, 0x12, 0x0a, 0x04, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x54, 0x79, 0x70, @@ -2444,286 +2492,300 @@ var file_internal_proto_rawDesc = []byte{ 0x6f, 0x6c, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x52, 0x50, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x52, 0x50, 0x12, 0x18, 0x0a, 0x07, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x0f, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x07, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0xb4, 0x01, 0x0a, 0x09, - 0x44, 0x61, 0x73, 0x68, 0x62, 0x6f, 0x61, 0x72, 0x64, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x44, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x02, 0x49, 0x44, 0x12, 0x12, 0x0a, 0x04, 0x4e, 0x61, 0x6d, - 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x2d, 0x0a, - 0x05, 0x63, 0x65, 0x6c, 0x6c, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x69, - 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2e, 0x44, 0x61, 0x73, 0x68, 0x62, 0x6f, 0x61, 0x72, - 0x64, 0x43, 0x65, 0x6c, 0x6c, 0x52, 0x05, 0x63, 0x65, 0x6c, 0x6c, 0x73, 0x12, 0x30, 0x0a, 0x09, - 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, - 0x12, 0x2e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2e, 0x54, 0x65, 0x6d, 0x70, 0x6c, - 0x61, 0x74, 0x65, 0x52, 0x09, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x73, 0x12, 0x22, - 0x0a, 0x0c, 0x4f, 0x72, 0x67, 0x61, 0x6e, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x05, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x4f, 0x72, 0x67, 0x61, 0x6e, 0x69, 0x7a, 0x61, 0x74, 0x69, - 0x6f, 0x6e, 0x22, 0x92, 0x05, 0x0a, 0x0d, 0x44, 0x61, 0x73, 0x68, 0x62, 0x6f, 0x61, 0x72, 0x64, - 0x43, 0x65, 0x6c, 0x6c, 0x12, 0x0c, 0x0a, 0x01, 0x78, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x52, - 0x01, 0x78, 0x12, 0x0c, 0x0a, 0x01, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x01, 0x79, - 0x12, 0x0c, 0x0a, 0x01, 0x77, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x01, 0x77, 0x12, 0x0c, - 0x0a, 0x01, 0x68, 0x18, 0x04, 0x20, 0x01, 0x28, 0x05, 0x52, 0x01, 0x68, 0x12, 0x29, 0x0a, 0x07, - 0x71, 0x75, 0x65, 0x72, 0x69, 0x65, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0f, 0x2e, - 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2e, 0x51, 0x75, 0x65, 0x72, 0x79, 0x52, 0x07, - 0x71, 0x75, 0x65, 0x72, 0x69, 0x65, 0x73, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, - 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x74, - 0x79, 0x70, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, - 0x0e, 0x0a, 0x02, 0x49, 0x44, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x49, 0x44, 0x12, - 0x35, 0x0a, 0x04, 0x61, 0x78, 0x65, 0x73, 0x18, 0x09, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x21, 0x2e, - 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2e, 0x44, 0x61, 0x73, 0x68, 0x62, 0x6f, 0x61, - 0x72, 0x64, 0x43, 0x65, 0x6c, 0x6c, 0x2e, 0x41, 0x78, 0x65, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, - 0x52, 0x04, 0x61, 0x78, 0x65, 0x73, 0x12, 0x27, 0x0a, 0x06, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x73, - 0x18, 0x0a, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, - 0x6c, 0x2e, 0x43, 0x6f, 0x6c, 0x6f, 0x72, 0x52, 0x06, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x73, 0x12, - 0x28, 0x0a, 0x06, 0x6c, 0x65, 0x67, 0x65, 0x6e, 0x64, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x0b, 0x32, - 0x10, 0x2e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2e, 0x4c, 0x65, 0x67, 0x65, 0x6e, - 0x64, 0x52, 0x06, 0x6c, 0x65, 0x67, 0x65, 0x6e, 0x64, 0x12, 0x3a, 0x0a, 0x0c, 0x74, 0x61, 0x62, - 0x6c, 0x65, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0b, 0x32, - 0x16, 0x2e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2e, 0x54, 0x61, 0x62, 0x6c, 0x65, - 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x0c, 0x74, 0x61, 0x62, 0x6c, 0x65, 0x4f, 0x70, - 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x3c, 0x0a, 0x0c, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x4f, 0x70, - 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x0d, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x69, 0x6e, - 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2e, 0x52, 0x65, 0x6e, 0x61, 0x6d, 0x61, 0x62, 0x6c, 0x65, - 0x46, 0x69, 0x65, 0x6c, 0x64, 0x52, 0x0c, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x4f, 0x70, 0x74, 0x69, - 0x6f, 0x6e, 0x73, 0x12, 0x1e, 0x0a, 0x0a, 0x74, 0x69, 0x6d, 0x65, 0x46, 0x6f, 0x72, 0x6d, 0x61, - 0x74, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x74, 0x69, 0x6d, 0x65, 0x46, 0x6f, 0x72, - 0x6d, 0x61, 0x74, 0x12, 0x3d, 0x0a, 0x0d, 0x64, 0x65, 0x63, 0x69, 0x6d, 0x61, 0x6c, 0x50, 0x6c, - 0x61, 0x63, 0x65, 0x73, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x69, 0x6e, 0x74, - 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2e, 0x44, 0x65, 0x63, 0x69, 0x6d, 0x61, 0x6c, 0x50, 0x6c, 0x61, - 0x63, 0x65, 0x73, 0x52, 0x0d, 0x64, 0x65, 0x63, 0x69, 0x6d, 0x61, 0x6c, 0x50, 0x6c, 0x61, 0x63, - 0x65, 0x73, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x6f, 0x74, 0x65, 0x18, 0x10, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x04, 0x6e, 0x6f, 0x74, 0x65, 0x12, 0x26, 0x0a, 0x0e, 0x6e, 0x6f, 0x74, 0x65, 0x56, 0x69, - 0x73, 0x69, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x18, 0x11, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, - 0x6e, 0x6f, 0x74, 0x65, 0x56, 0x69, 0x73, 0x69, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x1a, 0x47, - 0x0a, 0x09, 0x41, 0x78, 0x65, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, - 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x24, 0x0a, - 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x69, - 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2e, 0x41, 0x78, 0x69, 0x73, 0x52, 0x05, 0x76, 0x61, - 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x47, 0x0a, 0x0d, 0x44, 0x65, 0x63, 0x69, 0x6d, - 0x61, 0x6c, 0x50, 0x6c, 0x61, 0x63, 0x65, 0x73, 0x12, 0x1e, 0x0a, 0x0a, 0x69, 0x73, 0x45, 0x6e, - 0x66, 0x6f, 0x72, 0x63, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x69, 0x73, - 0x45, 0x6e, 0x66, 0x6f, 0x72, 0x63, 0x65, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x69, 0x67, 0x69, - 0x74, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x06, 0x64, 0x69, 0x67, 0x69, 0x74, 0x73, - 0x22, 0xbc, 0x01, 0x0a, 0x0c, 0x54, 0x61, 0x62, 0x6c, 0x65, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, - 0x73, 0x12, 0x2a, 0x0a, 0x10, 0x76, 0x65, 0x72, 0x74, 0x69, 0x63, 0x61, 0x6c, 0x54, 0x69, 0x6d, - 0x65, 0x41, 0x78, 0x69, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x76, 0x65, 0x72, - 0x74, 0x69, 0x63, 0x61, 0x6c, 0x54, 0x69, 0x6d, 0x65, 0x41, 0x78, 0x69, 0x73, 0x12, 0x30, 0x0a, - 0x06, 0x73, 0x6f, 0x72, 0x74, 0x42, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, - 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2e, 0x52, 0x65, 0x6e, 0x61, 0x6d, 0x61, 0x62, - 0x6c, 0x65, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x52, 0x06, 0x73, 0x6f, 0x72, 0x74, 0x42, 0x79, 0x12, - 0x1a, 0x0a, 0x08, 0x77, 0x72, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x18, 0x04, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x08, 0x77, 0x72, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x12, 0x26, 0x0a, 0x0e, 0x66, - 0x69, 0x78, 0x46, 0x69, 0x72, 0x73, 0x74, 0x43, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x18, 0x06, 0x20, - 0x01, 0x28, 0x08, 0x52, 0x0e, 0x66, 0x69, 0x78, 0x46, 0x69, 0x72, 0x73, 0x74, 0x43, 0x6f, 0x6c, - 0x75, 0x6d, 0x6e, 0x4a, 0x04, 0x08, 0x01, 0x10, 0x02, 0x4a, 0x04, 0x08, 0x05, 0x10, 0x06, 0x22, - 0x70, 0x0a, 0x0e, 0x52, 0x65, 0x6e, 0x61, 0x6d, 0x61, 0x62, 0x6c, 0x65, 0x46, 0x69, 0x65, 0x6c, - 0x64, 0x12, 0x22, 0x0a, 0x0c, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x4e, 0x61, 0x6d, - 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, - 0x6c, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, - 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x69, 0x73, 0x70, - 0x6c, 0x61, 0x79, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x69, 0x73, 0x69, 0x62, - 0x6c, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x76, 0x69, 0x73, 0x69, 0x62, 0x6c, - 0x65, 0x22, 0x67, 0x0a, 0x05, 0x43, 0x6f, 0x6c, 0x6f, 0x72, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x44, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x49, 0x44, 0x12, 0x12, 0x0a, 0x04, 0x54, 0x79, - 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x54, 0x79, 0x70, 0x65, 0x12, 0x10, - 0x0a, 0x03, 0x48, 0x65, 0x78, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x48, 0x65, 0x78, - 0x12, 0x12, 0x0a, 0x04, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, - 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x05, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x05, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x22, 0x3e, 0x0a, 0x06, 0x4c, 0x65, - 0x67, 0x65, 0x6e, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x54, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x04, 0x54, 0x79, 0x70, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x4f, 0x72, 0x69, 0x65, - 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x4f, - 0x72, 0x69, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0xb2, 0x01, 0x0a, 0x04, 0x41, - 0x78, 0x69, 0x73, 0x12, 0x22, 0x0a, 0x0c, 0x6c, 0x65, 0x67, 0x61, 0x63, 0x79, 0x42, 0x6f, 0x75, - 0x6e, 0x64, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x03, 0x52, 0x0c, 0x6c, 0x65, 0x67, 0x61, 0x63, - 0x79, 0x42, 0x6f, 0x75, 0x6e, 0x64, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x62, 0x6f, 0x75, 0x6e, 0x64, - 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x06, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x73, 0x12, - 0x14, 0x0a, 0x05, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, - 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x12, 0x16, 0x0a, 0x06, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x18, - 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x12, 0x16, 0x0a, - 0x06, 0x73, 0x75, 0x66, 0x66, 0x69, 0x78, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, - 0x75, 0x66, 0x66, 0x69, 0x78, 0x12, 0x12, 0x0a, 0x04, 0x62, 0x61, 0x73, 0x65, 0x18, 0x06, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x04, 0x62, 0x61, 0x73, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x63, 0x61, - 0x6c, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x73, 0x63, 0x61, 0x6c, 0x65, 0x22, - 0xdb, 0x01, 0x0a, 0x08, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x12, 0x0e, 0x0a, 0x02, - 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x49, 0x44, 0x12, 0x19, 0x0a, 0x08, - 0x74, 0x65, 0x6d, 0x70, 0x5f, 0x76, 0x61, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, - 0x74, 0x65, 0x6d, 0x70, 0x56, 0x61, 0x72, 0x12, 0x2f, 0x0a, 0x06, 0x76, 0x61, 0x6c, 0x75, 0x65, - 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, - 0x61, 0x6c, 0x2e, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x56, 0x61, 0x6c, 0x75, 0x65, - 0x52, 0x06, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, - 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x14, 0x0a, 0x05, - 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6c, 0x61, 0x62, - 0x65, 0x6c, 0x12, 0x2d, 0x0a, 0x05, 0x71, 0x75, 0x65, 0x72, 0x79, 0x18, 0x06, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x17, 0x2e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2e, 0x54, 0x65, 0x6d, - 0x70, 0x6c, 0x61, 0x74, 0x65, 0x51, 0x75, 0x65, 0x72, 0x79, 0x52, 0x05, 0x71, 0x75, 0x65, 0x72, - 0x79, 0x12, 0x1a, 0x0a, 0x08, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x44, 0x18, 0x07, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x08, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x44, 0x22, 0x67, 0x0a, - 0x0d, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x12, - 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x79, - 0x70, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x73, 0x65, 0x6c, 0x65, - 0x63, 0x74, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x73, 0x65, 0x6c, 0x65, - 0x63, 0x74, 0x65, 0x64, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x22, 0xb5, 0x01, 0x0a, 0x0d, 0x54, 0x65, 0x6d, 0x70, 0x6c, - 0x61, 0x74, 0x65, 0x51, 0x75, 0x65, 0x72, 0x79, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6d, 0x6d, - 0x61, 0x6e, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x61, - 0x6e, 0x64, 0x12, 0x0e, 0x0a, 0x02, 0x64, 0x62, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, - 0x64, 0x62, 0x12, 0x0e, 0x0a, 0x02, 0x72, 0x70, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, - 0x72, 0x70, 0x12, 0x20, 0x0a, 0x0b, 0x6d, 0x65, 0x61, 0x73, 0x75, 0x72, 0x65, 0x6d, 0x65, 0x6e, - 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x6d, 0x65, 0x61, 0x73, 0x75, 0x72, 0x65, - 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x74, 0x61, 0x67, 0x5f, 0x6b, 0x65, 0x79, 0x18, - 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x74, 0x61, 0x67, 0x4b, 0x65, 0x79, 0x12, 0x1b, 0x0a, - 0x09, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x08, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x4b, 0x65, 0x79, 0x12, 0x12, 0x0a, 0x04, 0x66, 0x6c, - 0x75, 0x78, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x66, 0x6c, 0x75, 0x78, 0x22, 0xb0, - 0x02, 0x0a, 0x06, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x44, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x02, 0x49, 0x44, 0x12, 0x12, 0x0a, 0x04, 0x4e, 0x61, 0x6d, - 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1a, 0x0a, - 0x08, 0x55, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x08, 0x55, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x50, 0x61, 0x73, - 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x50, 0x61, 0x73, - 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x10, 0x0a, 0x03, 0x55, 0x52, 0x4c, 0x18, 0x05, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x03, 0x55, 0x52, 0x4c, 0x12, 0x14, 0x0a, 0x05, 0x53, 0x72, 0x63, 0x49, 0x44, - 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x53, 0x72, 0x63, 0x49, 0x44, 0x12, 0x16, 0x0a, - 0x06, 0x41, 0x63, 0x74, 0x69, 0x76, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x41, - 0x63, 0x74, 0x69, 0x76, 0x65, 0x12, 0x22, 0x0a, 0x0c, 0x4f, 0x72, 0x67, 0x61, 0x6e, 0x69, 0x7a, - 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x4f, 0x72, 0x67, - 0x61, 0x6e, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x2e, 0x0a, 0x12, 0x49, 0x6e, 0x73, - 0x65, 0x63, 0x75, 0x72, 0x65, 0x53, 0x6b, 0x69, 0x70, 0x56, 0x65, 0x72, 0x69, 0x66, 0x79, 0x18, - 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, 0x12, 0x49, 0x6e, 0x73, 0x65, 0x63, 0x75, 0x72, 0x65, 0x53, - 0x6b, 0x69, 0x70, 0x56, 0x65, 0x72, 0x69, 0x66, 0x79, 0x12, 0x12, 0x0a, 0x04, 0x54, 0x79, 0x70, - 0x65, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x54, 0x79, 0x70, 0x65, 0x12, 0x22, 0x0a, - 0x0c, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x4a, 0x53, 0x4f, 0x4e, 0x18, 0x0b, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x0c, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x4a, 0x53, 0x4f, - 0x4e, 0x22, 0x9e, 0x01, 0x0a, 0x06, 0x4c, 0x61, 0x79, 0x6f, 0x75, 0x74, 0x12, 0x0e, 0x0a, 0x02, - 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x49, 0x44, 0x12, 0x20, 0x0a, 0x0b, - 0x41, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x0b, 0x41, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x20, - 0x0a, 0x0b, 0x4d, 0x65, 0x61, 0x73, 0x75, 0x72, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x18, 0x03, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x0b, 0x4d, 0x65, 0x61, 0x73, 0x75, 0x72, 0x65, 0x6d, 0x65, 0x6e, 0x74, - 0x12, 0x24, 0x0a, 0x05, 0x43, 0x65, 0x6c, 0x6c, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, - 0x0e, 0x2e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2e, 0x43, 0x65, 0x6c, 0x6c, 0x52, - 0x05, 0x43, 0x65, 0x6c, 0x6c, 0x73, 0x12, 0x1a, 0x0a, 0x08, 0x41, 0x75, 0x74, 0x6f, 0x66, 0x6c, - 0x6f, 0x77, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x41, 0x75, 0x74, 0x6f, 0x66, 0x6c, - 0x6f, 0x77, 0x22, 0xca, 0x02, 0x0a, 0x04, 0x43, 0x65, 0x6c, 0x6c, 0x12, 0x0c, 0x0a, 0x01, 0x78, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x52, 0x01, 0x78, 0x12, 0x0c, 0x0a, 0x01, 0x79, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x05, 0x52, 0x01, 0x79, 0x12, 0x0c, 0x0a, 0x01, 0x77, 0x18, 0x03, 0x20, 0x01, - 0x28, 0x05, 0x52, 0x01, 0x77, 0x12, 0x0c, 0x0a, 0x01, 0x68, 0x18, 0x04, 0x20, 0x01, 0x28, 0x05, - 0x52, 0x01, 0x68, 0x12, 0x29, 0x0a, 0x07, 0x71, 0x75, 0x65, 0x72, 0x69, 0x65, 0x73, 0x18, 0x05, - 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2e, - 0x51, 0x75, 0x65, 0x72, 0x79, 0x52, 0x07, 0x71, 0x75, 0x65, 0x72, 0x69, 0x65, 0x73, 0x12, 0x0c, - 0x0a, 0x01, 0x69, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x01, 0x69, 0x12, 0x12, 0x0a, 0x04, - 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, - 0x12, 0x18, 0x0a, 0x07, 0x79, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, - 0x03, 0x52, 0x07, 0x79, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x79, 0x6c, - 0x61, 0x62, 0x65, 0x6c, 0x73, 0x18, 0x09, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x79, 0x6c, 0x61, - 0x62, 0x65, 0x6c, 0x73, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x0a, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x2c, 0x0a, 0x04, 0x61, 0x78, 0x65, 0x73, - 0x18, 0x0b, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, - 0x6c, 0x2e, 0x43, 0x65, 0x6c, 0x6c, 0x2e, 0x41, 0x78, 0x65, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, - 0x52, 0x04, 0x61, 0x78, 0x65, 0x73, 0x1a, 0x47, 0x0a, 0x09, 0x41, 0x78, 0x65, 0x73, 0x45, 0x6e, + 0x28, 0x09, 0x52, 0x07, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x43, + 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x49, 0x44, 0x18, 0x10, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, + 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x49, 0x44, 0x12, 0x1c, 0x0a, 0x09, 0x41, 0x63, 0x63, + 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x44, 0x18, 0x11, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x41, 0x63, + 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x44, 0x12, 0x28, 0x0a, 0x0f, 0x4d, 0x61, 0x6e, 0x61, 0x67, + 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x12, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x0f, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x54, 0x6f, 0x6b, 0x65, + 0x6e, 0x12, 0x24, 0x0a, 0x0d, 0x44, 0x61, 0x74, 0x61, 0x62, 0x61, 0x73, 0x65, 0x54, 0x6f, 0x6b, + 0x65, 0x6e, 0x18, 0x13, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x44, 0x61, 0x74, 0x61, 0x62, 0x61, + 0x73, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x20, 0x0a, 0x0b, 0x54, 0x61, 0x67, 0x73, 0x43, + 0x53, 0x56, 0x50, 0x61, 0x74, 0x68, 0x18, 0x14, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x54, 0x61, + 0x67, 0x73, 0x43, 0x53, 0x56, 0x50, 0x61, 0x74, 0x68, 0x12, 0x28, 0x0a, 0x0f, 0x44, 0x65, 0x66, + 0x61, 0x75, 0x6c, 0x74, 0x44, 0x61, 0x74, 0x61, 0x62, 0x61, 0x73, 0x65, 0x18, 0x15, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x0f, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x44, 0x61, 0x74, 0x61, 0x62, + 0x61, 0x73, 0x65, 0x22, 0xb4, 0x01, 0x0a, 0x09, 0x44, 0x61, 0x73, 0x68, 0x62, 0x6f, 0x61, 0x72, + 0x64, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x02, 0x49, + 0x44, 0x12, 0x12, 0x0a, 0x04, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x04, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x2d, 0x0a, 0x05, 0x63, 0x65, 0x6c, 0x6c, 0x73, 0x18, 0x03, + 0x20, 0x03, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2e, + 0x44, 0x61, 0x73, 0x68, 0x62, 0x6f, 0x61, 0x72, 0x64, 0x43, 0x65, 0x6c, 0x6c, 0x52, 0x05, 0x63, + 0x65, 0x6c, 0x6c, 0x73, 0x12, 0x30, 0x0a, 0x09, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, + 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, + 0x61, 0x6c, 0x2e, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x52, 0x09, 0x74, 0x65, 0x6d, + 0x70, 0x6c, 0x61, 0x74, 0x65, 0x73, 0x12, 0x22, 0x0a, 0x0c, 0x4f, 0x72, 0x67, 0x61, 0x6e, 0x69, + 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x4f, 0x72, + 0x67, 0x61, 0x6e, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x92, 0x05, 0x0a, 0x0d, 0x44, + 0x61, 0x73, 0x68, 0x62, 0x6f, 0x61, 0x72, 0x64, 0x43, 0x65, 0x6c, 0x6c, 0x12, 0x0c, 0x0a, 0x01, + 0x78, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x52, 0x01, 0x78, 0x12, 0x0c, 0x0a, 0x01, 0x79, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x01, 0x79, 0x12, 0x0c, 0x0a, 0x01, 0x77, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x05, 0x52, 0x01, 0x77, 0x12, 0x0c, 0x0a, 0x01, 0x68, 0x18, 0x04, 0x20, 0x01, 0x28, + 0x05, 0x52, 0x01, 0x68, 0x12, 0x29, 0x0a, 0x07, 0x71, 0x75, 0x65, 0x72, 0x69, 0x65, 0x73, 0x18, + 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, + 0x2e, 0x51, 0x75, 0x65, 0x72, 0x79, 0x52, 0x07, 0x71, 0x75, 0x65, 0x72, 0x69, 0x65, 0x73, 0x12, + 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, + 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x44, 0x18, 0x08, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x02, 0x49, 0x44, 0x12, 0x35, 0x0a, 0x04, 0x61, 0x78, 0x65, 0x73, 0x18, + 0x09, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x21, 0x2e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, + 0x2e, 0x44, 0x61, 0x73, 0x68, 0x62, 0x6f, 0x61, 0x72, 0x64, 0x43, 0x65, 0x6c, 0x6c, 0x2e, 0x41, + 0x78, 0x65, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x04, 0x61, 0x78, 0x65, 0x73, 0x12, 0x27, + 0x0a, 0x06, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x73, 0x18, 0x0a, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0f, + 0x2e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2e, 0x43, 0x6f, 0x6c, 0x6f, 0x72, 0x52, + 0x06, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x73, 0x12, 0x28, 0x0a, 0x06, 0x6c, 0x65, 0x67, 0x65, 0x6e, + 0x64, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, + 0x61, 0x6c, 0x2e, 0x4c, 0x65, 0x67, 0x65, 0x6e, 0x64, 0x52, 0x06, 0x6c, 0x65, 0x67, 0x65, 0x6e, + 0x64, 0x12, 0x3a, 0x0a, 0x0c, 0x74, 0x61, 0x62, 0x6c, 0x65, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, + 0x73, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, + 0x61, 0x6c, 0x2e, 0x54, 0x61, 0x62, 0x6c, 0x65, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, + 0x0c, 0x74, 0x61, 0x62, 0x6c, 0x65, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x3c, 0x0a, + 0x0c, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x0d, 0x20, + 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2e, 0x52, + 0x65, 0x6e, 0x61, 0x6d, 0x61, 0x62, 0x6c, 0x65, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x52, 0x0c, 0x66, + 0x69, 0x65, 0x6c, 0x64, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x1e, 0x0a, 0x0a, 0x74, + 0x69, 0x6d, 0x65, 0x46, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x0a, 0x74, 0x69, 0x6d, 0x65, 0x46, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x12, 0x3d, 0x0a, 0x0d, 0x64, + 0x65, 0x63, 0x69, 0x6d, 0x61, 0x6c, 0x50, 0x6c, 0x61, 0x63, 0x65, 0x73, 0x18, 0x0f, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2e, 0x44, 0x65, + 0x63, 0x69, 0x6d, 0x61, 0x6c, 0x50, 0x6c, 0x61, 0x63, 0x65, 0x73, 0x52, 0x0d, 0x64, 0x65, 0x63, + 0x69, 0x6d, 0x61, 0x6c, 0x50, 0x6c, 0x61, 0x63, 0x65, 0x73, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x6f, + 0x74, 0x65, 0x18, 0x10, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x6f, 0x74, 0x65, 0x12, 0x26, + 0x0a, 0x0e, 0x6e, 0x6f, 0x74, 0x65, 0x56, 0x69, 0x73, 0x69, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, + 0x18, 0x11, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x6e, 0x6f, 0x74, 0x65, 0x56, 0x69, 0x73, 0x69, + 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x1a, 0x47, 0x0a, 0x09, 0x41, 0x78, 0x65, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x24, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2e, 0x41, 0x78, 0x69, 0x73, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, - 0x8b, 0x02, 0x0a, 0x05, 0x51, 0x75, 0x65, 0x72, 0x79, 0x12, 0x18, 0x0a, 0x07, 0x43, 0x6f, 0x6d, - 0x6d, 0x61, 0x6e, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x43, 0x6f, 0x6d, 0x6d, - 0x61, 0x6e, 0x64, 0x12, 0x0e, 0x0a, 0x02, 0x44, 0x42, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x02, 0x44, 0x42, 0x12, 0x0e, 0x0a, 0x02, 0x52, 0x50, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x02, 0x52, 0x50, 0x12, 0x1a, 0x0a, 0x08, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x42, 0x79, 0x73, 0x18, - 0x04, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x42, 0x79, 0x73, 0x12, - 0x16, 0x0a, 0x06, 0x57, 0x68, 0x65, 0x72, 0x65, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x09, 0x52, - 0x06, 0x57, 0x68, 0x65, 0x72, 0x65, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x4c, 0x61, 0x62, 0x65, 0x6c, - 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x12, 0x25, 0x0a, - 0x05, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x69, - 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2e, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x52, 0x05, 0x52, - 0x61, 0x6e, 0x67, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x08, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x2b, 0x0a, 0x06, - 0x53, 0x68, 0x69, 0x66, 0x74, 0x73, 0x18, 0x09, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x69, - 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x53, 0x68, 0x69, 0x66, - 0x74, 0x52, 0x06, 0x53, 0x68, 0x69, 0x66, 0x74, 0x73, 0x12, 0x12, 0x0a, 0x04, 0x54, 0x79, 0x70, - 0x65, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x54, 0x79, 0x70, 0x65, 0x22, 0x51, 0x0a, - 0x09, 0x54, 0x69, 0x6d, 0x65, 0x53, 0x68, 0x69, 0x66, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x4c, 0x61, - 0x62, 0x65, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x4c, 0x61, 0x62, 0x65, 0x6c, - 0x12, 0x12, 0x0a, 0x04, 0x55, 0x6e, 0x69, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, - 0x55, 0x6e, 0x69, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x51, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, - 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x51, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, - 0x22, 0x33, 0x0a, 0x05, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x55, 0x70, 0x70, - 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x55, 0x70, 0x70, 0x65, 0x72, 0x12, - 0x14, 0x0a, 0x05, 0x4c, 0x6f, 0x77, 0x65, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, - 0x4c, 0x6f, 0x77, 0x65, 0x72, 0x22, 0x5d, 0x0a, 0x09, 0x41, 0x6c, 0x65, 0x72, 0x74, 0x52, 0x75, - 0x6c, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, - 0x49, 0x44, 0x12, 0x12, 0x0a, 0x04, 0x4a, 0x53, 0x4f, 0x4e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x04, 0x4a, 0x53, 0x4f, 0x4e, 0x12, 0x14, 0x0a, 0x05, 0x53, 0x72, 0x63, 0x49, 0x44, 0x18, - 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x53, 0x72, 0x63, 0x49, 0x44, 0x12, 0x16, 0x0a, 0x06, - 0x4b, 0x61, 0x70, 0x61, 0x49, 0x44, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x4b, 0x61, - 0x70, 0x61, 0x49, 0x44, 0x22, 0xa4, 0x01, 0x0a, 0x04, 0x55, 0x73, 0x65, 0x72, 0x12, 0x0e, 0x0a, - 0x02, 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x02, 0x49, 0x44, 0x12, 0x12, 0x0a, - 0x04, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x4e, 0x61, 0x6d, - 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x18, 0x03, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x08, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x16, 0x0a, - 0x06, 0x53, 0x63, 0x68, 0x65, 0x6d, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x53, - 0x63, 0x68, 0x65, 0x6d, 0x65, 0x12, 0x24, 0x0a, 0x05, 0x52, 0x6f, 0x6c, 0x65, 0x73, 0x18, 0x05, - 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2e, - 0x52, 0x6f, 0x6c, 0x65, 0x52, 0x05, 0x52, 0x6f, 0x6c, 0x65, 0x73, 0x12, 0x1e, 0x0a, 0x0a, 0x53, - 0x75, 0x70, 0x65, 0x72, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, - 0x0a, 0x53, 0x75, 0x70, 0x65, 0x72, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x22, 0x3e, 0x0a, 0x04, 0x52, - 0x6f, 0x6c, 0x65, 0x12, 0x22, 0x0a, 0x0c, 0x4f, 0x72, 0x67, 0x61, 0x6e, 0x69, 0x7a, 0x61, 0x74, - 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x4f, 0x72, 0x67, 0x61, 0x6e, - 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x4e, 0x61, 0x6d, 0x65, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x4e, 0x61, 0x6d, 0x65, 0x22, 0xa5, 0x01, 0x0a, 0x07, - 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x12, 0x1a, 0x0a, 0x08, 0x50, 0x72, 0x6f, 0x76, 0x69, - 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x50, 0x72, 0x6f, 0x76, 0x69, - 0x64, 0x65, 0x72, 0x12, 0x16, 0x0a, 0x06, 0x53, 0x63, 0x68, 0x65, 0x6d, 0x65, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x06, 0x53, 0x63, 0x68, 0x65, 0x6d, 0x65, 0x12, 0x32, 0x0a, 0x14, 0x50, - 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x4f, 0x72, 0x67, 0x61, 0x6e, 0x69, 0x7a, 0x61, 0x74, - 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x14, 0x50, 0x72, 0x6f, 0x76, 0x69, - 0x64, 0x65, 0x72, 0x4f, 0x72, 0x67, 0x61, 0x6e, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, - 0x0e, 0x0a, 0x02, 0x49, 0x44, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x49, 0x44, 0x12, - 0x22, 0x0a, 0x0c, 0x4f, 0x72, 0x67, 0x61, 0x6e, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, - 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x4f, 0x72, 0x67, 0x61, 0x6e, 0x69, 0x7a, 0x61, 0x74, - 0x69, 0x6f, 0x6e, 0x22, 0x54, 0x0a, 0x0c, 0x4f, 0x72, 0x67, 0x61, 0x6e, 0x69, 0x7a, 0x61, 0x74, - 0x69, 0x6f, 0x6e, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x02, 0x49, 0x44, 0x12, 0x12, 0x0a, 0x04, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x04, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x44, 0x65, 0x66, 0x61, 0x75, - 0x6c, 0x74, 0x52, 0x6f, 0x6c, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x44, 0x65, - 0x66, 0x61, 0x75, 0x6c, 0x74, 0x52, 0x6f, 0x6c, 0x65, 0x22, 0x32, 0x0a, 0x06, 0x43, 0x6f, 0x6e, - 0x66, 0x69, 0x67, 0x12, 0x28, 0x0a, 0x04, 0x41, 0x75, 0x74, 0x68, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x14, 0x2e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2e, 0x41, 0x75, 0x74, - 0x68, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x04, 0x41, 0x75, 0x74, 0x68, 0x22, 0x3c, 0x0a, - 0x0a, 0x41, 0x75, 0x74, 0x68, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x2e, 0x0a, 0x12, 0x53, - 0x75, 0x70, 0x65, 0x72, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x4e, 0x65, 0x77, 0x55, 0x73, 0x65, 0x72, - 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x12, 0x53, 0x75, 0x70, 0x65, 0x72, 0x41, 0x64, - 0x6d, 0x69, 0x6e, 0x4e, 0x65, 0x77, 0x55, 0x73, 0x65, 0x72, 0x73, 0x22, 0x75, 0x0a, 0x12, 0x4f, - 0x72, 0x67, 0x61, 0x6e, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x6e, 0x66, 0x69, - 0x67, 0x12, 0x26, 0x0a, 0x0e, 0x4f, 0x72, 0x67, 0x61, 0x6e, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, - 0x6e, 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x4f, 0x72, 0x67, 0x61, 0x6e, - 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x44, 0x12, 0x37, 0x0a, 0x09, 0x4c, 0x6f, 0x67, - 0x56, 0x69, 0x65, 0x77, 0x65, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x69, - 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2e, 0x4c, 0x6f, 0x67, 0x56, 0x69, 0x65, 0x77, 0x65, - 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x09, 0x4c, 0x6f, 0x67, 0x56, 0x69, 0x65, 0x77, - 0x65, 0x72, 0x22, 0x46, 0x0a, 0x0f, 0x4c, 0x6f, 0x67, 0x56, 0x69, 0x65, 0x77, 0x65, 0x72, 0x43, - 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x33, 0x0a, 0x07, 0x43, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x73, - 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, - 0x6c, 0x2e, 0x4c, 0x6f, 0x67, 0x56, 0x69, 0x65, 0x77, 0x65, 0x72, 0x43, 0x6f, 0x6c, 0x75, 0x6d, - 0x6e, 0x52, 0x07, 0x43, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x73, 0x22, 0x79, 0x0a, 0x0f, 0x4c, 0x6f, - 0x67, 0x56, 0x69, 0x65, 0x77, 0x65, 0x72, 0x43, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x12, 0x12, 0x0a, - 0x04, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x4e, 0x61, 0x6d, - 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x50, 0x6f, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x05, 0x52, 0x08, 0x50, 0x6f, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x36, 0x0a, - 0x09, 0x45, 0x6e, 0x63, 0x6f, 0x64, 0x69, 0x6e, 0x67, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, - 0x32, 0x18, 0x2e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2e, 0x43, 0x6f, 0x6c, 0x75, - 0x6d, 0x6e, 0x45, 0x6e, 0x63, 0x6f, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x09, 0x45, 0x6e, 0x63, 0x6f, - 0x64, 0x69, 0x6e, 0x67, 0x73, 0x22, 0x4e, 0x0a, 0x0e, 0x43, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x45, - 0x6e, 0x63, 0x6f, 0x64, 0x69, 0x6e, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x54, 0x79, 0x70, 0x65, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x54, 0x79, 0x70, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x56, - 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x56, 0x61, 0x6c, 0x75, - 0x65, 0x12, 0x12, 0x0a, 0x04, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x04, 0x4e, 0x61, 0x6d, 0x65, 0x22, 0x3d, 0x0a, 0x09, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x49, 0x6e, - 0x66, 0x6f, 0x12, 0x18, 0x0a, 0x07, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x07, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x16, 0x0a, 0x06, - 0x43, 0x6f, 0x6d, 0x6d, 0x69, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x43, 0x6f, - 0x6d, 0x6d, 0x69, 0x74, 0x42, 0x0c, 0x5a, 0x0a, 0x2e, 0x3b, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, - 0x61, 0x6c, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x47, 0x0a, 0x0d, 0x44, 0x65, 0x63, 0x69, 0x6d, 0x61, 0x6c, 0x50, 0x6c, 0x61, 0x63, 0x65, 0x73, + 0x12, 0x1e, 0x0a, 0x0a, 0x69, 0x73, 0x45, 0x6e, 0x66, 0x6f, 0x72, 0x63, 0x65, 0x64, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x69, 0x73, 0x45, 0x6e, 0x66, 0x6f, 0x72, 0x63, 0x65, 0x64, + 0x12, 0x16, 0x0a, 0x06, 0x64, 0x69, 0x67, 0x69, 0x74, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, + 0x52, 0x06, 0x64, 0x69, 0x67, 0x69, 0x74, 0x73, 0x22, 0xbc, 0x01, 0x0a, 0x0c, 0x54, 0x61, 0x62, + 0x6c, 0x65, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x2a, 0x0a, 0x10, 0x76, 0x65, 0x72, + 0x74, 0x69, 0x63, 0x61, 0x6c, 0x54, 0x69, 0x6d, 0x65, 0x41, 0x78, 0x69, 0x73, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x08, 0x52, 0x10, 0x76, 0x65, 0x72, 0x74, 0x69, 0x63, 0x61, 0x6c, 0x54, 0x69, 0x6d, + 0x65, 0x41, 0x78, 0x69, 0x73, 0x12, 0x30, 0x0a, 0x06, 0x73, 0x6f, 0x72, 0x74, 0x42, 0x79, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, + 0x2e, 0x52, 0x65, 0x6e, 0x61, 0x6d, 0x61, 0x62, 0x6c, 0x65, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x52, + 0x06, 0x73, 0x6f, 0x72, 0x74, 0x42, 0x79, 0x12, 0x1a, 0x0a, 0x08, 0x77, 0x72, 0x61, 0x70, 0x70, + 0x69, 0x6e, 0x67, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x77, 0x72, 0x61, 0x70, 0x70, + 0x69, 0x6e, 0x67, 0x12, 0x26, 0x0a, 0x0e, 0x66, 0x69, 0x78, 0x46, 0x69, 0x72, 0x73, 0x74, 0x43, + 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0e, 0x66, 0x69, 0x78, + 0x46, 0x69, 0x72, 0x73, 0x74, 0x43, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x4a, 0x04, 0x08, 0x01, 0x10, + 0x02, 0x4a, 0x04, 0x08, 0x05, 0x10, 0x06, 0x22, 0x70, 0x0a, 0x0e, 0x52, 0x65, 0x6e, 0x61, 0x6d, + 0x61, 0x62, 0x6c, 0x65, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x12, 0x22, 0x0a, 0x0c, 0x69, 0x6e, 0x74, + 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x0c, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x20, 0x0a, + 0x0b, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x0b, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x4e, 0x61, 0x6d, 0x65, 0x12, + 0x18, 0x0a, 0x07, 0x76, 0x69, 0x73, 0x69, 0x62, 0x6c, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, + 0x52, 0x07, 0x76, 0x69, 0x73, 0x69, 0x62, 0x6c, 0x65, 0x22, 0x67, 0x0a, 0x05, 0x43, 0x6f, 0x6c, + 0x6f, 0x72, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, + 0x49, 0x44, 0x12, 0x12, 0x0a, 0x04, 0x54, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x04, 0x54, 0x79, 0x70, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x48, 0x65, 0x78, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x03, 0x48, 0x65, 0x78, 0x12, 0x12, 0x0a, 0x04, 0x4e, 0x61, 0x6d, 0x65, + 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x14, 0x0a, 0x05, + 0x56, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x56, 0x61, 0x6c, + 0x75, 0x65, 0x22, 0x3e, 0x0a, 0x06, 0x4c, 0x65, 0x67, 0x65, 0x6e, 0x64, 0x12, 0x12, 0x0a, 0x04, + 0x54, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x54, 0x79, 0x70, 0x65, + 0x12, 0x20, 0x0a, 0x0b, 0x4f, 0x72, 0x69, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x4f, 0x72, 0x69, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x22, 0xb2, 0x01, 0x0a, 0x04, 0x41, 0x78, 0x69, 0x73, 0x12, 0x22, 0x0a, 0x0c, 0x6c, + 0x65, 0x67, 0x61, 0x63, 0x79, 0x42, 0x6f, 0x75, 0x6e, 0x64, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, + 0x03, 0x52, 0x0c, 0x6c, 0x65, 0x67, 0x61, 0x63, 0x79, 0x42, 0x6f, 0x75, 0x6e, 0x64, 0x73, 0x12, + 0x16, 0x0a, 0x06, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, + 0x06, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x61, 0x62, 0x65, 0x6c, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x12, 0x16, 0x0a, + 0x06, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x70, + 0x72, 0x65, 0x66, 0x69, 0x78, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x75, 0x66, 0x66, 0x69, 0x78, 0x18, + 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x75, 0x66, 0x66, 0x69, 0x78, 0x12, 0x12, 0x0a, + 0x04, 0x62, 0x61, 0x73, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x62, 0x61, 0x73, + 0x65, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x63, 0x61, 0x6c, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x05, 0x73, 0x63, 0x61, 0x6c, 0x65, 0x22, 0xdb, 0x01, 0x0a, 0x08, 0x54, 0x65, 0x6d, 0x70, + 0x6c, 0x61, 0x74, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x02, 0x49, 0x44, 0x12, 0x19, 0x0a, 0x08, 0x74, 0x65, 0x6d, 0x70, 0x5f, 0x76, 0x61, 0x72, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x74, 0x65, 0x6d, 0x70, 0x56, 0x61, 0x72, 0x12, + 0x2f, 0x0a, 0x06, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, + 0x17, 0x2e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2e, 0x54, 0x65, 0x6d, 0x70, 0x6c, + 0x61, 0x74, 0x65, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x06, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, + 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, + 0x74, 0x79, 0x70, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x18, 0x05, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x05, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x12, 0x2d, 0x0a, 0x05, 0x71, 0x75, + 0x65, 0x72, 0x79, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x69, 0x6e, 0x74, 0x65, + 0x72, 0x6e, 0x61, 0x6c, 0x2e, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x51, 0x75, 0x65, + 0x72, 0x79, 0x52, 0x05, 0x71, 0x75, 0x65, 0x72, 0x79, 0x12, 0x1a, 0x0a, 0x08, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x49, 0x44, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x49, 0x44, 0x22, 0x67, 0x0a, 0x0d, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, + 0x65, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, + 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, + 0x12, 0x1a, 0x0a, 0x08, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x08, 0x52, 0x08, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x65, 0x64, 0x12, 0x10, 0x0a, 0x03, + 0x6b, 0x65, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x22, 0xb5, + 0x01, 0x0a, 0x0d, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x51, 0x75, 0x65, 0x72, 0x79, + 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x12, 0x0e, 0x0a, 0x02, 0x64, 0x62, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x64, 0x62, 0x12, 0x0e, 0x0a, 0x02, 0x72, 0x70, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x72, 0x70, 0x12, 0x20, 0x0a, 0x0b, 0x6d, 0x65, + 0x61, 0x73, 0x75, 0x72, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x0b, 0x6d, 0x65, 0x61, 0x73, 0x75, 0x72, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x17, 0x0a, 0x07, + 0x74, 0x61, 0x67, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x74, + 0x61, 0x67, 0x4b, 0x65, 0x79, 0x12, 0x1b, 0x0a, 0x09, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x5f, 0x6b, + 0x65, 0x79, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x4b, + 0x65, 0x79, 0x12, 0x12, 0x0a, 0x04, 0x66, 0x6c, 0x75, 0x78, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x04, 0x66, 0x6c, 0x75, 0x78, 0x22, 0xb0, 0x02, 0x0a, 0x06, 0x53, 0x65, 0x72, 0x76, 0x65, + 0x72, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x02, 0x49, + 0x44, 0x12, 0x12, 0x0a, 0x04, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x04, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x55, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, + 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x55, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, + 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x04, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x08, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x10, 0x0a, + 0x03, 0x55, 0x52, 0x4c, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x55, 0x52, 0x4c, 0x12, + 0x14, 0x0a, 0x05, 0x53, 0x72, 0x63, 0x49, 0x44, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, + 0x53, 0x72, 0x63, 0x49, 0x44, 0x12, 0x16, 0x0a, 0x06, 0x41, 0x63, 0x74, 0x69, 0x76, 0x65, 0x18, + 0x07, 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x41, 0x63, 0x74, 0x69, 0x76, 0x65, 0x12, 0x22, 0x0a, + 0x0c, 0x4f, 0x72, 0x67, 0x61, 0x6e, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x08, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x0c, 0x4f, 0x72, 0x67, 0x61, 0x6e, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x12, 0x2e, 0x0a, 0x12, 0x49, 0x6e, 0x73, 0x65, 0x63, 0x75, 0x72, 0x65, 0x53, 0x6b, 0x69, + 0x70, 0x56, 0x65, 0x72, 0x69, 0x66, 0x79, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, 0x12, 0x49, + 0x6e, 0x73, 0x65, 0x63, 0x75, 0x72, 0x65, 0x53, 0x6b, 0x69, 0x70, 0x56, 0x65, 0x72, 0x69, 0x66, + 0x79, 0x12, 0x12, 0x0a, 0x04, 0x54, 0x79, 0x70, 0x65, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x04, 0x54, 0x79, 0x70, 0x65, 0x12, 0x22, 0x0a, 0x0c, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, + 0x61, 0x4a, 0x53, 0x4f, 0x4e, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x4d, 0x65, 0x74, + 0x61, 0x64, 0x61, 0x74, 0x61, 0x4a, 0x53, 0x4f, 0x4e, 0x22, 0x9e, 0x01, 0x0a, 0x06, 0x4c, 0x61, + 0x79, 0x6f, 0x75, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x02, 0x49, 0x44, 0x12, 0x20, 0x0a, 0x0b, 0x41, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x41, 0x70, 0x70, 0x6c, 0x69, + 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x20, 0x0a, 0x0b, 0x4d, 0x65, 0x61, 0x73, 0x75, 0x72, + 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x4d, 0x65, 0x61, + 0x73, 0x75, 0x72, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x24, 0x0a, 0x05, 0x43, 0x65, 0x6c, 0x6c, + 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, + 0x61, 0x6c, 0x2e, 0x43, 0x65, 0x6c, 0x6c, 0x52, 0x05, 0x43, 0x65, 0x6c, 0x6c, 0x73, 0x12, 0x1a, + 0x0a, 0x08, 0x41, 0x75, 0x74, 0x6f, 0x66, 0x6c, 0x6f, 0x77, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, + 0x52, 0x08, 0x41, 0x75, 0x74, 0x6f, 0x66, 0x6c, 0x6f, 0x77, 0x22, 0xca, 0x02, 0x0a, 0x04, 0x43, + 0x65, 0x6c, 0x6c, 0x12, 0x0c, 0x0a, 0x01, 0x78, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x52, 0x01, + 0x78, 0x12, 0x0c, 0x0a, 0x01, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x01, 0x79, 0x12, + 0x0c, 0x0a, 0x01, 0x77, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x01, 0x77, 0x12, 0x0c, 0x0a, + 0x01, 0x68, 0x18, 0x04, 0x20, 0x01, 0x28, 0x05, 0x52, 0x01, 0x68, 0x12, 0x29, 0x0a, 0x07, 0x71, + 0x75, 0x65, 0x72, 0x69, 0x65, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x69, + 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2e, 0x51, 0x75, 0x65, 0x72, 0x79, 0x52, 0x07, 0x71, + 0x75, 0x65, 0x72, 0x69, 0x65, 0x73, 0x12, 0x0c, 0x0a, 0x01, 0x69, 0x18, 0x06, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x01, 0x69, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x07, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x79, 0x72, 0x61, 0x6e, + 0x67, 0x65, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x03, 0x52, 0x07, 0x79, 0x72, 0x61, 0x6e, 0x67, + 0x65, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x79, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x18, 0x09, 0x20, + 0x03, 0x28, 0x09, 0x52, 0x07, 0x79, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x12, 0x12, 0x0a, 0x04, + 0x74, 0x79, 0x70, 0x65, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, + 0x12, 0x2c, 0x0a, 0x04, 0x61, 0x78, 0x65, 0x73, 0x18, 0x0b, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, + 0x2e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2e, 0x43, 0x65, 0x6c, 0x6c, 0x2e, 0x41, + 0x78, 0x65, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x04, 0x61, 0x78, 0x65, 0x73, 0x1a, 0x47, + 0x0a, 0x09, 0x41, 0x78, 0x65, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, + 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x24, 0x0a, + 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x69, + 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2e, 0x41, 0x78, 0x69, 0x73, 0x52, 0x05, 0x76, 0x61, + 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x8b, 0x02, 0x0a, 0x05, 0x51, 0x75, 0x65, 0x72, + 0x79, 0x12, 0x18, 0x0a, 0x07, 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x07, 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x12, 0x0e, 0x0a, 0x02, 0x44, + 0x42, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x44, 0x42, 0x12, 0x0e, 0x0a, 0x02, 0x52, + 0x50, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x52, 0x50, 0x12, 0x1a, 0x0a, 0x08, 0x47, + 0x72, 0x6f, 0x75, 0x70, 0x42, 0x79, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, 0x47, + 0x72, 0x6f, 0x75, 0x70, 0x42, 0x79, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x57, 0x68, 0x65, 0x72, 0x65, + 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x09, 0x52, 0x06, 0x57, 0x68, 0x65, 0x72, 0x65, 0x73, 0x12, + 0x14, 0x0a, 0x05, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, + 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x12, 0x25, 0x0a, 0x05, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x18, 0x07, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2e, + 0x52, 0x61, 0x6e, 0x67, 0x65, 0x52, 0x05, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x16, 0x0a, 0x06, + 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x53, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x12, 0x2b, 0x0a, 0x06, 0x53, 0x68, 0x69, 0x66, 0x74, 0x73, 0x18, 0x09, + 0x20, 0x03, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2e, + 0x54, 0x69, 0x6d, 0x65, 0x53, 0x68, 0x69, 0x66, 0x74, 0x52, 0x06, 0x53, 0x68, 0x69, 0x66, 0x74, + 0x73, 0x12, 0x12, 0x0a, 0x04, 0x54, 0x79, 0x70, 0x65, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x04, 0x54, 0x79, 0x70, 0x65, 0x22, 0x51, 0x0a, 0x09, 0x54, 0x69, 0x6d, 0x65, 0x53, 0x68, 0x69, + 0x66, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x05, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x12, 0x12, 0x0a, 0x04, 0x55, 0x6e, 0x69, 0x74, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x55, 0x6e, 0x69, 0x74, 0x12, 0x1a, 0x0a, 0x08, + 0x51, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, + 0x51, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x22, 0x33, 0x0a, 0x05, 0x52, 0x61, 0x6e, 0x67, + 0x65, 0x12, 0x14, 0x0a, 0x05, 0x55, 0x70, 0x70, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, + 0x52, 0x05, 0x55, 0x70, 0x70, 0x65, 0x72, 0x12, 0x14, 0x0a, 0x05, 0x4c, 0x6f, 0x77, 0x65, 0x72, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x4c, 0x6f, 0x77, 0x65, 0x72, 0x22, 0x5d, 0x0a, + 0x09, 0x41, 0x6c, 0x65, 0x72, 0x74, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x44, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x49, 0x44, 0x12, 0x12, 0x0a, 0x04, 0x4a, 0x53, + 0x4f, 0x4e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x4a, 0x53, 0x4f, 0x4e, 0x12, 0x14, + 0x0a, 0x05, 0x53, 0x72, 0x63, 0x49, 0x44, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x53, + 0x72, 0x63, 0x49, 0x44, 0x12, 0x16, 0x0a, 0x06, 0x4b, 0x61, 0x70, 0x61, 0x49, 0x44, 0x18, 0x04, + 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x4b, 0x61, 0x70, 0x61, 0x49, 0x44, 0x22, 0xa4, 0x01, 0x0a, + 0x04, 0x55, 0x73, 0x65, 0x72, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x04, 0x52, 0x02, 0x49, 0x44, 0x12, 0x12, 0x0a, 0x04, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x04, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x50, 0x72, 0x6f, + 0x76, 0x69, 0x64, 0x65, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x50, 0x72, 0x6f, + 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x16, 0x0a, 0x06, 0x53, 0x63, 0x68, 0x65, 0x6d, 0x65, 0x18, + 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x53, 0x63, 0x68, 0x65, 0x6d, 0x65, 0x12, 0x24, 0x0a, + 0x05, 0x52, 0x6f, 0x6c, 0x65, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x69, + 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2e, 0x52, 0x6f, 0x6c, 0x65, 0x52, 0x05, 0x52, 0x6f, + 0x6c, 0x65, 0x73, 0x12, 0x1e, 0x0a, 0x0a, 0x53, 0x75, 0x70, 0x65, 0x72, 0x41, 0x64, 0x6d, 0x69, + 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x53, 0x75, 0x70, 0x65, 0x72, 0x41, 0x64, + 0x6d, 0x69, 0x6e, 0x22, 0x3e, 0x0a, 0x04, 0x52, 0x6f, 0x6c, 0x65, 0x12, 0x22, 0x0a, 0x0c, 0x4f, + 0x72, 0x67, 0x61, 0x6e, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x0c, 0x4f, 0x72, 0x67, 0x61, 0x6e, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, + 0x12, 0x0a, 0x04, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x4e, + 0x61, 0x6d, 0x65, 0x22, 0xa5, 0x01, 0x0a, 0x07, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x12, + 0x1a, 0x0a, 0x08, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x08, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x16, 0x0a, 0x06, 0x53, + 0x63, 0x68, 0x65, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x53, 0x63, 0x68, + 0x65, 0x6d, 0x65, 0x12, 0x32, 0x0a, 0x14, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x4f, + 0x72, 0x67, 0x61, 0x6e, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x14, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x4f, 0x72, 0x67, 0x61, 0x6e, + 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x44, 0x18, 0x04, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x02, 0x49, 0x44, 0x12, 0x22, 0x0a, 0x0c, 0x4f, 0x72, 0x67, 0x61, 0x6e, + 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x4f, + 0x72, 0x67, 0x61, 0x6e, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x54, 0x0a, 0x0c, 0x4f, + 0x72, 0x67, 0x61, 0x6e, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x0e, 0x0a, 0x02, 0x49, + 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x49, 0x44, 0x12, 0x12, 0x0a, 0x04, 0x4e, + 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x4e, 0x61, 0x6d, 0x65, 0x12, + 0x20, 0x0a, 0x0b, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x52, 0x6f, 0x6c, 0x65, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x52, 0x6f, 0x6c, + 0x65, 0x22, 0x32, 0x0a, 0x06, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x28, 0x0a, 0x04, 0x41, + 0x75, 0x74, 0x68, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x69, 0x6e, 0x74, 0x65, + 0x72, 0x6e, 0x61, 0x6c, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, + 0x04, 0x41, 0x75, 0x74, 0x68, 0x22, 0x3c, 0x0a, 0x0a, 0x41, 0x75, 0x74, 0x68, 0x43, 0x6f, 0x6e, + 0x66, 0x69, 0x67, 0x12, 0x2e, 0x0a, 0x12, 0x53, 0x75, 0x70, 0x65, 0x72, 0x41, 0x64, 0x6d, 0x69, + 0x6e, 0x4e, 0x65, 0x77, 0x55, 0x73, 0x65, 0x72, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, + 0x12, 0x53, 0x75, 0x70, 0x65, 0x72, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x4e, 0x65, 0x77, 0x55, 0x73, + 0x65, 0x72, 0x73, 0x22, 0x75, 0x0a, 0x12, 0x4f, 0x72, 0x67, 0x61, 0x6e, 0x69, 0x7a, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x26, 0x0a, 0x0e, 0x4f, 0x72, 0x67, + 0x61, 0x6e, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x0e, 0x4f, 0x72, 0x67, 0x61, 0x6e, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, + 0x44, 0x12, 0x37, 0x0a, 0x09, 0x4c, 0x6f, 0x67, 0x56, 0x69, 0x65, 0x77, 0x65, 0x72, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2e, + 0x4c, 0x6f, 0x67, 0x56, 0x69, 0x65, 0x77, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, + 0x09, 0x4c, 0x6f, 0x67, 0x56, 0x69, 0x65, 0x77, 0x65, 0x72, 0x22, 0x46, 0x0a, 0x0f, 0x4c, 0x6f, + 0x67, 0x56, 0x69, 0x65, 0x77, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x33, 0x0a, + 0x07, 0x43, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x19, + 0x2e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2e, 0x4c, 0x6f, 0x67, 0x56, 0x69, 0x65, + 0x77, 0x65, 0x72, 0x43, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x52, 0x07, 0x43, 0x6f, 0x6c, 0x75, 0x6d, + 0x6e, 0x73, 0x22, 0x79, 0x0a, 0x0f, 0x4c, 0x6f, 0x67, 0x56, 0x69, 0x65, 0x77, 0x65, 0x72, 0x43, + 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x04, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x50, 0x6f, 0x73, + 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x50, 0x6f, 0x73, + 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x36, 0x0a, 0x09, 0x45, 0x6e, 0x63, 0x6f, 0x64, 0x69, 0x6e, + 0x67, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x69, 0x6e, 0x74, 0x65, 0x72, + 0x6e, 0x61, 0x6c, 0x2e, 0x43, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x45, 0x6e, 0x63, 0x6f, 0x64, 0x69, + 0x6e, 0x67, 0x52, 0x09, 0x45, 0x6e, 0x63, 0x6f, 0x64, 0x69, 0x6e, 0x67, 0x73, 0x22, 0x4e, 0x0a, + 0x0e, 0x43, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x45, 0x6e, 0x63, 0x6f, 0x64, 0x69, 0x6e, 0x67, 0x12, + 0x12, 0x0a, 0x04, 0x54, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x54, + 0x79, 0x70, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x05, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x4e, 0x61, 0x6d, + 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x4e, 0x61, 0x6d, 0x65, 0x22, 0x3d, 0x0a, + 0x09, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x18, 0x0a, 0x07, 0x56, 0x65, + 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x56, 0x65, 0x72, + 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x16, 0x0a, 0x06, 0x43, 0x6f, 0x6d, 0x6d, 0x69, 0x74, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x43, 0x6f, 0x6d, 0x6d, 0x69, 0x74, 0x42, 0x0c, 0x5a, 0x0a, + 0x2e, 0x3b, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x33, } var ( @@ -2739,7 +2801,7 @@ func file_internal_proto_rawDescGZIP() []byte { } var file_internal_proto_msgTypes = make([]protoimpl.MessageInfo, 32) -var file_internal_proto_goTypes = []interface{}{ +var file_internal_proto_goTypes = []any{ (*Source)(nil), // 0: internal.Source (*Dashboard)(nil), // 1: internal.Dashboard (*DashboardCell)(nil), // 2: internal.DashboardCell @@ -2811,7 +2873,7 @@ func file_internal_proto_init() { return } if !protoimpl.UnsafeEnabled { - file_internal_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + file_internal_proto_msgTypes[0].Exporter = func(v any, i int) any { switch v := v.(*Source); i { case 0: return &v.state @@ -2823,7 +2885,7 @@ func file_internal_proto_init() { return nil } } - file_internal_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + file_internal_proto_msgTypes[1].Exporter = func(v any, i int) any { switch v := v.(*Dashboard); i { case 0: return &v.state @@ -2835,7 +2897,7 @@ func file_internal_proto_init() { return nil } } - file_internal_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { + file_internal_proto_msgTypes[2].Exporter = func(v any, i int) any { switch v := v.(*DashboardCell); i { case 0: return &v.state @@ -2847,7 +2909,7 @@ func file_internal_proto_init() { return nil } } - file_internal_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { + file_internal_proto_msgTypes[3].Exporter = func(v any, i int) any { switch v := v.(*DecimalPlaces); i { case 0: return &v.state @@ -2859,7 +2921,7 @@ func file_internal_proto_init() { return nil } } - file_internal_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { + file_internal_proto_msgTypes[4].Exporter = func(v any, i int) any { switch v := v.(*TableOptions); i { case 0: return &v.state @@ -2871,7 +2933,7 @@ func file_internal_proto_init() { return nil } } - file_internal_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { + file_internal_proto_msgTypes[5].Exporter = func(v any, i int) any { switch v := v.(*RenamableField); i { case 0: return &v.state @@ -2883,7 +2945,7 @@ func file_internal_proto_init() { return nil } } - file_internal_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} { + file_internal_proto_msgTypes[6].Exporter = func(v any, i int) any { switch v := v.(*Color); i { case 0: return &v.state @@ -2895,7 +2957,7 @@ func file_internal_proto_init() { return nil } } - file_internal_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} { + file_internal_proto_msgTypes[7].Exporter = func(v any, i int) any { switch v := v.(*Legend); i { case 0: return &v.state @@ -2907,7 +2969,7 @@ func file_internal_proto_init() { return nil } } - file_internal_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} { + file_internal_proto_msgTypes[8].Exporter = func(v any, i int) any { switch v := v.(*Axis); i { case 0: return &v.state @@ -2919,7 +2981,7 @@ func file_internal_proto_init() { return nil } } - file_internal_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} { + file_internal_proto_msgTypes[9].Exporter = func(v any, i int) any { switch v := v.(*Template); i { case 0: return &v.state @@ -2931,7 +2993,7 @@ func file_internal_proto_init() { return nil } } - file_internal_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} { + file_internal_proto_msgTypes[10].Exporter = func(v any, i int) any { switch v := v.(*TemplateValue); i { case 0: return &v.state @@ -2943,7 +3005,7 @@ func file_internal_proto_init() { return nil } } - file_internal_proto_msgTypes[11].Exporter = func(v interface{}, i int) interface{} { + file_internal_proto_msgTypes[11].Exporter = func(v any, i int) any { switch v := v.(*TemplateQuery); i { case 0: return &v.state @@ -2955,7 +3017,7 @@ func file_internal_proto_init() { return nil } } - file_internal_proto_msgTypes[12].Exporter = func(v interface{}, i int) interface{} { + file_internal_proto_msgTypes[12].Exporter = func(v any, i int) any { switch v := v.(*Server); i { case 0: return &v.state @@ -2967,7 +3029,7 @@ func file_internal_proto_init() { return nil } } - file_internal_proto_msgTypes[13].Exporter = func(v interface{}, i int) interface{} { + file_internal_proto_msgTypes[13].Exporter = func(v any, i int) any { switch v := v.(*Layout); i { case 0: return &v.state @@ -2979,7 +3041,7 @@ func file_internal_proto_init() { return nil } } - file_internal_proto_msgTypes[14].Exporter = func(v interface{}, i int) interface{} { + file_internal_proto_msgTypes[14].Exporter = func(v any, i int) any { switch v := v.(*Cell); i { case 0: return &v.state @@ -2991,7 +3053,7 @@ func file_internal_proto_init() { return nil } } - file_internal_proto_msgTypes[15].Exporter = func(v interface{}, i int) interface{} { + file_internal_proto_msgTypes[15].Exporter = func(v any, i int) any { switch v := v.(*Query); i { case 0: return &v.state @@ -3003,7 +3065,7 @@ func file_internal_proto_init() { return nil } } - file_internal_proto_msgTypes[16].Exporter = func(v interface{}, i int) interface{} { + file_internal_proto_msgTypes[16].Exporter = func(v any, i int) any { switch v := v.(*TimeShift); i { case 0: return &v.state @@ -3015,7 +3077,7 @@ func file_internal_proto_init() { return nil } } - file_internal_proto_msgTypes[17].Exporter = func(v interface{}, i int) interface{} { + file_internal_proto_msgTypes[17].Exporter = func(v any, i int) any { switch v := v.(*Range); i { case 0: return &v.state @@ -3027,7 +3089,7 @@ func file_internal_proto_init() { return nil } } - file_internal_proto_msgTypes[18].Exporter = func(v interface{}, i int) interface{} { + file_internal_proto_msgTypes[18].Exporter = func(v any, i int) any { switch v := v.(*AlertRule); i { case 0: return &v.state @@ -3039,7 +3101,7 @@ func file_internal_proto_init() { return nil } } - file_internal_proto_msgTypes[19].Exporter = func(v interface{}, i int) interface{} { + file_internal_proto_msgTypes[19].Exporter = func(v any, i int) any { switch v := v.(*User); i { case 0: return &v.state @@ -3051,7 +3113,7 @@ func file_internal_proto_init() { return nil } } - file_internal_proto_msgTypes[20].Exporter = func(v interface{}, i int) interface{} { + file_internal_proto_msgTypes[20].Exporter = func(v any, i int) any { switch v := v.(*Role); i { case 0: return &v.state @@ -3063,7 +3125,7 @@ func file_internal_proto_init() { return nil } } - file_internal_proto_msgTypes[21].Exporter = func(v interface{}, i int) interface{} { + file_internal_proto_msgTypes[21].Exporter = func(v any, i int) any { switch v := v.(*Mapping); i { case 0: return &v.state @@ -3075,7 +3137,7 @@ func file_internal_proto_init() { return nil } } - file_internal_proto_msgTypes[22].Exporter = func(v interface{}, i int) interface{} { + file_internal_proto_msgTypes[22].Exporter = func(v any, i int) any { switch v := v.(*Organization); i { case 0: return &v.state @@ -3087,7 +3149,7 @@ func file_internal_proto_init() { return nil } } - file_internal_proto_msgTypes[23].Exporter = func(v interface{}, i int) interface{} { + file_internal_proto_msgTypes[23].Exporter = func(v any, i int) any { switch v := v.(*Config); i { case 0: return &v.state @@ -3099,7 +3161,7 @@ func file_internal_proto_init() { return nil } } - file_internal_proto_msgTypes[24].Exporter = func(v interface{}, i int) interface{} { + file_internal_proto_msgTypes[24].Exporter = func(v any, i int) any { switch v := v.(*AuthConfig); i { case 0: return &v.state @@ -3111,7 +3173,7 @@ func file_internal_proto_init() { return nil } } - file_internal_proto_msgTypes[25].Exporter = func(v interface{}, i int) interface{} { + file_internal_proto_msgTypes[25].Exporter = func(v any, i int) any { switch v := v.(*OrganizationConfig); i { case 0: return &v.state @@ -3123,7 +3185,7 @@ func file_internal_proto_init() { return nil } } - file_internal_proto_msgTypes[26].Exporter = func(v interface{}, i int) interface{} { + file_internal_proto_msgTypes[26].Exporter = func(v any, i int) any { switch v := v.(*LogViewerConfig); i { case 0: return &v.state @@ -3135,7 +3197,7 @@ func file_internal_proto_init() { return nil } } - file_internal_proto_msgTypes[27].Exporter = func(v interface{}, i int) interface{} { + file_internal_proto_msgTypes[27].Exporter = func(v any, i int) any { switch v := v.(*LogViewerColumn); i { case 0: return &v.state @@ -3147,7 +3209,7 @@ func file_internal_proto_init() { return nil } } - file_internal_proto_msgTypes[28].Exporter = func(v interface{}, i int) interface{} { + file_internal_proto_msgTypes[28].Exporter = func(v any, i int) any { switch v := v.(*ColumnEncoding); i { case 0: return &v.state @@ -3159,7 +3221,7 @@ func file_internal_proto_init() { return nil } } - file_internal_proto_msgTypes[29].Exporter = func(v interface{}, i int) interface{} { + file_internal_proto_msgTypes[29].Exporter = func(v any, i int) any { switch v := v.(*BuildInfo); i { case 0: return &v.state diff --git a/kv/internal/internal.proto b/kv/internal/internal.proto index c12a9a2e36..4e56bbfc46 100644 --- a/kv/internal/internal.proto +++ b/kv/internal/internal.proto @@ -18,6 +18,12 @@ message Source { string Role = 13; // Role is the name of the miniumum role that a user must possess to access the resource string DefaultRP = 14; // DefaultRP is the default retention policy used in database queries to this source string Version = 15; // Version of the InfluxDB or Unknown + string ClusterID = 16; // Cluster ID of an InfluxDB Cloud Dedicated source + string AccountID = 17; // Account ID of an InfluxDB Cloud Dedicated source + string ManagementToken = 18; // Management token of an InfluxDB Cloud Dedicated source + string DatabaseToken = 19; // Database token of an InfluxDB Cloud Dedicated or other InfluxDB 3 source + string TagsCSVPath = 20; // TagsCSVPath is the path to a directory containing CSV files (per db) with tags for the source + string DefaultDatabase = 21; // DefaultDatabase is the default database used in queries for InfluxDB Cloud Dedicated when database list is not available } message Dashboard { @@ -115,7 +121,7 @@ message TemplateQuery { string measurement = 4; // Measurement is the optinally selected measurement for the query string tag_key = 5; // TagKey is the optionally selected tag key for the query string field_key = 6; // FieldKey is the optionally selected field key for the query - string flux = 7; // Flux script content + string flux = 7; // Flux script content } message Server { diff --git a/kv/internal/internal_test.go b/kv/internal/internal_test.go index eb4cca903e..22332ca72a 100644 --- a/kv/internal/internal_test.go +++ b/kv/internal/internal_test.go @@ -10,67 +10,81 @@ import ( ) func TestMarshalSource(t *testing.T) { - v := chronograf.Source{ - ID: 12, - Name: "Fountain of Truth", - Type: "influx", - Username: "docbrown", - Password: "1 point twenty-one g1g@w@tts", - URL: "http://twin-pines.mall.io:8086", - MetaURL: "http://twin-pines.meta.io:8086", - Default: true, - Telegraf: "telegraf", + tests := []struct { + name string + src chronograf.Source + }{ + { + name: "Source with Password", + src: chronograf.Source{ + ID: 12, + Name: "Fountain of Truth", + Type: "influx", + Username: "docbrown", + Password: "1 point twenty-one g1g@w@tts", + URL: "http://twin-pines.mall.io:8086", + MetaURL: "http://twin-pines.meta.io:8086", + Default: true, + Telegraf: "telegraf", + }, + }, + { + name: "Source with Shared Secret", + src: chronograf.Source{ + ID: 12, + Name: "Fountain of Truth", + Type: "influx", + Username: "docbrown", + SharedSecret: "hunter2s", + URL: "http://twin-pines.mall.io:8086", + MetaURL: "http://twin-pines.meta.io:8086", + Default: true, + Telegraf: "telegraf", + }, + }, + { + name: "Source for Cloud Dedicated", + src: chronograf.Source{ + ID: 12, + Name: "Fountain of Truth", + Type: "influx-v3-cloud-dedicated", + ClusterID: "3F762A1F-B609-4E7A-9657-8F0A39C27A58", + AccountID: "27F924B3-FF40-47B1-B587-3AB980B87EF4", + ManagementToken: "mgmt-token", + DatabaseToken: "database-token", + URL: "http://twin-pines.mall.io:8086", + MetaURL: "http://twin-pines.meta.io:8086", + Default: true, + Telegraf: "telegraf", + }, + }, } - var vv chronograf.Source - if buf, err := internal.MarshalSource(v); err != nil { - t.Fatal(err) - } else if err := internal.UnmarshalSource(buf, &vv); err != nil { - t.Fatal(err) - } else if !reflect.DeepEqual(v, vv) { - t.Fatalf("source protobuf copy error: got %#v, expected %#v", vv, v) - } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + v := tt.src - // Test if the new insecureskipverify works - v.InsecureSkipVerify = true - if buf, err := internal.MarshalSource(v); err != nil { - t.Fatal(err) - } else if err := internal.UnmarshalSource(buf, &vv); err != nil { - t.Fatal(err) - } else if !reflect.DeepEqual(v, vv) { - t.Fatalf("source protobuf copy error: got %#v, expected %#v", vv, v) - } -} -func TestMarshalSourceWithSecret(t *testing.T) { - v := chronograf.Source{ - ID: 12, - Name: "Fountain of Truth", - Type: "influx", - Username: "docbrown", - SharedSecret: "hunter2s", - URL: "http://twin-pines.mall.io:8086", - MetaURL: "http://twin-pines.meta.io:8086", - Default: true, - Telegraf: "telegraf", - } + // Test initial marshal/unmarshal. + var vv chronograf.Source + if buf, err := internal.MarshalSource(v); err != nil { + t.Fatalf("failed to marshal source: %v", err) + } else if err := internal.UnmarshalSource(buf, &vv); err != nil { + t.Fatalf("failed to unmarshal source: %v", err) + } else if !reflect.DeepEqual(v, vv) { + t.Fatalf("source protobuf copy error: got %#v, expected %#v", vv, v) + } - var vv chronograf.Source - if buf, err := internal.MarshalSource(v); err != nil { - t.Fatal(err) - } else if err := internal.UnmarshalSource(buf, &vv); err != nil { - t.Fatal(err) - } else if !reflect.DeepEqual(v, vv) { - t.Fatalf("source protobuf copy error: got %#v, expected %#v", vv, v) - } + // Test with InsecureSkipVerify set to true. + v.InsecureSkipVerify = true - // Test if the new insecureskipverify works - v.InsecureSkipVerify = true - if buf, err := internal.MarshalSource(v); err != nil { - t.Fatal(err) - } else if err := internal.UnmarshalSource(buf, &vv); err != nil { - t.Fatal(err) - } else if !reflect.DeepEqual(v, vv) { - t.Fatalf("source protobuf copy error: got %#v, expected %#v", vv, v) + if buf, err := internal.MarshalSource(v); err != nil { + t.Fatalf("failed to marshal source with InsecureSkipVerify: %v", err) + } else if err := internal.UnmarshalSource(buf, &vv); err != nil { + t.Fatalf("failed to unmarshal source with InsecureSkipVerify: %v", err) + } else if !reflect.DeepEqual(v, vv) { + t.Fatalf("source protobuf copy error with InsecureSkipVerify: got %#v, expected %#v", vv, v) + } + }) } } diff --git a/mocks/timeseries.go b/mocks/timeseries.go index de6319059c..39b5dea60b 100644 --- a/mocks/timeseries.go +++ b/mocks/timeseries.go @@ -25,7 +25,7 @@ type TimeSeries struct { } // New implements TimeSeriesClient -func (t *TimeSeries) New(chronograf.Source, chronograf.Logger) (chronograf.TimeSeries, error) { +func (t *TimeSeries) New(chronograf.Source, chronograf.Logger, chronograf.V3Config) (chronograf.TimeSeries, error) { return t, nil } diff --git a/server/builders.go b/server/builders.go index 0c78c13314..6a66194a2f 100644 --- a/server/builders.go +++ b/server/builders.go @@ -1,6 +1,8 @@ package server import ( + "fmt" + "github.com/influxdata/chronograf" "github.com/influxdata/chronograf/canned" "github.com/influxdata/chronograf/filestore" @@ -107,16 +109,22 @@ func (builder *MultiDashboardBuilder) Build(db chronograf.DashboardsStore) (*mul // SourcesBuilder builds a MultiSourceStore type SourcesBuilder interface { - Build(chronograf.SourcesStore) (*multistore.SourcesStore, error) + Build(chronograf.SourcesStore, string) (*multistore.SourcesStore, error) } // MultiSourceBuilder implements SourcesBuilder type MultiSourceBuilder struct { - InfluxDBURL string - InfluxDBUsername string - InfluxDBPassword string - InfluxDBOrg string - InfluxDBToken string + InfluxDBType string + InfluxDBURL string + InfluxDBUsername string + InfluxDBPassword string + InfluxDBOrg string + InfluxDBToken string + InfluxDBMgmtToken string + InfluxDBClusterID string + InfluxDBAccountID string + TagsCSVPath string + DefaultDB string Logger chronograf.Logger ID chronograf.ID @@ -124,7 +132,7 @@ type MultiSourceBuilder struct { } // Build will return a MultiSourceStore -func (fs *MultiSourceBuilder) Build(db chronograf.SourcesStore) (*multistore.SourcesStore, error) { +func (fs *MultiSourceBuilder) Build(db chronograf.SourcesStore, defaultOrgID string) (*multistore.SourcesStore, error) { // These dashboards are those handled from a directory files := filestore.NewSources(fs.Path, fs.ID, fs.Logger) @@ -132,11 +140,30 @@ func (fs *MultiSourceBuilder) Build(db chronograf.SourcesStore) (*multistore.Sou if fs.InfluxDBURL != "" { var influxdbType, username, password string - if fs.InfluxDBOrg == "" || fs.InfluxDBToken == "" { + var clusterID, accountID, mgmtToken, dbToken, tagsCSVPath, defaultDB string + if fs.InfluxDBType == chronograf.InfluxDBv3Core || fs.InfluxDBType == chronograf.InfluxDBv3Enterprise || fs.InfluxDBType == chronograf.InfluxDBv3Serverless { + // InfluxDB 3 Core, InfluxDB 3 Enterprise, InfluxDB 3 Serverless + influxdbType = fs.InfluxDBType + dbToken = fs.InfluxDBToken + } else if fs.InfluxDBType == chronograf.InfluxDBv3Clustered { + // InfluxDB Clustered + influxdbType = fs.InfluxDBType + mgmtToken = fs.InfluxDBMgmtToken + dbToken = fs.InfluxDBToken + } else if fs.InfluxDBType == chronograf.InfluxDBv3CloudDedicated { + // InfluxDB Cloud Dedicated + influxdbType = fs.InfluxDBType + clusterID = fs.InfluxDBClusterID + accountID = fs.InfluxDBAccountID + mgmtToken = fs.InfluxDBMgmtToken + dbToken = fs.InfluxDBToken + tagsCSVPath = fs.TagsCSVPath + defaultDB = fs.DefaultDB + } else if fs.InfluxDBOrg == "" || fs.InfluxDBToken == "" { // v1 InfluxDB username = fs.InfluxDBUsername password = fs.InfluxDBPassword - influxdbType = chronograf.InfluxDB + influxdbType = chronograf.InfluxDBv1 } else { // v2 InfluxDB username = fs.InfluxDBOrg @@ -144,18 +171,30 @@ func (fs *MultiSourceBuilder) Build(db chronograf.SourcesStore) (*multistore.Sou influxdbType = chronograf.InfluxDBv2 } - influxStore := &memdb.SourcesStore{ - Source: &chronograf.Source{ - ID: 0, - Name: fs.InfluxDBURL, - Type: influxdbType, - Username: username, - Password: password, - URL: fs.InfluxDBURL, - Default: true, - Version: "unknown", // a real version is re-fetched at runtime; use "unknown" version as a fallback, empty version would imply OSS 2.x - }} - stores = append([]chronograf.SourcesStore{influxStore}, stores...) + source := chronograf.Source{ + ID: 0, + Name: fs.InfluxDBURL, + Type: influxdbType, + Username: username, + Password: password, + ClusterID: clusterID, + AccountID: accountID, + ManagementToken: mgmtToken, + DatabaseToken: dbToken, + TagsCSVPath: tagsCSVPath, + URL: fs.InfluxDBURL, + DefaultDB: defaultDB, + Default: true, + Version: "unknown", // a real version is re-fetched at runtime; use "unknown" version as a fallback, empty version would imply OSS 2.x + } + + if err := ValidSourceRequest(&source, defaultOrgID); err == nil { + influxStore := &memdb.SourcesStore{Source: &source} + stores = append([]chronograf.SourcesStore{influxStore}, stores...) + } else { + // Log the error and ignore + fs.Logger.Error(fmt.Sprintf("Invalid %s source: %s", influxdbType, err)) + } } sources := &multistore.SourcesStore{ Stores: stores, diff --git a/server/builders_test.go b/server/builders_test.go index 39facf7794..f7347fabfc 100644 --- a/server/builders_test.go +++ b/server/builders_test.go @@ -20,7 +20,7 @@ func TestLayoutBuilder(t *testing.T) { func TestSourcesStoresBuilder(t *testing.T) { var b server.SourcesBuilder = &server.MultiSourceBuilder{} - sources, err := b.Build(nil) + sources, err := b.Build(nil, "") if err != nil { t.Fatalf("MultiSourceBuilder can't build a MultiSourcesStore: %v", err) } diff --git a/server/config/tls_options_test.go b/server/config/tls_options_test.go index 87a741baaf..15ab883f0f 100644 --- a/server/config/tls_options_test.go +++ b/server/config/tls_options_test.go @@ -3,10 +3,10 @@ package config_test import ( "crypto/tls" "crypto/x509" - "fmt" "testing" "github.com/influxdata/chronograf/server/config" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -144,7 +144,7 @@ func Test_CreateTLSConfig(t *testing.T) { Key: "tls_options_test.key", CACerts: "tls_options_test2.cert", }, - err: "open tls_options_test2.cert: no such file or directory", + err: "open tls_options_test2.cert:.*file.*", }, { name: "unsupported ca certs", @@ -175,8 +175,8 @@ func Test_CreateTLSConfig(t *testing.T) { } else { require.NotNil(t, err) require.Nil(t, config) - // Contains is used, because nested exceptions can evolve with go versions - require.Contains(t, fmt.Sprintf("%v", err), test.err) + // Regexp is used, because of platform difference message and nested exceptions can evolve with go versions + assert.Regexp(t, test.err, err.Error()) } }) } diff --git a/server/env.go b/server/env.go index bbf43ba2a9..7cf7e20a8a 100644 --- a/server/env.go +++ b/server/env.go @@ -11,6 +11,7 @@ type envResponse struct { TelegrafSystemInterval string `json:"telegrafSystemInterval"` HostPageDisabled bool `json:"hostPageDisabled"` CustomAutoRefresh string `json:"customAutoRefresh,omitempty"` + V3SupportEnabled bool `json:"v3SupportEnabled"` } func newEnvResponse(env chronograf.Environment) *envResponse { @@ -21,6 +22,7 @@ func newEnvResponse(env chronograf.Environment) *envResponse { TelegrafSystemInterval: env.TelegrafSystemInterval.String(), HostPageDisabled: env.HostPageDisabled, CustomAutoRefresh: env.CustomAutoRefresh, + V3SupportEnabled: env.V3SupportEnabled, } } diff --git a/server/env_test.go b/server/env_test.go index 22ea286ab0..6ed9d377a7 100644 --- a/server/env_test.go +++ b/server/env_test.go @@ -35,7 +35,7 @@ func TestEnvironment(t *testing.T) { wants: wants{ statusCode: 200, contentType: "application/json", - body: `{"links":{"self":"/chronograf/v1/env"},"telegrafSystemInterval":"1m0s","hostPageDisabled":false}`, + body: `{"links":{"self":"/chronograf/v1/env"},"telegrafSystemInterval":"1m0s","hostPageDisabled":false,"v3SupportEnabled":false}`, }, }, { @@ -49,7 +49,20 @@ func TestEnvironment(t *testing.T) { wants: wants{ statusCode: 200, contentType: "application/json", - body: `{"links":{"self":"/chronograf/v1/env"},"telegrafSystemInterval":"2m0s","hostPageDisabled":false,"customAutoRefresh": "500ms=500"}`, + body: `{"links":{"self":"/chronograf/v1/env"},"telegrafSystemInterval":"2m0s","hostPageDisabled":false,"customAutoRefresh": "500ms=500","v3SupportEnabled":false}`, + }, + }, + { + name: "Get environment with V3SupportEnabled", + fields: fields{ + Environment: chronograf.Environment{ + V3SupportEnabled: true, + }, + }, + wants: wants{ + statusCode: 200, + contentType: "application/json", + body: `{"links":{"self":"/chronograf/v1/env"},"telegrafSystemInterval":"0s","hostPageDisabled":false,"v3SupportEnabled":true}`, }, }, } diff --git a/server/influx.go b/server/influx.go index 0c87ccb5b4..d0b8179434 100644 --- a/server/influx.go +++ b/server/influx.go @@ -209,6 +209,9 @@ func setupQueryFromCommand(req *chronograf.Query) { } else if strings.Contains(command, " on ") { r := csv.NewReader(strings.NewReader(req.Command)) r.Comma = ' ' + // Without LazyQuotes=true the following query fails with `bare " in non-quoted-field` error: + // `SHOW TAG VALUES ON "mydb" FROM "mytable" WITH KEY IN ("tag1", "tag2", "tag3")` + r.LazyQuotes = true if tokens, err := r.Read(); err == nil { // filter empty tokens (i.e. redundant whitespaces, using https://go.dev/wiki/SliceTricks#filtering-without-allocating) fields := tokens[:0] diff --git a/server/server.go b/server/server.go index 153898f502..87ca77f55e 100644 --- a/server/server.go +++ b/server/server.go @@ -34,6 +34,7 @@ import ( "github.com/influxdata/chronograf/oauth2" "github.com/influxdata/chronograf/server/config" "github.com/influxdata/chronograf/util" + "github.com/influxdata/influxdb/influxql" client "github.com/influxdata/usage-client/v1" flags "github.com/jessevdk/go-flags" ) @@ -58,11 +59,23 @@ type Server struct { Cert flags.Filename `long:"cert" description:"Path to PEM encoded public key certificate. " env:"TLS_CERTIFICATE"` Key flags.Filename `long:"key" description:"Path to private key associated with given certificate. " env:"TLS_PRIVATE_KEY"` - InfluxDBURL string `long:"influxdb-url" description:"Location of your InfluxDB instance" env:"INFLUXDB_URL"` - InfluxDBUsername string `long:"influxdb-username" description:"Username for your InfluxDB instance" env:"INFLUXDB_USERNAME"` - InfluxDBPassword string `long:"influxdb-password" description:"Password for your InfluxDB instance" env:"INFLUXDB_PASSWORD"` - InfluxDBOrg string `long:"influxdb-org" description:"Organization for your InfluxDB v2 instance" env:"INFLUXDB_ORG"` - InfluxDBToken string `long:"influxdb-token" description:"Token for your InfluxDB v2 instance" env:"INFLUXDB_TOKEN"` + InfluxDBType string `long:"influxdb-type" description:"InfluxDB server type instance. Valid values: influx, influx-enterprise, influx-relay, influx-v2, influx-v3-core, influx-v3-enterprise, influx-v3-clustered, influx-v3-cloud-dedicated, influx-v3-serverless" env:"INFLUXDB_TYPE"` + InfluxDBURL string `long:"influxdb-url" description:"Location of your InfluxDB instance" env:"INFLUXDB_URL"` + InfluxDBUsername string `long:"influxdb-username" description:"Username for your InfluxDB instance" env:"INFLUXDB_USERNAME"` + InfluxDBPassword string `long:"influxdb-password" description:"Password for your InfluxDB instance" env:"INFLUXDB_PASSWORD"` + InfluxDBOrg string `long:"influxdb-org" description:"Organization for your InfluxDB v2 instance" env:"INFLUXDB_ORG"` + InfluxDBToken string `long:"influxdb-token" description:"Token for your InfluxDB v2, v3 Core/Enterprise, Cloud Dedicated or Serverless instance" env:"INFLUXDB_TOKEN"` + InfluxDBMgmtToken string `long:"influxdb-mgmt-token" description:"Management token for your InfluxDB Cloud Dedicated instance" env:"INFLUXDB_MGMT_TOKEN"` + InfluxDBClusterID string `long:"influxdb-cluster-id" description:"Cluster ID for your InfluxDB Cloud Dedicated instance" env:"INFLUXDB_CLUSTER_ID"` + InfluxDBAccountID string `long:"influxdb-account-id" description:"Account ID for your InfluxDB Cloud Dedicated instance" env:"INFLUXDB_ACCOUNT_ID"` + TagsCSVPath string `long:"tags-csv-path" description:"Path to a directory containing CSV files (per db) with tags for InfluxDB v3 sources. Used to populate the tags field in Query Editor for your InfluxDB Cloud Dedicated instance." env:"TAGS_CSV_PATH"` + InfluxDBDefaultDB string `long:"influxdb-default-db" description:"Default database for your InfluxDB instance" env:"INFLUXDB_DEFAULT_DB"` + + InfluxDBCloudDedicatedMgmtURL string `long:"influxdb-cloud-dedicated-mgmt-url" description:"Management URL for your InfluxDB Cloud Dedicated instance" env:"INFLUXDB_CLOUD_DEDICATED_MGMT_URL" default:"https://console.influxdata.com"` + InfluxDBClusteredClusterID string `long:"influxdb-clustered-cluster-id" description:"Cluster ID for your InfluxDB v3 Clustered instance" env:"INFLUXDB_CLUSTERED_CLUSTER_ID" default:"11111111-1111-1111-1111-111111111111"` + InfluxDBClusteredAccountID string `long:"influxdb-clustered-account-id" description:"Account ID for your InfluxDB v3 Clustered instance" env:"INFLUXDB_CLUSTERED_ACCOUNT_ID" default:"11111111-1111-1111-1111-111111111111"` + InfluxDBV3SupportEnabled bool `long:"influxdb-v3-support-enabled" description:"Enable InfluxDB v3 support" env:"INFLUXDB_V3_SUPPORT_ENABLED"` + InfluxDBV3TimeCondition string `long:"influxdb-v3-time-condition" description:"Time condition for SHOW TAG VALUES queries in InfluxDB v3 (e.g., 'time > now() - 1d')" env:"INFLUXDB_V3_TIME_CONDITION" default:"time > now() - 1d"` KapacitorURL string `long:"kapacitor-url" description:"Location of your Kapacitor instance" env:"KAPACITOR_URL"` KapacitorUsername string `long:"kapacitor-username" description:"Username of your Kapacitor instance" env:"KAPACITOR_USERNAME"` @@ -76,7 +89,7 @@ type Server struct { TokenSecret string `short:"t" long:"token-secret" description:"Secret to sign tokens" env:"TOKEN_SECRET"` JwksURL string `long:"jwks-url" description:"URL that returns OpenID Key Discovery JWKS document." env:"JWKS_URL"` UseIDToken bool `long:"use-id-token" description:"Enable id_token processing." env:"USE_ID_TOKEN"` - LoginHint string `long:"login-hint" description:"OpenID login_hint paramter to passed to authorization server during authentication" env:"LOGIN_HINT"` + LoginHint string `long:"login-hint" description:"OpenID login_hint parameter to passed to authorization server during authentication" env:"LOGIN_HINT"` AuthDuration time.Duration `long:"auth-duration" default:"720h" description:"Total duration of cookie life for authentication (in hours). 0 means authentication expires on browser close." env:"AUTH_DURATION"` InactivityDuration time.Duration `long:"inactivity-duration" default:"5m" description:"Duration for which a token is valid without any new activity." env:"INACTIVITY_DURATION"` @@ -542,14 +555,21 @@ func (s *Server) newBuilders(logger chronograf.Logger) builders { Path: s.ResourcesPath, }, Sources: &MultiSourceBuilder{ - InfluxDBURL: s.InfluxDBURL, - InfluxDBUsername: s.InfluxDBUsername, - InfluxDBPassword: s.InfluxDBPassword, - InfluxDBOrg: s.InfluxDBOrg, - InfluxDBToken: s.InfluxDBToken, - Logger: logger, - ID: idgen.NewTime(), - Path: s.ResourcesPath, + InfluxDBType: s.InfluxDBType, + InfluxDBURL: s.InfluxDBURL, + InfluxDBUsername: s.InfluxDBUsername, + InfluxDBPassword: s.InfluxDBPassword, + InfluxDBOrg: s.InfluxDBOrg, + InfluxDBToken: s.InfluxDBToken, + InfluxDBMgmtToken: s.InfluxDBMgmtToken, + InfluxDBClusterID: s.InfluxDBClusterID, + InfluxDBAccountID: s.InfluxDBAccountID, + TagsCSVPath: s.TagsCSVPath, + DefaultDB: s.InfluxDBDefaultDB, + + Logger: logger, + ID: idgen.NewTime(), + Path: s.ResourcesPath, }, Kapacitors: &MultiKapacitorBuilder{ KapacitorURL: s.KapacitorURL, @@ -606,6 +626,34 @@ func (s *Server) Serve(ctx context.Context) { go rotateSuperAdminNonce(ctx, s.NonceExpiration) logger := clog.New(clog.ParseLevel(s.LogLevel)) + logger.Info("Starting Chronograf ", s.BuildInfo.Version, s.BuildInfo.Commit) + + // Validate InfluxDBType if provided + if s.InfluxDBType != "" { + validTypes := []string{ + "influx", + "influx-enterprise", + "influx-relay", + "influx-v2", + "influx-v3-core", + "influx-v3-enterprise", + "influx-v3-clustered", + "influx-v3-cloud-dedicated", + "influx-v3-serverless", + } + isValid := false + for _, validType := range validTypes { + if s.InfluxDBType == validType { + isValid = true + break + } + } + if !isValid { + logger.Error("Invalid --influxdb-type value. Valid values: influx, influx-enterprise, influx-relay, influx-v2, influx-v3-core, influx-v3-enterprise, influx-v3-clustered, influx-v3-cloud-dedicated, influx-v3-serverless") + os.Exit(1) + } + } + customLinks, err := NewCustomLinks(s.CustomLinks) if err != nil { logger. @@ -661,7 +709,31 @@ func (s *Server) Serve(ctx context.Context) { } } - service := openService(ctx, db, s.newBuilders(logger), logger, s.useAuth()) + // Parse and validate v3 time condition at startup + var v3TimeConditionExpr influxql.Expr + if s.InfluxDBV3TimeCondition != "" { + expr, err := influxql.ParseExpr(s.InfluxDBV3TimeCondition) + if err != nil { + logger. + WithField("component", "server"). + WithField("time_condition", s.InfluxDBV3TimeCondition). + Error(fmt.Errorf("invalid InfluxDB v3 time condition: %w", err)) + return + } + v3TimeConditionExpr = expr + logger. + WithField("component", "server"). + WithField("time_condition", s.InfluxDBV3TimeCondition). + Info("InfluxDB v3 time condition validated and configured") + } + + service := openService(ctx, db, s.newBuilders(logger), logger, s.useAuth(), + chronograf.V3Config{ + CloudDedicatedManagementURL: s.InfluxDBCloudDedicatedMgmtURL, + ClusteredAccountID: s.InfluxDBClusteredAccountID, + ClusteredClusterID: s.InfluxDBClusteredClusterID, + TimeConditionExpr: v3TimeConditionExpr, + }) service.SuperAdminProviderGroups = superAdminProviderGroups{ auth0: s.Auth0SuperAdminOrg, } @@ -669,6 +741,7 @@ func (s *Server) Serve(ctx context.Context) { TelegrafSystemInterval: s.TelegrafSystemInterval, HostPageDisabled: s.HostPageDisabled, CustomAutoRefresh: s.CustomAutoRefresh, + V3SupportEnabled: s.InfluxDBV3SupportEnabled, } if !validBasepath(s.Basepath) { @@ -791,7 +864,7 @@ func (s *Server) Serve(ctx context.Context) { Info("Stopped serving chronograf at ", scheme, "://", listener.Addr()) } -func openService(ctx context.Context, db kv.Store, builder builders, logger chronograf.Logger, useAuth bool) Service { +func openService(ctx context.Context, db kv.Store, builder builders, logger chronograf.Logger, useAuth bool, v3Config chronograf.V3Config) Service { svc, err := kv.NewService(ctx, db, kv.WithLogger(logger)) if err != nil { logger.Error("Unable to create kv service", err) @@ -813,6 +886,13 @@ func openService(ctx context.Context, db kv.Store, builder builders, logger chro Error("Unable to construct a MultiOrganizationStore", err) os.Exit(1) } + defaultOrg, err := organizations.DefaultOrganization(ctx) + if err != nil { + logger. + WithField("component", "OrganizationsStore"). + Error("Unable to get default organization", err) + os.Exit(1) + } kapacitors, err := builder.Kapacitors.Build(svc.ServersStore()) if err != nil { @@ -822,7 +902,7 @@ func openService(ctx context.Context, db kv.Store, builder builders, logger chro os.Exit(1) } - sources, err := builder.Sources.Build(svc.SourcesStore()) + sources, err := builder.Sources.Build(svc.SourcesStore(), defaultOrg.ID) if err != nil { logger. WithField("component", "SourcesStore"). @@ -862,7 +942,8 @@ func openService(ctx context.Context, db kv.Store, builder builders, logger chro }, Logger: logger, UseAuth: useAuth, - Databases: &influx.Client{Logger: logger}, + V3Config: v3Config, + Databases: &influx.Client{Logger: logger, V3Config: v3Config}, } } diff --git a/server/service.go b/server/service.go index 2ea94ebb1a..8abb75131b 100644 --- a/server/service.go +++ b/server/service.go @@ -18,6 +18,7 @@ type Service struct { SuperAdminProviderGroups superAdminProviderGroups Env chronograf.Environment Databases chronograf.Databases + V3Config chronograf.V3Config } type superAdminProviderGroups struct { @@ -27,7 +28,7 @@ type superAdminProviderGroups struct { // TimeSeriesClient returns the correct client for a time series database. // todo(glinton): should this be always reconnecting? type TimeSeriesClient interface { - New(chronograf.Source, chronograf.Logger) (chronograf.TimeSeries, error) + New(chronograf.Source, chronograf.Logger, chronograf.V3Config) (chronograf.TimeSeries, error) } // ErrorMessage is the error response format for all service errors @@ -38,21 +39,22 @@ type ErrorMessage struct { // TimeSeries returns a new client connected to a time series database func (s *Service) TimeSeries(src chronograf.Source) (chronograf.TimeSeries, error) { - return s.TimeSeriesClient.New(src, s.Logger) + return s.TimeSeriesClient.New(src, s.Logger, s.V3Config) } // InfluxClient returns a new client to connect to OSS or Enterprise type InfluxClient struct{} // New creates a client to connect to OSS or enterprise -func (c *InfluxClient) New(src chronograf.Source, logger chronograf.Logger) (chronograf.TimeSeries, error) { +func (c *InfluxClient) New(src chronograf.Source, logger chronograf.Logger, v3Config chronograf.V3Config) (chronograf.TimeSeries, error) { client := &influx.Client{ - Logger: logger, + Logger: logger, + V3Config: v3Config, } if err := client.Connect(context.TODO(), &src); err != nil { return nil, err } - if src.Type == chronograf.InfluxEnterprise && src.MetaURL != "" { + if src.Type == chronograf.InfluxDBv1Enterprise && src.MetaURL != "" { tls := strings.Contains(src.MetaURL, "https") insecure := src.InsecureSkipVerify return enterprise.NewClientWithTimeSeries(logger, src.MetaURL, influx.DefaultAuthorization(&src), tls, insecure, client) diff --git a/server/sources.go b/server/sources.go index 6908e37d28..4e8dea64ab 100644 --- a/server/sources.go +++ b/server/sources.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/google/uuid" "github.com/influxdata/chronograf/enterprise" "github.com/influxdata/chronograf/flux" @@ -45,6 +46,11 @@ type authenticationResponse struct { } func sourceAuthenticationMethod(ctx context.Context, src chronograf.Source) authenticationResponse { + // Check for Token authentication (v2 and v3 Serverless) + if src.Type == chronograf.InfluxDBv2 || src.Type == chronograf.InfluxDBv3Serverless { + return authenticationResponse{ID: src.ID, AuthenticationMethod: "token"} + } + ldapEnabled := false if src.MetaURL != "" { authorizer := influx.DefaultAuthorization(&src) @@ -77,6 +83,10 @@ func hasFlux(ctx context.Context, src chronograf.Source) (bool, error) { if src.Version == "" /* v2 OSS reports no version */ || strings.HasPrefix(src.Version, "2.") { return src.Type == chronograf.InfluxDBv2 && src.Username != "", nil } + if chronograf.IsV3SrcType(src.Type) { + // InfluxDB 3 doesn't support Flux. + return false, nil + } url, err := url.ParseRequestURI(src.URL) if err != nil { @@ -134,7 +144,7 @@ func newSourceResponse(ctx context.Context, src chronograf.Source) sourceRespons // MetaURL is currently a string, but eventually, we'd like to change it // to a slice. Checking len(src.MetaURL) is functionally equivalent to // checking if it is equal to the empty string. - if src.Type == chronograf.InfluxEnterprise && len(src.MetaURL) != 0 { + if src.Type == chronograf.InfluxDBv1Enterprise && len(src.MetaURL) != 0 { res.Links.Roles = fmt.Sprintf("%s/%d/roles", httpAPISrcs, src.ID) } return res @@ -208,7 +218,8 @@ func (s *Service) sourceVersion(ctx context.Context, src *chronograf.Source) str func (s *Service) tsdbVersion(ctx context.Context, src *chronograf.Source) (string, error) { cli := &influx.Client{ - Logger: s.Logger, + Logger: s.Logger, + V3Config: s.V3Config, } if err := cli.Connect(ctx, src); err != nil { @@ -222,8 +233,12 @@ func (s *Service) tsdbVersion(ctx context.Context, src *chronograf.Source) (stri } func (s *Service) tsdbType(ctx context.Context, src *chronograf.Source) (string, error) { - if src.Type == chronograf.InfluxDBv2 { - return chronograf.InfluxDBv2, nil // v2 selected by the user + if src.Type == chronograf.InfluxDBv2 || + src.Type == chronograf.InfluxDBv3Core || + src.Type == chronograf.InfluxDBv3Enterprise || + src.Type == chronograf.InfluxDBv3Clustered || + src.Type == chronograf.InfluxDBv3CloudDedicated { + return src.Type, nil // type selected by the user } cli := &influx.Client{ Logger: s.Logger, @@ -241,7 +256,8 @@ func (s *Service) tsdbType(ctx context.Context, src *chronograf.Source) (string, func (s *Service) validateCredentials(ctx context.Context, src *chronograf.Source) error { cli := &influx.Client{ - Logger: s.Logger, + Logger: s.Logger, + V3Config: s.V3Config, } if err := cli.Connect(ctx, src); err != nil { return err @@ -347,7 +363,8 @@ func (s *Service) SourceHealth(w http.ResponseWriter, r *http.Request) { } cli := &influx.Client{ - Logger: s.Logger, + Logger: s.Logger, + V3Config: s.V3Config, } if err := cli.Connect(ctx, &src); err != nil { @@ -436,10 +453,14 @@ func (s *Service) UpdateSource(w http.ResponseWriter, r *http.Request) { if req.Type != "" { src.Type = req.Type } - if req.Telegraf != "" { - src.Telegraf = req.Telegraf - } + src.Telegraf = req.Telegraf src.DefaultRP = req.DefaultRP + src.DefaultDB = req.DefaultDB + src.ManagementToken = req.ManagementToken + src.DatabaseToken = req.DatabaseToken + src.ClusterID = req.ClusterID + src.AccountID = req.AccountID + src.TagsCSVPath = req.TagsCSVPath defaultOrg, err := s.Store.Organizations(ctx).DefaultOrganization(ctx) if err != nil { @@ -488,10 +509,15 @@ func ValidSourceRequest(s *chronograf.Source, defaultOrgID string) error { } // Validate Type if s.Type != "" { - if s.Type != chronograf.InfluxDB && + if s.Type != chronograf.InfluxDBv1 && + s.Type != chronograf.InfluxDBv1Enterprise && + s.Type != chronograf.InfluxDBv1Relay && s.Type != chronograf.InfluxDBv2 && - s.Type != chronograf.InfluxEnterprise && - s.Type != chronograf.InfluxRelay { + s.Type != chronograf.InfluxDBv3Core && + s.Type != chronograf.InfluxDBv3Enterprise && + s.Type != chronograf.InfluxDBv3Clustered && + s.Type != chronograf.InfluxDBv3CloudDedicated && + s.Type != chronograf.InfluxDBv3Serverless { return fmt.Errorf("invalid source type %s", s.Type) } } @@ -508,6 +534,56 @@ func ValidSourceRequest(s *chronograf.Source, defaultOrgID string) error { return fmt.Errorf("invalid URL; no URL scheme defined") } + if s.Type == chronograf.InfluxDBv3Core || s.Type == chronograf.InfluxDBv3Enterprise { + if len(s.DatabaseToken) == 0 { + return fmt.Errorf("database token required") + } + } + + if s.Type == chronograf.InfluxDBv3Serverless { + if len(s.DatabaseToken) == 0 { + return fmt.Errorf("database token required") + } + } + + if s.Type == chronograf.InfluxDBv3Clustered { + if len(s.DatabaseToken) == 0 { + return fmt.Errorf("database token required") + } + if len(s.ManagementToken) == 0 && len(s.DefaultDB) == 0 { + return fmt.Errorf("management token or default database is required") + } + } + + if s.Type == chronograf.InfluxDBv3CloudDedicated { + if len(s.DatabaseToken) == 0 { + return fmt.Errorf("database token required") + } + // ClusterID, AccountID, and ManagementToken are not required for InfluxDB 3 Cloud Dedicated + if len(s.ClusterID) == 0 && len(s.AccountID) == 0 && len(s.ManagementToken) == 0 { + if len(s.DefaultDB) == 0 { + return fmt.Errorf("default database is required for queries") + } + return nil + } + if len(s.ClusterID) == 0 { + return fmt.Errorf("cluster ID required") + } + if _, err := uuid.Parse(s.ClusterID); err != nil { + return fmt.Errorf("cluster ID is not a valid UUID") + } + if len(s.AccountID) == 0 { + return fmt.Errorf("account ID required") + } + if _, err := uuid.Parse(s.AccountID); err != nil { + return fmt.Errorf("account ID is not a valid UUID") + } + if len(s.ManagementToken) == 0 { + return fmt.Errorf("management token required") + } + + } + return nil } diff --git a/server/sources_test.go b/server/sources_test.go index 8d1dc874dc..850f84362f 100644 --- a/server/sources_test.go +++ b/server/sources_test.go @@ -45,7 +45,7 @@ func Test_ValidSourceRequest(t *testing.T) { source: &chronograf.Source{ ID: 1, Name: "I'm a really great source", - Type: chronograf.InfluxDB, + Type: chronograf.InfluxDBv1, Username: "fancy", Password: "i'm so", SharedSecret: "supersecret", @@ -89,7 +89,7 @@ func Test_ValidSourceRequest(t *testing.T) { source: &chronograf.Source{ ID: 1, Name: "I'm a really great source", - Type: chronograf.InfluxDB, + Type: chronograf.InfluxDBv1, Username: "fancy", Password: "i'm so", SharedSecret: "supersecret", @@ -104,7 +104,7 @@ func Test_ValidSourceRequest(t *testing.T) { source: &chronograf.Source{ ID: 1, Name: "I'm a really great source", - Type: chronograf.InfluxDB, + Type: chronograf.InfluxDBv1, Username: "fancy", Password: "i'm so", SharedSecret: "supersecret", @@ -150,13 +150,449 @@ func Test_ValidSourceRequest(t *testing.T) { }, }, }, + { + name: "InfluxDB Cloud Dedicated - supported", + args: args{ + source: &chronograf.Source{ + ID: 1, + Name: "I'm a really great source", + Type: chronograf.InfluxDBv3CloudDedicated, + Username: "", + Password: "", + SharedSecret: "supersecret", + ClusterID: "3F762A1F-B609-4E7A-9657-8F0A39C27A58", + AccountID: "27F924B3-FF40-47B1-B587-3AB980B87EF4", + ManagementToken: "mgmt-token", + DatabaseToken: "database-token", + URL: "http://www.any.url.com", + MetaURL: "http://www.so.meta.com", + InsecureSkipVerify: true, + Default: true, + Telegraf: "telegraf", + }, + }, + wants: wants{ + source: &chronograf.Source{ + ID: 1, + Name: "I'm a really great source", + Type: chronograf.InfluxDBv3CloudDedicated, + Username: "", + Password: "", + SharedSecret: "supersecret", + ClusterID: "3F762A1F-B609-4E7A-9657-8F0A39C27A58", + AccountID: "27F924B3-FF40-47B1-B587-3AB980B87EF4", + ManagementToken: "mgmt-token", + DatabaseToken: "database-token", + URL: "http://www.any.url.com", + MetaURL: "http://www.so.meta.com", + InsecureSkipVerify: true, + Default: true, + Telegraf: "telegraf", + }, + }, + }, + { + name: "InfluxDB Cloud Dedicated - without cluster/account IDs and management token", + args: args{ + source: &chronograf.Source{ + ID: 1, + Name: "I'm a really great source", + Type: chronograf.InfluxDBv3CloudDedicated, + Username: "", + Password: "", + SharedSecret: "supersecret", + ClusterID: "", + AccountID: "", + ManagementToken: "", + DatabaseToken: "database-token", + URL: "http://www.any.url.com", + MetaURL: "http://www.so.meta.com", + InsecureSkipVerify: true, + Default: true, + Telegraf: "telegraf", + DefaultDB: "defaultDB", + }, + }, + wants: wants{ + source: &chronograf.Source{ + ID: 1, + Name: "I'm a really great source", + Type: chronograf.InfluxDBv3CloudDedicated, + Username: "", + Password: "", + SharedSecret: "supersecret", + ClusterID: "", + AccountID: "", + ManagementToken: "", + DatabaseToken: "database-token", + URL: "http://www.any.url.com", + MetaURL: "http://www.so.meta.com", + InsecureSkipVerify: true, + Default: true, + Telegraf: "telegraf", + DefaultDB: "defaultDB", + }, + }, + }, + { + name: "InfluxDB Cloud Dedicated - missing cluster ID", + args: args{ + source: &chronograf.Source{ + ID: 1, + Name: "I'm a really great source", + Type: chronograf.InfluxDBv3CloudDedicated, + Username: "", + Password: "", + SharedSecret: "supersecret", + AccountID: "27F924B3-FF40-47B1-B587-3AB980B87EF4", + ManagementToken: "mgmt-token", + DatabaseToken: "database-token", + URL: "http://www.any.url.com", + MetaURL: "http://www.so.meta.com", + InsecureSkipVerify: true, + Default: true, + Telegraf: "telegraf", + }, + }, + wants: wants{ + err: fmt.Errorf("cluster ID required"), + }, + }, + { + name: "InfluxDB Cloud Dedicated - invalid cluster ID", + args: args{ + source: &chronograf.Source{ + ID: 1, + Name: "I'm a really great source", + Type: chronograf.InfluxDBv3CloudDedicated, + Username: "", + Password: "", + SharedSecret: "supersecret", + ClusterID: "not-a-uuid", + AccountID: "27F924B3-FF40-47B1-B587-3AB980B87EF4", + ManagementToken: "mgmt-token", + DatabaseToken: "database-token", + URL: "http://www.any.url.com", + MetaURL: "http://www.so.meta.com", + InsecureSkipVerify: true, + Default: true, + Telegraf: "telegraf", + }, + }, + wants: wants{ + err: fmt.Errorf("cluster ID is not a valid UUID"), + }, + }, + { + name: "InfluxDB Cloud Dedicated - missing account ID", + args: args{ + source: &chronograf.Source{ + ID: 1, + Name: "I'm a really great source", + Type: chronograf.InfluxDBv3CloudDedicated, + Username: "", + Password: "", + SharedSecret: "supersecret", + ClusterID: "3F762A1F-B609-4E7A-9657-8F0A39C27A58", + ManagementToken: "mgmt-token", + DatabaseToken: "database-token", + URL: "http://www.any.url.com", + MetaURL: "http://www.so.meta.com", + InsecureSkipVerify: true, + Default: true, + Telegraf: "telegraf", + }, + }, + wants: wants{ + err: fmt.Errorf("account ID required"), + }, + }, + { + name: "InfluxDB Cloud Dedicated - invalid account ID", + args: args{ + source: &chronograf.Source{ + ID: 1, + Name: "I'm a really great source", + Type: chronograf.InfluxDBv3CloudDedicated, + Username: "", + Password: "", + SharedSecret: "supersecret", + ClusterID: "3F762A1F-B609-4E7A-9657-8F0A39C27A58", + AccountID: "not-a-uuid", + ManagementToken: "mgmt-token", + DatabaseToken: "database-token", + URL: "http://www.any.url.com", + MetaURL: "http://www.so.meta.com", + InsecureSkipVerify: true, + Default: true, + Telegraf: "telegraf", + }, + }, + wants: wants{ + err: fmt.Errorf("account ID is not a valid UUID"), + }, + }, + { + name: "InfluxDB Cloud Dedicated - missing management token", + args: args{ + source: &chronograf.Source{ + ID: 1, + Name: "I'm a really great source", + Type: chronograf.InfluxDBv3CloudDedicated, + Username: "", + Password: "", + SharedSecret: "supersecret", + ClusterID: "3F762A1F-B609-4E7A-9657-8F0A39C27A58", + AccountID: "27F924B3-FF40-47B1-B587-3AB980B87EF4", + DatabaseToken: "database-token", + URL: "http://www.any.url.com", + MetaURL: "http://www.so.meta.com", + InsecureSkipVerify: true, + Default: true, + Telegraf: "telegraf", + }, + }, + wants: wants{ + err: fmt.Errorf("management token required"), + }, + }, + { + name: "InfluxDB Cloud Dedicated - missing database token", + args: args{ + source: &chronograf.Source{ + ID: 1, + Name: "I'm a really great source", + Type: chronograf.InfluxDBv3CloudDedicated, + Username: "", + Password: "", + SharedSecret: "supersecret", + ClusterID: "3F762A1F-B609-4E7A-9657-8F0A39C27A58", + AccountID: "27F924B3-FF40-47B1-B587-3AB980B87EF4", + ManagementToken: "mgmt-token", + URL: "http://www.any.url.com", + MetaURL: "http://www.so.meta.com", + InsecureSkipVerify: true, + Default: true, + Telegraf: "telegraf", + }, + }, + wants: wants{ + err: fmt.Errorf("database token required"), + }, + }, + { + name: "InfluxDB Cloud Dedicated - missing default DB", + args: args{ + source: &chronograf.Source{ + ID: 1, + Name: "I'm a really great source", + Type: chronograf.InfluxDBv3CloudDedicated, + Username: "", + Password: "", + SharedSecret: "supersecret", + AccountID: "", + ManagementToken: "", + DatabaseToken: "database-token", + URL: "http://www.any.url.com", + MetaURL: "http://www.so.meta.com", + InsecureSkipVerify: true, + Default: true, + Telegraf: "telegraf", + }, + }, + wants: wants{ + err: fmt.Errorf("default database is required for queries"), + }, + }, + { + name: "InfluxDB 3 Core - supported", + args: args{ + source: &chronograf.Source{ + ID: 1, + Name: "I'm a really great source", + Type: chronograf.InfluxDBv3Core, + DatabaseToken: "database-token", + URL: "http://www.any.url.com", + InsecureSkipVerify: true, + Default: true, + Telegraf: "telegraf", + }, + }, + wants: wants{ + source: &chronograf.Source{ + ID: 1, + Name: "I'm a really great source", + Type: chronograf.InfluxDBv3Core, + DatabaseToken: "database-token", + URL: "http://www.any.url.com", + InsecureSkipVerify: true, + Default: true, + Telegraf: "telegraf", + }, + }, + }, + { + name: "InfluxDB 3 Core - missing database token", + args: args{ + source: &chronograf.Source{ + ID: 1, + Name: "I'm a really great source", + Type: chronograf.InfluxDBv3Core, + URL: "http://www.any.url.com", + InsecureSkipVerify: true, + Default: true, + Telegraf: "telegraf", + }, + }, + wants: wants{ + err: fmt.Errorf("database token required"), + }, + }, + { + name: "InfluxDB 3 Enterprise - supported", + args: args{ + source: &chronograf.Source{ + ID: 1, + Name: "I'm a really great source", + Type: chronograf.InfluxDBv3Enterprise, + DatabaseToken: "database-token", + URL: "http://www.any.url.com", + InsecureSkipVerify: true, + Default: true, + Telegraf: "telegraf", + }, + }, + wants: wants{ + source: &chronograf.Source{ + ID: 1, + Name: "I'm a really great source", + Type: chronograf.InfluxDBv3Enterprise, + DatabaseToken: "database-token", + URL: "http://www.any.url.com", + InsecureSkipVerify: true, + Default: true, + Telegraf: "telegraf", + }, + }, + }, + { + name: "InfluxDB 3 Enterprise - missing database token", + args: args{ + source: &chronograf.Source{ + ID: 1, + Name: "I'm a really great source", + Type: chronograf.InfluxDBv3Enterprise, + URL: "http://www.any.url.com", + InsecureSkipVerify: true, + Default: true, + Telegraf: "telegraf", + }, + }, + wants: wants{ + err: fmt.Errorf("database token required"), + }, + }, + { + name: "InfluxDB 3 Clustered - supported", + args: args{ + source: &chronograf.Source{ + ID: 1, + Name: "I'm a really great source", + Type: chronograf.InfluxDBv3Clustered, + ManagementToken: "mgmt-token", + DatabaseToken: "database-token", + URL: "http://www.any.url.com", + InsecureSkipVerify: true, + Default: true, + Telegraf: "telegraf", + }, + }, + wants: wants{ + source: &chronograf.Source{ + ID: 1, + Name: "I'm a really great source", + Type: chronograf.InfluxDBv3Clustered, + ManagementToken: "mgmt-token", + DatabaseToken: "database-token", + URL: "http://www.any.url.com", + InsecureSkipVerify: true, + Default: true, + Telegraf: "telegraf", + }, + }, + }, + { + name: "InfluxDB 3 Clustered - default DB without management token", + args: args{ + source: &chronograf.Source{ + ID: 1, + Name: "I'm a really great source", + Type: chronograf.InfluxDBv3Clustered, + DatabaseToken: "database-token", + URL: "http://www.any.url.com", + InsecureSkipVerify: true, + Default: true, + DefaultDB: "defaultDB", + Telegraf: "telegraf", + }, + }, + wants: wants{ + source: &chronograf.Source{ + ID: 1, + Name: "I'm a really great source", + Type: chronograf.InfluxDBv3Clustered, + DatabaseToken: "database-token", + URL: "http://www.any.url.com", + InsecureSkipVerify: true, + Default: true, + DefaultDB: "defaultDB", + Telegraf: "telegraf", + }, + }, + }, + { + name: "InfluxDB 3 Clustered - missing management token and default db", + args: args{ + source: &chronograf.Source{ + ID: 1, + Name: "I'm a really great source", + Type: chronograf.InfluxDBv3Clustered, + DatabaseToken: "database-token", + URL: "http://www.any.url.com", + InsecureSkipVerify: true, + Default: true, + Telegraf: "telegraf", + }, + }, + wants: wants{ + err: fmt.Errorf("management token or default database is required"), + }, + }, + { + name: "InfluxDB 3 Clustered - missing database token", + args: args{ + source: &chronograf.Source{ + ID: 1, + Name: "I'm a really great source", + Type: chronograf.InfluxDBv3Clustered, + ManagementToken: "mgmt-token", + URL: "http://www.any.url.com", + InsecureSkipVerify: true, + Default: true, + Telegraf: "telegraf", + }, + }, + wants: wants{ + err: fmt.Errorf("database token required"), + }, + }, { name: "bad url", args: args{ source: &chronograf.Source{ ID: 1, Name: "I'm a really great source", - Type: chronograf.InfluxDB, + Type: chronograf.InfluxDBv1, Username: "fancy", Password: "i'm so", SharedSecret: "supersecret", @@ -183,7 +619,9 @@ func Test_ValidSourceRequest(t *testing.T) { } return } - if err.Error() != tt.wants.err.Error() { + if err != nil && tt.wants.err == nil { + t.Errorf("%q. ValidSourceRequest() = %q", tt.name, err) + } else if err.Error() != tt.wants.err.Error() { if err != nil && tt.wants.err != nil { if strings.HasPrefix(err.Error(), tt.wants.err.Error()) { // error messages vary between go versions, but they have the same prefixes @@ -353,13 +791,15 @@ func TestService_UpdateSource(t *testing.T) { r *http.Request } tests := []struct { - name string - args args - fields fields - ID string - wantStatusCode int - wantContentType string - wantBody func(string) string + name string + args args + fields fields + ID string + requestBody func(string) string + mockServerHandler http.HandlerFunc + wantStatusCode int + wantContentType string + wantBody func(string) string }{ { name: "Update source updates fields", @@ -391,64 +831,402 @@ func TestService_UpdateSource(t *testing.T) { }, Logger: log.New(log.DebugLevel), }, - ID: "1", + ID: "1", + requestBody: func(url string) string { + return fmt.Sprintf(`{"name":"marty","password":"the_lake","username":"bob","type":"influx","telegraf":"murlin","defaultRP":"pineapple","url":"%s","metaUrl":"http://murl"}`, url) + }, wantStatusCode: 200, wantContentType: "application/json", wantBody: func(url string) string { return fmt.Sprintf(`{"id":"1","name":"marty","type":"influx","username":"bob","url":"%s","metaUrl":"http://murl","default":false,"telegraf":"murlin","organization":"1337","defaultRP":"pineapple","authentication":"basic","links":{"self":"/chronograf/v1/sources/1","kapacitors":"/chronograf/v1/sources/1/kapacitors","services":"/chronograf/v1/sources/1/services","proxy":"/chronograf/v1/sources/1/proxy","queries":"/chronograf/v1/sources/1/queries","write":"/chronograf/v1/sources/1/write","permissions":"/chronograf/v1/sources/1/permissions","users":"/chronograf/v1/sources/1/users","databases":"/chronograf/v1/sources/1/dbs","annotations":"/chronograf/v1/sources/1/annotations","health":"/chronograf/v1/sources/1/health"}} +`, url) + }, + }, + { + name: "Update InfluxDB v3 Core source", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "PATCH", + "http://any.url", + nil), + }, + fields: fields{ + SourcesStore: &mocks.SourcesStore{ + GetF: func(ctx context.Context, ID int) (chronograf.Source, error) { + return chronograf.Source{ + ID: 2, + URL: "http://old.url", + Type: chronograf.InfluxDBv3Core, + DatabaseToken: "old-token", + }, nil + }, + UpdateF: func(ctx context.Context, upd chronograf.Source) error { + return nil + }, + }, + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: "1337", + Name: "pineapple_kingdom", + }, nil + }, + }, + Logger: log.New(log.DebugLevel), + }, + ID: "2", + requestBody: func(url string) string { + return fmt.Sprintf(`{"name":"v3-core","type":"influx-v3-core","url":"%s","databaseToken":"test-token-123","defaultDB":"mydb","telegraf":"telegraf"}`, url) + }, + mockServerHandler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // v3 Core sources validate by querying /api/v3/query_influxql + if r.URL.Path == "/api/v3/query_influxql" { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`[]`)) + } else { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{}`)) + } + }), + wantStatusCode: 200, + wantContentType: "application/json", + wantBody: func(url string) string { + return fmt.Sprintf(`{"id":"2","name":"v3-core","type":"influx-v3-core","databaseToken":"test-token-123","url":"%s","default":false,"telegraf":"telegraf","organization":"1337","defaultRP":"","defaultDB":"mydb","version":"Unknown","authentication":"unknown","links":{"self":"/chronograf/v1/sources/2","kapacitors":"/chronograf/v1/sources/2/kapacitors","services":"/chronograf/v1/sources/2/services","proxy":"/chronograf/v1/sources/2/proxy","queries":"/chronograf/v1/sources/2/queries","write":"/chronograf/v1/sources/2/write","permissions":"/chronograf/v1/sources/2/permissions","users":"/chronograf/v1/sources/2/users","databases":"/chronograf/v1/sources/2/dbs","annotations":"/chronograf/v1/sources/2/annotations","health":"/chronograf/v1/sources/2/health"}} +`, url) + }, + }, + { + name: "Update InfluxDB v3 Enterprise source", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "PATCH", + "http://any.url", + nil), + }, + fields: fields{ + SourcesStore: &mocks.SourcesStore{ + GetF: func(ctx context.Context, ID int) (chronograf.Source, error) { + return chronograf.Source{ + ID: 3, + Type: chronograf.InfluxDBv3Enterprise, + DatabaseToken: "old-enterprise-token", + }, nil + }, + UpdateF: func(ctx context.Context, upd chronograf.Source) error { + return nil + }, + }, + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: "1337", + Name: "pineapple_kingdom", + }, nil + }, + }, + Logger: log.New(log.DebugLevel), + }, + ID: "3", + requestBody: func(url string) string { + return fmt.Sprintf(`{"name":"v3-enterprise","type":"influx-v3-enterprise","url":"%s","databaseToken":"enterprise-token-456","defaultDB":"enterprise_db","telegraf":"telegraf"}`, url) + }, + mockServerHandler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // v3 Enterprise sources validate by querying /api/v3/query_influxql + if r.URL.Path == "/api/v3/query_influxql" { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`[]`)) + } else { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{}`)) + } + }), + wantStatusCode: 200, + wantContentType: "application/json", + wantBody: func(url string) string { + return fmt.Sprintf(`{"id":"3","name":"v3-enterprise","type":"influx-v3-enterprise","databaseToken":"enterprise-token-456","url":"%s","default":false,"telegraf":"telegraf","organization":"1337","defaultRP":"","defaultDB":"enterprise_db","version":"Unknown","authentication":"unknown","links":{"self":"/chronograf/v1/sources/3","kapacitors":"/chronograf/v1/sources/3/kapacitors","services":"/chronograf/v1/sources/3/services","proxy":"/chronograf/v1/sources/3/proxy","queries":"/chronograf/v1/sources/3/queries","write":"/chronograf/v1/sources/3/write","permissions":"/chronograf/v1/sources/3/permissions","users":"/chronograf/v1/sources/3/users","databases":"/chronograf/v1/sources/3/dbs","annotations":"/chronograf/v1/sources/3/annotations","health":"/chronograf/v1/sources/3/health"}} +`, url) + }, + }, + { + name: "Update InfluxDB v3 Clustered source", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "PATCH", + "http://any.url", + nil), + }, + fields: fields{ + SourcesStore: &mocks.SourcesStore{ + GetF: func(ctx context.Context, ID int) (chronograf.Source, error) { + return chronograf.Source{ + ID: 4, + Type: chronograf.InfluxDBv3Clustered, + DatabaseToken: "old-db-token", + ManagementToken: "old-mgmt-token", + }, nil + }, + UpdateF: func(ctx context.Context, upd chronograf.Source) error { + return nil + }, + }, + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: "1337", + Name: "pineapple_kingdom", + }, nil + }, + }, + Logger: log.New(log.DebugLevel), + }, + ID: "4", + requestBody: func(url string) string { + return fmt.Sprintf(`{"name":"v3-clustered","type":"influx-v3-clustered","url":"%s","databaseToken":"db-token-789","managementToken":"mgmt-token-789","clusterId":"a1b2c3d4-e5f6-7890-abcd-ef1234567890","accountId":"f1e2d3c4-b5a6-9870-dcba-fe9876543210","defaultDB":"clustered_db","telegraf":"telegraf"}`, url) + }, + mockServerHandler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // v3 Clustered sources validate by accessing the management API + if strings.Contains(r.URL.Path, "/api/v0/accounts/") || r.URL.Path == "/api/v3/query_influxql" { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"databases":[]}`)) + } else { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{}`)) + } + }), + wantStatusCode: 200, + wantContentType: "application/json", + wantBody: func(url string) string { + return fmt.Sprintf(`{"id":"4","name":"v3-clustered","type":"influx-v3-clustered","clusterId":"a1b2c3d4-e5f6-7890-abcd-ef1234567890","accountId":"f1e2d3c4-b5a6-9870-dcba-fe9876543210","managementToken":"mgmt-token-789","databaseToken":"db-token-789","url":"%s","default":false,"telegraf":"telegraf","organization":"1337","defaultRP":"","defaultDB":"clustered_db","authentication":"unknown","links":{"self":"/chronograf/v1/sources/4","kapacitors":"/chronograf/v1/sources/4/kapacitors","services":"/chronograf/v1/sources/4/services","proxy":"/chronograf/v1/sources/4/proxy","queries":"/chronograf/v1/sources/4/queries","write":"/chronograf/v1/sources/4/write","permissions":"/chronograf/v1/sources/4/permissions","users":"/chronograf/v1/sources/4/users","databases":"/chronograf/v1/sources/4/dbs","annotations":"/chronograf/v1/sources/4/annotations","health":"/chronograf/v1/sources/4/health"}} +`, url) + }, + }, + { + name: "Update InfluxDB v3 Cloud Dedicated source", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "PATCH", + "http://any.url", + nil), + }, + fields: fields{ + SourcesStore: &mocks.SourcesStore{ + GetF: func(ctx context.Context, ID int) (chronograf.Source, error) { + return chronograf.Source{ + ID: 5, + Type: chronograf.InfluxDBv3CloudDedicated, + DatabaseToken: "old-cloud-token", + ManagementToken: "old-cloud-mgmt", + ClusterID: "11111111-1111-1111-1111-111111111111", + AccountID: "22222222-2222-2222-2222-222222222222", + }, nil + }, + UpdateF: func(ctx context.Context, upd chronograf.Source) error { + return nil + }, + }, + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: "1337", + Name: "pineapple_kingdom", + }, nil + }, + }, + Logger: log.New(log.DebugLevel), + }, + ID: "5", + requestBody: func(url string) string { + return fmt.Sprintf(`{"name":"v3-cloud-dedicated","type":"influx-v3-cloud-dedicated","url":"%s","databaseToken":"cloud-token-abc","managementToken":"cloud-mgmt-abc","clusterId":"12345678-1234-5678-1234-567812345678","accountId":"87654321-4321-8765-4321-876543218765","defaultDB":"cloud_db","telegraf":"telegraf"}`, url) + }, + mockServerHandler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // v3 Cloud Dedicated sources validate by accessing the management API + if strings.Contains(r.URL.Path, "/api/v0/accounts/") || r.URL.Path == "/api/v3/query_influxql" { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"databases":[]}`)) + } else { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{}`)) + } + }), + wantStatusCode: 200, + wantContentType: "application/json", + wantBody: func(url string) string { + return fmt.Sprintf(`{"id":"5","name":"v3-cloud-dedicated","type":"influx-v3-cloud-dedicated","clusterId":"12345678-1234-5678-1234-567812345678","accountId":"87654321-4321-8765-4321-876543218765","managementToken":"cloud-mgmt-abc","databaseToken":"cloud-token-abc","url":"%s","default":false,"telegraf":"telegraf","organization":"1337","defaultRP":"","defaultDB":"cloud_db","authentication":"unknown","links":{"self":"/chronograf/v1/sources/5","kapacitors":"/chronograf/v1/sources/5/kapacitors","services":"/chronograf/v1/sources/5/services","proxy":"/chronograf/v1/sources/5/proxy","queries":"/chronograf/v1/sources/5/queries","write":"/chronograf/v1/sources/5/write","permissions":"/chronograf/v1/sources/5/permissions","users":"/chronograf/v1/sources/5/users","databases":"/chronograf/v1/sources/5/dbs","annotations":"/chronograf/v1/sources/5/annotations","health":"/chronograf/v1/sources/5/health"}} +`, url) + }, + }, + { + name: "Update InfluxDB v3 Cloud Dedicated source to default DB", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "PATCH", + "http://any.url", + nil), + }, + fields: fields{ + SourcesStore: &mocks.SourcesStore{ + GetF: func(ctx context.Context, ID int) (chronograf.Source, error) { + return chronograf.Source{ + ID: 6, + Type: chronograf.InfluxDBv3CloudDedicated, + DatabaseToken: "old-cloud-token", + ManagementToken: "old-cloud-mgmt", + ClusterID: "11111111-1111-1111-1111-111111111111", + AccountID: "22222222-2222-2222-2222-222222222222", + }, nil + }, + UpdateF: func(ctx context.Context, upd chronograf.Source) error { + return nil + }, + }, + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: "1337", + Name: "pineapple_kingdom", + }, nil + }, + }, + Logger: log.New(log.DebugLevel), + }, + ID: "6", + requestBody: func(url string) string { + return fmt.Sprintf(`{"name":"v3-cloud-dedicated","type":"influx-v3-cloud-dedicated","url":"%s","databaseToken":"old-cloud-token","managementToken":"","clusterId":"","accountId":"","defaultDB":"my-db","telegraf":"my-db"}`, url) + }, + mockServerHandler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // v3 Cloud Dedicated sources validate by accessing the management API + if strings.Contains(r.URL.Path, "/api/v0/accounts/") || r.URL.Path == "/api/v3/query_influxql" { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"databases":[]}`)) + } else { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{}`)) + } + }), + wantStatusCode: 200, + wantContentType: "application/json", + wantBody: func(url string) string { + return fmt.Sprintf(`{"id":"6","name":"v3-cloud-dedicated","type":"influx-v3-cloud-dedicated","databaseToken":"old-cloud-token","url":"%s","default":false,"telegraf":"my-db","organization":"1337","defaultRP":"","defaultDB":"my-db","authentication":"unknown","links":{"self":"/chronograf/v1/sources/6","kapacitors":"/chronograf/v1/sources/6/kapacitors","services":"/chronograf/v1/sources/6/services","proxy":"/chronograf/v1/sources/6/proxy","queries":"/chronograf/v1/sources/6/queries","write":"/chronograf/v1/sources/6/write","permissions":"/chronograf/v1/sources/6/permissions","users":"/chronograf/v1/sources/6/users","databases":"/chronograf/v1/sources/6/dbs","annotations":"/chronograf/v1/sources/6/annotations","health":"/chronograf/v1/sources/6/health"}} +`, url) + }, + }, + { + name: "Update InfluxDB v3 Serverless source", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "PATCH", + "http://any.url", + nil), + }, + fields: fields{ + SourcesStore: &mocks.SourcesStore{ + GetF: func(ctx context.Context, ID int) (chronograf.Source, error) { + return chronograf.Source{ + ID: 7, + Type: chronograf.InfluxDBv3Serverless, + DatabaseToken: "old-serverless-token", + }, nil + }, + UpdateF: func(ctx context.Context, upd chronograf.Source) error { + return nil + }, + }, + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: "1337", + Name: "pineapple_kingdom", + }, nil + }, + }, + Logger: log.New(log.DebugLevel), + }, + ID: "7", + requestBody: func(url string) string { + return fmt.Sprintf(`{"name":"v3-serverless","type":"influx-v3-serverless","url":"%s","databaseToken":"serverless-token-xyz","defaultDB":"serverless_db","telegraf":"telegraf"}`, url) + }, + mockServerHandler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // v3 Serverless uses /query endpoint like v1 + if r.URL.Path == "/query" { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"results":[{"statement_id":0,"series":[{"name":"databases","columns":["name"],"values":[["_internal"]]}]}]}`)) + } else { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{}`)) + } + }), + wantStatusCode: 200, + wantContentType: "application/json", + wantBody: func(url string) string { + return fmt.Sprintf(`{"id":"7","name":"v3-serverless","type":"influx-v3-serverless","databaseToken":"serverless-token-xyz","url":"%s","default":false,"telegraf":"telegraf","organization":"1337","defaultRP":"","defaultDB":"serverless_db","authentication":"token","links":{"self":"/chronograf/v1/sources/7","kapacitors":"/chronograf/v1/sources/7/kapacitors","services":"/chronograf/v1/sources/7/services","proxy":"/chronograf/v1/sources/7/proxy","queries":"/chronograf/v1/sources/7/queries","write":"/chronograf/v1/sources/7/write","permissions":"/chronograf/v1/sources/7/permissions","users":"/chronograf/v1/sources/7/users","databases":"/chronograf/v1/sources/7/dbs","annotations":"/chronograf/v1/sources/7/annotations","health":"/chronograf/v1/sources/7/health"}} `, url) }, }, } for _, tt := range tests { - h := &Service{ - Store: &mocks.Store{ - SourcesStore: tt.fields.SourcesStore, - OrganizationsStore: tt.fields.OrganizationsStore, - }, - Logger: tt.fields.Logger, - } - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/query" { - w.WriteHeader(http.StatusOK) // credentials - } else { - w.WriteHeader(http.StatusNoContent) - w.Header().Set("X-Influxdb-Build", "ENT") + t.Run(tt.name, func(t *testing.T) { + mockHandler := tt.mockServerHandler + if mockHandler == nil { + mockHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/query" { + w.WriteHeader(http.StatusOK) // credentials + } else { + w.WriteHeader(http.StatusNoContent) + w.Header().Set("X-Influxdb-Build", "ENT") + } + w.Write(([]byte)("{}")) + }) } - w.Write(([]byte)("{}")) - })) - defer ts.Close() + ts := httptest.NewServer(mockHandler) + defer ts.Close() - tt.args.r = tt.args.r.WithContext(httprouter.WithParams( - context.Background(), - httprouter.Params{ - { - Key: "id", - Value: tt.ID, + h := &Service{ + Store: &mocks.Store{ + SourcesStore: tt.fields.SourcesStore, + OrganizationsStore: tt.fields.OrganizationsStore, }, - })) - tt.args.r.Body = ioutil.NopCloser( - bytes.NewReader([]byte( - fmt.Sprintf(`{"name":"marty","password":"the_lake","username":"bob","type":"influx","telegraf":"murlin","defaultRP":"pineapple","url":"%s","metaUrl":"http://murl"}`, ts.URL)), - ), - ) - h.UpdateSource(tt.args.w, tt.args.r) + Logger: tt.fields.Logger, + V3Config: chronograf.V3Config{ + CloudDedicatedManagementURL: ts.URL, + }, + } - resp := tt.args.w.Result() - contentType := resp.Header.Get("Content-Type") - body, _ := ioutil.ReadAll(resp.Body) + tt.args.r = tt.args.r.WithContext(httprouter.WithParams( + context.Background(), + httprouter.Params{ + { + Key: "id", + Value: tt.ID, + }, + })) + tt.args.r.Body = ioutil.NopCloser( + bytes.NewReader([]byte(tt.requestBody(ts.URL))), + ) + h.UpdateSource(tt.args.w, tt.args.r) - if resp.StatusCode != tt.wantStatusCode { - t.Errorf("%q. UpdateSource() = got %v, want %v", tt.name, resp.StatusCode, tt.wantStatusCode) - } - if contentType != tt.wantContentType { - t.Errorf("%q. UpdateSource() = got %v, want %v", tt.name, contentType, tt.wantContentType) - } - wantBody := tt.wantBody(ts.URL) - if string(body) != wantBody { - t.Errorf("%q. UpdateSource() =\ngot ***%v***\nwant ***%v***\n", tt.name, string(body), wantBody) - } + resp := tt.args.w.Result() + contentType := resp.Header.Get("Content-Type") + body, _ := ioutil.ReadAll(resp.Body) + + if resp.StatusCode != tt.wantStatusCode { + t.Errorf("%q. UpdateSource() = got %v, want %v", tt.name, resp.StatusCode, tt.wantStatusCode) + } + if contentType != tt.wantContentType { + t.Errorf("%q. UpdateSource() = got %v, want %v", tt.name, contentType, tt.wantContentType) + } + wantBody := tt.wantBody(ts.URL) + if string(body) != wantBody { + t.Errorf("%q. UpdateSource() =\ngot ***%v***\nwant ***%v***\n", tt.name, string(body), wantBody) + } + }) } + } func TestService_NewSourceUser(t *testing.T) { diff --git a/server/swagger.json b/server/swagger.json index ca839950b2..c4b22a3686 100644 --- a/server/swagger.json +++ b/server/swagger.json @@ -5204,7 +5204,7 @@ "type": "string", "description": "Format of the data source", "readOnly": true, - "enum": ["influx", "influx-enterprise", "influx-relay"] + "enum": ["influx", "influx-enterprise", "influx-relay", "influx-v2", "influx-v3-core", "influx-v3-enterprise", "influx-v3-clustered", "influx-v3-cloud-dedicated"] }, "username": { "type": "string", diff --git a/ui/src/admin/components/DatabaseManager.js b/ui/src/admin/components/DatabaseManager.js index 273f34899c..8e9c3ed73a 100644 --- a/ui/src/admin/components/DatabaseManager.js +++ b/ui/src/admin/components/DatabaseManager.js @@ -7,6 +7,7 @@ import FancyScrollbar from 'src/shared/components/FancyScrollbar' const DatabaseManager = ({ databases, isRFDisplayed, + isDBReadOnly, isAddDBDisabled, addDatabase, onEditDatabase, @@ -52,6 +53,7 @@ const DatabaseManager = ({ key={db.links.self} database={db} isRFDisplayed={isRFDisplayed} + isDBReadOnly={isDBReadOnly} onEditDatabase={onEditDatabase} onKeyDownDatabase={onKeyDownDatabase} onCancelDatabase={onCancelDatabase} @@ -82,6 +84,7 @@ DatabaseManager.propTypes = { databases: arrayOf(shape()), addDatabase: func, isRFDisplayed: bool, + isDBReadOnly: bool, isAddDBDisabled: bool, onEditDatabase: func, onKeyDownDatabase: func, diff --git a/ui/src/admin/components/DatabaseRow.js b/ui/src/admin/components/DatabaseRow.js index ed568a4605..c817f1cc9f 100644 --- a/ui/src/admin/components/DatabaseRow.js +++ b/ui/src/admin/components/DatabaseRow.js @@ -129,6 +129,7 @@ class DatabaseRow extends Component { database, onDelete, isDeletable, + isEditable, isRFDisplayed, } = this.props const {isEditing} = this.state @@ -233,6 +234,7 @@ class DatabaseRow extends Component {