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
14 changes: 14 additions & 0 deletions automations.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

54 changes: 54 additions & 0 deletions automations_fire.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package flashduty

import (
"context"
"fmt"
"net/http"
"net/url"
"strings"
)

// AutomationFireAPITriggerRequest is the payload accepted by an Automation
// HTTP POST trigger.
type AutomationFireAPITriggerRequest struct {
// Context text passed to this Automation run.
Text string `json:"text,omitempty" toon:"text,omitempty"`
}

// AutomationFireAPITriggerResponse is the result returned by an Automation
// HTTP POST trigger.
type AutomationFireAPITriggerResponse struct {
// Result type. The API-trigger success path returns routine_fire.
Type string `json:"type" toon:"type"`
// Started session ID returned by the API-trigger success path.
SessionID string `json:"session_id" toon:"session_id"`
// Console URL for the started session.
SessionURL string `json:"session_url" toon:"session_url"`
}

// TriggerWriteFire triggers an Automation run through its HTTP POST trigger URL.
//
// This endpoint authenticates with the trigger's one-time bearer token rather
// than the account app_key used by the generated API methods.
//
// API: POST /safari/automation/triggers/{trigger_id}/fire (automation-trigger-write-fire).
func (s *AutomationsService) TriggerWriteFire(ctx context.Context, triggerID, token string, req *AutomationFireAPITriggerRequest) (*AutomationFireAPITriggerResponse, *Response, error) {
triggerID = strings.TrimSpace(triggerID)
if triggerID == "" {
return nil, nil, fmt.Errorf("flashduty: automation trigger_id is required")
}
token = strings.TrimSpace(token)
if token == "" {
return nil, nil, fmt.Errorf("flashduty: automation trigger token is required")
}

path := "/safari/automation/triggers/" + url.PathEscape(triggerID) + "/fire"
out := new(AutomationFireAPITriggerResponse)
resp, err := s.client.doMethodWithoutAppKey(ctx, http.MethodPost, path, req, out, func(httpReq *http.Request) {
httpReq.Header.Set("Authorization", "Bearer "+token)
})
if err != nil {
return nil, resp, err
}
return out, resp, nil
}
54 changes: 54 additions & 0 deletions automations_fire_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package flashduty

import (
"context"
"io"
"net/http"
"strings"
"testing"
)

func TestAutomationTriggerWriteFireUsesBearerToken(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/safari/automation/triggers/trig_abc/fire" {
t.Errorf("path = %s", r.URL.Path)
}
if got := r.URL.Query().Get("app_key"); got != "" {
t.Errorf("app_key must not be sent, got %q", got)
}
if got := r.Header.Get("Authorization"); got != "Bearer tok_secret" {
t.Errorf("Authorization = %q", got)
}
body, _ := io.ReadAll(r.Body)
if !strings.Contains(string(body), `"text":"deployment finished"`) {
t.Errorf("body = %s", body)
}
w.Header().Set("Flashcat-Request-Id", "RIDF")
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, `{"request_id":"RIDF","data":{"type":"routine_fire","session_id":"sess_1","session_url":"/safari/session/sess_1"}}`)
})

out, resp, err := c.Automations.TriggerWriteFire(context.Background(), "trig_abc", "tok_secret", &AutomationFireAPITriggerRequest{Text: "deployment finished"})
if err != nil {
t.Fatalf("TriggerWriteFire error: %v", err)
}
if resp == nil || resp.StatusCode != http.StatusOK || resp.RequestID != "RIDF" {
t.Fatalf("response meta = %+v", resp)
}
if out == nil || out.Type != "routine_fire" || out.SessionID != "sess_1" || out.SessionURL != "/safari/session/sess_1" {
t.Fatalf("decoded response = %+v", out)
}
}

func TestAutomationTriggerWriteFireValidatesInputs(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatal("request should not be sent")
})

if _, _, err := c.Automations.TriggerWriteFire(context.Background(), "", "tok", nil); err == nil {
t.Fatal("expected empty trigger_id error")
}
if _, _, err := c.Automations.TriggerWriteFire(context.Background(), "trig_abc", "", nil); err == nil {
t.Fatal("expected empty token error")
}
}
146 changes: 146 additions & 0 deletions diagnostics_schema_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package flashduty

