Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,9 @@ OpsOrch uses structured expressions for querying logs and metrics, replacing fre
}
```

### Provider Deep Links
Normalized resources now carry an optional `url` deep link back to the upstream system. Adapters should populate this string whenever the provider exposes a canonical UI link so OpsOrch clients can jump directly to the source incident, alert, log view, metric chart, ticket, team, service, or message. The field is passthrough only—OpsOrch does not generate, log, or modify these URLs—so adapters remain responsible for ensuring they do not leak secrets.

### Adapter Architecture
OpsOrch Core contains **no provider logic**.
Adapters implement capability interfaces in their own repos and register with the registry.
Expand Down
80 changes: 67 additions & 13 deletions api/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,27 +21,38 @@ import (
"github.com/opsorch/opsorch-core/service"
)

const (
incidentBaseURL = "https://incidents.test/"
alertBaseURL = "https://alerts.test/"
logEntryURL = "https://logs.test/stream"
metricSeriesURL = "https://metrics.test/series/cpu"
metricDescriptorURL = "https://metrics.test/describe/cpu"
ticketBaseURL = "https://tickets.test/"
messagingResultURL = "https://messages.test/m1"
serviceURL = "https://services.test/svc1"
)

// stubIncidentProvider implements incident.Provider for tests.
type stubIncidentProvider struct{}

func (s stubIncidentProvider) Query(ctx context.Context, query schema.IncidentQuery) ([]schema.Incident, error) {
res := []schema.Incident{{ID: "1", Title: "test", CreatedAt: time.Now(), UpdatedAt: time.Now()}}
res := []schema.Incident{{ID: "1", Title: "test", URL: incidentBaseURL + "1", CreatedAt: time.Now(), UpdatedAt: time.Now()}}
if query.Limit > 0 && query.Limit < len(res) {
return res[:query.Limit], nil
}
return res, nil
}

func (s stubIncidentProvider) Get(ctx context.Context, id string) (schema.Incident, error) {
return schema.Incident{ID: id, Title: "test", CreatedAt: time.Now(), UpdatedAt: time.Now()}, nil
return schema.Incident{ID: id, Title: "test", URL: incidentBaseURL + id, CreatedAt: time.Now(), UpdatedAt: time.Now()}, nil
}

func (s stubIncidentProvider) Create(ctx context.Context, in schema.CreateIncidentInput) (schema.Incident, error) {
return schema.Incident{ID: "new", Title: in.Title, CreatedAt: time.Now(), UpdatedAt: time.Now()}, nil
return schema.Incident{ID: "new", Title: in.Title, URL: incidentBaseURL + "new", CreatedAt: time.Now(), UpdatedAt: time.Now()}, nil
}

func (s stubIncidentProvider) Update(ctx context.Context, id string, in schema.UpdateIncidentInput) (schema.Incident, error) {
return schema.Incident{ID: id, Title: derefString(in.Title, "test"), CreatedAt: time.Now(), UpdatedAt: time.Now()}, nil
return schema.Incident{ID: id, Title: derefString(in.Title, "test"), URL: incidentBaseURL + id, CreatedAt: time.Now(), UpdatedAt: time.Now()}, nil
}

func (s stubIncidentProvider) GetTimeline(ctx context.Context, id string) ([]schema.TimelineEntry, error) {
Expand All @@ -62,21 +73,21 @@ func derefString(s *string, fallback string) string {
type stubAlertProvider struct{}

func (s stubAlertProvider) Query(ctx context.Context, query schema.AlertQuery) ([]schema.Alert, error) {
res := []schema.Alert{{ID: "a1", Title: "test alert", Status: "firing", Severity: "critical", CreatedAt: time.Now(), UpdatedAt: time.Now()}}
res := []schema.Alert{{ID: "a1", Title: "test alert", Status: "firing", Severity: "critical", URL: alertBaseURL + "a1", CreatedAt: time.Now(), UpdatedAt: time.Now()}}
if query.Limit > 0 && query.Limit < len(res) {
return res[:query.Limit], nil
}
return res, nil
}

func (s stubAlertProvider) Get(ctx context.Context, id string) (schema.Alert, error) {
return schema.Alert{ID: id, Title: "test alert", Status: "firing", Severity: "critical", CreatedAt: time.Now(), UpdatedAt: time.Now()}, nil
return schema.Alert{ID: id, Title: "test alert", Status: "firing", Severity: "critical", URL: alertBaseURL + id, CreatedAt: time.Now(), UpdatedAt: time.Now()}, nil
}

type stubLogProvider struct{}

func (s stubLogProvider) Query(ctx context.Context, q schema.LogQuery) ([]schema.LogEntry, error) {
return []schema.LogEntry{{Timestamp: time.Now(), Message: "log-entry"}}, nil
return []schema.LogEntry{{Timestamp: time.Now(), Message: "log-entry", URL: logEntryURL}}, nil
}

type stubMetricProvider struct{}
Expand All @@ -85,6 +96,7 @@ func (s stubMetricProvider) Query(ctx context.Context, q schema.MetricQuery) ([]
return []schema.MetricSeries{{
Name: "cpu",
Points: []schema.MetricPoint{{Timestamp: time.Now(), Value: 1.0}},
URL: metricSeriesURL,
}}, nil
}

Expand All @@ -94,43 +106,44 @@ func (s stubMetricProvider) Describe(ctx context.Context, scope schema.QueryScop
Type: "gauge",
Description: "CPU usage",
Labels: []string{"host"},
URL: metricDescriptorURL,
}}, nil
}

type stubTicketProvider struct{}

func (s stubTicketProvider) Query(ctx context.Context, query schema.TicketQuery) ([]schema.Ticket, error) {
now := time.Now()
return []schema.Ticket{{ID: "t1", Title: "ticket", Status: "open", CreatedAt: now, UpdatedAt: now}}, nil
return []schema.Ticket{{ID: "t1", Title: "ticket", Status: "open", URL: ticketBaseURL + "t1", CreatedAt: now, UpdatedAt: now}}, nil
}

func (s stubTicketProvider) Get(ctx context.Context, id string) (schema.Ticket, error) {
now := time.Now()
return schema.Ticket{ID: id, Title: "ticket", Status: "open", CreatedAt: now, UpdatedAt: now}, nil
return schema.Ticket{ID: id, Title: "ticket", Status: "open", URL: ticketBaseURL + id, CreatedAt: now, UpdatedAt: now}, nil
}

func (s stubTicketProvider) Create(ctx context.Context, in schema.CreateTicketInput) (schema.Ticket, error) {
now := time.Now()
return schema.Ticket{ID: "new", Title: in.Title, Status: "open", CreatedAt: now, UpdatedAt: now}, nil
return schema.Ticket{ID: "new", Title: in.Title, Status: "open", URL: ticketBaseURL + "new", CreatedAt: now, UpdatedAt: now}, nil
}

func (s stubTicketProvider) Update(ctx context.Context, id string, in schema.UpdateTicketInput) (schema.Ticket, error) {
now := time.Now()
title := derefString(in.Title, "ticket")
status := derefString(in.Status, "open")
return schema.Ticket{ID: id, Title: title, Status: status, CreatedAt: now, UpdatedAt: now}, nil
return schema.Ticket{ID: id, Title: title, Status: status, URL: ticketBaseURL + id, CreatedAt: now, UpdatedAt: now}, nil
}

type stubMessagingProvider struct{}

func (s stubMessagingProvider) Send(ctx context.Context, msg schema.Message) (schema.MessageResult, error) {
return schema.MessageResult{ID: "m1", Channel: msg.Channel, Metadata: msg.Metadata, SentAt: time.Now()}, nil
return schema.MessageResult{ID: "m1", Channel: msg.Channel, URL: messagingResultURL, Metadata: msg.Metadata, SentAt: time.Now()}, nil
}

type stubServiceProvider struct{}

func (s stubServiceProvider) Query(ctx context.Context, q schema.ServiceQuery) ([]schema.Service, error) {
return []schema.Service{{ID: "svc1", Name: "Service 1"}}, nil
return []schema.Service{{ID: "svc1", Name: "Service 1", URL: serviceURL}}, nil
}

type memorySecret struct {
Expand Down Expand Up @@ -282,6 +295,9 @@ func TestIncidentQuery(t *testing.T) {
if len(out) != 1 || out[0].ID != "1" {
t.Fatalf("unexpected incident response: %+v", out)
}
if out[0].URL != incidentBaseURL+"1" {
t.Fatalf("expected incident url %s1, got %s", incidentBaseURL, out[0].URL)
}
}

func TestIncidentQueryViaPlugin(t *testing.T) {
Expand Down Expand Up @@ -437,6 +453,9 @@ func TestLogQuery(t *testing.T) {
if len(out) != 1 || out[0].Message != "log-entry" {
t.Fatalf("unexpected log response: %+v", out)
}
if out[0].URL != logEntryURL {
t.Fatalf("expected log url %s, got %s", logEntryURL, out[0].URL)
}
}

func TestMetricQuery(t *testing.T) {
Expand All @@ -457,6 +476,9 @@ func TestMetricQuery(t *testing.T) {
if len(out) != 1 || out[0].Name != "cpu" {
t.Fatalf("unexpected metric response: %+v", out)
}
if out[0].URL != metricSeriesURL {
t.Fatalf("expected metric url %s, got %s", metricSeriesURL, out[0].URL)
}
}

func TestMetricDescribe(t *testing.T) {
Expand All @@ -476,6 +498,9 @@ func TestMetricDescribe(t *testing.T) {
if len(out["metrics"]) != 1 || out["metrics"][0].Name != "cpu" {
t.Fatalf("unexpected metric describe response: %+v", out)
}
if out["metrics"][0].URL != metricDescriptorURL {
t.Fatalf("expected metric descriptor url %s, got %s", metricDescriptorURL, out["metrics"][0].URL)
}
}

func TestServiceQuery(t *testing.T) {
Expand All @@ -488,6 +513,13 @@ func TestServiceQuery(t *testing.T) {
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var services []schema.Service
if err := json.NewDecoder(w.Body).Decode(&services); err != nil {
t.Fatalf("decode services: %v", err)
}
if len(services) != 1 || services[0].URL != serviceURL {
t.Fatalf("unexpected service response: %+v", services)
}
}

func TestTicketCreateAndGet(t *testing.T) {
Expand All @@ -507,6 +539,9 @@ func TestTicketCreateAndGet(t *testing.T) {
if len(tickets) != 1 || tickets[0].ID != "t1" {
t.Fatalf("unexpected ticket list: %+v", tickets)
}
if tickets[0].URL != ticketBaseURL+"t1" {
t.Fatalf("expected ticket url %st1, got %s", ticketBaseURL, tickets[0].URL)
}

body, _ := json.Marshal(schema.CreateTicketInput{Title: "t"})
req := httptest.NewRequest(http.MethodPost, "/tickets", bytes.NewReader(body))
Expand All @@ -515,6 +550,13 @@ func TestTicketCreateAndGet(t *testing.T) {
if w.Code != http.StatusCreated {
t.Fatalf("create expected 201, got %d", w.Code)
}
var created schema.Ticket
if err := json.NewDecoder(w.Body).Decode(&created); err != nil {
t.Fatalf("decode created ticket: %v", err)
}
if created.URL != ticketBaseURL+"new" {
t.Fatalf("expected created ticket url %snew, got %s", ticketBaseURL, created.URL)
}

req2 := httptest.NewRequest(http.MethodGet, "/tickets/abc", nil)
w2 := httptest.NewRecorder()
Expand All @@ -529,6 +571,9 @@ func TestTicketCreateAndGet(t *testing.T) {
if tkt.ID != "abc" {
t.Fatalf("unexpected ticket id: %s", tkt.ID)
}
if tkt.URL != ticketBaseURL+"abc" {
t.Fatalf("expected ticket url %sabc, got %s", ticketBaseURL, tkt.URL)
}
}

func TestMessagingSend(t *testing.T) {
Expand All @@ -547,6 +592,9 @@ func TestMessagingSend(t *testing.T) {
if res.ID != "m1" || res.Channel != "c" {
t.Fatalf("unexpected messaging response: %+v", res)
}
if res.URL != messagingResultURL {
t.Fatalf("expected messaging url %s, got %s", messagingResultURL, res.URL)
}
}

func TestMissingProvidersReturn501(t *testing.T) {
Expand Down Expand Up @@ -656,6 +704,9 @@ func TestAlertQuery(t *testing.T) {
if len(out) != 1 || out[0].ID != "a1" {
t.Fatalf("unexpected alert response: %+v", out)
}
if out[0].URL != alertBaseURL+"a1" {
t.Fatalf("expected alert url %sa1, got %s", alertBaseURL, out[0].URL)
}
}

func TestAlertGet(t *testing.T) {
Expand All @@ -676,6 +727,9 @@ func TestAlertGet(t *testing.T) {
if out.ID != "abc" {
t.Fatalf("unexpected alert ID: %s", out.ID)
}
if out.URL != alertBaseURL+"abc" {
t.Fatalf("expected alert url %sabc, got %s", alertBaseURL, out.URL)
}
}

func TestAlertMissingProvider(t *testing.T) {
Expand Down
11 changes: 11 additions & 0 deletions api/team_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ func TestTeamProperty_TeamQueryBodyProcessing(t *testing.T) {
ID: "team-1",
Name: query.Name,
Tags: query.Tags,
URL: fmt.Sprintf("https://teams.test/%s", query.Name),
},
}
return teams, nil
Expand Down Expand Up @@ -282,6 +283,10 @@ func TestTeamProperty_TeamQueryBodyProcessing(t *testing.T) {
if tc.query.Name != "" && teams[0].Name != tc.query.Name {
t.Errorf("expected name %s, got %s", tc.query.Name, teams[0].Name)
}
expectedURL := fmt.Sprintf("https://teams.test/%s", tc.query.Name)
if teams[0].URL != expectedURL {
t.Errorf("expected team url %s, got %s", expectedURL, teams[0].URL)
}
})
}
}
Expand All @@ -301,6 +306,7 @@ func TestTeamProperty_TeamIDRetrieval(t *testing.T) {
ID: "team-123",
Name: "Backend Team",
Parent: "engineering",
URL: "https://teams.test/team-123",
Tags: map[string]string{"department": "engineering"},
},
expectError: false,
Expand All @@ -311,6 +317,7 @@ func TestTeamProperty_TeamIDRetrieval(t *testing.T) {
mockTeam: schema.Team{
ID: "team-abc-123_def",
Name: "Platform Team",
URL: "https://teams.test/team-abc-123_def",
},
expectError: false,
},
Expand All @@ -320,6 +327,7 @@ func TestTeamProperty_TeamIDRetrieval(t *testing.T) {
mockTeam: schema.Team{
ID: "very-long-team-id-with-many-characters-12345678901234567890",
Name: "Data Science Team",
URL: "https://teams.test/very-long-team-id-with-many-characters-12345678901234567890",
},
expectError: false,
},
Expand Down Expand Up @@ -370,6 +378,9 @@ func TestTeamProperty_TeamIDRetrieval(t *testing.T) {
if team.Parent != tc.mockTeam.Parent {
t.Errorf("expected parent %s, got %s", tc.mockTeam.Parent, team.Parent)
}
if team.URL != tc.mockTeam.URL {
t.Errorf("expected team url %s, got %s", tc.mockTeam.URL, team.URL)
}
}
})
}
Expand Down
1 change: 1 addition & 0 deletions schema/alert.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ type Alert struct {
Status string `json:"status"`
Severity string `json:"severity"`
Service string `json:"service,omitempty"`
URL string `json:"url,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`

