Compare commits

..

2 Commits

Author SHA1 Message Date
Tushar Vats
973429a22f fix(logs): stop hasToken and hasAny/hasAll 500s on separator and big-int needles (#12261)
Some checks are pending
build-staging / staging (push) Blocked by required conditions
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
hasToken(body, <needle>) returned a 500 whenever the needle contained a
token separator or whitespace (ClickHouse code 36). Validate the needle
up front in conditionForHasToken (the single choke point for both legacy
and JSON modes) and return a clean 400 pointing at CONTAINS instead.

hasAny/hasAll on a legacy body array returned a 500 for a quoted integer
needle >= 2^32: clickhouse-go binds it as a concrete Array(UInt64), which
has no supertype with the Array(Nullable(Int64)) extraction that the
function must unify (code 386). CAST the needle array to Array(Int64) to
match the extraction. Scalar has() and the scalar OR-fallback are
value-level and unaffected.
2026-07-24 12:56:56 +00:00
Swapnil Nakade
9f5ad1ba26 fix: list gcp accounts was missing config (#12175)
* fix: list gcp accounts was missing config

* refactor: updating cloudintegration integration tests

* refactor: moving variables to test file

* chore: removing verbose comments
2026-07-24 10:56:33 +00:00
20 changed files with 519 additions and 226 deletions

View File

@@ -1592,6 +1592,13 @@ components:
required:
- config
type: object
CommonDisplay:
properties:
description:
type: string
name:
type: string
type: object
CommonJSONRef:
properties:
$ref:
@@ -2725,6 +2732,11 @@ components:
type: object
DashboardtypesDashboardSpec:
properties:
datasources:
additionalProperties:
$ref: '#/components/schemas/DashboardtypesDatasourceSpec'
nullable: true
type: object
display:
$ref: '#/components/schemas/DashboardtypesDisplay'
duration:
@@ -2789,6 +2801,39 @@ components:
required:
- version
type: object
DashboardtypesDatasourcePlugin:
discriminator:
mapping:
signoz/Datasource: '#/components/schemas/DashboardtypesDatasourcePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesSigNozDatasourceSpec'
propertyName: kind
oneOf:
- $ref: '#/components/schemas/DashboardtypesDatasourcePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesSigNozDatasourceSpec'
type: object
DashboardtypesDatasourcePluginKind:
enum:
- signoz/Datasource
type: string
DashboardtypesDatasourcePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesSigNozDatasourceSpec:
properties:
kind:
enum:
- signoz/Datasource
type: string
spec:
$ref: '#/components/schemas/DashboardtypesSigNozDatasourceSpec'
required:
- kind
- spec
type: object
DashboardtypesDatasourceSpec:
properties:
default:
type: boolean
display:
$ref: '#/components/schemas/CommonDisplay'
plugin:
$ref: '#/components/schemas/DashboardtypesDatasourcePlugin'
type: object
DashboardtypesDisplay:
properties:
description:
@@ -3565,6 +3610,8 @@ components:
required:
- queryValue
type: object
DashboardtypesSigNozDatasourceSpec:
type: object
DashboardtypesSource:
enum:
- user

View File

@@ -3236,6 +3236,17 @@ export interface CloudintegrationtypesUpdatableServiceDTO {
config: CloudintegrationtypesServiceConfigDTO;
}
export interface CommonDisplayDTO {
/**
* @type string
*/
description?: string;
/**
* @type string
*/
name?: string;
}
export interface CommonJSONRefDTO {
/**
* @type string
@@ -3979,6 +3990,44 @@ export interface DashboardtypesDashboardPanelRefDTO {
panelName: string;
}
export enum DashboardtypesDatasourcePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesSigNozDatasourceSpecDTOKind {
'signoz/Datasource' = 'signoz/Datasource',
}
export interface DashboardtypesSigNozDatasourceSpecDTO {
[key: string]: unknown;
}
export interface DashboardtypesDatasourcePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesSigNozDatasourceSpecDTO {
/**
* @enum signoz/Datasource
* @type string
*/
kind: DashboardtypesDatasourcePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesSigNozDatasourceSpecDTOKind;
spec: DashboardtypesSigNozDatasourceSpecDTO;
}
export type DashboardtypesDatasourcePluginDTO =
DashboardtypesDatasourcePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesSigNozDatasourceSpecDTO;
export interface DashboardtypesDatasourceSpecDTO {
/**
* @type boolean
*/
default?: boolean;
display?: CommonDisplayDTO;
plugin?: DashboardtypesDatasourcePluginDTO;
}
export type DashboardtypesDashboardSpecDTODatasourcesAnyOf = {
[key: string]: DashboardtypesDatasourceSpecDTO;
};
/**
* @nullable
*/
export type DashboardtypesDashboardSpecDTODatasources =
DashboardtypesDashboardSpecDTODatasourcesAnyOf | null;
export enum DashboardtypesPanelKindDTO {
Panel = 'Panel',
}
@@ -4758,6 +4807,10 @@ export type DashboardtypesVariableDTO =
| DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpecDTO;
export interface DashboardtypesDashboardSpecDTO {
/**
* @type object,null
*/
datasources?: DashboardtypesDashboardSpecDTODatasources;
display: DashboardtypesDisplayDTO;
/**
* @type string
@@ -4833,6 +4886,9 @@ export interface DashboardtypesDashboardViewDTO {
updatedAt?: string;
}
export enum DashboardtypesDatasourcePluginKindDTO {
'signoz/Datasource' = 'signoz/Datasource',
}
export interface TagtypesGettableTagDTO {
/**
* @type string

View File

@@ -89,7 +89,8 @@ func (c *conditionBuilder) conditionForArrayFunction(
for i, v := range list {
vals[i] = legacyCoerceNeedle(v, elemType)
}
arrayCond := fmt.Sprintf("%s(%s, %s)", operator.FunctionName(), arrayExpr, sb.Var(vals))
// Pin the needle array type to the haystack; scalar fallback below coerces value-level.
arrayCond := fmt.Sprintf("%s(%s, %s)", operator.FunctionName(), arrayExpr, castNeedleArray(elemType, sb.Var(vals)))
if !hasScalar {
return arrayCond, nil
}
@@ -113,6 +114,27 @@ func (c *conditionBuilder) conditionForArrayFunction(
return fmt.Sprintf("(%s OR ifNull(%s, false))", arrayCond, sb.And(sb.E(scalarExpr, typedNeedle), scalarGuard)), nil
}
// castNeedleArray pins an Int64 needle array to Array(Int64) so it matches the Array(Nullable(Int64))
// haystack; without it a needle >= 2^32 binds as Array(UInt64) and hasAny/hasAll error (code 386).
func castNeedleArray(elemType telemetrytypes.FieldDataType, arg string) string {
if elemType == telemetrytypes.FieldDataTypeInt64 {
return fmt.Sprintf("CAST(%s AS Array(Int64))", arg)
}
return arg
}
// firstTokenSeparator returns the first char of s that hasToken treats as a token separator
// (anything other than an ASCII letter or digit), and whether one was found.
func firstTokenSeparator(s string) (string, bool) {
for _, r := range s {
isAlphaNum := (r >= '0' && r <= '9') || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')
if !isAlphaNum {
return string(r), true
}
}
return "", false
}
// conditionForHasToken builds a hasToken full-text search over the body column, resolving the
// column from the key name + use_json_body flag.
func (c *conditionBuilder) conditionForHasToken(
@@ -129,11 +151,19 @@ func (c *conditionBuilder) conditionForHasToken(
}
// hasToken matches string tokens only.
if _, ok := needle.(string); !ok {
needleStr, ok := needle.(string)
if !ok {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput,
"function `hasToken` expects value parameter to be a string").WithUrl(hasTokenFunctionDocURL)
}
// A multi-token needle makes CH hasToken error (code 36); reject up front as a 400. Both modes flow here.
if sep, found := firstTokenSeparator(needleStr); found {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput,
"function `hasToken` matches a single whole token, but %q contains the separator %q; use a substring filter (e.g. `body CONTAINS '%s'`) to search across separators",
needleStr, sep, needleStr).WithUrl(hasTokenFunctionDocURL)
}
bodyJSONEnabled := c.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(orgID))
if !bodyJSONEnabled {

View File

@@ -104,6 +104,23 @@ func TestFilterExprLogsBodyJSON(t *testing.T) {
expectedArgs: []any{int64(200), int64(200)},
expectedErrorContains: "",
},
{
// Big-int needle CAST to Array(Int64) to match the haystack (else 386).
category: "json",
query: `hasAny(body.ids, ['9007199254740993', '9007199254740994'])`,
shouldPass: true,
expectedQuery: `WHERE (hasAny(JSONExtract(JSON_QUERY(body, '$."ids"[*]'), 'Array(Nullable(Int64))'), CAST(? AS Array(Int64))) OR ifNull((JSONExtract(JSON_VALUE(body, '$."ids"'), 'Nullable(Int64)') IN (?, ?) AND JSONType(body, 'ids') NOT IN ('Array', 'Object', 'Null')), false))`,
expectedArgs: []any{[]any{int64(9007199254740993), int64(9007199254740994)}, int64(9007199254740993), int64(9007199254740994)},
expectedErrorContains: "",
},
{
category: "json",
query: `hasAll(body.ids, ['9007199254740993', '9007199254740994'])`,
shouldPass: true,
expectedQuery: `WHERE (hasAll(JSONExtract(JSON_QUERY(body, '$."ids"[*]'), 'Array(Nullable(Int64))'), CAST(? AS Array(Int64))) OR ifNull(((JSONExtract(JSON_VALUE(body, '$."ids"'), 'Nullable(Int64)') = ? AND JSONExtract(JSON_VALUE(body, '$."ids"'), 'Nullable(Int64)') = ?) AND JSONType(body, 'ids') NOT IN ('Array', 'Object', 'Null')), false))`,
expectedArgs: []any{[]any{int64(9007199254740993), int64(9007199254740994)}, int64(9007199254740993), int64(9007199254740994)},
expectedErrorContains: "",
},
{
category: "json",
query: "body.message = hello",

View File

@@ -1561,6 +1561,19 @@ func TestFilterExprLogs(t *testing.T) {
expectedArgs: []any{"download"},
expectedErrorContains: "function `hasToken` expects value parameter to be a string",
},
// A multi-token needle (separator/whitespace) is a clean 400, not a CH execution error.
{
category: "hasTokenUnderscoreNeedle",
query: "hasToken(body, \"user_id\")",
shouldPass: false,
expectedErrorContains: "function `hasToken` matches a single whole token",
},
{
category: "hasTokenWhitespaceNeedle",
query: "hasToken(body, \"production node\")",
shouldPass: false,
expectedErrorContains: "function `hasToken` matches a single whole token",
},
// extra / mis-shaped value arguments are rejected, not silently dropped.
{
category: "hasExtraArgs",

View File

@@ -137,6 +137,13 @@ func NewAccountFromStorable(storableAccount *StorableCloudIntegration) (*Account
return nil, err
}
account.Config.Azure = azureConfig
case CloudProviderTypeGCP:
gcpConfig := new(GCPAccountConfig)
err := json.Unmarshal([]byte(storableAccount.Config), gcpConfig)
if err != nil {
return nil, err
}
account.Config.GCP = gcpConfig
}
if storableAccount.LastAgentReport != nil {

View File

@@ -17,17 +17,16 @@ import (
// DashboardSpec is the SigNoz dashboard v2 spec shape. It mirrors
// dashboard.Spec (Perses) field-for-field, except every common.Plugin
// occurrence is replaced with a typed SigNoz plugin whose OpenAPI schema is a
// per-site discriminated oneOf. Perses's datasources field is deliberately
// dropped: SigNoz never reads it (queries carry their own signal/source), so
// the drift test allowlists it as an intentional omission.
// per-site discriminated oneOf.
type DashboardSpec struct {
Display Display `json:"display" required:"true"`
Variables []Variable `json:"variables" required:"true" nullable:"false"`
Panels map[string]*Panel `json:"panels" required:"true" nullable:"false"`
Layouts []Layout `json:"layouts" required:"true" nullable:"false"`
Duration common.DurationString `json:"duration"`
RefreshInterval common.DurationString `json:"refreshInterval"`
Links []Link `json:"links,omitzero"`
Display Display `json:"display" required:"true"`
Datasources map[string]*DatasourceSpec `json:"datasources,omitzero"`
Variables []Variable `json:"variables" required:"true" nullable:"false"`
Panels map[string]*Panel `json:"panels" required:"true" nullable:"false"`
Layouts []Layout `json:"layouts" required:"true" nullable:"false"`
Duration common.DurationString `json:"duration"`
RefreshInterval common.DurationString `json:"refreshInterval"`
Links []Link `json:"links,omitzero"`
}
// ══════════════════════════════════════════════
@@ -107,7 +106,7 @@ func (d *DashboardSpec) validatePanels() error {
}
panelKind := panel.Spec.Plugin.Kind
if len(panel.Spec.Queries) != 1 {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s.spec.queries: panel must have one query, found %d", path, len(panel.Spec.Queries))
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s.spec.queries: panel must have one query", path)
}
allowed := allowedQueryKinds[panelKind]
for qi, q := range panel.Spec.Queries {
@@ -270,8 +269,8 @@ func (d *DashboardSpec) validateLayouts() error {
return errors.NewInternalf(errors.CodeInternal, "spec.layouts[%d].spec: unexpected layout spec type %T", li, layout.Spec)
}
if grid.Display != nil {
if n := utf8.RuneCountInString(grid.Display.Title); n > MaxLayoutTitleLen {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.display.title: layout name must be at most %d characters, got %d", li, MaxLayoutTitleLen, n)
if n := utf8.RuneCountInString(grid.Display.Title); n > MaxDisplayNameLen {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.display.title: layout name must be at most %d characters, got %d", li, MaxDisplayNameLen, n)
}
}
if err := validateGridLayoutGeometry(grid, li); err != nil {

View File

@@ -30,7 +30,7 @@ const basePostableJSON = `{
"spec": {
"name": "service",
"allowAllValue": true,
"allowMultiple": true,
"allowMultiple": false,
"plugin": {
"kind": "signoz/DynamicVariable",
"spec": {"name": "service.name", "signal": "metrics"}

View File

@@ -44,7 +44,7 @@ func TestInvalidateNotAJSON(t *testing.T) {
// TestUnmarshalErrorPreservesNestedMessage guards the wrap on dec.Decode in
// DashboardSpec.UnmarshalJSON. The wrap stamps a consistent type/code on
// decode failures, but must not smother the rich messages produced by nested
// UnmarshalJSON methods (panel/query/variable plugin envelopes).
// UnmarshalJSON methods (panel/query/variable/datasource plugin envelopes).
func TestUnmarshalErrorPreservesNestedMessage(t *testing.T) {
data := []byte(`{
"panels": {
@@ -91,7 +91,7 @@ func TestValidateOnlyVariables(t *testing.T) {
"spec": {
"name": "service",
"allowAllValue": true,
"allowMultiple": true,
"allowMultiple": false,
"plugin": {
"kind": "signoz/DynamicVariable",
"spec": {
@@ -237,12 +237,6 @@ func TestInvalidateListVariableCrossFields(t *testing.T) {
assert.Contains(t, err.Error(), "customAllValue cannot be set")
})
t.Run("allowAllValue without allowMultiple", func(t *testing.T) {
_, err := unmarshalDashboard(listVar(`"allowAllValue": true, "allowMultiple": false,`))
require.Error(t, err)
assert.Contains(t, err.Error(), "allowAllValue cannot be set")
})
t.Run("list defaultValue without allowMultiple", func(t *testing.T) {
_, err := unmarshalDashboard(listVar(`"allowAllValue": false, "allowMultiple": false, "defaultValue": ["a", "b"],`))
require.Error(t, err)
@@ -447,6 +441,20 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
}`,
wantContain: "FakeVariable",
},
{
name: "unknown datasource plugin",
data: `{
"datasources": {
"ds1": {
"default": true,
"plugin": {"kind": "FakeDatasource", "spec": {}}
}
},
"links": [],
"layouts": []
}`,
wantContain: "FakeDatasource",
},
}
for _, tt := range tests {
@@ -1731,61 +1739,55 @@ func TestInvalidateDuplicatePanelReference(t *testing.T) {
assert.Contains(t, err.Error(), "spec.layouts[0].spec.items[1].content")
}
// Every display name — dashboard, panel, variable — is bounded at MaxDisplayNameLen,
// while the grid layout title has its own, larger bound (MaxLayoutTitleLen). The name
// is one over the relevant limit in each case, and the message reads "<json path>:
// <field> name must be at most ...", pairing the locatable path (like the other spec
// errors) with a human field label.
// Every display name — dashboard, panel, variable — and the grid layout title is
// bounded at MaxDisplayNameLen. The name is one over the limit in each case, and
// the message reads "<json path>: <field> name must be at most ...", pairing the
// locatable path (like the other spec errors) with a human field label.
func TestInvalidateDisplayNameTooLong(t *testing.T) {
tooLong := strings.Repeat("x", MaxDisplayNameLen+1)
lengthMsg := fmt.Sprintf("must be at most %d characters, got %d", MaxDisplayNameLen, MaxDisplayNameLen+1)
testCases := []struct {
scenario string
limit int
dashboardJSONFmt string
expectedPath string
expectedLabel string
scenario string
dashboardJSON string
expectedPath string
expectedLabel string
}{
{
scenario: "dashboard display name",
limit: MaxDisplayNameLen,
dashboardJSONFmt: `{"display": {"name": "%s"}, "links": [], "layouts": []}`,
expectedLabel: "dashboard",
expectedPath: "spec.display.name",
scenario: "dashboard display name",
dashboardJSON: `{"display": {"name": "` + tooLong + `"}, "links": [], "layouts": []}`,
expectedLabel: "dashboard",
expectedPath: "spec.display.name",
},
{
scenario: "panel display name",
limit: MaxDisplayNameLen,
dashboardJSONFmt: `{"panels": {"p1": {"kind": "Panel", "spec": {"links": [], "display": {"name": "%s"}, "plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": []}}}, "links": [], "layouts": []}`,
expectedLabel: "panel",
expectedPath: "spec.panels.p1.spec.display.name",
scenario: "panel display name",
dashboardJSON: `{"panels": {"p1": {"kind": "Panel", "spec": {"links": [],"display": {"name": "` + tooLong + `"}, "plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": []}}}, "links": [], "layouts": []}`,
expectedLabel: "panel",
expectedPath: "spec.panels.p1.spec.display.name",
},
{
scenario: "list variable display name",
limit: MaxDisplayNameLen,
dashboardJSONFmt: `{"variables": [{"kind": "ListVariable", "spec": {"name": "svc", "display": {"name": "%s"}, "plugin": {"kind": "signoz/DynamicVariable", "spec": {"name": "service.name", "signal": "metrics"}}}}], "links": [], "layouts": []}`,
expectedLabel: "variable",
expectedPath: "spec.variables[0].spec.display.name",
scenario: "list variable display name",
dashboardJSON: `{"variables": [{"kind": "ListVariable", "spec": {"name": "svc", "display": {"name": "` + tooLong + `"}, "plugin": {"kind": "signoz/DynamicVariable", "spec": {"name": "service.name", "signal": "metrics"}}}}], "links": [], "layouts": []}`,
expectedLabel: "variable",
expectedPath: "spec.variables[0].spec.display.name",
},
{
scenario: "text variable display name",
limit: MaxDisplayNameLen,
dashboardJSONFmt: `{"variables": [{"kind": "TextVariable", "spec": {"name": "mytext", "value": "v", "display": {"name": "%s"}}}], "links": [], "layouts": []}`,
expectedLabel: "variable",
expectedPath: "spec.variables[0].spec.display.name",
scenario: "text variable display name",
dashboardJSON: `{"variables": [{"kind": "TextVariable", "spec": {"name": "mytext", "value": "v", "display": {"name": "` + tooLong + `"}}}], "links": [], "layouts": []}`,
expectedLabel: "variable",
expectedPath: "spec.variables[0].spec.display.name",
},
{
scenario: "layout title",
limit: MaxLayoutTitleLen,
dashboardJSONFmt: `{"links": [], "layouts": [{"kind": "Grid", "spec": {"display": {"title": "%s"}, "items": []}}]}`,
expectedLabel: "layout",
expectedPath: "spec.layouts[0].spec.display.title",
scenario: "layout title",
dashboardJSON: `{"links": [], "layouts": [{"kind": "Grid", "spec": {"display": {"title": "` + tooLong + `"}, "items": []}}]}`,
expectedLabel: "layout",
expectedPath: "spec.layouts[0].spec.display.title",
},
}
for _, testCase := range testCases {
t.Run(testCase.scenario, func(t *testing.T) {
tooLong := strings.Repeat("x", testCase.limit+1)
lengthMsg := fmt.Sprintf("must be at most %d characters, got %d", testCase.limit, testCase.limit+1)
_, err := unmarshalDashboard(fmt.Appendf(nil, testCase.dashboardJSONFmt, tooLong))
_, err := unmarshalDashboard([]byte(testCase.dashboardJSON))
require.Error(t, err)
// Message is "<path>: <label> name must be at most N characters, got M".
want := testCase.expectedPath + ": " + testCase.expectedLabel + " name " + lengthMsg

View File

@@ -6,12 +6,12 @@ package dashboardtypes
import (
"reflect"
"slices"
"sort"
"strings"
"testing"
"github.com/perses/spec/go/dashboard"
"github.com/perses/spec/go/datasource"
"github.com/stretchr/testify/assert"
)
@@ -21,27 +21,22 @@ func TestDashboardSpecMatchesPerses(t *testing.T) {
name string
ours reflect.Type
perses reflect.Type
// omit lists json fields intentionally dropped from our type that Perses
// still has, so their absence is not reported as drift.
omit []string
}{
{name: "DashboardSpec", ours: typeOf[DashboardSpec](), perses: typeOf[dashboard.Spec](), omit: []string{"datasources"}},
{name: "Panel", ours: typeOf[Panel](), perses: typeOf[dashboard.Panel]()},
{name: "PanelSpec", ours: typeOf[PanelSpec](), perses: typeOf[dashboard.PanelSpec]()},
{name: "Query", ours: typeOf[Query](), perses: typeOf[dashboard.Query]()},
{name: "QuerySpec", ours: typeOf[QuerySpec](), perses: typeOf[dashboard.QuerySpec]()},
{name: "Variable", ours: typeOf[Variable](), perses: typeOf[dashboard.Variable]()},
{name: "ListVariableSpec", ours: typeOf[ListVariableSpec](), perses: typeOf[dashboard.ListVariableSpec]()},
{name: "TextVariableSpec", ours: typeOf[TextVariableSpec](), perses: typeOf[dashboard.TextVariableSpec]()},
{name: "Layout", ours: typeOf[Layout](), perses: typeOf[dashboard.Layout]()},
{"DashboardSpec", typeOf[DashboardSpec](), typeOf[dashboard.Spec]()},
{"Panel", typeOf[Panel](), typeOf[dashboard.Panel]()},
{"PanelSpec", typeOf[PanelSpec](), typeOf[dashboard.PanelSpec]()},
{"Query", typeOf[Query](), typeOf[dashboard.Query]()},
{"QuerySpec", typeOf[QuerySpec](), typeOf[dashboard.QuerySpec]()},
{"DatasourceSpec", typeOf[DatasourceSpec](), typeOf[datasource.Spec]()},
{"Variable", typeOf[Variable](), typeOf[dashboard.Variable]()},
{"ListVariableSpec", typeOf[ListVariableSpec](), typeOf[dashboard.ListVariableSpec]()},
{"TextVariableSpec", typeOf[TextVariableSpec](), typeOf[dashboard.TextVariableSpec]()},
{"Layout", typeOf[Layout](), typeOf[dashboard.Layout]()},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
missing, extra := drift(c.ours, c.perses)
for _, f := range c.omit {
missing = slices.DeleteFunc(missing, func(m string) bool { return m == f })
}
assert.Empty(t, missing,
"DashboardSpec (%s) is missing json fields present on Perses %s — upstream likely added or renamed a field",

View File

@@ -215,6 +215,54 @@ func (v VariablePluginVariant[S]) PrepareJSONSchema(s *jsonschema.Schema) error
return restrictKindToOneValue(s, v.Kind)
}
// ══════════════════════════════════════════════
// Datasource plugin
// ══════════════════════════════════════════════
type DatasourcePlugin struct {
Kind DatasourcePluginKind `json:"kind" required:"true"`
Spec any `json:"spec" required:"true"`
}
func (DatasourcePlugin) PrepareJSONSchema(s *jsonschema.Schema) error {
return markDiscriminator(s, "kind", map[string]string{
string(DatasourceKindSigNoz): schemaRef("DashboardtypesDatasourcePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesSigNozDatasourceSpec"),
})
}
func (p *DatasourcePlugin) UnmarshalJSON(data []byte) error {
kind, specJSON, err := extractKindAndSpec(data)
if err != nil {
return err
}
factory, ok := datasourcePluginSpecs[DatasourcePluginKind(kind)]
if !ok {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "unknown datasource plugin kind %q; allowed values: %s", kind, allowedValuesForKind(slices.Sorted(maps.Keys(datasourcePluginSpecs))))
}
spec, err := decodeSpec(specJSON, factory(), kind)
if err != nil {
return err
}
p.Kind = DatasourcePluginKind(kind)
p.Spec = *spec
return nil
}
func (DatasourcePlugin) JSONSchemaOneOf() []any {
return []any{
DatasourcePluginVariant[SigNozDatasourceSpec]{Kind: string(DatasourceKindSigNoz)},
}
}
type DatasourcePluginVariant[S any] struct {
Kind string `json:"kind" required:"true"`
Spec S `json:"spec" required:"true"`
}
func (v DatasourcePluginVariant[S]) PrepareJSONSchema(s *jsonschema.Schema) error {
return restrictKindToOneValue(s, v.Kind)
}
// ══════════════════════════════════════════════
// Helpers
// ══════════════════════════════════════════════
@@ -242,6 +290,10 @@ var (
VariableKindQuery: func() any { return new(QueryVariableSpec) },
VariableKindCustom: func() any { return new(CustomVariableSpec) },
}
datasourcePluginSpecs = map[DatasourcePluginKind]func() any{
DatasourceKindSigNoz: func() any { return new(SigNozDatasourceSpec) },
}
allowedQueryKinds = map[PanelPluginKind][]QueryPluginKind{
PanelKindTimeSeries: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindBarChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},

View File

@@ -16,14 +16,10 @@ import (
"github.com/swaggest/jsonschema-go"
)
// MaxDisplayNameLen bounds the human-readable display names — dashboard, panel,
// and variable. The grid layout title has its own, larger bound (MaxLayoutTitleLen).
// MaxDisplayNameLen bounds every human-readable display name — dashboard, panel,
// and variable display names, plus the grid layout title.
const MaxDisplayNameLen = 128
// MaxLayoutTitleLen bounds a grid layout title. It is larger than MaxDisplayNameLen
// because v1 section (row) titles ran longer.
const MaxLayoutTitleLen = 256
type Display struct {
Name string `json:"name" required:"true"`
// Description always serializes ("" included) so a create -> GET round-trip
@@ -38,6 +34,16 @@ func (d Display) Validate(label, path string) error {
return nil
}
// ══════════════════════════════════════════════
// Datasource
// ══════════════════════════════════════════════
type DatasourceSpec struct {
Display *common.Display `json:"display,omitempty"`
Default bool `json:"default"`
Plugin DatasourcePlugin `json:"plugin"`
}
// ══════════════════════════════════════════════
// Panel
// ══════════════════════════════════════════════
@@ -225,9 +231,6 @@ func (s *ListVariableSpec) validate(path string) error {
if s.CustomAllValue != "" && !s.AllowAllValue {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: customAllValue cannot be set if allowAllValue is not set to true", path)
}
if s.AllowAllValue && !s.AllowMultiple {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: allowAllValue cannot be set if allowMultiple is not set to true", path)
}
if s.DefaultValue != nil && len(s.DefaultValue.SliceValues) > 0 && !s.AllowMultiple {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: defaultValue cannot be a list if allowMultiple is not set to true", path)
}

View File

@@ -179,6 +179,21 @@ func (PanelPluginKind) Enum() []any {
return []any{PanelKindTimeSeries, PanelKindBarChart, PanelKindNumber, PanelKindPieChart, PanelKindTable, PanelKindHistogram, PanelKindList}
}
type DatasourcePluginKind string
const (
DatasourceKindSigNoz DatasourcePluginKind = "signoz/Datasource"
)
func (DatasourcePluginKind) Enum() []any {
return []any{DatasourceKindSigNoz}
}
// SigNozDatasourceSpec is the (empty) signoz/Datasource plugin spec. Naming the
// type gives the variant a concrete, non-nullable spec schema instead of an
// inline free-form one.
type SigNozDatasourceSpec struct{}
type TimeSeriesPanelSpec struct {
Visualization TimeSeriesVisualization `json:"visualization"`
Formatting PanelFormatting `json:"formatting"`

View File

@@ -4,6 +4,15 @@
"description": "Trying to cover as many concepts here as possible"
},
"duration": "1h",
"datasources": {
"SigNozDatasource": {
"default": true,
"plugin": {
"kind": "signoz/Datasource",
"spec": {}
}
}
},
"variables": [
{
"kind": "ListVariable",
@@ -13,7 +22,7 @@
"name": "serviceName"
},
"allowAllValue": true,
"allowMultiple": true,
"allowMultiple": false,
"sort": "none",
"plugin": {
"kind": "signoz/DynamicVariable",

View File

@@ -3,6 +3,15 @@
"name": "NV dashboard with sections",
"description": ""
},
"datasources": {
"SigNozDatasource": {
"default": true,
"plugin": {
"kind": "signoz/Datasource",
"spec": {}
}
}
},
"links": [],
"panels": {
"b424e23b": {

View File

@@ -1,6 +1,7 @@
"""Fixtures for cloud integration tests."""
from collections.abc import Callable
from dataclasses import dataclass, field
from http import HTTPStatus
import pytest
@@ -20,6 +21,29 @@ from fixtures.logger import setup_logger
logger = setup_logger(__name__)
# Per-provider config shape.
@dataclass(frozen=True)
class ProviderAccountSpec:
# provider slug used in the URL path and config key (e.g. "aws", "gcp").
provider: str
# params for the account created by default.
initial_params: dict
# params for the config an update (PUT) test sends.
updated_params: dict
# params -> the provider-keyed `config` block for a POST/PUT body.
build_config: Callable[[dict], dict]
# params -> the full config block the API is expected to return under
# config[provider] on GET/list. This may differ from what build_config sends:
# e.g. AWS accepts deploymentRegion on POST but the API does not echo it back.
expected_config: Callable[[dict], dict]
# id shown in parametrized test names; defaults to the provider slug.
id: str = field(default="")
def __post_init__(self) -> None:
if not self.id:
object.__setattr__(self, "id", self.provider)
@pytest.fixture(scope="function")
def deprecated_create_cloud_integration_account(
request: pytest.FixtureRequest,
@@ -97,19 +121,26 @@ def create_cloud_integration_account(
cloud_provider: str = "aws",
deployment_region: str = "us-east-1",
regions: list[str] | None = None,
config: dict | None = None,
) -> dict:
if regions is None:
regions = ["us-east-1"]
endpoint = f"/api/v1/cloud_integrations/{cloud_provider}/accounts"
request_payload = {
"config": {
# `config`, when given, is the fully-formed provider-keyed config block
# (e.g. built via a ProviderAccountSpec.build_config) and is used as-is.
# Otherwise fall back to the AWS shape built from deployment_region/regions,
# preserving existing AWS callers.
if config is None:
if regions is None:
regions = ["us-east-1"]
config = {
cloud_provider: {
"deploymentRegion": deployment_region,
"regions": regions,
}
},
}
endpoint = f"/api/v1/cloud_integrations/{cloud_provider}/accounts"
request_payload = {
"config": config,
"credentials": {
"sigNozApiURL": "https://test-deployment.test.signoz.cloud",
"sigNozApiKey": "test-api-key-789",

View File

@@ -2,16 +2,60 @@ import uuid
from collections.abc import Callable
from http import HTTPStatus
import pytest
import requests
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, add_license
from fixtures.cloudintegrations import simulate_agent_checkin
from fixtures.cloudintegrations import (
ProviderAccountSpec,
simulate_agent_checkin,
)
from fixtures.logger import setup_logger
logger = setup_logger(__name__)
CLOUD_PROVIDER = "aws"
AWS_ACCOUNT_SPEC = ProviderAccountSpec(
provider="aws",
initial_params={"deployment_region": "us-east-1", "regions": ["us-east-1"]},
updated_params={"deployment_region": "us-east-1", "regions": ["us-east-1", "us-west-2", "eu-west-1"]},
build_config=lambda p: {"aws": {"deploymentRegion": p["deployment_region"], "regions": p["regions"]}},
expected_config=lambda p: {"regions": p["regions"]},
)
GCP_ACCOUNT_SPEC = ProviderAccountSpec(
provider="gcp",
initial_params={
"deployment_project_id": "signoz-test-project",
"deployment_region": "us-central1",
"project_ids": ["signoz-test-project"],
},
updated_params={
"deployment_project_id": "signoz-test-project",
"deployment_region": "us-central1",
"project_ids": ["signoz-test-project", "signoz-test-project-2"],
},
build_config=lambda p: {
"gcp": {
"deploymentProjectId": p["deployment_project_id"],
"deploymentRegion": p["deployment_region"],
"projectIds": p["project_ids"],
}
},
expected_config=lambda p: {
"deploymentProjectId": p["deployment_project_id"],
"deploymentRegion": p["deployment_region"],
"projectIds": p["project_ids"],
},
)
PROVIDER_ACCOUNT_SPECS = [AWS_ACCOUNT_SPEC, GCP_ACCOUNT_SPEC]
provider_spec = pytest.mark.parametrize(
"spec",
PROVIDER_ACCOUNT_SPECS,
ids=[s.id for s in PROVIDER_ACCOUNT_SPECS],
)
def test_apply_license(
@@ -24,16 +68,18 @@ def test_apply_license(
add_license(signoz, make_http_mocks, get_token)
@provider_spec
def test_list_accounts_empty(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
spec: ProviderAccountSpec,
) -> None:
"""List accounts returns an empty list when no accounts have checked in."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -46,24 +92,30 @@ def test_list_accounts_empty(
assert len(data["accounts"]) == 0, "accounts list should be empty when no accounts have checked in"
@provider_spec
def test_list_accounts_after_checkin(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
spec: ProviderAccountSpec,
) -> None:
"""List accounts returns an account after it has checked in."""
"""List accounts returns an account, with its provider config, after check-in."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER, regions=["us-east-1"])
account = create_cloud_integration_account(
admin_token,
spec.provider,
config=spec.build_config(spec.initial_params),
)
account_id = account["id"]
provider_account_id = str(uuid.uuid4())
checkin = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account_id, provider_account_id)
checkin = simulate_agent_checkin(signoz, admin_token, spec.provider, account_id, provider_account_id)
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -74,25 +126,31 @@ def test_list_accounts_after_checkin(
found = next((a for a in data["accounts"] if a["id"] == account_id), None)
assert found is not None, f"Account {account_id} should appear in list after check-in"
assert found["providerAccountId"] == provider_account_id, "providerAccountId should match"
assert found["config"]["aws"]["regions"] == ["us-east-1"], "regions should match account config"
assert found["config"][spec.provider] == spec.expected_config(spec.initial_params), "config should match account config"
assert found["agentReport"] is not None, "agentReport should be present after check-in"
assert found["removedAt"] is None, "removedAt should be null for a live account"
@provider_spec
def test_get_account(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
spec: ProviderAccountSpec,
) -> None:
"""Get a specific account by ID returns the account with correct fields."""
"""Get a specific account by ID returns the account with its provider config."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER, regions=["us-east-1", "eu-west-1"])
account = create_cloud_integration_account(
admin_token,
spec.provider,
config=spec.build_config(spec.initial_params),
)
account_id = account["id"]
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -101,23 +159,22 @@ def test_get_account(
data = response.json()["data"]
assert data["id"] == account_id, "id should match"
assert data["config"]["aws"]["regions"] == [
"us-east-1",
"eu-west-1",
], "regions should match"
assert data["config"][spec.provider] == spec.expected_config(spec.initial_params), "config should match"
assert data["removedAt"] is None, "removedAt should be null"
@provider_spec
def test_get_account_not_found(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
spec: ProviderAccountSpec,
) -> None:
"""Get a non-existent account returns 404."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{uuid.uuid4()}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{uuid.uuid4()}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -125,46 +182,52 @@ def test_get_account_not_found(
assert response.status_code == HTTPStatus.NOT_FOUND, f"Expected 404, got {response.status_code}"
@provider_spec
def test_update_account(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
spec: ProviderAccountSpec,
) -> None:
"""Update account config and verify the change is persisted via GET."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER, regions=["us-east-1"])
account = create_cloud_integration_account(
admin_token,
spec.provider,
config=spec.build_config(spec.initial_params),
)
account_id = account["id"]
checkin = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account_id, str(uuid.uuid4()))
checkin = simulate_agent_checkin(signoz, admin_token, spec.provider, account_id, str(uuid.uuid4()))
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
updated_regions = ["us-east-1", "us-west-2", "eu-west-1"]
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
json={"config": {"aws": {"regions": updated_regions}}},
json={"config": spec.build_config(spec.updated_params)},
timeout=10,
)
assert response.status_code == HTTPStatus.NO_CONTENT, f"Expected 204, got {response.status_code}"
get_response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
assert get_response.status_code == HTTPStatus.OK
assert get_response.json()["data"]["config"]["aws"]["regions"] == updated_regions, "Regions should reflect the update"
assert get_response.json()["data"]["config"][spec.provider] == spec.expected_config(spec.updated_params), "Config should reflect the update"
@provider_spec
def test_update_account_after_checkin_preserves_connected_status(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
spec: ProviderAccountSpec,
) -> None:
"""Updating config after agent check-in must not remove the account from the connected list.
@@ -175,17 +238,21 @@ def test_update_account_after_checkin_preserves_connected_status(
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# 1. Create account
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER, regions=["us-east-1"])
account = create_cloud_integration_account(
admin_token,
spec.provider,
config=spec.build_config(spec.initial_params),
)
account_id = account["id"]
provider_account_id = str(uuid.uuid4())
# 2. Agent checks in — sets account_id and last_agent_report
checkin = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account_id, provider_account_id)
checkin = simulate_agent_checkin(signoz, admin_token, spec.provider, account_id, provider_account_id)
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
# 3. Verify the account appears in the connected list
list_response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -195,18 +262,17 @@ def test_update_account_after_checkin_preserves_connected_status(
assert found_before is not None, "Account should be listed after check-in"
# 4. Update account config
updated_regions = ["us-east-1", "us-west-2"]
update_response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
json={"config": {"aws": {"regions": updated_regions}}},
json={"config": spec.build_config(spec.updated_params)},
timeout=10,
)
assert update_response.status_code == HTTPStatus.NO_CONTENT, f"Expected 204, got {update_response.status_code}"
# 5. Verify the account still appears in the connected list with correct fields
list_response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -216,27 +282,33 @@ def test_update_account_after_checkin_preserves_connected_status(
assert found_after is not None, "Account must still be listed after config update (account_id should not be reset)"
assert found_after["providerAccountId"] == provider_account_id, "providerAccountId should be preserved after update"
assert found_after["agentReport"] is not None, "agentReport should be preserved after update"
assert found_after["config"]["aws"]["regions"] == updated_regions, "Config should reflect the update"
assert found_after["config"][spec.provider] == spec.expected_config(spec.updated_params), "Config should reflect the update"
assert found_after["removedAt"] is None, "removedAt should still be null"
@provider_spec
def test_disconnect_account(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
spec: ProviderAccountSpec,
) -> None:
"""Disconnect an account removes it from the connected list."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
account = create_cloud_integration_account(
admin_token,
spec.provider,
config=spec.build_config(spec.initial_params),
)
account_id = account["id"]
checkin = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account_id, str(uuid.uuid4()))
checkin = simulate_agent_checkin(signoz, admin_token, spec.provider, account_id, str(uuid.uuid4()))
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -244,7 +316,7 @@ def test_disconnect_account(
assert response.status_code == HTTPStatus.NO_CONTENT, f"Expected 204, got {response.status_code}"
list_response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -252,16 +324,18 @@ def test_disconnect_account(
assert not any(a["id"] == account_id for a in accounts), "Disconnected account should not appear in the connected list"
@provider_spec
def test_disconnect_account_idempotent(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
spec: ProviderAccountSpec,
) -> None:
"""Disconnect on a non-existent account ID returns 204 (blind update, no existence check)."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{uuid.uuid4()}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{uuid.uuid4()}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)

View File

@@ -196,70 +196,6 @@ def test_create_rejects_long_display_name(
assert response.json()["error"]["code"] == "dashboard_invalid_input"
assert "spec.display.name: dashboard name must be at most 128 characters" in response.json()["error"]["message"]
# A grid layout title has its own, larger bound of 256 characters; one over
# must be rejected.
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={
"schemaVersion": "v6",
"name": "long-layout-title",
"spec": {
"display": {"name": "Long Layout Title"},
"links": [],
"layouts": [{"kind": "Grid", "spec": {"display": {"title": "x" * 257}, "items": []}}],
},
},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert response.json()["error"]["code"] == "dashboard_invalid_input"
assert "spec.layouts[0].spec.display.title: layout name must be at most 256 characters" in response.json()["error"]["message"]
def test_create_rejects_all_value_without_multiselect(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# A list variable cannot offer an "all" value unless it also allows selecting
# multiple values — allowAllValue without allowMultiple must be rejected.
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={
"schemaVersion": "v6",
"name": "all-without-multi",
"spec": {
"display": {"name": "All Without Multi"},
"links": [],
"variables": [
{
"kind": "ListVariable",
"spec": {
"name": "svc",
"allowAllValue": True,
"allowMultiple": False,
"plugin": {
"kind": "signoz/DynamicVariable",
"spec": {"name": "service.name", "signal": "metrics"},
},
},
}
],
},
"tags": [],
},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert response.json()["error"]["code"] == "dashboard_invalid_input"
assert "allowAllValue cannot be set" in response.json()["error"]["message"]
def test_create_rejects_invalid_grid_layout(
signoz: SigNoz,
@@ -1772,8 +1708,8 @@ def test_dashboard_v2_roundtrip_preserves_zero_values(
dashboard = {
"schemaVersion": "v6",
# image (dashboard-level) and spec duration/refreshInterval each
# round-trip their zero value ("") rather than being dropped.
# image (dashboard-level) and spec duration/refreshInterval/datasources
# each round-trip their zero value ("" / {}) rather than being dropped.
"image": "",
"name": "roundtrip-zero-values",
"tags": [],
@@ -1781,6 +1717,7 @@ def test_dashboard_v2_roundtrip_preserves_zero_values(
"display": {"name": "Roundtrip Zero Values", "description": ""},
"duration": "",
"refreshInterval": "",
"datasources": {},
"variables": [
# TextVariable: constant false must echo back (not be dropped).
{"kind": "TextVariable", "spec": {"display": {"name": "tv"}, "value": "x", "constant": False, "name": "tv"}},
@@ -1966,6 +1903,7 @@ def test_dashboard_v2_roundtrip_preserves_zero_values(
("dashboard image empty", result_data["image"], ""),
("spec duration empty", result_spec["duration"], ""),
("spec refreshInterval empty", result_spec["refreshInterval"], ""),
("spec empty datasources round-trip", result_spec["datasources"], {}),
]
for description, actual, expected in roundtrip_cases:
assert actual == expected, description

View File

@@ -1178,12 +1178,11 @@ def test_logs_json_body_hastoken_separator_needle_errors(
export_json_types: Callable[[list[Logs]], None],
needle: str,
) -> None:
"""KNOWN BUG (currently 500) — same defect as the flag-off path. In JSON mode hasToken searches
body.message via ClickHouse hasToken(LOWER(body.message), LOWER(?)), which rejects a needle
containing whitespace or separator characters (`.`/`_`/`-`/space) with error code 36. The needle
is passed straight through, so the query fails at execution and the raw error surfaces as a 500
instead of a 400 / graceful fallback. Flip to 400 (or a fallback match) once the needle is
validated."""
"""Same defect as the flag-off path. In JSON mode hasToken searches body.message via
ClickHouse hasToken(LOWER(body.message), LOWER(?)), which rejects a needle containing
whitespace or separator characters (`.`/`_`/`-`/space) with error code 36 at execution. The
needle is validated up front, so a multi-token needle is a clean 400 (hasToken matches a single
whole token) rather than a raw execution 500."""
now = datetime.now(tz=UTC)
logs = [
Logs(timestamp=now - timedelta(seconds=1), resources={"service.name": "app-service"}, body_v2=json.dumps({"message": "client 192.168.1.1 user_id error-code production node"}), body_promoted="", severity_text="INFO"),
@@ -1199,8 +1198,8 @@ def test_logs_json_body_hastoken_separator_needle_errors(
request_type=RequestType.RAW,
queries=[build_raw_query("A", "logs", order=[build_order_by("timestamp")], limit=100, filter_expression=f"hasToken(body, '{needle}')")],
)
assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR, f"{needle}: expected 500, got {response.status_code}: {response.text}"
assert "Needle must not contain whitespace or separator characters" in response.text, response.text
assert response.status_code == HTTPStatus.BAD_REQUEST, f"{needle}: expected 400, got {response.status_code}: {response.text}"
assert "matches a single whole token" in response.text, response.text
def test_logs_json_body_unregistered_path_warns(

View File

@@ -1197,13 +1197,11 @@ def test_logs_json_body_hastoken_separator_needle_errors(
insert_logs: Callable[[list[Logs]], None],
needle: str,
) -> None:
"""KNOWN BUG (currently 500). hasToken maps to ClickHouse hasToken(LOWER(body), LOWER(?)),
which rejects a needle containing whitespace or separator characters (`.`/`_`/`-`/space) with
error code 36 ("Needle must not contain whitespace or separator characters"). The querier
passes the user needle straight through, so the query fails during execution and the raw CH
error surfaces as a 500 rather than a 400 / graceful fallback. Common needles like an IP,
`user_id` or a UUID hit this. Flip this to 400 (or a fallback match) once the needle is
validated."""
"""hasToken maps to ClickHouse hasToken(LOWER(body), LOWER(?)), which rejects a needle
containing whitespace or separator characters (`.`/`_`/`-`/space) with error code 36 at
execution. The querier validates the needle up front and returns a clean 400 (hasToken matches
a single whole token) instead of letting the raw CH error surface as a 500. Common needles like
an IP, `user_id` or a UUID hit this."""
now = datetime.now(tz=UTC)
insert_logs(
[
@@ -1219,8 +1217,8 @@ def test_logs_json_body_hastoken_separator_needle_errors(
request_type=RequestType.RAW,
queries=[build_raw_query("A", "logs", order=[build_order_by("timestamp")], limit=100, filter_expression=f"hasToken(body, '{needle}')")],
)
assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR, f"{needle}: expected 500, got {response.status_code}: {response.text}"
assert "Needle must not contain whitespace or separator characters" in response.text, response.text
assert response.status_code == HTTPStatus.BAD_REQUEST, f"{needle}: expected 400, got {response.status_code}: {response.text}"
assert "matches a single whole token" in response.text, response.text
@pytest.mark.parametrize(
@@ -1230,20 +1228,18 @@ def test_logs_json_body_hastoken_separator_needle_errors(
pytest.param("hasAll", id="hasall"),
],
)
def test_logs_json_body_hasany_hasall_large_quoted_int_errors(
def test_logs_json_body_hasany_hasall_large_quoted_int_matches(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
func: str,
) -> None:
"""KNOWN BUG (currently 500). A quoted integer needle >= 2^32 in hasAny/hasAll fails with
ClickHouse code 386 ("no supertype for types UInt64, Int64") -> 500. The body array is
extracted as Array(Nullable(Int64)), but the needle array is bound as Array(UInt64) for values
that don't fit UInt32, and CH has no Int64/UInt64 supertype at the array-vs-array type level.
Single has() is unaffected because scalar-vs-array coercion is value-level (asserted below as a
contrast). When fixed (bind the needle array as Int64) this should return 200 with an exact
match like has()."""
"""A quoted integer needle >= 2^32 in hasAny/hasAll used to 500: the body array is extracted as
Array(Nullable(Int64)) but the needle array bound as a concrete Array(UInt64), and CH has no
Int64/UInt64 supertype at the array-vs-array level (code 386). The needle array is now CAST to
Array(Int64) to match the extraction, so it returns 200 with an exact match like has() (asserted
below as a contrast)."""
now = datetime.now(tz=UTC)
insert_logs(
[
@@ -1254,7 +1250,7 @@ def test_logs_json_body_hasany_hasall_large_quoted_int_errors(
start_ms = int((now - timedelta(seconds=10)).timestamp() * 1000)
end_ms = int(now.timestamp() * 1000)
# hasAny/hasAll with a quoted big int (>= 2^32) -> 500 (no supertype).
# hasAny/hasAll with a quoted big int (>= 2^32) -> 200 with an exact match (needle CAST to Int64).
response = make_query_request(
signoz,
token,
@@ -1263,7 +1259,8 @@ def test_logs_json_body_hasany_hasall_large_quoted_int_errors(
request_type=RequestType.RAW,
queries=[build_raw_query("A", "logs", order=[build_order_by("timestamp")], limit=100, filter_expression=f"{func}(body.id, ['9007199254740993'])")],
)
assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR, f"{func}: expected 500, got {response.status_code}: {response.text}"
assert response.status_code == HTTPStatus.OK, f"{func}: expected 200, got {response.status_code}: {response.text}"
assert len(get_rows(response)) == 1, f"{func} with a quoted big int should match exactly one log"
# Contrast: single has() with the same quoted big int works (exact Int64 match, 1 row).
response = make_query_request(