import (
"encoding/json"
"testing"
)

func TestDiagnoseResponseOmitsAbsentUnionFields(t *testing.T) {
const metricResponse = `{
"schema_version":"2",
"operation":"metric_trends",
"ds_type":"prometheus",
"ds_name":"prod-prometheus",
"query":"up",
"window":{"start":"2026-07-14T06:00:00Z","end":"2026-07-14T07:00:00Z"},
"results":[{
"method":"window_compare",
"window":{"start":"2026-07-14T06:00:00Z","end":"2026-07-14T07:00:00Z"},
"summary":{"series_total":1,"series_analyzed":1,"selected_series_total":0,"series_returned":0,"analysis_truncated":false,"evidence_summary":"No series matched the selection rules."},
"series_evidence":[],
"warnings":[]
}]
}`

var response DiagnoseResponse
if err := json.Unmarshal([]byte(metricResponse), &response); err != nil {
t.Fatalf("unmarshal metric diagnose response: %v", err)
}
encoded, err := json.Marshal(response)
if err != nil {
t.Fatalf("marshal metric diagnose response: %v", err)
}
var output map[string]any
if err := json.Unmarshal(encoded, &output); err != nil {
t.Fatalf("decode marshalled metric response: %v", err)
}
if _, found := output["data_handling"]; found {
t.Fatalf("metric result fabricated data_handling: %s", encoded)
}
result := output["results"].([]any)[0].(map[string]any)
if _, found := result["pattern_evidence"]; found {
t.Fatalf("metric result fabricated pattern_evidence: %s", encoded)
}
if _, found := result["series_evidence"]; !found {
t.Fatalf("metric result lost series_evidence: %s", encoded)
}
summary := result["summary"].(map[string]any)
if _, found := summary["current_sample"]; found {
t.Fatalf("metric result fabricated log summary: %s", encoded)
}
if _, found := summary["series_total"]; !found {
t.Fatalf("metric result lost metric summary: %s", encoded)
}
}

func TestDiagnoseResponseOmitsAbsentMetricEvidenceFields(t *testing.T) {
const metricResponse = `{
"schema_version":"2",
"operation":"metric_trends",
"ds_type":"prometheus",
"ds_name":"prod-prometheus",
"query":"up",
"window":{"start":"2026-07-14T06:00:00Z","end":"2026-07-14T07:00:00Z"},
"results":[{
"method":"window_compare",
"window":{"start":"2026-07-14T06:00:00Z","end":"2026-07-14T07:00:00Z"},
"summary":{"series_total":1,"series_analyzed":1,"selected_series_total":1,"series_returned":1,"analysis_truncated":false,"evidence_summary":"One series changed."},
"series_evidence":[{"labels":{"instance":"api-1"},"observations":["The current average increased."]}],
"warnings":[]
}]
}`

var response DiagnoseResponse
if err := json.Unmarshal([]byte(metricResponse), &response); err != nil {
t.Fatalf("unmarshal metric diagnose response: %v", err)
}
encoded, err := json.Marshal(response)
if err != nil {
t.Fatalf("marshal metric diagnose response: %v", err)
}
var output map[string]any
if err := json.Unmarshal(encoded, &output); err != nil {
t.Fatalf("decode marshalled metric response: %v", err)
}
evidence := output["results"].([]any)[0].(map[string]any)["series_evidence"].([]any)[0].(map[string]any)
for _, field := range []string{"comparison_status", "current_window_stats", "baseline_window_stats"} {
if _, found := evidence[field]; found {
t.Fatalf("metric evidence fabricated %s: %s", field, encoded)
}
}
}

