Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions cmd/query/connections/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,17 @@ func maskedConnection(c *models.Connection) resolvedConnection {
// testConnection probes the resolved URL: a TCP connect proves the host:port is
// reachable, and http/https URLs additionally report the response status.
func testConnection(ctx dbcontext.Context, c *models.Connection) testResult {
if c.Type == models.ConnectionTypeOpenTelemetry {
openTelemetry, err := dbconnection.NewOpenTelemetry(c)
if err != nil {
return testResult{OK: false, Message: err.Error()}
}
nested, err := openTelemetry.ResolveOpenSearch(ctx)
if err != nil {
return testResult{OK: false, Message: err.Error()}
}
return testConnection(ctx, nested)
}
if c.URL == "" {
return testResult{OK: false, Message: "connection has no URL to test"}
}
Expand Down
12 changes: 12 additions & 0 deletions cmd/query/connections/browser.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,18 @@ func (h *connectionBrowserHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
http.Error(w, err.Error(), http.StatusNotFound)
return
}
if conn.Type == models.ConnectionTypeOpenTelemetry {
openTelemetry, resolveErr := dbconnection.NewOpenTelemetry(conn)
if resolveErr != nil {
http.Error(w, resolveErr.Error(), http.StatusUnprocessableEntity)
return
}
conn, resolveErr = openTelemetry.ResolveOpenSearch(h.ctx)
if resolveErr != nil {
http.Error(w, resolveErr.Error(), http.StatusUnprocessableEntity)
return
}
}
tail = strings.TrimSuffix(tail, "/")

switch {
Expand Down
10 changes: 10 additions & 0 deletions cmd/query/connections/info.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,16 @@ func discoverServer(ctx context.Context, connectionContext dbcontext.Context, co
info, err = discoverSQLServer(ctx, connectionContext, connection)
case models.ConnectionTypeOpenSearch:
info, err = discoverOpenSearch(ctx, connectionContext, connection)
case models.ConnectionTypeOpenTelemetry:
var openTelemetry dbconnection.OpenTelemetry
openTelemetry, err = dbconnection.NewOpenTelemetry(connection)
if err == nil {
var nested *models.Connection
nested, err = openTelemetry.ResolveOpenSearch(connectionContext)
if err == nil {
info, err = discoverOpenSearch(ctx, connectionContext, nested)
}
}
case models.ConnectionTypePrometheus:
info, err = discoverPrometheus(ctx, connectionContext, connection)
case models.ConnectionTypeRedis:
Expand Down
30 changes: 30 additions & 0 deletions cmd/query/connections/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"strings"

"github.com/flanksource/clicky"
dbconnection "github.com/flanksource/commons-db/connection"
dbcontext "github.com/flanksource/commons-db/context"
"github.com/flanksource/commons-db/models"
"github.com/google/uuid"
Expand Down Expand Up @@ -191,6 +192,9 @@ func createConnection(db *gorm.DB, body map[string]any) (*models.Connection, err
if err != nil {
return nil, err
}
if err := validateNestedConnection(db, c); err != nil {
return nil, err
}
c.ID = uuid.Nil // let the DB assign the id
if err := db.Create(c).Error; err != nil {
return nil, fmt.Errorf("create connection: %w", err)
Expand All @@ -209,6 +213,9 @@ func updateConnection(db *gorm.DB, id string, body map[string]any) (*models.Conn
if err != nil {
return nil, err
}
if err := validateNestedConnection(db, incoming); err != nil {
return nil, err
}
applyConnectionUpdate(existing, incoming)
if err := db.Save(existing).Error; err != nil {
return nil, fmt.Errorf("update connection: %w", err)
Expand All @@ -217,6 +224,29 @@ func updateConnection(db *gorm.DB, id string, body map[string]any) (*models.Conn
return existing, nil
}

func validateNestedConnection(db *gorm.DB, candidate *models.Connection) error {
if candidate.Type != models.ConnectionTypeOpenTelemetry {
return nil
}
openTelemetry, err := dbconnection.NewOpenTelemetry(candidate)
if err != nil {
return err
}
name := strings.TrimPrefix(openTelemetry.Connection, "connection://")
if strings.Contains(name, "/") {
parts := strings.Split(name, "/")
name = parts[len(parts)-1]
}
nested, err := findConnection(db, name)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
if err != nil {
return fmt.Errorf("resolve nested OpenSearch connection: %w", err)
}
if nested.Type != models.ConnectionTypeOpenSearch {
return fmt.Errorf("nested connection %q has type %q, expected %q", nested.Name, nested.Type, models.ConnectionTypeOpenSearch)
}
return nil
}

// deleteConnection removes a connection by id, erroring when absent.
func deleteConnection(db *gorm.DB, id string) error {
res := db.Where("id = ?", id).Delete(&models.Connection{})
Expand Down
20 changes: 20 additions & 0 deletions cmd/query/connections/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"testing"

"github.com/flanksource/commons-db/models"
"github.com/flanksource/commons-db/types"
"github.com/google/uuid"
)

Expand Down Expand Up @@ -41,6 +42,25 @@ func TestConnectionFromBodyRequiresNameAndType(t *testing.T) {
}
}

func TestValidateOpenTelemetryRequiresNestedOpenSearchConnection(t *testing.T) {
database := connectionInfoTestDB(t)
search := models.Connection{ID: uuid.New(), Name: "OS", Type: models.ConnectionTypeOpenSearch}
if err := database.Create(&search).Error; err != nil {
t.Fatal(err)
}
candidate := &models.Connection{
Name: "traces", Type: models.ConnectionTypeOpenTelemetry,
Properties: types.JSONStringMap{"connection": "connection://OS"},
}
if err := validateNestedConnection(database, candidate); err != nil {
t.Fatal(err)
}
candidate.Properties["connection"] = "connection://traces"
if err := validateNestedConnection(database, candidate); err == nil {
t.Fatal("expected self-referencing OpenTelemetry connection to fail")
}
}

func TestApplyConnectionUpdatePreservesSecretWhenBlank(t *testing.T) {
existing, _ := connectionFromBody(map[string]any{"name": "db", "type": "postgres", "password": "old"})
incoming, _ := connectionFromBody(map[string]any{"name": "db2", "type": "mysql"}) // no password
Expand Down
12 changes: 7 additions & 5 deletions cmd/query/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ module github.com/flanksource/commons-db/cmd/query
go 1.26.1

require (
github.com/flanksource/clicky v1.21.37-0.20260715094201-d847a9108f43
github.com/flanksource/clicky/aichat v1.21.36
github.com/flanksource/captain v0.0.12-0.20260715131932-f441c766f3fd
github.com/flanksource/clicky v1.21.38-0.20260716031053-78eecd7e914b
github.com/flanksource/clicky/aichat v1.21.38-0.20260716031053-78eecd7e914b
github.com/flanksource/clicky/valkey v1.21.34
github.com/flanksource/commons v1.53.1
github.com/flanksource/commons-db v0.1.15
Expand All @@ -15,6 +16,7 @@ require (
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
github.com/valkey-io/valkey-go v1.0.75
go.yaml.in/yaml/v3 v3.0.4
gorm.io/gorm v1.31.0
k8s.io/api v0.36.1
k8s.io/apimachinery v0.36.1
Expand Down Expand Up @@ -127,8 +129,7 @@ require (
github.com/fatih/color v1.18.0 // indirect
github.com/felixge/httpsnoop v1.1.0 // indirect
github.com/fergusstrange/embedded-postgres v1.34.0 // indirect
github.com/firebase/genkit/go v1.8.0 // indirect
github.com/flanksource/captain v0.0.9 // indirect
github.com/firebase/genkit/go v1.10.0 // indirect
github.com/flanksource/gomplate/v3 v3.24.82 // indirect
github.com/flanksource/is-healthy v1.0.88 // indirect
github.com/flanksource/kubectl-neat v1.0.4 // indirect
Expand Down Expand Up @@ -197,6 +198,7 @@ require (
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/jmespath/go-jmespath v0.4.1-0.20220621161143-b0104c826a24 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect
Expand Down Expand Up @@ -272,6 +274,7 @@ require (
github.com/samber/lo v1.53.0 // indirect
github.com/samber/oops v1.21.0 // indirect
github.com/segmentio/asm v1.2.1 // indirect
github.com/segmentio/encoding v0.5.4 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/shirou/gopsutil/v3 v3.24.5 // indirect
github.com/shoenig/go-m1cpu v0.1.7 // indirect
Expand Down Expand Up @@ -329,7 +332,6 @@ require (
go.opentelemetry.io/otel/trace v1.44.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
gocloud.dev v0.43.0 // indirect
golang.org/x/crypto v0.53.0 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
Expand Down
4 changes: 4 additions & 0 deletions cmd/query/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,8 @@ github.com/jmespath/go-jmespath v0.4.1-0.20220621161143-b0104c826a24 h1:liMMTbpW
github.com/jmespath/go-jmespath v0.4.1-0.20220621161143-b0104c826a24/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE=
Expand Down Expand Up @@ -686,6 +688,8 @@ github.com/samber/oops v1.21.0 h1:18atcO4oEigNFuGXqr3NZWZ6P0XOSEXyBSAMXdQRxTc=
github.com/samber/oops v1.21.0/go.mod h1:Hsm/sKPxtCfPh0w/cE3xVoRfSiE1joDRiStPAsmG9bo=
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
Expand Down
48 changes: 30 additions & 18 deletions cmd/query/internal/app/chat.go
Original file line number Diff line number Diff line change
@@ -1,35 +1,47 @@
package app

import (
"context"
"strings"

"github.com/flanksource/clicky/aichat"
captools "github.com/flanksource/captain/pkg/ai/tools"
capchat "github.com/flanksource/captain/pkg/aichat"
"github.com/flanksource/captain/pkg/api"
clickyaichat "github.com/flanksource/clicky/aichat"
"github.com/spf13/cobra"
)

// newQueryChatServer exposes the same Clicky/Cobra operations that back the
// explorer as in-process AI tools. Provider initialization remains lazy: the
// server can start without an API key and reports the configuration error only
// when chat is used.
func newQueryChatServer(root *cobra.Command) *aichat.Server {
return aichat.NewServer(aichat.Options{
RootCmd: root,
System: "You are a database operations assistant. Use the available tools " +
"to inspect connections, query profiles, and profile results. Prefer tools " +
"over guessing, never invent connection details, and summarize results clearly.",
Threads: aichat.NewMemThreadStore(),
ToolFilter: isQueryChatTool,
ToolApprovalPolicy: queryToolRequiresApproval,
func newQueryChatServer(root *cobra.Command) (*capchat.Service, error) {
provider, err := clickyaichat.NewCobraToolProvider(clickyaichat.CobraToolProviderOptions{
Root: root, Filter: isQueryChatTool, Permission: queryToolPermission,
})
if err != nil {
return nil, err
}
return capchat.NewService(capchat.ServiceOptions{
Settings: capchat.RuntimeSettingsProviderFunc(func(context.Context) (capchat.RuntimeSettings, error) {
return capchat.RuntimeSettings{
Spec: api.Spec{Model: api.Model{Name: "api:sonnet-5"}},
System: "You are a database operations assistant. Use the available tools " +
"to inspect connections, query profiles, and profile results. Prefer tools " +
"over guessing, never invent connection details, and summarize results clearly.",
}, nil
}),
Tools: provider, Threads: capchat.NewMemoryThreadStore(),
}), nil
}

// isQueryChatTool removes query's process-management and long-running
// interactive commands from the tool catalog. Starting another server,
// printing schemas, or blocking on a live trace/top stream is useful on the
// CLI but is not a meaningful operation for the in-app assistant (sessions are
// managed via the REST API instead).
func isQueryChatTool(tool aichat.ToolInfo) bool {
name := strings.ToLower(strings.TrimSpace(tool.OperationName))
func isQueryChatTool(tool captools.ToolInfo) bool {
name := strings.ToLower(strings.TrimSpace(tool.Annotation("clicky/operation")))
if name == "" {
name = strings.ToLower(strings.TrimSpace(tool.Name))
}
Expand All @@ -44,15 +56,15 @@ func isQueryChatTool(tool aichat.ToolInfo) bool {
// queryToolRequiresApproval auto-runs safe HTTP/read verbs and gates everything
// else. The UI may still make a deliberate per-tool On/Ask/Off choice for an
// individual request.
func queryToolRequiresApproval(tool aichat.ToolInfo, _ any) bool {
switch strings.ToUpper(strings.TrimSpace(tool.Method)) {
func queryToolPermission(tool captools.ToolInfo) api.ToolMode {
switch strings.ToUpper(strings.TrimSpace(tool.Annotation("clicky/method"))) {
case "GET", "HEAD", "OPTIONS":
return false
return api.ToolModeOn
}
switch strings.ToLower(strings.TrimSpace(tool.ClickyVerb)) {
switch strings.ToLower(strings.TrimSpace(tool.Annotation("clicky/verb"))) {
case "get", "list":
return false
return api.ToolModeOn
default:
return true
return api.ToolModeAsk
}
}
39 changes: 21 additions & 18 deletions cmd/query/internal/app/chat_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,21 @@ import (
"net/http/httptest"
"testing"

"github.com/flanksource/clicky/aichat"
captools "github.com/flanksource/captain/pkg/ai/tools"
"github.com/flanksource/captain/pkg/api"
"github.com/spf13/cobra"
)

func TestIsQueryChatTool(t *testing.T) {
tests := []struct {
name string
tool aichat.ToolInfo
tool captools.ToolInfo
want bool
}{
{name: "connection list", tool: aichat.ToolInfo{OperationName: "connection"}, want: true},
{name: "dynamic profile", tool: aichat.ToolInfo{Name: "profile-orders"}, want: true},
{name: "serve", tool: aichat.ToolInfo{OperationName: "serve"}, want: false},
{name: "schema fallback name", tool: aichat.ToolInfo{Name: "schema"}, want: false},
{name: "connection list", tool: captools.ToolInfo{Annotations: map[string]string{"clicky/operation": "connection"}}, want: true},
{name: "dynamic profile", tool: captools.ToolInfo{Name: "profile-orders"}, want: true},
{name: "serve", tool: captools.ToolInfo{Annotations: map[string]string{"clicky/operation": "serve"}}, want: false},
{name: "schema fallback name", tool: captools.ToolInfo{Name: "schema"}, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand All @@ -30,22 +31,22 @@ func TestIsQueryChatTool(t *testing.T) {
}
}

func TestQueryToolRequiresApproval(t *testing.T) {
func TestQueryToolPermission(t *testing.T) {
tests := []struct {
name string
tool aichat.ToolInfo
want bool
tool captools.ToolInfo
want api.ToolMode
}{
{name: "get method", tool: aichat.ToolInfo{Method: "GET"}, want: false},
{name: "head method", tool: aichat.ToolInfo{Method: "head"}, want: false},
{name: "list verb", tool: aichat.ToolInfo{ClickyVerb: "list"}, want: false},
{name: "post method", tool: aichat.ToolInfo{Method: "POST"}, want: true},
{name: "unknown defaults safe", tool: aichat.ToolInfo{}, want: true},
{name: "get method", tool: captools.ToolInfo{Annotations: map[string]string{"clicky/method": "GET"}}, want: api.ToolModeOn},
{name: "head method", tool: captools.ToolInfo{Annotations: map[string]string{"clicky/method": "head"}}, want: api.ToolModeOn},
{name: "list verb", tool: captools.ToolInfo{Annotations: map[string]string{"clicky/verb": "list"}}, want: api.ToolModeOn},
{name: "post method", tool: captools.ToolInfo{Annotations: map[string]string{"clicky/method": "POST"}}, want: api.ToolModeAsk},
{name: "unknown defaults safe", tool: captools.ToolInfo{}, want: api.ToolModeAsk},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := queryToolRequiresApproval(tt.tool, nil); got != tt.want {
t.Fatalf("queryToolRequiresApproval(%+v) = %v, want %v", tt.tool, got, tt.want)
if got := queryToolPermission(tt.tool); got != tt.want {
t.Fatalf("queryToolPermission(%+v) = %v, want %v", tt.tool, got, tt.want)
}
})
}
Expand All @@ -63,8 +64,10 @@ func TestQueryChatServerCatalogFiltersProcessCommands(t *testing.T) {
&cobra.Command{Use: "serve", Run: func(*cobra.Command, []string) {}},
&cobra.Command{Use: "schema", Run: func(*cobra.Command, []string) {}},
)
chat := newQueryChatServer(root)
t.Cleanup(func() { _ = chat.Close() })
chat, err := newQueryChatServer(root)
if err != nil {
t.Fatalf("newQueryChatServer: %v", err)
}

req := httptest.NewRequest(http.MethodGet, "/api/chat/tools", nil)
res := httptest.NewRecorder()
Expand Down
Loading
Loading