From 0d457992cc3ce47e7b4e172dea9cfb1518b864d5 Mon Sep 17 00:00:00 2001 From: Miguel Valdes Date: Mon, 1 Jun 2026 13:21:06 -0500 Subject: [PATCH 1/3] feat(foundryctl): mechanic initial layout --- Makefile | 9 +- cmd/foundryctl/config.go | 25 +++- cmd/foundryctl/main.go | 1 + cmd/foundryctl/mechanic.go | 76 +++++++++++ internal/domain/event.go | 11 +- internal/foundry/mechanic.go | 35 ++++++ internal/mechanic/resource.go | 132 +++++++++++++++++++ internal/mechanic/resource_test.go | 195 +++++++++++++++++++++++++++++ 8 files changed, 477 insertions(+), 7 deletions(-) create mode 100644 cmd/foundryctl/mechanic.go create mode 100644 internal/foundry/mechanic.go create mode 100644 internal/mechanic/resource.go create mode 100644 internal/mechanic/resource_test.go diff --git a/Makefile b/Makefile index d6828d80..53335133 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,10 @@ -.PHONY: clean gauge forge cast test gen-examples gen-schemas gen-docs docs +.PHONY: clean gauge forge cast mechanic test gen-examples gen-schemas gen-docs docs FOUNDRYCTL := go run ./cmd/foundryctl GOTMPL := go run go.opentelemetry.io/build-tools/gotmpl@latest CASTINGS_JSON = $$(cat docs/examples/castings.json) NO_LEDGER := "--no-ledger" +RESOURCE ?= signoz/alert/019c8af3-416a-7562-838d-879aec44f566 clean: cd pours/deployment && docker compose down --remove-orphans --volumes @@ -19,6 +20,12 @@ forge: cast: $(FOUNDRYCTL) cast --debug $(NO_LEDGER) -f ./tmp/casting.yaml +# Override the resource path with RESOURCE, e.g. +# make mechanic RESOURCE="signoz alert " +# make mechanic RESOURCE=telemetrystore/table/distributed_samples_v4 +mechanic: + $(FOUNDRYCTL) mechanic inspect --debug $(NO_LEDGER) -f ./tmp/casting.yaml $(RESOURCE) + test: make forge make docker diff --git a/cmd/foundryctl/config.go b/cmd/foundryctl/config.go index e39d0a80..dba9a3df 100644 --- a/cmd/foundryctl/config.go +++ b/cmd/foundryctl/config.go @@ -1,6 +1,10 @@ package main -import "github.com/spf13/cobra" +import ( + "os" + + "github.com/spf13/cobra" +) var ( // Stores common configuration across all commands. @@ -14,6 +18,9 @@ var ( // Stores catalog configuration. catalogCfg catalogConfig + + // Stores mechanic configuration. + mechanicCfg mechanicConfig ) type commonConfig struct { @@ -55,3 +62,19 @@ type catalogConfig struct { func (c *catalogConfig) RegisterFlags(cmd *cobra.Command) { cmd.Flags().StringVarP(&c.OutPath, "output", "o", "", "Path to write castings.json") } + +// mechanicConfig holds connection overrides for the mechanic verbs. They take +// precedence over the resolved casting's status addresses and let mechanic run +// against a deployment that was not provisioned by foundry. Each flag defaults +// to its environment variable so secrets need not appear in shell history. +type mechanicConfig struct { + Signoz string + ClickhouseDSN string + MetastoreDSN string +} + +func (c *mechanicConfig) RegisterFlags(cmd *cobra.Command) { + cmd.Flags().StringVar(&c.Signoz, "signoz", os.Getenv("FOUNDRY_SIGNOZ"), "Override the SigNoz API address, host:port (default $FOUNDRY_SIGNOZ).") + cmd.Flags().StringVar(&c.ClickhouseDSN, "clickhouse-dsn", os.Getenv("FOUNDRY_CLICKHOUSE_DSN"), "Override the ClickHouse DSN, user:pass@host:port (default $FOUNDRY_CLICKHOUSE_DSN).") + cmd.Flags().StringVar(&c.MetastoreDSN, "metastore-dsn", os.Getenv("FOUNDRY_METASTORE_DSN"), "Override the metastore DSN (default $FOUNDRY_METASTORE_DSN).") +} diff --git a/cmd/foundryctl/main.go b/cmd/foundryctl/main.go index a4a01d02..0426347a 100644 --- a/cmd/foundryctl/main.go +++ b/cmd/foundryctl/main.go @@ -28,6 +28,7 @@ func main() { registerGenCmd(rootCmd) registerCatalogCmd(rootCmd) registerVersionCmd(rootCmd) + registerMechanicCmd(rootCmd) defer closeRoot() diff --git a/cmd/foundryctl/mechanic.go b/cmd/foundryctl/mechanic.go new file mode 100644 index 00000000..de44b6c9 --- /dev/null +++ b/cmd/foundryctl/mechanic.go @@ -0,0 +1,76 @@ +package main + +import ( + "context" + "log/slog" + + "github.com/signoz/foundry/internal/domain" + "github.com/signoz/foundry/internal/foundry" + "github.com/signoz/foundry/internal/mechanic" + "github.com/spf13/cobra" +) + +func registerMechanicCmd(rootCmd *cobra.Command) { + mechanicCmd := &cobra.Command{ + Use: "mechanic", + Short: "Diagnose a running deployment.", + } + + registerMechanicInspectCmd(mechanicCmd) + + rootCmd.AddCommand(mechanicCmd) +} + +func registerMechanicInspectCmd(mechanicCmd *cobra.Command) { + inspectCmd := &cobra.Command{ + Use: "inspect ", + Short: "Inspect a named entity within a deployment.", + Long: `Inspect a named entity within a deployment. + +The resource path accepts both slash and positional forms, with an optional +leading casting kind (resolved from the lock file when omitted): + + foundryctl mechanic inspect signoz alert + foundryctl mechanic inspect signoz/alert/ + foundryctl mechanic inspect installation signoz alert + foundryctl mechanic inspect telemetrystore table + +When the deployment was not provisioned by foundry, supply connection details +via flags (or their environment variables) to override the lock file.`, + Args: cobra.MinimumNArgs(1), + RunE: recoverRunE(domain.EventMechanic, func(cmd *cobra.Command, args []string) (domain.Properties, error) { + return runMechanicInspect(cmd.Context(), rootLogger, commonCfg.File, args) + }), + } + + mechanicCfg.RegisterFlags(inspectCmd) + mechanicCmd.AddCommand(inspectCmd) +} + +func runMechanicInspect(ctx context.Context, logger *slog.Logger, configPath string, args []string) (domain.Properties, error) { + resource, err := mechanic.ParseResource(args) + if err != nil { + return domain.NewProperties(), err + } + + f, err := foundry.New(logger) + if err != nil { + return domain.NewProperties(), err + } + + machinery, err := f.Config.GetV1Alpha1Lock(ctx, configPath) + if err != nil { + return domain.NewProperties(), err + } + + props := machinery.TrackableProperties() + + overrides := mechanic.Overrides{ + Signoz: mechanicCfg.Signoz, + ClickhouseDSN: mechanicCfg.ClickhouseDSN, + MetastoreDSN: mechanicCfg.MetastoreDSN, + } + + err = f.Inspect(ctx, machinery, resource, overrides) + return props, err +} diff --git a/internal/domain/event.go b/internal/domain/event.go index 28f06fcb..66683b7a 100644 --- a/internal/domain/event.go +++ b/internal/domain/event.go @@ -17,13 +17,14 @@ type Event struct { } var ( - EventGauge = Event{name: "gauge"} - EventForge = Event{name: "forge"} - EventCast = Event{name: "cast"} - EventCatalog = Event{name: "catalog"} + EventGauge = Event{name: "gauge"} + EventForge = Event{name: "forge"} + EventCast = Event{name: "cast"} + EventCatalog = Event{name: "catalog"} + EventMechanic = Event{name: "mechanic"} ) -var allEvents = []Event{EventGauge, EventForge, EventCast, EventCatalog} +var allEvents = []Event{EventGauge, EventForge, EventCast, EventCatalog, EventMechanic} // NewEvent accepts only the names of declared base Event values. The returned // Event has no outcome; use Succeeded or Failed to attach one. diff --git a/internal/foundry/mechanic.go b/internal/foundry/mechanic.go new file mode 100644 index 00000000..74aaa404 --- /dev/null +++ b/internal/foundry/mechanic.go @@ -0,0 +1,35 @@ +package foundry + +import ( + "context" + "log/slog" + + "github.com/signoz/foundry/api/v1alpha1" + foundryerrors "github.com/signoz/foundry/internal/errors" + "github.com/signoz/foundry/internal/mechanic" +) + +// Inspect resolves a mechanic resource path against the loaded casting and runs the targeted inspection. +func (foundry *Foundry) Inspect(ctx context.Context, machinery v1alpha1.Machinery, resource mechanic.Resource, overrides mechanic.Overrides) error { + resolved, err := resource.Resolve(machinery.Kind()) + if err != nil { + return err + } + + if resolved.EntityKind == "" || resolved.EntityID == "" { + return foundryerrors.Newf(foundryerrors.TypeInvalidInput, "inspect requires , got %q", resolved.String()) + } + + foundry.Logger.InfoContext( + ctx, "mechanic inspect target resolved", + slog.String("kind", resolved.Kind.String()), + slog.String("molding", resolved.Molding.String()), + slog.String("entity.kind", resolved.EntityKind), + slog.String("entity.id", resolved.EntityID), + slog.Bool("override.signoz", overrides.Signoz != ""), + slog.Bool("override.clickhouse", overrides.ClickhouseDSN != ""), + slog.Bool("override.metastore", overrides.MetastoreDSN != ""), + ) + + return nil +} diff --git a/internal/mechanic/resource.go b/internal/mechanic/resource.go new file mode 100644 index 00000000..1243763b --- /dev/null +++ b/internal/mechanic/resource.go @@ -0,0 +1,132 @@ +// Package mechanic implements the foundryctl mechanic verbs (status, diagnose, +// inspect) that diagnose a running deployment. It owns the resource-path +// grammar shared across those verbs and, in time, the catalog of checks +// partitioned by casting Kind and molding. +package mechanic + +import ( + "slices" + "strings" + + "github.com/signoz/foundry/api/v1alpha1" + "github.com/signoz/foundry/internal/errors" +) + +// Resource is a parsed mechanic resource path. The canonical form is +type Resource struct { + Kind v1alpha1.Kind + KindExplicit bool + Molding v1alpha1.MoldingKind + EntityKind string + EntityID string +} + +// Overrides carries optional connection details supplied via flags or environment, used when the lock file is unavailable. +type Overrides struct { + Signoz string + ClickhouseDSN string + MetastoreDSN string +} + +var moldingsByKind = map[v1alpha1.Kind][]v1alpha1.MoldingKind{ + v1alpha1.KindInstallation: { + v1alpha1.MoldingKindSignoz, + v1alpha1.MoldingKindTelemetryStore, + v1alpha1.MoldingKindMetaStore, + v1alpha1.MoldingKindIngester, + v1alpha1.MoldingKindTelemetryKeeper, + }, + v1alpha1.KindCollectionAgent: { + v1alpha1.MoldingKindCollector, + }, +} + +// ParseResource normalizes a resource path into a Resource. +func ParseResource(args []string) (Resource, error) { + raw := strings.Join(args, "/") + + segments := make([]string, 0, len(args)) + for s := range strings.SplitSeq(raw, "/") { + if s = strings.TrimSpace(s); s != "" { + segments = append(segments, s) + } + } + + if len(segments) == 0 { + return Resource{}, errors.Newf(errors.TypeInvalidInput, "resource path is empty") + } + + var res Resource + + if kind, ok := matchKind(segments[0]); ok { + res.Kind = kind + res.KindExplicit = true + segments = segments[1:] + } + + if len(segments) == 0 { + return Resource{}, errors.Newf(errors.TypeInvalidInput, "resource path %q is missing a molding", raw) + } + + if err := res.Molding.UnmarshalText([]byte(segments[0])); err != nil { + return Resource{}, errors.Wrapf(err, errors.TypeInvalidInput, "resource path %q", raw) + } + segments = segments[1:] + + if len(segments) > 0 { + res.EntityKind = segments[0] + segments = segments[1:] + } + if len(segments) > 0 { + res.EntityID = segments[0] + segments = segments[1:] + } + if len(segments) > 0 { + return Resource{}, errors.Newf(errors.TypeInvalidInput, "resource path %q has too many segments", raw) + } + + return res, nil +} + +// Resolve fills an implicit Kind from the casting's kind and verifies the molding belongs to that kind. +func (r Resource) Resolve(kind v1alpha1.Kind) (Resource, error) { + if !r.KindExplicit { + r.Kind = kind + } + + moldings, ok := moldingsByKind[r.Kind] + if !ok { + return Resource{}, errors.Newf(errors.TypeUnsupported, "unsupported casting kind %q", r.Kind) + } + + if slices.Contains(moldings, r.Molding) { + return r, nil + } + + return Resource{}, errors.Newf(errors.TypeInvalidInput, "molding %q is not valid for kind %q", r.Molding, r.Kind) +} + +// String renders the resource in canonical slash form. +func (r Resource) String() string { + parts := make([]string, 0, 4) + if r.Kind.String() != "" { + parts = append(parts, r.Kind.String()) + } + parts = append(parts, r.Molding.String()) + if r.EntityKind != "" { + parts = append(parts, r.EntityKind) + } + if r.EntityID != "" { + parts = append(parts, r.EntityID) + } + return strings.Join(parts, "/") +} + +func matchKind(s string) (v1alpha1.Kind, bool) { + for _, kind := range v1alpha1.Kinds() { + if strings.EqualFold(kind.String(), s) { + return kind, true + } + } + return v1alpha1.Kind{}, false +} diff --git a/internal/mechanic/resource_test.go b/internal/mechanic/resource_test.go new file mode 100644 index 00000000..99a3624b --- /dev/null +++ b/internal/mechanic/resource_test.go @@ -0,0 +1,195 @@ +package mechanic + +import ( + "testing" + + "github.com/signoz/foundry/api/v1alpha1" + "github.com/stretchr/testify/assert" +) + +func TestParseResource(t *testing.T) { + tests := []struct { + name string + args []string + pass bool + expectedKind v1alpha1.Kind + expectedKindExplicit bool + expectedMolding v1alpha1.MoldingKind + expectedEntityKind string + expectedEntityID string + }{ + { + name: "Positional_ImplicitKind", + args: []string{"signoz", "alert", "019c8af3"}, + pass: true, + expectedMolding: v1alpha1.MoldingKindSignoz, + expectedEntityKind: "alert", + expectedEntityID: "019c8af3", + }, + { + name: "Slash_ImplicitKind", + args: []string{"signoz/alert/019c8af3"}, + pass: true, + expectedMolding: v1alpha1.MoldingKindSignoz, + expectedEntityKind: "alert", + expectedEntityID: "019c8af3", + }, + { + name: "Positional_ExplicitKind", + args: []string{"installation", "signoz", "alert", "019c8af3"}, + pass: true, + expectedKind: v1alpha1.KindInstallation, + expectedKindExplicit: true, + expectedMolding: v1alpha1.MoldingKindSignoz, + expectedEntityKind: "alert", + expectedEntityID: "019c8af3", + }, + { + name: "Slash_ExplicitKind", + args: []string{"installation/signoz/alert/019c8af3"}, + pass: true, + expectedKind: v1alpha1.KindInstallation, + expectedKindExplicit: true, + expectedMolding: v1alpha1.MoldingKindSignoz, + expectedEntityKind: "alert", + expectedEntityID: "019c8af3", + }, + { + name: "ExplicitKind_CaseInsensitive", + args: []string{"Installation", "signoz", "alert", "019c8af3"}, + pass: true, + expectedKind: v1alpha1.KindInstallation, + expectedKindExplicit: true, + expectedMolding: v1alpha1.MoldingKindSignoz, + expectedEntityKind: "alert", + expectedEntityID: "019c8af3", + }, + { + name: "CollectionAgent_Collector", + args: []string{"collectionagent/collector/exporter/clickhouse"}, + pass: true, + expectedKind: v1alpha1.KindCollectionAgent, + expectedKindExplicit: true, + expectedMolding: v1alpha1.MoldingKindCollector, + expectedEntityKind: "exporter", + expectedEntityID: "clickhouse", + }, + { + name: "MixedSlashAndPositional", + args: []string{"signoz/alert", "019c8af3"}, + pass: true, + expectedMolding: v1alpha1.MoldingKindSignoz, + expectedEntityKind: "alert", + expectedEntityID: "019c8af3", + }, + { + name: "MoldingAndEntityKind_NoID", + args: []string{"telemetrystore", "table"}, + pass: true, + expectedMolding: v1alpha1.MoldingKindTelemetryStore, + expectedEntityKind: "table", + }, + { + name: "MoldingOnly", + args: []string{"signoz"}, + pass: true, + expectedMolding: v1alpha1.MoldingKindSignoz, + }, + { + name: "ExplicitKindAndMoldingOnly", + args: []string{"installation", "telemetrystore"}, + pass: true, + expectedKind: v1alpha1.KindInstallation, + expectedKindExplicit: true, + expectedMolding: v1alpha1.MoldingKindTelemetryStore, + }, + { + name: "Empty_Invalid", + args: []string{}, + pass: false, + }, + { + name: "KindOnly_Invalid", + args: []string{"installation"}, + pass: false, + }, + { + name: "UnknownMolding_Invalid", + args: []string{"postgres", "table", "foo"}, + pass: false, + }, + { + name: "TooManySegments_Invalid", + args: []string{"installation", "signoz", "alert", "019c8af3", "extra"}, + pass: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res, err := ParseResource(tt.args) + if !tt.pass { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.expectedKind, res.Kind) + assert.Equal(t, tt.expectedKindExplicit, res.KindExplicit) + assert.Equal(t, tt.expectedMolding, res.Molding) + assert.Equal(t, tt.expectedEntityKind, res.EntityKind) + assert.Equal(t, tt.expectedEntityID, res.EntityID) + }) + } +} + +func TestResourceResolve(t *testing.T) { + tests := []struct { + name string + resource Resource + lockKind v1alpha1.Kind + pass bool + expectedKind v1alpha1.Kind + }{ + { + name: "ImplicitKind_FilledFromLock", + resource: Resource{Molding: v1alpha1.MoldingKindSignoz, EntityKind: "alert", EntityID: "x"}, + lockKind: v1alpha1.KindInstallation, + pass: true, + expectedKind: v1alpha1.KindInstallation, + }, + { + name: "ExplicitKind_Kept", + resource: Resource{Kind: v1alpha1.KindInstallation, KindExplicit: true, Molding: v1alpha1.MoldingKindSignoz}, + lockKind: v1alpha1.KindInstallation, + pass: true, + expectedKind: v1alpha1.KindInstallation, + }, + { + name: "MoldingNotInKind_Invalid", + resource: Resource{Molding: v1alpha1.MoldingKindCollector}, + lockKind: v1alpha1.KindInstallation, + pass: false, + }, + { + name: "CollectionAgent_Collector_Valid", + resource: Resource{Molding: v1alpha1.MoldingKindCollector}, + lockKind: v1alpha1.KindCollectionAgent, + pass: true, + expectedKind: v1alpha1.KindCollectionAgent, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resolved, err := tt.resource.Resolve(tt.lockKind) + if !tt.pass { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.expectedKind, resolved.Kind) + }) + } +} From 5d852faf73edd7110d4b698e8a28ee2abda623eb Mon Sep 17 00:00:00 2001 From: Miguel Valdes Date: Mon, 1 Jun 2026 13:25:47 -0500 Subject: [PATCH 2/3] fix(foundryctl): lint issues on resource definition --- internal/mechanic/resource.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/mechanic/resource.go b/internal/mechanic/resource.go index 1243763b..6ee35d83 100644 --- a/internal/mechanic/resource.go +++ b/internal/mechanic/resource.go @@ -12,7 +12,7 @@ import ( "github.com/signoz/foundry/internal/errors" ) -// Resource is a parsed mechanic resource path. The canonical form is +// Resource is a parsed mechanic resource path. type Resource struct { Kind v1alpha1.Kind KindExplicit bool From 7967eb0cb394f33cdf42822a7d6dcb946dada9a0 Mon Sep 17 00:00:00 2001 From: Miguel Valdes Date: Tue, 9 Jun 2026 15:29:50 -0500 Subject: [PATCH 3/3] feat(mechanic): implement alert inspect with docker metastore and telemetrystore --- casting.yaml.lock | 643 +++++++++++++++++++++++ internal/foundry/mechanic.go | 112 +++- internal/mechanic/alert.go | 46 ++ internal/mechanic/connection.go | 77 +++ internal/mechanic/connection_test.go | 75 +++ internal/mechanic/metastore.go | 202 +++++++ internal/mechanic/metastore_test.go | 112 ++++ internal/mechanic/telemetrystore.go | 125 +++++ internal/mechanic/telemetrystore_test.go | 118 +++++ 9 files changed, 1506 insertions(+), 4 deletions(-) create mode 100644 casting.yaml.lock create mode 100644 internal/mechanic/alert.go create mode 100644 internal/mechanic/connection.go create mode 100644 internal/mechanic/connection_test.go create mode 100644 internal/mechanic/metastore.go create mode 100644 internal/mechanic/metastore_test.go create mode 100644 internal/mechanic/telemetrystore.go create mode 100644 internal/mechanic/telemetrystore_test.go diff --git a/casting.yaml.lock b/casting.yaml.lock new file mode 100644 index 00000000..9aa43196 --- /dev/null +++ b/casting.yaml.lock @@ -0,0 +1,643 @@ +apiVersion: v1alpha1 +kind: Installation +metadata: + name: dev +spec: + deployment: + flavor: compose + mode: docker + ingester: + spec: + cluster: + replicas: 1 + config: + data: + ingester.yaml: | + connectors: + signozmeter: + metrics_flush_interval: 1h + dimensions: + - name: service.name + - name: deployment.environment + - name: host.name + receivers: + otlp: + protocols: + grpc: + endpoint: "0.0.0.0:4317" + http: + endpoint: "0.0.0.0:4318" + processors: + batch: + send_batch_size: 50000 + send_batch_max_size: 55000 + timeout: 5s + batch/meter: + send_batch_size: 20000 + send_batch_max_size: 25000 + timeout: 5s + signozspanmetrics/delta: + metrics_exporter: signozclickhousemetrics + metrics_flush_interval: 60s + latency_histogram_buckets: + - 100us + - 1ms + - 2ms + - 6ms + - 10ms + - 50ms + - 100ms + - 250ms + - 500ms + - 1000ms + - 1400ms + - 2000ms + - 5s + - 10s + - 20s + - 40s + - 60s + dimensions_cache_size: 100000 + aggregation_temporality: AGGREGATION_TEMPORALITY_DELTA + enable_exp_histogram: true + dimensions: + - name: service.namespace + default: default + - name: deployment.environment + default: default + - name: signoz.collector.id + - name: service.version + exporters: + clickhousetraces: + datasource: tcp://dev-telemetrystore-clickhouse-0-0:9000/signoz_traces + low_cardinal_exception_grouping: ${env:LOW_CARDINAL_EXCEPTION_GROUPING} + use_new_schema: true + timeout: 45s + sending_queue: + enabled: false + signozclickhousemetrics: + dsn: tcp://dev-telemetrystore-clickhouse-0-0:9000/signoz_metrics + timeout: 45s + sending_queue: + enabled: false + clickhouselogsexporter: + dsn: tcp://dev-telemetrystore-clickhouse-0-0:9000/signoz_logs + use_new_schema: true + timeout: 45s + sending_queue: + enabled: false + signozclickhousemeter: + dsn: tcp://dev-telemetrystore-clickhouse-0-0:9000/signoz_meter + timeout: 45s + sending_queue: + enabled: false + metadataexporter: + enabled: true + dsn: tcp://dev-telemetrystore-clickhouse-0-0:9000/signoz_metadata + timeout: 45s + cache: + provider: in_memory + extensions: + signoz_health_check: + endpoint: "0.0.0.0:13133" + pprof: + endpoint: "0.0.0.0:1777" + service: + telemetry: + logs: + encoding: json + extensions: + - signoz_health_check + - pprof + pipelines: + traces: + receivers: + - otlp + processors: + - signozspanmetrics/delta + - batch + exporters: + - clickhousetraces + - signozmeter + - metadataexporter + metrics: + receivers: + - otlp + processors: + - batch + exporters: + - signozclickhousemetrics + - signozmeter + - metadataexporter + logs: + receivers: + - otlp + processors: + - batch + exporters: + - clickhouselogsexporter + - signozmeter + - metadataexporter + metrics/meter: + receivers: + - signozmeter + processors: + - batch/meter + exporters: + - signozclickhousemeter + opamp.yaml: | + server_endpoint: ws://dev-signoz-0:4320/v1/opamp + enabled: true + env: + SIGNOZ_OTEL_COLLECTOR_TIMEOUT: 10m + image: signoz/signoz-otel-collector:latest + version: latest + status: + addresses: + otlp: + - tcp://dev-ingester:4318 + - tcp://dev-ingester:4317 + config: + data: + ingester.yaml: | + connectors: + signozmeter: + metrics_flush_interval: 1h + dimensions: + - name: service.name + - name: deployment.environment + - name: host.name + receivers: + otlp: + protocols: + grpc: + endpoint: "0.0.0.0:4317" + http: + endpoint: "0.0.0.0:4318" + processors: + batch: + send_batch_size: 50000 + send_batch_max_size: 55000 + timeout: 5s + batch/meter: + send_batch_size: 20000 + send_batch_max_size: 25000 + timeout: 5s + signozspanmetrics/delta: + metrics_exporter: signozclickhousemetrics + metrics_flush_interval: 60s + latency_histogram_buckets: + - 100us + - 1ms + - 2ms + - 6ms + - 10ms + - 50ms + - 100ms + - 250ms + - 500ms + - 1000ms + - 1400ms + - 2000ms + - 5s + - 10s + - 20s + - 40s + - 60s + dimensions_cache_size: 100000 + aggregation_temporality: AGGREGATION_TEMPORALITY_DELTA + enable_exp_histogram: true + dimensions: + - name: service.namespace + default: default + - name: deployment.environment + default: default + - name: signoz.collector.id + - name: service.version + exporters: + clickhousetraces: + datasource: tcp://dev-telemetrystore-clickhouse-0-0:9000/signoz_traces + low_cardinal_exception_grouping: ${env:LOW_CARDINAL_EXCEPTION_GROUPING} + use_new_schema: true + timeout: 45s + sending_queue: + enabled: false + signozclickhousemetrics: + dsn: tcp://dev-telemetrystore-clickhouse-0-0:9000/signoz_metrics + timeout: 45s + sending_queue: + enabled: false + clickhouselogsexporter: + dsn: tcp://dev-telemetrystore-clickhouse-0-0:9000/signoz_logs + use_new_schema: true + timeout: 45s + sending_queue: + enabled: false + signozclickhousemeter: + dsn: tcp://dev-telemetrystore-clickhouse-0-0:9000/signoz_meter + timeout: 45s + sending_queue: + enabled: false + metadataexporter: + enabled: true + dsn: tcp://dev-telemetrystore-clickhouse-0-0:9000/signoz_metadata + timeout: 45s + cache: + provider: in_memory + extensions: + signoz_health_check: + endpoint: "0.0.0.0:13133" + pprof: + endpoint: "0.0.0.0:1777" + service: + telemetry: + logs: + encoding: json + extensions: + - signoz_health_check + - pprof + pipelines: + traces: + receivers: + - otlp + processors: + - signozspanmetrics/delta + - batch + exporters: + - clickhousetraces + - signozmeter + - metadataexporter + metrics: + receivers: + - otlp + processors: + - batch + exporters: + - signozclickhousemetrics + - signozmeter + - metadataexporter + logs: + receivers: + - otlp + processors: + - batch + exporters: + - clickhouselogsexporter + - signozmeter + - metadataexporter + metrics/meter: + receivers: + - signozmeter + processors: + - batch/meter + exporters: + - signozclickhousemeter + opamp.yaml: | + server_endpoint: ws://dev-signoz-0:4320/v1/opamp + env: + SIGNOZ_OTEL_COLLECTOR_TIMEOUT: 10m + metastore: + kind: sqlite + spec: + cluster: + replicas: 1 + config: {} + enabled: true + image: postgres:16 + version: "16" + status: + addresses: + dsn: null + config: {} + signoz: + spec: + cluster: + replicas: 1 + config: {} + enabled: true + env: + SIGNOZ_INSTRUMENTATION_LOGS_LEVEL: error + SIGNOZ_SQLSTORE_PROVIDER: sqlite + SIGNOZ_SQLSTORE_SQLITE_PATH: /var/lib/signoz/signoz.db + SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_DSN: tcp://dev-telemetrystore-clickhouse-0-0:9000 + SIGNOZ_TELEMETRYSTORE_PROVIDER: clickhouse + SIGNOZ_VERSION_BANNER_ENABLED: "false" + image: signoz/signoz:v0.127.0 + version: latest + status: + addresses: + apiserver: + - tcp://dev-signoz-0:8080 + opamp: + - ws://dev-signoz-0:4320 + config: {} + env: + SIGNOZ_INSTRUMENTATION_LOGS_LEVEL: error + SIGNOZ_SQLSTORE_PROVIDER: sqlite + SIGNOZ_SQLSTORE_SQLITE_PATH: /var/lib/signoz/signoz.db + SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_DSN: tcp://dev-telemetrystore-clickhouse-0-0:9000 + SIGNOZ_TELEMETRYSTORE_PROVIDER: clickhouse + SIGNOZ_VERSION_BANNER_ENABLED: "false" + telemetrykeeper: + kind: clickhousekeeper + spec: + cluster: + replicas: 1 + config: + data: + keeper-0.yaml: | + listen_host: 0.0.0.0 + logger: + level: information + console: true + keeper_server: + four_letter_word_white_list: "*" + coordination_settings: + operation_timeout_ms: 10000 + raft_logs_level: warning + session_timeout_ms: 30000 + force_sync: false + snapshot_distance: 100000 + snapshots_to_keep: 3 + log_storage_path: /var/lib/clickhouse/coordination/log + raft_configuration: + server: + - hostname: dev-telemetrykeeper-clickhousekeeper-0 + port: 9234 + id: 0 + server_id: 0 + snapshot_storage_path: /var/lib/clickhouse/coordination/snapshots + tcp_port: 9181 + enabled: true + image: clickhouse/clickhouse-keeper:25.5.6 + version: 25.5.6 + status: + addresses: + client: + - tcp://dev-telemetrykeeper-clickhousekeeper-0:9181 + raft: + - tcp://dev-telemetrykeeper-clickhousekeeper-0:9234 + config: + data: + keeper-0.yaml: | + listen_host: 0.0.0.0 + logger: + level: information + console: true + keeper_server: + four_letter_word_white_list: "*" + coordination_settings: + operation_timeout_ms: 10000 + raft_logs_level: warning + session_timeout_ms: 30000 + force_sync: false + snapshot_distance: 100000 + snapshots_to_keep: 3 + log_storage_path: /var/lib/clickhouse/coordination/log + raft_configuration: + server: + - hostname: dev-telemetrykeeper-clickhousekeeper-0 + port: 9234 + id: 0 + server_id: 0 + snapshot_storage_path: /var/lib/clickhouse/coordination/snapshots + tcp_port: 9181 + telemetrystore: + kind: clickhouse + spec: + cluster: + replicas: 0 + shards: 1 + config: + data: + config-0-0.yaml: | + path: /var/lib/clickhouse/ + tmp_path: /var/lib/clickhouse/tmp/ + user_files_path: /var/lib/clickhouse/user_files/ + format_schema_path: /var/lib/clickhouse/format_schemas/ + dictionaries_config: '*_dictionary.xml' + display_name: cluster + distributed_ddl: + path: /clickhouse/task_queue/ddl + http_port: 8123 + interserver_http_port: 9009 + listen_host: 0.0.0.0 + logger: + console: 1 + count: 10 + formatting: + type: console + level: information + size: 1000M + macros: + replica: "00" + shard: "00" + profiles: + default: + allow_simdjson: 0 + load_balancing: random + log_queries: 1 + quotas: + default: + interval: + duration: 3600 + errors: 0 + execution_time: 0 + queries: 0 + read_rows: 0 + result_rows: 0 + user_directories: + users_xml: + path: users.xml + remote_servers: + cluster: + shard: + - replica: + - host: dev-telemetrystore-clickhouse-0-0 + port: 9000 + zookeeper: + node: + - host: dev-telemetrykeeper-clickhousekeeper-0 + port: 9181 + query_log: + flush_interval_milliseconds: 30000 + partition_by: toYYYYMM(event_date) + ttl: "event_date + INTERVAL 1 DAY DELETE" + query_thread_log: + ttl: "event_date + INTERVAL 1 DAY DELETE" + query_metric_log: + ttl: "event_date + INTERVAL 1 DAY DELETE" + query_views_log: + ttl: "event_date + INTERVAL 1 DAY DELETE" + part_log: + ttl: "event_date + INTERVAL 1 DAY DELETE" + metric_log: + flush_interval_milliseconds: 30000 + ttl: "event_date + INTERVAL 1 DAY DELETE" + asynchronous_metric_log: + ttl: "event_date + INTERVAL 1 DAY DELETE" + trace_log: + flush_interval_milliseconds: 30000 + ttl: "event_date + INTERVAL 1 DAY DELETE" + error_log: + ttl: "event_date + INTERVAL 1 DAY DELETE" + latency_log: + ttl: "event_date + INTERVAL 1 DAY DELETE" + processors_profile_log: + flush_interval_milliseconds: 30000 + ttl: "event_date + INTERVAL 1 DAY DELETE" + session_log: + ttl: "event_date + INTERVAL 1 DAY DELETE" + text_log: + flush_interval_milliseconds: 30000 + ttl: "event_date + INTERVAL 1 DAY DELETE" + zookeeper_log: + ttl: "event_date + INTERVAL 1 DAY DELETE" + tcp_port: 9000 + user_defined_executable_functions_config: '*function.yaml' + user_scripts_path: /var/lib/clickhouse/user_scripts/ + users: + default: + access_management: 1 + named_collection_control: 1 + networks: + ip: ::/0 + password: "" + profile: default + quota: default + show_named_collection: 1 + show_named_collection_secrets: 1 + functions.yaml: | + functions: + argument: + - name: buckets + type: Array(Float64) + - name: counts + type: Array(Float64) + - name: quantile + type: Array(Float64) + command: ./histogramQuantile + format: CSV + name: histogramQuantile + return_type: Float64 + type: executable + enabled: true + image: clickhouse/clickhouse-server:25.5.6 + version: 25.5.6 + status: + addresses: + tcp: + - tcp://dev-telemetrystore-clickhouse-0-0:9000 + config: + data: + config-0-0.yaml: | + path: /var/lib/clickhouse/ + tmp_path: /var/lib/clickhouse/tmp/ + user_files_path: /var/lib/clickhouse/user_files/ + format_schema_path: /var/lib/clickhouse/format_schemas/ + dictionaries_config: '*_dictionary.xml' + display_name: cluster + distributed_ddl: + path: /clickhouse/task_queue/ddl + http_port: 8123 + interserver_http_port: 9009 + listen_host: 0.0.0.0 + logger: + console: 1 + count: 10 + formatting: + type: console + level: information + size: 1000M + macros: + replica: "00" + shard: "00" + profiles: + default: + allow_simdjson: 0 + load_balancing: random + log_queries: 1 + quotas: + default: + interval: + duration: 3600 + errors: 0 + execution_time: 0 + queries: 0 + read_rows: 0 + result_rows: 0 + user_directories: + users_xml: + path: users.xml + remote_servers: + cluster: + shard: + - replica: + - host: dev-telemetrystore-clickhouse-0-0 + port: 9000 + zookeeper: + node: + - host: dev-telemetrykeeper-clickhousekeeper-0 + port: 9181 + query_log: + flush_interval_milliseconds: 30000 + partition_by: toYYYYMM(event_date) + ttl: "event_date + INTERVAL 1 DAY DELETE" + query_thread_log: + ttl: "event_date + INTERVAL 1 DAY DELETE" + query_metric_log: + ttl: "event_date + INTERVAL 1 DAY DELETE" + query_views_log: + ttl: "event_date + INTERVAL 1 DAY DELETE" + part_log: + ttl: "event_date + INTERVAL 1 DAY DELETE" + metric_log: + flush_interval_milliseconds: 30000 + ttl: "event_date + INTERVAL 1 DAY DELETE" + asynchronous_metric_log: + ttl: "event_date + INTERVAL 1 DAY DELETE" + trace_log: + flush_interval_milliseconds: 30000 + ttl: "event_date + INTERVAL 1 DAY DELETE" + error_log: + ttl: "event_date + INTERVAL 1 DAY DELETE" + latency_log: + ttl: "event_date + INTERVAL 1 DAY DELETE" + processors_profile_log: + flush_interval_milliseconds: 30000 + ttl: "event_date + INTERVAL 1 DAY DELETE" + session_log: + ttl: "event_date + INTERVAL 1 DAY DELETE" + text_log: + flush_interval_milliseconds: 30000 + ttl: "event_date + INTERVAL 1 DAY DELETE" + zookeeper_log: + ttl: "event_date + INTERVAL 1 DAY DELETE" + tcp_port: 9000 + user_defined_executable_functions_config: '*function.yaml' + user_scripts_path: /var/lib/clickhouse/user_scripts/ + users: + default: + access_management: 1 + named_collection_control: 1 + networks: + ip: ::/0 + password: "" + profile: default + quota: default + show_named_collection: 1 + show_named_collection_secrets: 1 + functions.yaml: | + functions: + argument: + - name: buckets + type: Array(Float64) + - name: counts + type: Array(Float64) + - name: quantile + type: Array(Float64) + command: ./histogramQuantile + format: CSV + name: histogramQuantile + return_type: Float64 + type: executable diff --git a/internal/foundry/mechanic.go b/internal/foundry/mechanic.go index 74aaa404..9b33817a 100644 --- a/internal/foundry/mechanic.go +++ b/internal/foundry/mechanic.go @@ -2,13 +2,42 @@ package foundry import ( "context" + "encoding/json" "log/slog" + "os" + "strings" "github.com/signoz/foundry/api/v1alpha1" foundryerrors "github.com/signoz/foundry/internal/errors" "github.com/signoz/foundry/internal/mechanic" + "github.com/signoz/foundry/internal/writer" ) +// entityKindAlert is the entity-kind segment that targets a SigNoz alert rule. +const entityKindAlert = "alert" + +// probeQuery is the placeholder diagnostic mechanic runs against the telemetry +// store. The per-signal queries that decide whether an alert is a false alarm +// are defined in a later phase; for now this proves mechanic can reach and +// query ClickHouse directly. +const probeQuery = "SELECT version()" + +// alertInspection is the result mechanic emits for an inspected alert: the rule +// metadata, the ClickHouse tables its signals map to, and the output of the +// diagnostic probe run against the telemetry store. +type alertInspection struct { + Alert mechanic.Alert `json:"alert"` + Tables []string `json:"tables"` + ClickhouseVersion string `json:"clickhouseVersion"` +} + +// MarshalJSON satisfies json.Marshaler so the inspection can stream to stdout +// via writer.WriteOutput. The alias breaks the method recursion. +func (a alertInspection) MarshalJSON() ([]byte, error) { + type alias alertInspection + return json.Marshal(alias(a)) +} + // Inspect resolves a mechanic resource path against the loaded casting and runs the targeted inspection. func (foundry *Foundry) Inspect(ctx context.Context, machinery v1alpha1.Machinery, resource mechanic.Resource, overrides mechanic.Overrides) error { resolved, err := resource.Resolve(machinery.Kind()) @@ -26,10 +55,85 @@ func (foundry *Foundry) Inspect(ctx context.Context, machinery v1alpha1.Machiner slog.String("molding", resolved.Molding.String()), slog.String("entity.kind", resolved.EntityKind), slog.String("entity.id", resolved.EntityID), - slog.Bool("override.signoz", overrides.Signoz != ""), - slog.Bool("override.clickhouse", overrides.ClickhouseDSN != ""), - slog.Bool("override.metastore", overrides.MetastoreDSN != ""), ) - return nil + conn := mechanic.ResolveConnection(machinery, overrides) + foundry.Logger.InfoContext( + ctx, "mechanic connection resolved", + slog.String("clickhouse", conn.Clickhouse.Value), + slog.String("clickhouse.source", string(conn.Clickhouse.Source)), + slog.String("metastore", conn.Metastore.Value), + slog.String("metastore.source", string(conn.Metastore.Source)), + slog.String("signoz", conn.Signoz.Value), + slog.String("signoz.source", string(conn.Signoz.Source)), + ) + + // The current reachers exec into the running containers named in the lock, + // so connection overrides do not change where mechanic connects. They are + // reserved for the direct-driver path; warn rather than silently ignore them. + if conn.UsesOverride() { + foundry.Logger.WarnContext(ctx, "connection overrides are not applied in exec mode; reaching containers from the lock file") + } + + switch resolved.EntityKind { + case entityKindAlert: + return foundry.inspectAlert(ctx, machinery, resolved.EntityID) + default: + return foundryerrors.Newf(foundryerrors.TypeUnsupported, "inspect does not support entity kind %q yet", resolved.EntityKind) + } +} + +// inspectAlert looks the alert rule up in the deployment's metastore, maps the +// signals its queries target to ClickHouse tables, probes the telemetry store, +// and writes the inspection to stdout. +func (foundry *Foundry) inspectAlert(ctx context.Context, machinery v1alpha1.Machinery, id string) error { + executor := mechanic.NewExecExecutor() + + metastore, err := mechanic.NewMetaStore(executor, machinery) + if err != nil { + return err + } + + alert, err := metastore.Rule(ctx, id) + if err != nil { + return err + } + + foundry.Logger.InfoContext(ctx, "alert metadata resolved", + slog.String("alert.id", alert.ID), + slog.String("alert.name", alert.Name), + ) + + // Map the alert's query signals to their backing ClickHouse tables. This is + // best-effort context for the diagnostics to come; a rule whose signal we + // can't map should not block reaching the telemetry store. Keep tables a + // non-nil slice so the emitted JSON carries [] rather than null. + tables := []string{} + mapped, err := mechanic.SignalTables(alert.Data) + if err != nil { + foundry.Logger.WarnContext(ctx, "could not map alert signals to clickhouse tables", foundryerrors.LogAttr(err)) + } else { + tables = append(tables, mapped...) + foundry.Logger.InfoContext(ctx, "alert signals mapped to clickhouse tables", + slog.String("tables", strings.Join(tables, ","))) + } + + telemetrystore, err := mechanic.NewTelemetryStore(executor, machinery) + if err != nil { + return err + } + + version, err := telemetrystore.Query(ctx, probeQuery) + if err != nil { + return err + } + + foundry.Logger.InfoContext(ctx, "telemetrystore reached", + slog.String("clickhouse.version", version)) + + return writer.WriteOutput(os.Stdout, alertInspection{ + Alert: alert, + Tables: tables, + ClickhouseVersion: version, + }) } diff --git a/internal/mechanic/alert.go b/internal/mechanic/alert.go new file mode 100644 index 00000000..c0c03417 --- /dev/null +++ b/internal/mechanic/alert.go @@ -0,0 +1,46 @@ +package mechanic + +import ( + "encoding/json" + + "github.com/tidwall/gjson" +) + +// Alert is the metadata mechanic surfaces for a SigNoz alert rule. Data is the +// raw rule JSON as stored in the metastore's rule.data column; Name is decoded +// from it. +type Alert struct { + ID string `json:"id"` + Name string `json:"name"` + Data json.RawMessage `json:"data"` +} + +// MarshalJSON lets Alert satisfy json.Marshaler so it can be streamed to stdout +// via writer.WriteOutput. The alias breaks the method recursion. +func (a Alert) MarshalJSON() ([]byte, error) { + type alias Alert + return json.Marshal(alias(a)) +} + +// decodeAlert builds an Alert from a metastore row's id and raw rule JSON, +// extracting the human-readable name from the rule payload. +func decodeAlert(id string, data []byte) Alert { + return Alert{ + ID: id, + Name: alertName(data), + Data: json.RawMessage(data), + } +} + +// alertName pulls the rule's display name from its JSON payload, tolerating the +// handful of keys SigNoz has used across versions. +func alertName(data []byte) string { + for _, key := range []string{"alert", "alertName", "name"} { + if r := gjson.GetBytes(data, key); r.Exists() { + if s := r.String(); s != "" { + return s + } + } + } + return "" +} diff --git a/internal/mechanic/connection.go b/internal/mechanic/connection.go new file mode 100644 index 00000000..3c163684 --- /dev/null +++ b/internal/mechanic/connection.go @@ -0,0 +1,77 @@ +package mechanic + +import ( + "strings" + + "github.com/signoz/foundry/api/v1alpha1" + "github.com/signoz/foundry/api/v1alpha1/installation" +) + +// Source records where an Endpoint's value came from so logs and downstream +// callers can tell an operator-supplied override apart from a lock-derived +// default. +type Source string + +const ( + // SourceUnset means neither an override nor the lock file provided a value. + SourceUnset Source = "" + // SourceOverride means the value came from a flag or environment override. + SourceOverride Source = "override" + // SourceLock means the value was resolved from the casting lock file. + SourceLock Source = "lock" +) + +// Endpoint is a single resolved connection address paired with its origin. +type Endpoint struct { + Value string + Source Source +} + +// Connection holds the effective endpoints mechanic will dial. Each field is +// resolved by taking the override when set and otherwise falling back to the +// lock file's resolved status addresses. +type Connection struct { + Signoz Endpoint + Clickhouse Endpoint + Metastore Endpoint +} + +// UsesOverride reports whether any endpoint was sourced from an override rather +// than the lock file. +func (c Connection) UsesOverride() bool { + return c.Signoz.Source == SourceOverride || + c.Clickhouse.Source == SourceOverride || + c.Metastore.Source == SourceOverride +} + +// ResolveConnection derives the effective connection details from the lock file +// machinery, letting overrides win over the lock-derived addresses. Kinds that +// carry no telemetry/meta store (e.g. CollectionAgent) contribute no lock +// addresses; overrides still apply. +func ResolveConnection(machinery v1alpha1.Machinery, overrides Overrides) Connection { + var signoz, clickhouse, metastore string + + if c, ok := machinery.(*installation.Casting); ok { + signoz = strings.Join(c.Spec.Signoz.Status.Addresses.APIServer, ",") + clickhouse = strings.Join(c.Spec.TelemetryStore.Status.Addresses.TCP, ",") + metastore = strings.Join(c.Spec.MetaStore.Status.Addresses.DSN, ",") + } + + return Connection{ + Signoz: resolveEndpoint(overrides.Signoz, signoz), + Clickhouse: resolveEndpoint(overrides.ClickhouseDSN, clickhouse), + Metastore: resolveEndpoint(overrides.MetastoreDSN, metastore), + } +} + +// resolveEndpoint applies override-wins precedence and tags the resulting value +// with its source. +func resolveEndpoint(override, fromLock string) Endpoint { + if override != "" { + return Endpoint{Value: override, Source: SourceOverride} + } + if fromLock != "" { + return Endpoint{Value: fromLock, Source: SourceLock} + } + return Endpoint{Source: SourceUnset} +} diff --git a/internal/mechanic/connection_test.go b/internal/mechanic/connection_test.go new file mode 100644 index 00000000..a07631ef --- /dev/null +++ b/internal/mechanic/connection_test.go @@ -0,0 +1,75 @@ +package mechanic + +import ( + "testing" + + "github.com/signoz/foundry/api/v1alpha1/installation" + "github.com/stretchr/testify/assert" +) + +func TestResolveConnection(t *testing.T) { + lock := func() *installation.Casting { + c := installation.Default() + c.Spec.Signoz.Status.Addresses.APIServer = []string{"signoz:8080"} + c.Spec.TelemetryStore.Status.Addresses.TCP = []string{"tcp://ch-0:9000", "tcp://ch-1:9000"} + c.Spec.MetaStore.Status.Addresses.DSN = []string{"postgres://meta:5432"} + return c + } + + tests := []struct { + name string + machinery *installation.Casting + overrides Overrides + expected Connection + }{ + { + name: "LockFallback", + machinery: lock(), + expected: Connection{ + Signoz: Endpoint{Value: "signoz:8080", Source: SourceLock}, + Clickhouse: Endpoint{Value: "tcp://ch-0:9000,tcp://ch-1:9000", Source: SourceLock}, + Metastore: Endpoint{Value: "postgres://meta:5432", Source: SourceLock}, + }, + }, + { + name: "OverridesWin", + machinery: lock(), + overrides: Overrides{ + Signoz: "signoz.example:443", + ClickhouseDSN: "user:pass@ch.example:9000", + MetastoreDSN: "postgres://override:5432", + }, + expected: Connection{ + Signoz: Endpoint{Value: "signoz.example:443", Source: SourceOverride}, + Clickhouse: Endpoint{Value: "user:pass@ch.example:9000", Source: SourceOverride}, + Metastore: Endpoint{Value: "postgres://override:5432", Source: SourceOverride}, + }, + }, + { + name: "PartialOverride", + machinery: lock(), + overrides: Overrides{ClickhouseDSN: "user:pass@ch.example:9000"}, + expected: Connection{ + Signoz: Endpoint{Value: "signoz:8080", Source: SourceLock}, + Clickhouse: Endpoint{Value: "user:pass@ch.example:9000", Source: SourceOverride}, + Metastore: Endpoint{Value: "postgres://meta:5432", Source: SourceLock}, + }, + }, + { + name: "NoLockNoOverride", + machinery: installation.Default(), + expected: Connection{ + Signoz: Endpoint{Source: SourceUnset}, + Clickhouse: Endpoint{Source: SourceUnset}, + Metastore: Endpoint{Source: SourceUnset}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + conn := ResolveConnection(tt.machinery, tt.overrides) + assert.Equal(t, tt.expected, conn) + }) + } +} diff --git a/internal/mechanic/metastore.go b/internal/mechanic/metastore.go new file mode 100644 index 00000000..beef3e95 --- /dev/null +++ b/internal/mechanic/metastore.go @@ -0,0 +1,202 @@ +package mechanic + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "regexp" + "strings" + + "github.com/signoz/foundry/api/v1alpha1" + "github.com/signoz/foundry/api/v1alpha1/installation" + "github.com/signoz/foundry/internal/domain" + "github.com/signoz/foundry/internal/errors" +) + +// ruleQuery looks up a single alert rule by id. id is interpolated as a literal +// because the store CLIs (psql, sqlite3) are driven as text with no driver to +// parameterize; safeID guards the value before it reaches here. +const ruleQuery = "SELECT id, data FROM rule WHERE id = '%s'" + +// fieldSep is an ASCII unit separator used to split columns in store CLI +// output. Unlike a comma or pipe it will not collide with the JSON payload in +// the data column. +const fieldSep = "\x1f" + +// sqliteDefaultPath is where SigNoz keeps its embedded sqlite database inside +// the signoz container when SIGNOZ_SQLSTORE_SQLITE_PATH is absent from the lock. +const sqliteDefaultPath = "/var/lib/signoz/signoz.db" + +// metastorePostgresCredential is the value the metastore molding provisions for +// the postgres user, database, and password alike (all three are "signoz"). See +// internal/molding/metastoremolding. +const metastorePostgresCredential = "signoz" + +// safeID restricts ids interpolated into SQL to the UUID/integer alphabet +// SigNoz uses for rule ids, closing off injection via the text-driven CLIs. +var safeID = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) + +// Executor runs an external command and returns its stdout. Abstracted so the +// store reachers can be exercised without a live deployment. +type Executor interface { + Output(ctx context.Context, name string, args ...string) ([]byte, error) +} + +// MetaStore reads SigNoz state from a deployment's metadata store. +type MetaStore interface { + // Rule returns the alert rule stored under id. + Rule(ctx context.Context, id string) (Alert, error) +} + +// NewMetaStore selects the reach strategy for the deployment's metastore. Phase +// 1 supports docker/compose only by executing into the running store container; +// other targets return TypeUnsupported. +func NewMetaStore(executor Executor, machinery v1alpha1.Machinery) (MetaStore, error) { + c, err := dockerComposeCasting(machinery) + if err != nil { + return nil, err + } + + switch c.Spec.MetaStore.Kind { + case installation.MetaStoreKindPostgres: + container, err := firstHost(c.Spec.MetaStore.Status.Addresses.DSN) + if err != nil { + return nil, errors.Wrapf(err, errors.TypeNotFound, "metastore address missing from lock, run forge first") + } + return &dockerPostgresMetaStore{executor: executor, container: container}, nil + + case installation.MetaStoreKindSQLite: + container, err := firstHost(c.Spec.Signoz.Status.Addresses.APIServer) + if err != nil { + return nil, errors.Wrapf(err, errors.TypeNotFound, "signoz address missing from lock, run forge first") + } + path := c.Spec.Signoz.Spec.Env["SIGNOZ_SQLSTORE_SQLITE_PATH"] + if path == "" { + path = sqliteDefaultPath + } + return &dockerSQLiteMetaStore{executor: executor, container: container, path: path}, nil + + default: + return nil, errors.Newf(errors.TypeUnsupported, "unsupported metastore kind %q", c.Spec.MetaStore.Kind) + } +} + +// dockerPostgresMetaStore reaches a postgres metastore by exec-ing psql inside +// the running postgres container. Credentials are the fixed signoz/signoz/signoz +// trio the metastore molding provisions. +type dockerPostgresMetaStore struct { + executor Executor + container string +} + +func (m *dockerPostgresMetaStore) Rule(ctx context.Context, id string) (Alert, error) { + if !safeID.MatchString(id) { + return Alert{}, errors.Newf(errors.TypeInvalidInput, "invalid alert id %q", id) + } + + out, err := m.executor.Output(ctx, "docker", "exec", + "-e", "PGPASSWORD="+metastorePostgresCredential, m.container, + "psql", "-U", metastorePostgresCredential, "-d", metastorePostgresCredential, + "-tA", "-F", fieldSep, + "-c", fmt.Sprintf(ruleQuery, id), + ) + if err != nil { + return Alert{}, errors.Wrapf(err, errors.TypeInternal, "failed to query metastore via container %q", m.container) + } + + return parseRuleRow(out, id) +} + +// dockerSQLiteMetaStore reaches an embedded sqlite metastore by exec-ing sqlite3 +// against the database file inside the running signoz container. +type dockerSQLiteMetaStore struct { + executor Executor + container string + path string +} + +func (m *dockerSQLiteMetaStore) Rule(ctx context.Context, id string) (Alert, error) { + if !safeID.MatchString(id) { + return Alert{}, errors.Newf(errors.TypeInvalidInput, "invalid alert id %q", id) + } + + // The signoz image ships without a sqlite3 client, so install it (as root, + // since signoz may run unprivileged) before querying. apk add is idempotent, + // and running it as its own exec keeps its output out of the query parse. + if _, err := m.executor.Output(ctx, "docker", "exec", "-u", "root", m.container, + "apk", "add", "--no-cache", "sqlite", + ); err != nil { + return Alert{}, errors.Wrapf(err, errors.TypeInternal, "failed to install sqlite3 in container %q", m.container) + } + + out, err := m.executor.Output(ctx, "docker", "exec", m.container, + "sqlite3", "-separator", fieldSep, m.path, + fmt.Sprintf(ruleQuery, id), + ) + if err != nil { + return Alert{}, errors.Wrapf(err, errors.TypeInternal, "failed to query sqlite metastore via container %q", m.container) + } + + return parseRuleRow(out, id) +} + +// parseRuleRow decodes the first row of a store CLI's output. +// An empty result means no rule matched the id. +func parseRuleRow(out []byte, id string) (Alert, error) { + row := strings.TrimSpace(string(out)) + if row == "" { + return Alert{}, errors.Newf(errors.TypeNotFound, "no alert found with id %q", id) + } + + if idx := strings.IndexByte(row, '\n'); idx >= 0 { + row = row[:idx] + } + + col, data, ok := strings.Cut(row, fieldSep) + if !ok { + return Alert{}, errors.Newf(errors.TypeInternal, "unexpected metastore row format for id %q", id) + } + + return decodeAlert(strings.TrimSpace(col), []byte(strings.TrimSpace(data))), nil +} + +// firstHost returns the host of the first address, the container name to exec +// into for docker/compose deployments. +func firstHost(addresses []string) (string, error) { + if len(addresses) == 0 { + return "", errors.Newf(errors.TypeNotFound, "no address available") + } + + addr, err := domain.ParseAddress(addresses[0]) + if err != nil { + return "", err + } + + return addr.Host(), nil +} + +// execExecutor is the production Executor backed by os/exec. +type execExecutor struct{} + +// NewExecExecutor returns an Executor that shells out via os/exec. +func NewExecExecutor() Executor { + return execExecutor{} +} + +func (execExecutor) Output(ctx context.Context, name string, args ...string) ([]byte, error) { + cmd := exec.CommandContext(ctx, name, args...) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + if msg := strings.TrimSpace(stderr.String()); msg != "" { + return nil, errors.Wrapf(err, errors.TypeInternal, "%s", msg) + } + return nil, err + } + + return stdout.Bytes(), nil +} diff --git a/internal/mechanic/metastore_test.go b/internal/mechanic/metastore_test.go new file mode 100644 index 00000000..11bc7c8e --- /dev/null +++ b/internal/mechanic/metastore_test.go @@ -0,0 +1,112 @@ +package mechanic + +import ( + "context" + "testing" + + "github.com/signoz/foundry/api/v1alpha1" + "github.com/signoz/foundry/api/v1alpha1/installation" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// execCall captures one invocation of the fake executor. +type execCall struct { + name string + args []string +} + +// fakeExecutor records every command and returns canned output. Shared across +// the mechanic package tests (also used by telemetrystore_test.go). +type fakeExecutor struct { + calls []execCall + out []byte + err error +} + +func (f *fakeExecutor) Output(_ context.Context, name string, args ...string) ([]byte, error) { + f.calls = append(f.calls, execCall{name: name, args: args}) + return f.out, f.err +} + +func dockerCasting(kind installation.MetaStoreKind) *installation.Casting { + c := installation.Default() + c.Spec.Deployment = v1alpha1.TypeDeployment{Mode: v1alpha1.ModeDocker, Flavor: v1alpha1.FlavorCompose} + c.Spec.MetaStore.Kind = kind + c.Spec.MetaStore.Status.Addresses.DSN = []string{"tcp://dev-metastore-postgres-0:5432"} + c.Spec.Signoz.Status.Addresses.APIServer = []string{"tcp://dev-signoz-0:8080"} + c.Spec.Signoz.Spec.Env = map[string]string{"SIGNOZ_SQLSTORE_SQLITE_PATH": "/var/lib/signoz/signoz.db"} + return c +} + +func TestNewMetaStoreUnsupported(t *testing.T) { + c := installation.Default() + c.Spec.Deployment = v1alpha1.TypeDeployment{Mode: v1alpha1.ModeKubernetes, Flavor: v1alpha1.FlavorHelm} + + _, err := NewMetaStore(&fakeExecutor{}, c) + assert.Error(t, err) +} + +func TestPostgresRuleQuery(t *testing.T) { + exec := &fakeExecutor{out: []byte("019c8af3\x1f{\"alert\":\"High latency\"}\n")} + + store, err := NewMetaStore(exec, dockerCasting(installation.MetaStoreKindPostgres)) + require.NoError(t, err) + + alert, err := store.Rule(context.Background(), "019c8af3") + require.NoError(t, err) + + assert.Equal(t, "019c8af3", alert.ID) + assert.Equal(t, "High latency", alert.Name) + assert.JSONEq(t, `{"alert":"High latency"}`, string(alert.Data)) + + require.Len(t, exec.calls, 1) + assert.Equal(t, "docker", exec.calls[0].name) + assert.Equal(t, []string{ + "exec", "-e", "PGPASSWORD=signoz", "dev-metastore-postgres-0", + "psql", "-U", "signoz", "-d", "signoz", "-tA", "-F", fieldSep, + "-c", "SELECT id, data FROM rule WHERE id = '019c8af3'", + }, exec.calls[0].args) +} + +func TestSQLiteRuleQuery(t *testing.T) { + exec := &fakeExecutor{out: []byte("42\x1f{\"alert\":\"Disk full\"}\n")} + + store, err := NewMetaStore(exec, dockerCasting(installation.MetaStoreKindSQLite)) + require.NoError(t, err) + + alert, err := store.Rule(context.Background(), "42") + require.NoError(t, err) + + assert.Equal(t, "42", alert.ID) + assert.Equal(t, "Disk full", alert.Name) + + require.Len(t, exec.calls, 2) + assert.Equal(t, "docker", exec.calls[0].name) + assert.Equal(t, []string{ + "exec", "-u", "root", "dev-signoz-0", "apk", "add", "--no-cache", "sqlite", + }, exec.calls[0].args) + assert.Equal(t, []string{ + "exec", "dev-signoz-0", + "sqlite3", "-separator", fieldSep, "/var/lib/signoz/signoz.db", + "SELECT id, data FROM rule WHERE id = '42'", + }, exec.calls[1].args) +} + +func TestRuleRejectsUnsafeID(t *testing.T) { + store, err := NewMetaStore(&fakeExecutor{}, dockerCasting(installation.MetaStoreKindPostgres)) + require.NoError(t, err) + + _, err = store.Rule(context.Background(), "1' OR '1'='1") + assert.Error(t, err) +} + +func TestRuleNotFound(t *testing.T) { + exec := &fakeExecutor{out: []byte("\n")} + + store, err := NewMetaStore(exec, dockerCasting(installation.MetaStoreKindPostgres)) + require.NoError(t, err) + + _, err = store.Rule(context.Background(), "missing") + assert.Error(t, err) +} diff --git a/internal/mechanic/telemetrystore.go b/internal/mechanic/telemetrystore.go new file mode 100644 index 00000000..3717f561 --- /dev/null +++ b/internal/mechanic/telemetrystore.go @@ -0,0 +1,125 @@ +package mechanic + +import ( + "context" + "strings" + + "github.com/signoz/foundry/api/v1alpha1" + "github.com/signoz/foundry/api/v1alpha1/installation" + "github.com/signoz/foundry/internal/errors" + "github.com/tidwall/gjson" +) + +// signalTables maps a SigNoz query-builder signal to the ClickHouse table that +// backs it. Per-signal diagnostic queries (added later) run against these. +var signalTables = map[string]string{ + "traces": "signoz_traces.distributed_signoz_index_v3", + "logs": "signoz_logs.distributed_logs_v2", + "metrics": "signoz_metrics.distributed_samples_v4", +} + +// signalPaths are the gjson paths to a rule's builder-query signals within the +// alert data payload. The compositeQuery sits at the root in some rule versions +// and under condition in others; both are tried. +var signalPaths = []string{ + "compositeQuery.queries.#.spec.signal", + "condition.compositeQuery.queries.#.spec.signal", +} + +// TableForSignal returns the ClickHouse table backing a query-builder signal. +func TableForSignal(signal string) (string, error) { + table, ok := signalTables[signal] + if !ok { + return "", errors.Newf(errors.TypeUnsupported, "no clickhouse table mapping for signal %q", signal) + } + return table, nil +} + +// SignalTables extracts the distinct signals an alert's builder queries target +// (from the data payload's compositeQuery) and maps each to its ClickHouse +// table, preserving first-seen order. +func SignalTables(data []byte) ([]string, error) { + seen := make(map[string]struct{}) + var tables []string + + for _, path := range signalPaths { + for _, result := range gjson.GetBytes(data, path).Array() { + signal := result.String() + if signal == "" { + continue + } + + table, err := TableForSignal(signal) + if err != nil { + return nil, err + } + + if _, ok := seen[table]; ok { + continue + } + seen[table] = struct{}{} + tables = append(tables, table) + } + } + + return tables, nil +} + +// TelemetryStore runs read-only queries against a deployment's telemetry store +// (ClickHouse). +type TelemetryStore interface { + // Query runs sql against the telemetry store and returns its trimmed output. + Query(ctx context.Context, sql string) (string, error) +} + +// NewTelemetryStore selects the reach strategy for the deployment's telemetry +// store. Phase 1 supports docker/compose only, executing clickhouse-client +// inside the running ClickHouse container. +func NewTelemetryStore(executor Executor, machinery v1alpha1.Machinery) (TelemetryStore, error) { + c, err := dockerComposeCasting(machinery) + if err != nil { + return nil, err + } + + container, err := firstHost(c.Spec.TelemetryStore.Status.Addresses.TCP) + if err != nil { + return nil, errors.Wrapf(err, errors.TypeNotFound, "telemetrystore address missing from lock, run forge first") + } + + return &dockerClickhouseTelemetryStore{executor: executor, container: container}, nil +} + +// dockerClickhouseTelemetryStore reaches ClickHouse by exec-ing clickhouse-client +// inside the running container. SigNoz provisions the default user with an empty +// password, so no credentials are passed. +type dockerClickhouseTelemetryStore struct { + executor Executor + container string +} + +func (t *dockerClickhouseTelemetryStore) Query(ctx context.Context, sql string) (string, error) { + out, err := t.executor.Output(ctx, "docker", "exec", t.container, + "clickhouse-client", "--query", sql, + ) + if err != nil { + return "", errors.Wrapf(err, errors.TypeInternal, "failed to query telemetrystore via container %q", t.container) + } + + return strings.TrimSpace(string(out)), nil +} + +// dockerComposeCasting asserts the machinery is an Installation deployed via +// docker/compose, the only target mechanic inspect reaches today. +func dockerComposeCasting(machinery v1alpha1.Machinery) (*installation.Casting, error) { + c, ok := machinery.(*installation.Casting) + if !ok { + return nil, errors.Newf(errors.TypeUnsupported, "mechanic inspect supports the Installation kind only, got %q", machinery.Kind()) + } + + deployment := c.Spec.Deployment + if deployment.Mode != v1alpha1.ModeDocker || deployment.Flavor != v1alpha1.FlavorCompose { + return nil, errors.Newf(errors.TypeUnsupported, "mechanic inspect supports docker/compose only, got %s/%s", deployment.Mode, deployment.Flavor) + } + + return c, nil +} diff --git a/internal/mechanic/telemetrystore_test.go b/internal/mechanic/telemetrystore_test.go new file mode 100644 index 00000000..044b839a --- /dev/null +++ b/internal/mechanic/telemetrystore_test.go @@ -0,0 +1,118 @@ +package mechanic + +import ( + "context" + "testing" + + "github.com/signoz/foundry/api/v1alpha1" + "github.com/signoz/foundry/api/v1alpha1/installation" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSignalTables(t *testing.T) { + tests := []struct { + name string + data string + expected []string + pass bool + }{ + { + name: "RootCompositeQuery_Traces", + data: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"traces"}}]}}`, + expected: []string{"signoz_traces.distributed_signoz_index_v3"}, + pass: true, + }, + { + name: "ConditionCompositeQuery_Logs", + data: `{"condition":{"compositeQuery":{"queries":[{"spec":{"signal":"logs"}}]}}}`, + expected: []string{"signoz_logs.distributed_logs_v2"}, + pass: true, + }, + { + name: "Metrics", + data: `{"compositeQuery":{"queries":[{"spec":{"signal":"metrics"}}]}}`, + expected: []string{"signoz_metrics.distributed_samples_v4"}, + pass: true, + }, + { + name: "MultipleSignalsDeduped", + data: `{"compositeQuery":{"queries":[{"spec":{"signal":"traces"}},{"spec":{"signal":"logs"}},{"spec":{"signal":"traces"}}]}}`, + expected: []string{"signoz_traces.distributed_signoz_index_v3", "signoz_logs.distributed_logs_v2"}, + pass: true, + }, + { + name: "NoCompositeQuery", + data: `{"alert":"some name"}`, + expected: nil, + pass: true, + }, + { + name: "UnknownSignal", + data: `{"compositeQuery":{"queries":[{"spec":{"signal":"events"}}]}}`, + pass: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tables, err := SignalTables([]byte(tt.data)) + if !tt.pass { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.expected, tables) + }) + } +} + +func TestTelemetryStoreQuery(t *testing.T) { + c := installation.Default() + c.Spec.Deployment = v1alpha1.TypeDeployment{Mode: v1alpha1.ModeDocker, Flavor: v1alpha1.FlavorCompose} + c.Spec.TelemetryStore.Status.Addresses.TCP = []string{"tcp://dev-telemetrystore-clickhouse-0-0:9000"} + + exec := &fakeExecutor{out: []byte("25.5.6.1\n")} + + store, err := NewTelemetryStore(exec, c) + require.NoError(t, err) + + out, err := store.Query(context.Background(), "SELECT version()") + require.NoError(t, err) + + assert.Equal(t, "25.5.6.1", out) + require.Len(t, exec.calls, 1) + assert.Equal(t, "docker", exec.calls[0].name) + assert.Equal(t, []string{ + "exec", "dev-telemetrystore-clickhouse-0-0", + "clickhouse-client", "--query", "SELECT version()", + }, exec.calls[0].args) +} + +func TestNewTelemetryStoreUnsupported(t *testing.T) { + c := installation.Default() + // default deployment is not docker/compose + _, err := NewTelemetryStore(&fakeExecutor{}, c) + assert.Error(t, err) +} + +func TestNewTelemetryStoreMissingAddress(t *testing.T) { + c := installation.Default() + c.Spec.Deployment = v1alpha1.TypeDeployment{Mode: v1alpha1.ModeDocker, Flavor: v1alpha1.FlavorCompose} + c.Spec.TelemetryStore.Status.Addresses.TCP = nil + + _, err := NewTelemetryStore(&fakeExecutor{}, c) + assert.Error(t, err) +} + +func TestTelemetryStoreQueryError(t *testing.T) { + c := installation.Default() + c.Spec.Deployment = v1alpha1.TypeDeployment{Mode: v1alpha1.ModeDocker, Flavor: v1alpha1.FlavorCompose} + c.Spec.TelemetryStore.Status.Addresses.TCP = []string{"tcp://dev-telemetrystore-clickhouse-0-0:9000"} + + store, err := NewTelemetryStore(&fakeExecutor{err: assert.AnError}, c) + require.NoError(t, err) + + _, err = store.Query(context.Background(), "SELECT version()") + assert.Error(t, err) +}