func TestDiagnoseResponseOmitsAbsentLogEvidenceFields(t *testing.T) {
const logResponse = `{
"schema_version":"2",
"operation":"log_patterns",
"ds_type":"elasticsearch",
"ds_name":"prod-logs",
"query":"service:api",
"window":{"start":"2026-07-14T06:00:00Z","end":"2026-07-14T07:00:00Z"},
"results":[{
"method":"pattern_snapshot",
"window":{"start":"2026-07-14T06:00:00Z","end":"2026-07-14T07:00:00Z"},
"summary":{
"current_sample":{"logs_scanned":10,"patterns_aggregated":1,"logs_not_aggregated_due_to_cluster_limit":0,"pattern_matching_limited":false,"truncated":false},
"aggregated_pattern_evidence_total":1,
"pattern_evidence_returned":1,
"pattern_evidence_truncated_by_max_patterns":false,
"evidence_summary":"One pattern was observed."
},
"pattern_evidence":[{"pattern_id":"abc123","pattern_template":"request failed"}],
"warnings":[]
}],
"data_handling":{"log_redaction_applied":true,"log_redaction_coverage":"best_effort","untrusted_data_fields":[]}
}`

var response DiagnoseResponse
if err := json.Unmarshal([]byte(logResponse), &response); err != nil {
t.Fatalf("unmarshal log diagnose response: %v", err)
}
encoded, err := json.Marshal(response)
if err != nil {
t.Fatalf("marshal log diagnose response: %v", err)
}
var output map[string]any
if err := json.Unmarshal(encoded, &output); err != nil {
t.Fatalf("decode marshalled log response: %v", err)
}
result := output["results"].([]any)[0].(map[string]any)
evidence := result["pattern_evidence"].([]any)[0].(map[string]any)
for _, field := range []string{"comparison_status", "current_window", "baseline_window", "observations", "redacted_log_examples"} {
if _, found := evidence[field]; found {
t.Fatalf("log evidence fabricated %s: %s", field, encoded)
}
}
summary := result["summary"].(map[string]any)
for _, field := range []string{"baseline_sample", "patterns_aggregated_only_in_baseline_sample"} {
if _, found := summary[field]; found {
t.Fatalf("log summary fabricated %s: %s", field, encoded)
}
}
currentSample := summary["current_sample"].(map[string]any)
if _, found := currentSample["sampling_bias"]; found {
t.Fatalf("log sample fabricated sampling_bias: %s", encoded)
}
}
25 changes: 21 additions & 4 deletions flashduty.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,14 +97,20 @@ type pageMeta struct {
// parameter and JSON-encoding body when non-nil. Most Flashduty endpoints are
// POST actions; a handful are GET with query parameters.
func (c *Client) newRequest(ctx context.Context, method, path string, body any) (*http.Request, error) {
return c.newRequestWithAppKey(ctx, method, path, body, true)
}

func (c *Client) newRequestWithAppKey(ctx context.Context, method, path string, body any, withAppKey bool) (*http.Request, error) {
rel, err := url.Parse(strings.TrimPrefix(path, "/"))
if err != nil {
return nil, fmt.Errorf("flashduty: invalid path %q: %w", path, err)
}
u := c.BaseURL.ResolveReference(rel)
q := u.Query()
q.Set("app_key", c.appKey)
u.RawQuery = q.Encode()
if withAppKey {
q := u.Query()
q.Set("app_key", c.appKey)
u.RawQuery = q.Encode()
}

var buf io.Reader
var rawBody []byte
Expand Down Expand Up @@ -164,10 +170,21 @@ func (c *Client) doGet(ctx context.Context, path string, opt, out any) (*Respons
// (when non-nil), and returns a Response. A non-nil envelope error or a non-2xx
// status yields an *ErrorResponse (or *RateLimitError on 429).
func (c *Client) doMethod(ctx context.Context, method, path string, body, out any) (*Response, error) {
req, err := c.newRequest(ctx, method, path, body)
return c.doMethodWithAppKey(ctx, method, path, body, out, true, nil)
}

func (c *Client) doMethodWithoutAppKey(ctx context.Context, method, path string, body, out any, configure func(*http.Request)) (*Response, error) {
return c.doMethodWithAppKey(ctx, method, path, body, out, false, configure)
}

func (c *Client) doMethodWithAppKey(ctx context.Context, method, path string, body, out any, withAppKey bool, configure func(*http.Request)) (*Response, error) {
req, err := c.newRequestWithAppKey(ctx, method, path, body, withAppKey)
if err != nil {
return nil, err
}
if configure != nil {
configure(req)
}
httpResp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("flashduty: request to %s failed: %v", sanitizeURL(req.URL), sanitizeError(err))
Expand Down
Loading