diff --git a/dialtesting/netpath.go b/dialtesting/netpath.go new file mode 100644 index 00000000..4e824ba8 --- /dev/null +++ b/dialtesting/netpath.go @@ -0,0 +1,487 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the MIT License. +// This product includes software developed at Guance Cloud (https://www.guance.com/). +// Copyright 2021-present Guance, Inc. + +package dialtesting + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "maps" + "net" + "strconv" + "strings" + "sync" + "text/template" + "time" +) + +const ( + netPathMetricName = "netpath_dial_testing" + defaultNetPathTimeout = time.Second + maxNetPathTimeout = 30 * time.Second + defaultNetPathTTL = 30 + maxNetPathTTL = 60 + maxUDPNetPathTTL = 255 + defaultNetPathE2EQueries = 10 + maxNetPathE2EQueries = 100 + defaultNetPathTracerouteQueries = 3 + maxNetPathTracerouteQueries = 10 +) + +// NetPathAdvanceOptions contains optional NetPath probe settings. +type NetPathAdvanceOptions struct { + Timeout string `json:"timeout"` + SourceName string `json:"source_name,omitempty"` + TargetName string `json:"target_name,omitempty"` + MaxTTL int `json:"max_ttl,omitempty"` + E2EQueries int `json:"e2e_queries,omitempty"` + TracerouteQueries int `json:"traceroute_queries,omitempty"` +} + +// NetPathCondition describes one result assertion in a task definition. +type NetPathCondition struct { + Op string `json:"op"` + Target json.RawMessage `json:"target"` +} + +// NetPathSuccess contains the assertions supported by a NetPath task. +type NetPathSuccess struct { + E2ERTTAvg []*NetPathCondition `json:"e2e_rtt_avg,omitempty"` + E2ERTTMin []*NetPathCondition `json:"e2e_rtt_min,omitempty"` + E2ERTTMax []*NetPathCondition `json:"e2e_rtt_max,omitempty"` + E2ERTTVariationAvg []*NetPathCondition `json:"e2e_rtt_variation_avg,omitempty"` + E2ERTTVariationMax []*NetPathCondition `json:"e2e_rtt_variation_max,omitempty"` + E2EProbeLossPercent []*NetPathCondition `json:"e2e_probe_loss_percent,omitempty"` + HopCount []*NetPathCondition `json:"hop_count,omitempty"` + E2EStatus []*NetPathCondition `json:"e2e_status,omitempty"` + TracerouteStatus []*NetPathCondition `json:"traceroute_status,omitempty"` +} + +// NetPathProbeConfig is the normalized task configuration passed to the executor. +type NetPathProbeConfig struct { + ID string + Name string + Host string + Port uint16 + Protocol string + Timeout time.Duration + MaxTTL int + TracerouteQueries int + E2EQueries int + ScheduledAt time.Time +} + +// NetPathExecutor lets the caller own probe execution and result processing. +type NetPathExecutor interface { + Run(context.Context, NetPathProbeConfig) error + Clear() + CheckResult() ([]string, bool) + GetResults() (map[string]string, map[string]any) + SetError(string) +} + +// NetPathTask implements the common dialtesting task lifecycle for NetPath. +type NetPathTask struct { + *Task + + Protocol string `json:"protocol"` + Host string `json:"host"` + Port string `json:"port,omitempty"` + AdvanceOptions NetPathAdvanceOptions `json:"advance_options"` + SuccessWhen []*NetPathSuccess `json:"success_when"` + SuccessWhenLogic string `json:"success_when_logic,omitempty"` + + rawTask *NetPathTask + executor NetPathExecutor + + mu sync.Mutex + cancel context.CancelFunc +} + +var ( + _ TaskChild = (*NetPathTask)(nil) + _ ITask = (*NetPathTask)(nil) +) + +// SetExecutor sets the caller-owned executor used when the task runs. +func (t *NetPathTask) SetExecutor(executor NetPathExecutor) { + t.mu.Lock() + t.executor = executor + t.mu.Unlock() +} + +func (t *NetPathTask) init() error { + if strings.EqualFold(t.CurStatus, StatusStop) { + return nil + } + + config, err := t.probeConfig(time.Time{}) + if err != nil { + return err + } + if err := validateNetPathProbeConfig(config); err != nil { + return err + } + return t.validateAssertions() +} + +func (t *NetPathTask) check() error { + if strings.TrimSpace(t.Host) == "" { + return errors.New("host should not be empty") + } + + protocol := strings.ToLower(strings.TrimSpace(t.Protocol)) + switch protocol { + case "tcp", "udp", "icmp": + default: + return fmt.Errorf("unsupported protocol %q", t.Protocol) + } + + if ip := net.ParseIP(strings.TrimSpace(t.Host)); ip != nil && ip.To4() == nil { + return errors.New("IPv6 host is not supported") + } + + if protocol == "icmp" { + if strings.TrimSpace(t.Port) != "" { + return errors.New("port must be omitted for ICMP") + } + return nil + } + + port, err := strconv.ParseUint(strings.TrimSpace(t.Port), 10, 16) + if err != nil || port == 0 { + return errors.New("port must be between 1 and 65535") + } + return nil +} + +func (t *NetPathTask) run() error { + executor := t.getExecutor() + if executor == nil { + return errors.New("netpath executor is not configured") + } + + config, err := t.probeConfig(time.Now()) + if err != nil { + return err + } + + ctx, cancel := context.WithCancel(context.Background()) + t.mu.Lock() + t.cancel = cancel + t.mu.Unlock() + defer func() { + cancel() + t.mu.Lock() + t.cancel = nil + t.mu.Unlock() + }() + + if err := executor.Run(ctx, config); err != nil { + executor.SetError(err.Error()) + } + return nil +} + +func (t *NetPathTask) stop() { + t.mu.Lock() + cancel := t.cancel + t.cancel = nil + t.mu.Unlock() + if cancel != nil { + cancel() + } +} + +func (t *NetPathTask) clear() { + if executor := t.getExecutor(); executor != nil { + executor.Clear() + } +} + +func (t *NetPathTask) checkResult() ([]string, bool) { + executor := t.getExecutor() + if executor == nil { + return []string{"netpath executor is not configured"}, false + } + reasons, success := executor.CheckResult() + return append([]string(nil), reasons...), success +} + +func (t *NetPathTask) getResults() (map[string]string, map[string]any) { + executor := t.getExecutor() + if executor == nil { + return map[string]string{}, map[string]any{} + } + tags, fields := executor.GetResults() + return maps.Clone(tags), maps.Clone(fields) +} + +func (t *NetPathTask) getExecutor() NetPathExecutor { + t.mu.Lock() + defer t.mu.Unlock() + return t.executor +} + +func (t *NetPathTask) getVariableValue(Variable) (string, error) { + return "", errors.New("NETPATH task does not extract variables") +} + +func (t *NetPathTask) class() string { + return ClassNetPath +} + +func (t *NetPathTask) metricName() string { + return netPathMetricName +} + +func (t *NetPathTask) getHostName() ([]string, error) { + host := strings.TrimSpace(t.Host) + if host == "" { + return nil, errors.New("host should not be empty") + } + return []string{host}, nil +} + +func (t *NetPathTask) getRawTask(taskString string) (string, error) { + task := NetPathTask{} + if err := json.Unmarshal([]byte(taskString), &task); err != nil { + return "", fmt.Errorf("unmarshal NETPATH task failed: %w", err) + } + task.Task = nil + + data, err := json.Marshal(&task) + if err != nil { + return "", fmt.Errorf("marshal NETPATH task failed: %w", err) + } + return string(data), nil +} + +func (t *NetPathTask) renderTemplate(fm template.FuncMap) error { + if t.rawTask == nil { + rawTask := &NetPathTask{} + if err := t.NewRawTask(rawTask); err != nil { + return fmt.Errorf("new raw NETPATH task failed: %w", err) + } + rawTask.Task = nil + t.rawTask = rawTask + } + + raw, err := json.Marshal(t.rawTask) + if err != nil { + return fmt.Errorf("marshal raw NETPATH task failed: %w", err) + } + rendered, err := t.GetParsedString(string(raw), fm) + if err != nil { + return fmt.Errorf("render NETPATH task failed: %w", err) + } + + task := &NetPathTask{} + if err := json.Unmarshal([]byte(rendered), task); err != nil { + return fmt.Errorf("unmarshal rendered NETPATH task failed: %w", err) + } + t.Protocol = task.Protocol + t.Host = task.Host + t.Port = task.Port + t.AdvanceOptions = task.AdvanceOptions + t.SuccessWhen = task.SuccessWhen + t.SuccessWhenLogic = task.SuccessWhenLogic + return nil +} + +func (t *NetPathTask) initTask() { + if t.Task == nil { + t.Task = &Task{} + } +} + +func (t *NetPathTask) setReqError(message string) { + if executor := t.getExecutor(); executor != nil { + executor.SetError(message) + } +} + +func (t *NetPathTask) probeConfig(scheduledAt time.Time) (NetPathProbeConfig, error) { + port := uint64(0) + var err error + if strings.TrimSpace(t.Port) != "" { + port, err = strconv.ParseUint(strings.TrimSpace(t.Port), 10, 16) + if err != nil || port == 0 { + return NetPathProbeConfig{}, errors.New("port must be between 1 and 65535") + } + } + + timeout := defaultNetPathTimeout + if value := strings.TrimSpace(t.AdvanceOptions.Timeout); value != "" { + timeout, err = time.ParseDuration(value) + if err != nil { + return NetPathProbeConfig{}, fmt.Errorf("invalid timeout: %w", err) + } + } + maxTTL := t.AdvanceOptions.MaxTTL + if maxTTL == 0 { + maxTTL = defaultNetPathTTL + } + e2eQueries := t.AdvanceOptions.E2EQueries + if e2eQueries == 0 { + e2eQueries = defaultNetPathE2EQueries + } + tracerouteQueries := t.AdvanceOptions.TracerouteQueries + if tracerouteQueries == 0 { + tracerouteQueries = defaultNetPathTracerouteQueries + } + + return NetPathProbeConfig{ + ID: t.GetExternalID(), + Name: t.Name, + Host: strings.TrimSpace(t.Host), + Port: uint16(port), + Protocol: strings.ToLower(strings.TrimSpace(t.Protocol)), + Timeout: timeout, + MaxTTL: maxTTL, + TracerouteQueries: tracerouteQueries, + E2EQueries: e2eQueries, + ScheduledAt: scheduledAt, + }, nil +} + +func validateNetPathProbeConfig(config NetPathProbeConfig) error { + if config.Timeout < time.Second || config.Timeout > maxNetPathTimeout { + return fmt.Errorf("timeout must be between 1s and %s", maxNetPathTimeout) + } + + ttlLimit := maxNetPathTTL + if config.Protocol == "udp" { + ttlLimit = maxUDPNetPathTTL + } + if config.MaxTTL < 1 || config.MaxTTL > ttlLimit { + return fmt.Errorf("max_ttl must be between 1 and %d", ttlLimit) + } + if config.TracerouteQueries < 1 || config.TracerouteQueries > maxNetPathTracerouteQueries { + return fmt.Errorf("traceroute_queries must be between 1 and %d", maxNetPathTracerouteQueries) + } + if config.E2EQueries < 1 || config.E2EQueries > maxNetPathE2EQueries { + return fmt.Errorf("e2e_queries must be between 1 and %d", maxNetPathE2EQueries) + } + return nil +} + +func (t *NetPathTask) validateAssertions() error { + if len(t.SuccessWhen) == 0 { + return errors.New("success_when is required") + } + + logic := strings.ToLower(strings.TrimSpace(t.SuccessWhenLogic)) + if logic != "" && logic != "and" && logic != "or" { + return errors.New("success_when_logic must be and or or") + } + + count := 0 + for _, success := range t.SuccessWhen { + if success == nil { + continue + } + for _, assertions := range success.assertions() { + for _, condition := range assertions.conditions { + count++ + if err := validateNetPathCondition(assertions.field, condition); err != nil { + return err + } + } + } + } + if count == 0 { + return errors.New("success_when must contain at least one assertion") + } + return nil +} + +type netPathAssertions struct { + field string + conditions []*NetPathCondition +} + +func (s *NetPathSuccess) assertions() []netPathAssertions { + return []netPathAssertions{ + {field: "e2e_rtt_avg", conditions: s.E2ERTTAvg}, + {field: "e2e_rtt_min", conditions: s.E2ERTTMin}, + {field: "e2e_rtt_max", conditions: s.E2ERTTMax}, + {field: "e2e_rtt_variation_avg", conditions: s.E2ERTTVariationAvg}, + {field: "e2e_rtt_variation_max", conditions: s.E2ERTTVariationMax}, + {field: "e2e_probe_loss_percent", conditions: s.E2EProbeLossPercent}, + {field: "hop_count", conditions: s.HopCount}, + {field: "e2e_status", conditions: s.E2EStatus}, + {field: "traceroute_status", conditions: s.TracerouteStatus}, + } +} + +func validateNetPathCondition(field string, condition *NetPathCondition) error { + if condition == nil { + return fmt.Errorf("%s assertion is null", field) + } + + op := strings.ToLower(strings.TrimSpace(condition.Op)) + if isNetPathStatusField(field) { + if op != "eq" { + return fmt.Errorf("%s only supports eq", field) + } + if _, err := condition.stringTarget(); err != nil { + return fmt.Errorf("%s: %w", field, err) + } + return nil + } + + switch op { + case "eq", "lt", "leq", "gt", "geq": + default: + return fmt.Errorf("%s has unsupported operator %q", field, condition.Op) + } + if isNetPathDurationField(field) { + if _, err := condition.durationTarget(); err != nil { + return fmt.Errorf("%s: %w", field, err) + } + } else if _, err := condition.numberTarget(); err != nil { + return fmt.Errorf("%s: %w", field, err) + } + return nil +} + +func isNetPathStatusField(field string) bool { + return field == "e2e_status" || field == "traceroute_status" +} + +func isNetPathDurationField(field string) bool { + return strings.HasPrefix(field, "e2e_rtt") +} + +func (c *NetPathCondition) stringTarget() (string, error) { + var target string + if err := json.Unmarshal(c.Target, &target); err != nil || strings.TrimSpace(target) == "" { + return "", errors.New("target must be a non-empty string") + } + return target, nil +} + +func (c *NetPathCondition) durationTarget() (time.Duration, error) { + target, err := c.stringTarget() + if err != nil { + return 0, err + } + duration, err := time.ParseDuration(target) + if err != nil || duration < 0 { + return 0, errors.New("target must be a duration") + } + return duration, nil +} + +func (c *NetPathCondition) numberTarget() (float64, error) { + var target float64 + if err := json.Unmarshal(c.Target, &target); err != nil { + return 0, errors.New("target must be numeric") + } + return target, nil +} diff --git a/dialtesting/netpath_test.go b/dialtesting/netpath_test.go new file mode 100644 index 00000000..3e4bea28 --- /dev/null +++ b/dialtesting/netpath_test.go @@ -0,0 +1,231 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the MIT License. +// This product includes software developed at Guance Cloud (https://www.guance.com/). +// Copyright 2021-present Guance, Inc. + +package dialtesting + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type testNetPathExecutor struct { + config NetPathProbeConfig + tags map[string]string + fields map[string]any + reasons []string + success bool + started chan struct{} + waitForCancel bool + clearCount int +} + +func (e *testNetPathExecutor) Run(ctx context.Context, config NetPathProbeConfig) error { + e.config = config + if e.started != nil { + close(e.started) + } + if e.waitForCancel { + <-ctx.Done() + return ctx.Err() + } + return nil +} + +func (e *testNetPathExecutor) Clear() { + e.clearCount++ +} + +func (e *testNetPathExecutor) CheckResult() ([]string, bool) { + return e.reasons, e.success +} + +func (e *testNetPathExecutor) GetResults() (map[string]string, map[string]any) { + return e.tags, e.fields +} + +func (e *testNetPathExecutor) SetError(message string) { + e.reasons = []string{message} + e.success = false +} + +func TestNetPathTaskFactoryAndExecutor(t *testing.T) { + child, err := CreateTaskChild("netpath") + require.NoError(t, err) + + task, err := NewTask(validNetPathTaskJSON(), child) + require.NoError(t, err) + require.NoError(t, task.Check()) + assert.Equal(t, ClassNetPath, task.Class()) + assert.Equal(t, netPathMetricName, task.MetricName()) + + netPathTask, ok := task.(*NetPathTask) + require.True(t, ok) + executor := &testNetPathExecutor{ + tags: map[string]string{"status": "OK"}, + fields: map[string]any{"success": int64(1)}, + success: true, + } + netPathTask.SetExecutor(executor) + + require.NoError(t, task.Run()) + assert.Equal(t, 1, executor.clearCount) + assert.Equal(t, "task_1", executor.config.ID) + assert.Equal(t, "example.com", executor.config.Host) + assert.Equal(t, uint16(443), executor.config.Port) + assert.Equal(t, "tcp", executor.config.Protocol) + assert.Equal(t, 30, executor.config.MaxTTL) + assert.Equal(t, 10, executor.config.E2EQueries) + assert.Equal(t, 3, executor.config.TracerouteQueries) + + reasons, success := task.CheckResult() + assert.True(t, success) + assert.Empty(t, reasons) + + tags, fields := task.GetResults() + assert.Equal(t, "OK", tags["status"]) + assert.Equal(t, int64(1), fields["success"]) + assert.NotContains(t, fields["task"], "access_key") + assert.NotContains(t, fields["task"], "post_url") +} + +func TestNetPathTaskValidation(t *testing.T) { + tests := []struct { + name string + replace func(map[string]any) + want string + }{ + { + name: "unsupported protocol", + replace: func(task map[string]any) { + task["protocol"] = "http" + }, + want: "unsupported protocol", + }, + { + name: "tcp requires port", + replace: func(task map[string]any) { + delete(task, "port") + }, + want: "port must be between", + }, + { + name: "icmp rejects port", + replace: func(task map[string]any) { + task["protocol"] = "icmp" + }, + want: "port must be omitted", + }, + { + name: "IPv6 is unsupported", + replace: func(task map[string]any) { + task["host"] = "2001:db8::1" + }, + want: "IPv6 host is not supported", + }, + { + name: "timeout is bounded", + replace: func(task map[string]any) { + task["advance_options"].(map[string]any)["timeout"] = "31s" + }, + want: "timeout must be between", + }, + { + name: "assertions are required", + replace: func(task map[string]any) { + task["success_when"] = []any{} + }, + want: "success_when is required", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var raw map[string]any + require.NoError(t, json.Unmarshal([]byte(validNetPathTaskJSON()), &raw)) + test.replace(raw) + data, err := json.Marshal(raw) + require.NoError(t, err) + + child, err := CreateTaskChild(ClassNetPath) + require.NoError(t, err) + task, err := NewTask(string(data), child) + require.NoError(t, err) + require.ErrorContains(t, task.Check(), test.want) + }) + } +} + +func TestNetPathTaskTemplateAndCancellation(t *testing.T) { + raw := strings.ReplaceAll(validNetPathTaskJSON(), "example.com", "{{host}}") + raw = strings.ReplaceAll(raw, `"443"`, `"{{port}}"`) + + child, err := CreateTaskChild(ClassNetPath) + require.NoError(t, err) + task, err := NewTask(raw, child) + require.NoError(t, err) + + netPathTask, ok := task.(*NetPathTask) + require.True(t, ok) + executor := &testNetPathExecutor{ + started: make(chan struct{}), + waitForCancel: true, + } + netPathTask.SetExecutor(executor) + + require.NoError(t, task.RenderTemplateAndInit(map[string]Variable{ + "host-id": {UUID: "host-id", Value: "rendered.example.com"}, + "port-id": {UUID: "port-id", Value: "8443"}, + })) + assert.Equal(t, "rendered.example.com", netPathTask.Host) + assert.Equal(t, "8443", netPathTask.Port) + + done := make(chan error, 1) + go func() { + done <- task.Run() + }() + <-executor.started + task.Stop() + require.NoError(t, <-done) + + reasons, success := task.CheckResult() + assert.False(t, success) + require.NotEmpty(t, reasons) + assert.Contains(t, reasons[0], "canceled") +} + +func validNetPathTaskJSON() string { + return `{ + "external_id": "task_1", + "name": "netpath task", + "access_key": "ak_test", + "post_url": "https://example.com/v1/write", + "status": "OK", + "frequency": "1m", + "schedule_type": "frequency", + "protocol": "tcp", + "host": "example.com", + "port": "443", + "advance_options": { + "timeout": "3s", + "source_name": "source-a" + }, + "config_vars": [ + {"id": "host-id", "type": "global", "name": "host", "value": "example.com", "secure": false}, + {"id": "port-id", "type": "global", "name": "port", "value": "443", "secure": false} + ], + "success_when": [{ + "e2e_rtt_avg": [{"op": "lt", "target": "2ms"}], + "e2e_probe_loss_percent": [{"op": "leq", "target": 1}], + "e2e_status": [{"op": "eq", "target": "OK"}], + "traceroute_status": [{"op": "eq", "target": "OK"}] + }], + "success_when_logic": "and" + }` +} diff --git a/dialtesting/task.go b/dialtesting/task.go index 4f7941ae..b1228cdd 100644 --- a/dialtesting/task.go +++ b/dialtesting/task.go @@ -33,6 +33,7 @@ const ( ClassSSL = "SSL" ClassDNS = "DNS" ClassHeadless = "BROWSER" + ClassNetPath = "NETPATH" ClassOther = "OTHER" ClassWait = "WAIT" ClassMulti = "MULTI" @@ -248,6 +249,9 @@ func CreateTaskChild(taskType string) (TaskChild, error) { case "ssl", ClassSSL: ct = &SSLTask{} + case "netpath", ClassNetPath: + ct = &NetPathTask{} + default: return nil, fmt.Errorf("unknown task type %s", taskType) }