Expand Down
1 change: 1 addition & 0 deletions schema/incident.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ type Incident struct {
Status string `json:"status"`
Severity string `json:"severity"`
Service string `json:"service,omitempty"`
URL string `json:"url,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
Fields map[string]any `json:"fields,omitempty"`
Expand Down
1 change: 1 addition & 0 deletions schema/log.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ type LogEntry struct {
Message string `json:"message"`
Severity string `json:"severity,omitempty"`
Service string `json:"service,omitempty"`
URL string `json:"url,omitempty"`
Labels map[string]string `json:"labels,omitempty"` // filterable
Fields map[string]any `json:"fields,omitempty"` // structured JSON
Metadata map[string]any `json:"metadata,omitempty"` // provider-specific
Expand Down
1 change: 1 addition & 0 deletions schema/messaging.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,6 @@ type MessageResult struct {
ID string `json:"id"`
Channel string `json:"channel"`
SentAt time.Time `json:"sentAt"`
URL string `json:"url,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
2 changes: 2 additions & 0 deletions schema/metric.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ type MetricDescriptor struct {
Description string `json:"description"` // Human-readable description
Labels []string `json:"labels"` // Available label keys
Unit string `json:"unit,omitempty"` // "bytes", "seconds", "requests"
URL string `json:"url,omitempty"` // Upstream link to the metric (e.g. Prometheus expression browser)
Metadata map[string]any `json:"metadata,omitempty"` // Provider-specific metadata
}

Expand All @@ -59,6 +60,7 @@ type MetricSeries struct {
Service string `json:"service,omitempty"`
Labels map[string]any `json:"labels,omitempty"`
Points []MetricPoint `json:"points"`
URL string `json:"url,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}

Expand Down
3 changes: 3 additions & 0 deletions schema/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ type Service struct {
// Providers may map this from title, name, alias, or other upstream fields.
Name string `json:"name"`

// URL is the deep link to the service in the provider's UI.
URL string `json:"url,omitempty"`

// Tags contains normalized key/value tags for filtering and correlation.
// Providers flatten their native tag/label formats into string pairs.
Tags map[string]string `json:"tags,omitempty"`
Expand Down
3 changes: 3 additions & 0 deletions schema/team.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ type Team struct {
// value if you need the parent details.
Parent string `json:"parent,omitempty"`

// URL is the deep link to the team in the provider's UI.
URL string `json:"url,omitempty"`

// Tags contains normalized key/value tags for filtering and correlation.
Tags map[string]string `json:"tags,omitempty"`

Expand Down
1 change: 1 addition & 0 deletions schema/ticket.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ type Ticket struct {
Status string `json:"status"`
Assignees []string `json:"assignees,omitempty"`
Reporter string `json:"reporter,omitempty"`
URL string `json:"url,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
Fields map[string]any `json:"fields,omitempty"`
Expand Down
Loading