mirror of
https://github.com/TwiN/gatus.git
synced 2026-02-04 13:31:46 +00:00
feat(suite): Implement Suites (#1239)
* feat(suite): Implement Suites Fixes #1230 * Update docs * Fix variable alignment * Prevent always-run endpoint from running if a context placeholder fails to resolve in the URL * Return errors when a context placeholder path fails to resolve * Add a couple of unit tests * Add a couple of unit tests * fix(ui): Update group count properly Fixes #1233 * refactor: Pass down entire config instead of several sub-configs * fix: Change default suite interval and timeout * fix: Deprecate disable-monitoring-lock in favor of concurrency * fix: Make sure there are no duplicate keys * Refactor some code * Update watchdog/watchdog.go * Update web/app/src/components/StepDetailsModal.vue Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * chore: Remove useless log * fix: Set default concurrency to 3 instead of 5 --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
109
config/config.go
109
config/config.go
@@ -17,8 +17,10 @@ import (
|
||||
"github.com/TwiN/gatus/v5/config/announcement"
|
||||
"github.com/TwiN/gatus/v5/config/connectivity"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"github.com/TwiN/gatus/v5/config/key"
|
||||
"github.com/TwiN/gatus/v5/config/maintenance"
|
||||
"github.com/TwiN/gatus/v5/config/remote"
|
||||
"github.com/TwiN/gatus/v5/config/suite"
|
||||
"github.com/TwiN/gatus/v5/config/ui"
|
||||
"github.com/TwiN/gatus/v5/config/web"
|
||||
"github.com/TwiN/gatus/v5/security"
|
||||
@@ -35,6 +37,9 @@ const (
|
||||
// DefaultFallbackConfigurationFilePath is the default fallback path that will be used to search for the
|
||||
// configuration file if DefaultConfigurationFilePath didn't work
|
||||
DefaultFallbackConfigurationFilePath = "config/config.yml"
|
||||
|
||||
// DefaultConcurrency is the default number of endpoints/suites that can be monitored concurrently
|
||||
DefaultConcurrency = 3
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -67,8 +72,14 @@ type Config struct {
|
||||
// DisableMonitoringLock Whether to disable the monitoring lock
|
||||
// The monitoring lock is what prevents multiple endpoints from being processed at the same time.
|
||||
// Disabling this may lead to inaccurate response times
|
||||
//
|
||||
// Deprecated: Use Concurrency instead TODO: REMOVE THIS IN v6.0.0
|
||||
DisableMonitoringLock bool `yaml:"disable-monitoring-lock,omitempty"`
|
||||
|
||||
// Concurrency is the maximum number of endpoints/suites that can be monitored concurrently
|
||||
// Defaults to DefaultConcurrency. Set to 0 for unlimited concurrency.
|
||||
Concurrency int `yaml:"concurrency,omitempty"`
|
||||
|
||||
// Security is the configuration for securing access to Gatus
|
||||
Security *security.Config `yaml:"security,omitempty"`
|
||||
|
||||
@@ -81,6 +92,9 @@ type Config struct {
|
||||
// ExternalEndpoints is the list of all external endpoints
|
||||
ExternalEndpoints []*endpoint.ExternalEndpoint `yaml:"external-endpoints,omitempty"`
|
||||
|
||||
// Suites is the list of suites to monitor
|
||||
Suites []*suite.Suite `yaml:"suites,omitempty"`
|
||||
|
||||
// Storage is the configuration for how the data is stored
|
||||
Storage *storage.Config `yaml:"storage,omitempty"`
|
||||
|
||||
@@ -309,6 +323,13 @@ func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) {
|
||||
if err := validateAnnouncementsConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateSuitesConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateUniqueKeys(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
validateAndSetConcurrencyDefaults(config)
|
||||
// Cross-config changes
|
||||
config.UI.MaximumNumberOfResults = config.Storage.MaximumNumberOfResults
|
||||
}
|
||||
@@ -405,7 +426,7 @@ func validateEndpointsConfig(config *Config) error {
|
||||
logr.Infof("[config.validateEndpointsConfig] Validated %d endpoints", len(config.Endpoints))
|
||||
// Validate external endpoints
|
||||
for _, ee := range config.ExternalEndpoints {
|
||||
logr.Debugf("[config.validateEndpointsConfig] Validating external endpoint '%s'", ee.Name)
|
||||
logr.Debugf("[config.validateEndpointsConfig] Validating external endpoint '%s'", ee.Key())
|
||||
if endpointKey := ee.Key(); duplicateValidationMap[endpointKey] {
|
||||
return fmt.Errorf("invalid external endpoint %s: name and group combination must be unique", ee.Key())
|
||||
} else {
|
||||
@@ -419,6 +440,78 @@ func validateEndpointsConfig(config *Config) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSuitesConfig(config *Config) error {
|
||||
if config.Suites == nil || len(config.Suites) == 0 {
|
||||
logr.Info("[config.validateSuitesConfig] No suites configured")
|
||||
return nil
|
||||
}
|
||||
suiteNames := make(map[string]bool)
|
||||
for _, suite := range config.Suites {
|
||||
// Check for duplicate suite names
|
||||
if suiteNames[suite.Name] {
|
||||
return fmt.Errorf("duplicate suite name: %s", suite.Key())
|
||||
}
|
||||
suiteNames[suite.Name] = true
|
||||
// Validate the suite configuration
|
||||
if err := suite.ValidateAndSetDefaults(); err != nil {
|
||||
return fmt.Errorf("invalid suite '%s': %w", suite.Key(), err)
|
||||
}
|
||||
// Check that endpoints referenced in Store mappings use valid placeholders
|
||||
for _, suiteEndpoint := range suite.Endpoints {
|
||||
if suiteEndpoint.Store != nil {
|
||||
for contextKey, placeholder := range suiteEndpoint.Store {
|
||||
// Basic validation that the context key is a valid identifier
|
||||
if len(contextKey) == 0 {
|
||||
return fmt.Errorf("suite '%s' endpoint '%s' has empty context key in store mapping", suite.Key(), suiteEndpoint.Key())
|
||||
}
|
||||
if len(placeholder) == 0 {
|
||||
return fmt.Errorf("suite '%s' endpoint '%s' has empty placeholder in store mapping for key '%s'", suite.Key(), suiteEndpoint.Key(), contextKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
logr.Infof("[config.validateSuitesConfig] Validated %d suite(s)", len(config.Suites))
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateUniqueKeys(config *Config) error {
|
||||
keyMap := make(map[string]string) // key -> description for error messages
|
||||
// Check all endpoints
|
||||
for _, ep := range config.Endpoints {
|
||||
epKey := ep.Key()
|
||||
if existing, exists := keyMap[epKey]; exists {
|
||||
return fmt.Errorf("duplicate key '%s': endpoint '%s' conflicts with %s", epKey, ep.Key(), existing)
|
||||
}
|
||||
keyMap[epKey] = fmt.Sprintf("endpoint '%s'", ep.Key())
|
||||
}
|
||||
// Check all external endpoints
|
||||
for _, ee := range config.ExternalEndpoints {
|
||||
eeKey := ee.Key()
|
||||
if existing, exists := keyMap[eeKey]; exists {
|
||||
return fmt.Errorf("duplicate key '%s': external endpoint '%s' conflicts with %s", eeKey, ee.Key(), existing)
|
||||
}
|
||||
keyMap[eeKey] = fmt.Sprintf("external endpoint '%s'", ee.Key())
|
||||
}
|
||||
// Check all suites
|
||||
for _, suite := range config.Suites {
|
||||
suiteKey := suite.Key()
|
||||
if existing, exists := keyMap[suiteKey]; exists {
|
||||
return fmt.Errorf("duplicate key '%s': suite '%s' conflicts with %s", suiteKey, suite.Key(), existing)
|
||||
}
|
||||
keyMap[suiteKey] = fmt.Sprintf("suite '%s'", suite.Key())
|
||||
// Check endpoints within suites (they generate keys using suite group + endpoint name)
|
||||
for _, ep := range suite.Endpoints {
|
||||
epKey := key.ConvertGroupAndNameToKey(suite.Group, ep.Name)
|
||||
if existing, exists := keyMap[epKey]; exists {
|
||||
return fmt.Errorf("duplicate key '%s': endpoint '%s' in suite '%s' conflicts with %s", epKey, epKey, suite.Key(), existing)
|
||||
}
|
||||
keyMap[epKey] = fmt.Sprintf("endpoint '%s' in suite '%s'", epKey, suite.Key())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSecurityConfig(config *Config) error {
|
||||
if config.Security != nil {
|
||||
if config.Security.IsValid() {
|
||||
@@ -531,3 +624,17 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi
|
||||
}
|
||||
logr.Infof("[config.validateAlertingConfig] configuredProviders=%s; ignoredProviders=%s", validProviders, invalidProviders)
|
||||
}
|
||||
|
||||
func validateAndSetConcurrencyDefaults(config *Config) {
|
||||
if config.DisableMonitoringLock {
|
||||
config.Concurrency = 0
|
||||
logr.Warn("WARNING: The 'disable-monitoring-lock' configuration has been deprecated and will be removed in v6.0.0")
|
||||
logr.Warn("WARNING: Please set 'concurrency: 0' instead")
|
||||
logr.Debug("[config.validateAndSetConcurrencyDefaults] DisableMonitoringLock is true, setting unlimited (0) concurrency")
|
||||
} else if config.Concurrency <= 0 && !config.DisableMonitoringLock {
|
||||
config.Concurrency = DefaultConcurrency
|
||||
logr.Debugf("[config.validateAndSetConcurrencyDefaults] Setting default concurrency to %d", config.Concurrency)
|
||||
} else {
|
||||
logr.Debugf("[config.validateAndSetConcurrencyDefaults] Using configured concurrency of %d", config.Concurrency)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2105,3 +2105,382 @@ func TestConfig_GetUniqueExtraMetricLabels(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAndValidateConfigBytesWithDuplicateKeysAcrossEntityTypes(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
shouldError bool
|
||||
expectedErr string
|
||||
config string
|
||||
}{
|
||||
{
|
||||
name: "endpoint-suite-same-key",
|
||||
shouldError: true,
|
||||
expectedErr: "duplicate key 'backend_test-api': suite 'backend_test-api' conflicts with endpoint 'backend_test-api'",
|
||||
config: `
|
||||
endpoints:
|
||||
- name: test-api
|
||||
group: backend
|
||||
url: https://example.com/api
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
|
||||
suites:
|
||||
- name: test-api
|
||||
group: backend
|
||||
interval: 30s
|
||||
endpoints:
|
||||
- name: step1
|
||||
url: https://example.com/test
|
||||
conditions:
|
||||
- "[STATUS] == 200"`,
|
||||
},
|
||||
{
|
||||
name: "endpoint-suite-different-keys",
|
||||
shouldError: false,
|
||||
config: `
|
||||
endpoints:
|
||||
- name: api-service
|
||||
group: backend
|
||||
url: https://example.com/api
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
|
||||
suites:
|
||||
- name: integration-tests
|
||||
group: testing
|
||||
interval: 30s
|
||||
endpoints:
|
||||
- name: step1
|
||||
url: https://example.com/test
|
||||
conditions:
|
||||
- "[STATUS] == 200"`,
|
||||
},
|
||||
{
|
||||
name: "endpoint-external-endpoint-suite-unique-keys",
|
||||
shouldError: false,
|
||||
config: `
|
||||
endpoints:
|
||||
- name: api-service
|
||||
group: backend
|
||||
url: https://example.com/api
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
|
||||
external-endpoints:
|
||||
- name: monitoring-agent
|
||||
group: infrastructure
|
||||
token: "secret-token"
|
||||
heartbeat:
|
||||
interval: 5m
|
||||
|
||||
suites:
|
||||
- name: integration-tests
|
||||
group: testing
|
||||
interval: 30s
|
||||
endpoints:
|
||||
- name: step1
|
||||
url: https://example.com/test
|
||||
conditions:
|
||||
- "[STATUS] == 200"`,
|
||||
},
|
||||
{
|
||||
name: "suite-with-same-key-as-external-endpoint",
|
||||
shouldError: true,
|
||||
expectedErr: "duplicate key 'monitoring_health-check': suite 'monitoring_health-check' conflicts with external endpoint 'monitoring_health-check'",
|
||||
config: `
|
||||
endpoints:
|
||||
- name: dummy
|
||||
url: https://example.com/dummy
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
|
||||
external-endpoints:
|
||||
- name: health-check
|
||||
group: monitoring
|
||||
token: "secret-token"
|
||||
heartbeat:
|
||||
interval: 5m
|
||||
|
||||
suites:
|
||||
- name: health-check
|
||||
group: monitoring
|
||||
interval: 30s
|
||||
endpoints:
|
||||
- name: step1
|
||||
url: https://example.com/test
|
||||
conditions:
|
||||
- "[STATUS] == 200"`,
|
||||
},
|
||||
{
|
||||
name: "endpoint-with-same-name-as-suite-endpoint-different-groups",
|
||||
shouldError: false,
|
||||
config: `
|
||||
endpoints:
|
||||
- name: api-health
|
||||
group: backend
|
||||
url: https://example.com/health
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
|
||||
suites:
|
||||
- name: integration-suite
|
||||
group: testing
|
||||
interval: 30s
|
||||
endpoints:
|
||||
- name: api-health
|
||||
url: https://example.com/api/health
|
||||
conditions:
|
||||
- "[STATUS] == 200"`,
|
||||
},
|
||||
{
|
||||
name: "endpoint-conflicting-with-suite-endpoint",
|
||||
shouldError: true,
|
||||
expectedErr: "duplicate key 'backend_api-health': endpoint 'backend_api-health' in suite 'backend_integration-suite' conflicts with endpoint 'backend_api-health'",
|
||||
config: `
|
||||
endpoints:
|
||||
- name: api-health
|
||||
group: backend
|
||||
url: https://example.com/health
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
|
||||
suites:
|
||||
- name: integration-suite
|
||||
group: backend
|
||||
interval: 30s
|
||||
endpoints:
|
||||
- name: api-health
|
||||
url: https://example.com/api/health
|
||||
conditions:
|
||||
- "[STATUS] == 200"`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
_, err := parseAndValidateConfigBytes([]byte(scenario.config))
|
||||
if scenario.shouldError {
|
||||
if err == nil {
|
||||
t.Error("should've returned an error")
|
||||
} else if scenario.expectedErr != "" && err.Error() != scenario.expectedErr {
|
||||
t.Errorf("expected error message '%s', got '%s'", scenario.expectedErr, err.Error())
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Errorf("shouldn't have returned an error, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAndValidateConfigBytesWithSuites(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
shouldError bool
|
||||
expectedErr string
|
||||
config string
|
||||
}{
|
||||
{
|
||||
name: "suite-with-no-name",
|
||||
shouldError: true,
|
||||
expectedErr: "invalid suite 'testing_': suite must have a name",
|
||||
config: `
|
||||
endpoints:
|
||||
- name: dummy
|
||||
url: https://example.com/dummy
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
|
||||
suites:
|
||||
- group: testing
|
||||
interval: 30s
|
||||
endpoints:
|
||||
- name: step1
|
||||
url: https://example.com/test
|
||||
conditions:
|
||||
- "[STATUS] == 200"`,
|
||||
},
|
||||
{
|
||||
name: "suite-with-no-endpoints",
|
||||
shouldError: true,
|
||||
expectedErr: "invalid suite 'testing_empty-suite': suite must have at least one endpoint",
|
||||
config: `
|
||||
endpoints:
|
||||
- name: dummy
|
||||
url: https://example.com/dummy
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
|
||||
suites:
|
||||
- name: empty-suite
|
||||
group: testing
|
||||
interval: 30s
|
||||
endpoints: []`,
|
||||
},
|
||||
{
|
||||
name: "suite-with-duplicate-endpoint-names",
|
||||
shouldError: true,
|
||||
expectedErr: "invalid suite 'testing_duplicate-test': suite cannot have duplicate endpoint names: duplicate endpoint name 'step1'",
|
||||
config: `
|
||||
endpoints:
|
||||
- name: dummy
|
||||
url: https://example.com/dummy
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
|
||||
suites:
|
||||
- name: duplicate-test
|
||||
group: testing
|
||||
interval: 30s
|
||||
endpoints:
|
||||
- name: step1
|
||||
url: https://example.com/test1
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- name: step1
|
||||
url: https://example.com/test2
|
||||
conditions:
|
||||
- "[STATUS] == 200"`,
|
||||
},
|
||||
{
|
||||
name: "suite-with-invalid-negative-timeout",
|
||||
shouldError: true,
|
||||
expectedErr: "invalid suite 'testing_negative-timeout-suite': suite timeout must be positive",
|
||||
config: `
|
||||
endpoints:
|
||||
- name: dummy
|
||||
url: https://example.com/dummy
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
|
||||
suites:
|
||||
- name: negative-timeout-suite
|
||||
group: testing
|
||||
interval: 30s
|
||||
timeout: -5m
|
||||
endpoints:
|
||||
- name: step1
|
||||
url: https://example.com/test
|
||||
conditions:
|
||||
- "[STATUS] == 200"`,
|
||||
},
|
||||
{
|
||||
name: "valid-suite-with-defaults",
|
||||
shouldError: false,
|
||||
config: `
|
||||
endpoints:
|
||||
- name: api-service
|
||||
group: backend
|
||||
url: https://example.com/api
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
|
||||
suites:
|
||||
- name: integration-test
|
||||
group: testing
|
||||
endpoints:
|
||||
- name: step1
|
||||
url: https://example.com/test
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- name: step2
|
||||
url: https://example.com/validate
|
||||
conditions:
|
||||
- "[STATUS] == 200"`,
|
||||
},
|
||||
{
|
||||
name: "valid-suite-with-all-fields",
|
||||
shouldError: false,
|
||||
config: `
|
||||
endpoints:
|
||||
- name: api-service
|
||||
group: backend
|
||||
url: https://example.com/api
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
|
||||
suites:
|
||||
- name: full-integration-test
|
||||
group: testing
|
||||
enabled: true
|
||||
interval: 15m
|
||||
timeout: 10m
|
||||
context:
|
||||
base_url: "https://example.com"
|
||||
user_id: 12345
|
||||
endpoints:
|
||||
- name: authentication
|
||||
url: https://example.com/auth
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- name: user-profile
|
||||
url: https://example.com/profile
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- "[BODY].user_id == 12345"`,
|
||||
},
|
||||
{
|
||||
name: "valid-suite-with-endpoint-inheritance",
|
||||
shouldError: false,
|
||||
config: `
|
||||
endpoints:
|
||||
- name: api-service
|
||||
group: backend
|
||||
url: https://example.com/api
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
|
||||
suites:
|
||||
- name: inheritance-test
|
||||
group: parent-group
|
||||
endpoints:
|
||||
- name: child-endpoint
|
||||
url: https://example.com/test
|
||||
conditions:
|
||||
- "[STATUS] == 200"`,
|
||||
},
|
||||
{
|
||||
name: "valid-suite-with-store-functionality",
|
||||
shouldError: false,
|
||||
config: `
|
||||
endpoints:
|
||||
- name: api-service
|
||||
group: backend
|
||||
url: https://example.com/api
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
|
||||
suites:
|
||||
- name: store-test
|
||||
group: testing
|
||||
endpoints:
|
||||
- name: get-token
|
||||
url: https://example.com/auth
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
store:
|
||||
auth_token: "[BODY].token"
|
||||
- name: use-token
|
||||
url: https://example.com/data
|
||||
headers:
|
||||
Authorization: "Bearer {auth_token}"
|
||||
conditions:
|
||||
- "[STATUS] == 200"`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
_, err := parseAndValidateConfigBytes([]byte(scenario.config))
|
||||
if scenario.shouldError {
|
||||
if err == nil {
|
||||
t.Error("should've returned an error")
|
||||
} else if scenario.expectedErr != "" && err.Error() != scenario.expectedErr {
|
||||
t.Errorf("expected error message '%s', got '%s'", scenario.expectedErr, err.Error())
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Errorf("shouldn't have returned an error, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,82 +7,11 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/jsonpath"
|
||||
"github.com/TwiN/gatus/v5/config/gontext"
|
||||
"github.com/TwiN/gatus/v5/pattern"
|
||||
)
|
||||
|
||||
// Placeholders
|
||||
const (
|
||||
// StatusPlaceholder is a placeholder for a HTTP status.
|
||||
//
|
||||
// Values that could replace the placeholder: 200, 404, 500, ...
|
||||
StatusPlaceholder = "[STATUS]"
|
||||
|
||||
// IPPlaceholder is a placeholder for an IP.
|
||||
//
|
||||
// Values that could replace the placeholder: 127.0.0.1, 10.0.0.1, ...
|
||||
IPPlaceholder = "[IP]"
|
||||
|
||||
// DNSRCodePlaceholder is a placeholder for DNS_RCODE
|
||||
//
|
||||
// Values that could replace the placeholder: NOERROR, FORMERR, SERVFAIL, NXDOMAIN, NOTIMP, REFUSED
|
||||
DNSRCodePlaceholder = "[DNS_RCODE]"
|
||||
|
||||
// ResponseTimePlaceholder is a placeholder for the request response time, in milliseconds.
|
||||
//
|
||||
// Values that could replace the placeholder: 1, 500, 1000, ...
|
||||
ResponseTimePlaceholder = "[RESPONSE_TIME]"
|
||||
|
||||
// BodyPlaceholder is a placeholder for the Body of the response
|
||||
//
|
||||
// Values that could replace the placeholder: {}, {"data":{"name":"john"}}, ...
|
||||
BodyPlaceholder = "[BODY]"
|
||||
|
||||
// ConnectedPlaceholder is a placeholder for whether a connection was successfully established.
|
||||
//
|
||||
// Values that could replace the placeholder: true, false
|
||||
ConnectedPlaceholder = "[CONNECTED]"
|
||||
|
||||
// CertificateExpirationPlaceholder is a placeholder for the duration before certificate expiration, in milliseconds.
|
||||
//
|
||||
// Values that could replace the placeholder: 4461677039 (~52 days)
|
||||
CertificateExpirationPlaceholder = "[CERTIFICATE_EXPIRATION]"
|
||||
|
||||
// DomainExpirationPlaceholder is a placeholder for the duration before the domain expires, in milliseconds.
|
||||
DomainExpirationPlaceholder = "[DOMAIN_EXPIRATION]"
|
||||
)
|
||||
|
||||
// Functions
|
||||
const (
|
||||
// LengthFunctionPrefix is the prefix for the length function
|
||||
//
|
||||
// Usage: len([BODY].articles) == 10, len([BODY].name) > 5
|
||||
LengthFunctionPrefix = "len("
|
||||
|
||||
// HasFunctionPrefix is the prefix for the has function
|
||||
//
|
||||
// Usage: has([BODY].errors) == true
|
||||
HasFunctionPrefix = "has("
|
||||
|
||||
// PatternFunctionPrefix is the prefix for the pattern function
|
||||
//
|
||||
// Usage: [IP] == pat(192.168.*.*)
|
||||
PatternFunctionPrefix = "pat("
|
||||
|
||||
// AnyFunctionPrefix is the prefix for the any function
|
||||
//
|
||||
// Usage: [IP] == any(1.1.1.1, 1.0.0.1)
|
||||
AnyFunctionPrefix = "any("
|
||||
|
||||
// FunctionSuffix is the suffix for all functions
|
||||
FunctionSuffix = ")"
|
||||
)
|
||||
|
||||
// Other constants
|
||||
const (
|
||||
// InvalidConditionElementSuffix is the suffix that will be appended to an invalid condition
|
||||
InvalidConditionElementSuffix = "(INVALID)"
|
||||
|
||||
// maximumLengthBeforeTruncatingWhenComparedWithPattern is the maximum length an element being compared to a
|
||||
// pattern can have.
|
||||
//
|
||||
@@ -97,50 +26,50 @@ type Condition string
|
||||
// Validate checks if the Condition is valid
|
||||
func (c Condition) Validate() error {
|
||||
r := &Result{}
|
||||
c.evaluate(r, false)
|
||||
c.evaluate(r, false, nil)
|
||||
if len(r.Errors) != 0 {
|
||||
return errors.New(r.Errors[0])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// evaluate the Condition with the Result of the health check
|
||||
func (c Condition) evaluate(result *Result, dontResolveFailedConditions bool) bool {
|
||||
// evaluate the Condition with the Result and an optional context
|
||||
func (c Condition) evaluate(result *Result, dontResolveFailedConditions bool, context *gontext.Gontext) bool {
|
||||
condition := string(c)
|
||||
success := false
|
||||
conditionToDisplay := condition
|
||||
if strings.Contains(condition, " == ") {
|
||||
parameters, resolvedParameters := sanitizeAndResolve(strings.Split(condition, " == "), result)
|
||||
parameters, resolvedParameters := sanitizeAndResolveWithContext(strings.Split(condition, " == "), result, context)
|
||||
success = isEqual(resolvedParameters[0], resolvedParameters[1])
|
||||
if !success && !dontResolveFailedConditions {
|
||||
conditionToDisplay = prettify(parameters, resolvedParameters, "==")
|
||||
}
|
||||
} else if strings.Contains(condition, " != ") {
|
||||
parameters, resolvedParameters := sanitizeAndResolve(strings.Split(condition, " != "), result)
|
||||
parameters, resolvedParameters := sanitizeAndResolveWithContext(strings.Split(condition, " != "), result, context)
|
||||
success = !isEqual(resolvedParameters[0], resolvedParameters[1])
|
||||
if !success && !dontResolveFailedConditions {
|
||||
conditionToDisplay = prettify(parameters, resolvedParameters, "!=")
|
||||
}
|
||||
} else if strings.Contains(condition, " <= ") {
|
||||
parameters, resolvedParameters := sanitizeAndResolveNumerical(strings.Split(condition, " <= "), result)
|
||||
parameters, resolvedParameters := sanitizeAndResolveNumericalWithContext(strings.Split(condition, " <= "), result, context)
|
||||
success = resolvedParameters[0] <= resolvedParameters[1]
|
||||
if !success && !dontResolveFailedConditions {
|
||||
conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, "<=")
|
||||
}
|
||||
} else if strings.Contains(condition, " >= ") {
|
||||
parameters, resolvedParameters := sanitizeAndResolveNumerical(strings.Split(condition, " >= "), result)
|
||||
parameters, resolvedParameters := sanitizeAndResolveNumericalWithContext(strings.Split(condition, " >= "), result, context)
|
||||
success = resolvedParameters[0] >= resolvedParameters[1]
|
||||
if !success && !dontResolveFailedConditions {
|
||||
conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, ">=")
|
||||
}
|
||||
} else if strings.Contains(condition, " > ") {
|
||||
parameters, resolvedParameters := sanitizeAndResolveNumerical(strings.Split(condition, " > "), result)
|
||||
parameters, resolvedParameters := sanitizeAndResolveNumericalWithContext(strings.Split(condition, " > "), result, context)
|
||||
success = resolvedParameters[0] > resolvedParameters[1]
|
||||
if !success && !dontResolveFailedConditions {
|
||||
conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, ">")
|
||||
}
|
||||
} else if strings.Contains(condition, " < ") {
|
||||
parameters, resolvedParameters := sanitizeAndResolveNumerical(strings.Split(condition, " < "), result)
|
||||
parameters, resolvedParameters := sanitizeAndResolveNumericalWithContext(strings.Split(condition, " < "), result, context)
|
||||
success = resolvedParameters[0] < resolvedParameters[1]
|
||||
if !success && !dontResolveFailedConditions {
|
||||
conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, "<")
|
||||
@@ -235,79 +164,29 @@ func isEqual(first, second string) bool {
|
||||
return first == second
|
||||
}
|
||||
|
||||
// sanitizeAndResolve sanitizes and resolves a list of elements and returns the list of parameters as well as a list
|
||||
// of resolved parameters
|
||||
func sanitizeAndResolve(elements []string, result *Result) ([]string, []string) {
|
||||
// sanitizeAndResolveWithContext sanitizes and resolves a list of elements with an optional context
|
||||
func sanitizeAndResolveWithContext(elements []string, result *Result, context *gontext.Gontext) ([]string, []string) {
|
||||
parameters := make([]string, len(elements))
|
||||
resolvedParameters := make([]string, len(elements))
|
||||
body := strings.TrimSpace(string(result.Body))
|
||||
for i, element := range elements {
|
||||
element = strings.TrimSpace(element)
|
||||
parameters[i] = element
|
||||
switch strings.ToUpper(element) {
|
||||
case StatusPlaceholder:
|
||||
element = strconv.Itoa(result.HTTPStatus)
|
||||
case IPPlaceholder:
|
||||
element = result.IP
|
||||
case ResponseTimePlaceholder:
|
||||
element = strconv.Itoa(int(result.Duration.Milliseconds()))
|
||||
case BodyPlaceholder:
|
||||
element = body
|
||||
case DNSRCodePlaceholder:
|
||||
element = result.DNSRCode
|
||||
case ConnectedPlaceholder:
|
||||
element = strconv.FormatBool(result.Connected)
|
||||
case CertificateExpirationPlaceholder:
|
||||
element = strconv.FormatInt(result.CertificateExpiration.Milliseconds(), 10)
|
||||
case DomainExpirationPlaceholder:
|
||||
element = strconv.FormatInt(result.DomainExpiration.Milliseconds(), 10)
|
||||
default:
|
||||
// if contains the BodyPlaceholder, then evaluate json path
|
||||
if strings.Contains(element, BodyPlaceholder) {
|
||||
checkingForLength := false
|
||||
checkingForExistence := false
|
||||
if strings.HasPrefix(element, LengthFunctionPrefix) && strings.HasSuffix(element, FunctionSuffix) {
|
||||
checkingForLength = true
|
||||
element = strings.TrimSuffix(strings.TrimPrefix(element, LengthFunctionPrefix), FunctionSuffix)
|
||||
}
|
||||
if strings.HasPrefix(element, HasFunctionPrefix) && strings.HasSuffix(element, FunctionSuffix) {
|
||||
checkingForExistence = true
|
||||
element = strings.TrimSuffix(strings.TrimPrefix(element, HasFunctionPrefix), FunctionSuffix)
|
||||
}
|
||||
resolvedElement, resolvedElementLength, err := jsonpath.Eval(strings.TrimPrefix(strings.TrimPrefix(element, BodyPlaceholder), "."), result.Body)
|
||||
if checkingForExistence {
|
||||
if err != nil {
|
||||
element = "false"
|
||||
} else {
|
||||
element = "true"
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
if err.Error() != "unexpected end of JSON input" {
|
||||
result.AddError(err.Error())
|
||||
}
|
||||
if checkingForLength {
|
||||
element = LengthFunctionPrefix + element + FunctionSuffix + " " + InvalidConditionElementSuffix
|
||||
} else {
|
||||
element = element + " " + InvalidConditionElementSuffix
|
||||
}
|
||||
} else {
|
||||
if checkingForLength {
|
||||
element = strconv.Itoa(resolvedElementLength)
|
||||
} else {
|
||||
element = resolvedElement
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use the unified ResolvePlaceholder function
|
||||
resolved, err := ResolvePlaceholder(element, result, context)
|
||||
if err != nil {
|
||||
// If there's an error, add it to the result
|
||||
result.AddError(err.Error())
|
||||
resolvedParameters[i] = element + " " + InvalidConditionElementSuffix
|
||||
} else {
|
||||
resolvedParameters[i] = resolved
|
||||
}
|
||||
resolvedParameters[i] = element
|
||||
}
|
||||
return parameters, resolvedParameters
|
||||
}
|
||||
|
||||
func sanitizeAndResolveNumerical(list []string, result *Result) (parameters []string, resolvedNumericalParameters []int64) {
|
||||
parameters, resolvedParameters := sanitizeAndResolve(list, result)
|
||||
func sanitizeAndResolveNumericalWithContext(list []string, result *Result, context *gontext.Gontext) (parameters []string, resolvedNumericalParameters []int64) {
|
||||
parameters, resolvedParameters := sanitizeAndResolveWithContext(list, result, context)
|
||||
for _, element := range resolvedParameters {
|
||||
if duration, err := time.ParseDuration(element); duration != 0 && err == nil {
|
||||
// If the string is a duration, convert it to milliseconds
|
||||
|
||||
@@ -8,7 +8,7 @@ func BenchmarkCondition_evaluateWithBodyStringAny(b *testing.B) {
|
||||
condition := Condition("[BODY].name == any(john.doe, jane.doe)")
|
||||
for n := 0; n < b.N; n++ {
|
||||
result := &Result{Body: []byte("{\"name\": \"john.doe\"}")}
|
||||
condition.evaluate(result, false)
|
||||
condition.evaluate(result, false, nil)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
}
|
||||
@@ -17,7 +17,7 @@ func BenchmarkCondition_evaluateWithBodyStringAnyFailure(b *testing.B) {
|
||||
condition := Condition("[BODY].name == any(john.doe, jane.doe)")
|
||||
for n := 0; n < b.N; n++ {
|
||||
result := &Result{Body: []byte("{\"name\": \"bob.doe\"}")}
|
||||
condition.evaluate(result, false)
|
||||
condition.evaluate(result, false, nil)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
}
|
||||
@@ -26,7 +26,7 @@ func BenchmarkCondition_evaluateWithBodyString(b *testing.B) {
|
||||
condition := Condition("[BODY].name == john.doe")
|
||||
for n := 0; n < b.N; n++ {
|
||||
result := &Result{Body: []byte("{\"name\": \"john.doe\"}")}
|
||||
condition.evaluate(result, false)
|
||||
condition.evaluate(result, false, nil)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
}
|
||||
@@ -35,7 +35,7 @@ func BenchmarkCondition_evaluateWithBodyStringFailure(b *testing.B) {
|
||||
condition := Condition("[BODY].name == john.doe")
|
||||
for n := 0; n < b.N; n++ {
|
||||
result := &Result{Body: []byte("{\"name\": \"bob.doe\"}")}
|
||||
condition.evaluate(result, false)
|
||||
condition.evaluate(result, false, nil)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
}
|
||||
@@ -44,7 +44,7 @@ func BenchmarkCondition_evaluateWithBodyStringFailureInvalidPath(b *testing.B) {
|
||||
condition := Condition("[BODY].user.name == bob.doe")
|
||||
for n := 0; n < b.N; n++ {
|
||||
result := &Result{Body: []byte("{\"name\": \"bob.doe\"}")}
|
||||
condition.evaluate(result, false)
|
||||
condition.evaluate(result, false, nil)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
}
|
||||
@@ -53,7 +53,7 @@ func BenchmarkCondition_evaluateWithBodyStringLen(b *testing.B) {
|
||||
condition := Condition("len([BODY].name) == 8")
|
||||
for n := 0; n < b.N; n++ {
|
||||
result := &Result{Body: []byte("{\"name\": \"john.doe\"}")}
|
||||
condition.evaluate(result, false)
|
||||
condition.evaluate(result, false, nil)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
}
|
||||
@@ -62,7 +62,7 @@ func BenchmarkCondition_evaluateWithBodyStringLenFailure(b *testing.B) {
|
||||
condition := Condition("len([BODY].name) == 8")
|
||||
for n := 0; n < b.N; n++ {
|
||||
result := &Result{Body: []byte("{\"name\": \"bob.doe\"}")}
|
||||
condition.evaluate(result, false)
|
||||
condition.evaluate(result, false, nil)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
}
|
||||
@@ -71,7 +71,7 @@ func BenchmarkCondition_evaluateWithStatus(b *testing.B) {
|
||||
condition := Condition("[STATUS] == 200")
|
||||
for n := 0; n < b.N; n++ {
|
||||
result := &Result{HTTPStatus: 200}
|
||||
condition.evaluate(result, false)
|
||||
condition.evaluate(result, false, nil)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
}
|
||||
@@ -80,7 +80,7 @@ func BenchmarkCondition_evaluateWithStatusFailure(b *testing.B) {
|
||||
condition := Condition("[STATUS] == 200")
|
||||
for n := 0; n < b.N; n++ {
|
||||
result := &Result{HTTPStatus: 400}
|
||||
condition.evaluate(result, false)
|
||||
condition.evaluate(result, false, nil)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
}
|
||||
|
||||
@@ -755,7 +755,7 @@ func TestCondition_evaluate(t *testing.T) {
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
scenario.Condition.evaluate(scenario.Result, scenario.DontResolveFailedConditions)
|
||||
scenario.Condition.evaluate(scenario.Result, scenario.DontResolveFailedConditions, nil)
|
||||
if scenario.Result.ConditionResults[0].Success != scenario.ExpectedSuccess {
|
||||
t.Errorf("Condition '%s' should have been success=%v", scenario.Condition, scenario.ExpectedSuccess)
|
||||
}
|
||||
@@ -769,7 +769,7 @@ func TestCondition_evaluate(t *testing.T) {
|
||||
func TestCondition_evaluateWithInvalidOperator(t *testing.T) {
|
||||
condition := Condition("[STATUS] ? 201")
|
||||
result := &Result{HTTPStatus: 201}
|
||||
condition.evaluate(result, false)
|
||||
condition.evaluate(result, false, nil)
|
||||
if result.Success {
|
||||
t.Error("condition was invalid, result should've been a failure")
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ import (
|
||||
"github.com/TwiN/gatus/v5/config/endpoint/dns"
|
||||
sshconfig "github.com/TwiN/gatus/v5/config/endpoint/ssh"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint/ui"
|
||||
"github.com/TwiN/gatus/v5/config/gontext"
|
||||
"github.com/TwiN/gatus/v5/config/key"
|
||||
"github.com/TwiN/gatus/v5/config/maintenance"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
@@ -134,6 +136,18 @@ type Endpoint struct {
|
||||
|
||||
// LastReminderSent is the time at which the last reminder was sent for this endpoint.
|
||||
LastReminderSent time.Time `yaml:"-"`
|
||||
|
||||
///////////////////////
|
||||
// SUITE-ONLY FIELDS //
|
||||
///////////////////////
|
||||
|
||||
// Store is a map of values to extract from the result and store in the suite context
|
||||
// This field is only used when the endpoint is part of a suite
|
||||
Store map[string]string `yaml:"store,omitempty"`
|
||||
|
||||
// AlwaysRun defines whether to execute this endpoint even if previous endpoints in the suite failed
|
||||
// This field is only used when the endpoint is part of a suite
|
||||
AlwaysRun bool `yaml:"always-run,omitempty"`
|
||||
}
|
||||
|
||||
// IsEnabled returns whether the endpoint is enabled or not
|
||||
@@ -255,7 +269,7 @@ func (e *Endpoint) DisplayName() string {
|
||||
|
||||
// Key returns the unique key for the Endpoint
|
||||
func (e *Endpoint) Key() string {
|
||||
return ConvertGroupAndEndpointNameToKey(e.Group, e.Name)
|
||||
return key.ConvertGroupAndNameToKey(e.Group, e.Name)
|
||||
}
|
||||
|
||||
// Close HTTP connections between watchdog and endpoints to avoid dangling socket file descriptors
|
||||
@@ -269,16 +283,26 @@ func (e *Endpoint) Close() {
|
||||
|
||||
// EvaluateHealth sends a request to the endpoint's URL and evaluates the conditions of the endpoint.
|
||||
func (e *Endpoint) EvaluateHealth() *Result {
|
||||
return e.EvaluateHealthWithContext(nil)
|
||||
}
|
||||
|
||||
// EvaluateHealthWithContext sends a request to the endpoint's URL with context support and evaluates the conditions
|
||||
func (e *Endpoint) EvaluateHealthWithContext(context *gontext.Gontext) *Result {
|
||||
result := &Result{Success: true, Errors: []string{}}
|
||||
// Preprocess the endpoint with context if provided
|
||||
processedEndpoint := e
|
||||
if context != nil {
|
||||
processedEndpoint = e.preprocessWithContext(result, context)
|
||||
}
|
||||
// Parse or extract hostname from URL
|
||||
if e.DNSConfig != nil {
|
||||
result.Hostname = strings.TrimSuffix(e.URL, ":53")
|
||||
} else if e.Type() == TypeICMP {
|
||||
if processedEndpoint.DNSConfig != nil {
|
||||
result.Hostname = strings.TrimSuffix(processedEndpoint.URL, ":53")
|
||||
} else if processedEndpoint.Type() == TypeICMP {
|
||||
// To handle IPv6 addresses, we need to handle the hostname differently here. This is to avoid, for instance,
|
||||
// "1111:2222:3333::4444" being displayed as "1111:2222:3333:" because :4444 would be interpreted as a port.
|
||||
result.Hostname = strings.TrimPrefix(e.URL, "icmp://")
|
||||
result.Hostname = strings.TrimPrefix(processedEndpoint.URL, "icmp://")
|
||||
} else {
|
||||
urlObject, err := url.Parse(e.URL)
|
||||
urlObject, err := url.Parse(processedEndpoint.URL)
|
||||
if err != nil {
|
||||
result.AddError(err.Error())
|
||||
} else {
|
||||
@@ -287,11 +311,11 @@ func (e *Endpoint) EvaluateHealth() *Result {
|
||||
}
|
||||
}
|
||||
// Retrieve IP if necessary
|
||||
if e.needsToRetrieveIP() {
|
||||
e.getIP(result)
|
||||
if processedEndpoint.needsToRetrieveIP() {
|
||||
processedEndpoint.getIP(result)
|
||||
}
|
||||
// Retrieve domain expiration if necessary
|
||||
if e.needsToRetrieveDomainExpiration() && len(result.Hostname) > 0 {
|
||||
if processedEndpoint.needsToRetrieveDomainExpiration() && len(result.Hostname) > 0 {
|
||||
var err error
|
||||
if result.DomainExpiration, err = client.GetDomainExpiration(result.Hostname); err != nil {
|
||||
result.AddError(err.Error())
|
||||
@@ -299,42 +323,91 @@ func (e *Endpoint) EvaluateHealth() *Result {
|
||||
}
|
||||
// Call the endpoint (if there's no errors)
|
||||
if len(result.Errors) == 0 {
|
||||
e.call(result)
|
||||
processedEndpoint.call(result)
|
||||
} else {
|
||||
result.Success = false
|
||||
}
|
||||
// Evaluate the conditions
|
||||
for _, condition := range e.Conditions {
|
||||
success := condition.evaluate(result, e.UIConfig.DontResolveFailedConditions)
|
||||
for _, condition := range processedEndpoint.Conditions {
|
||||
success := condition.evaluate(result, processedEndpoint.UIConfig.DontResolveFailedConditions, context)
|
||||
if !success {
|
||||
result.Success = false
|
||||
}
|
||||
}
|
||||
result.Timestamp = time.Now()
|
||||
// Clean up parameters that we don't need to keep in the results
|
||||
if e.UIConfig.HideURL {
|
||||
if processedEndpoint.UIConfig.HideURL {
|
||||
for errIdx, errorString := range result.Errors {
|
||||
result.Errors[errIdx] = strings.ReplaceAll(errorString, e.URL, "<redacted>")
|
||||
result.Errors[errIdx] = strings.ReplaceAll(errorString, processedEndpoint.URL, "<redacted>")
|
||||
}
|
||||
}
|
||||
if e.UIConfig.HideHostname {
|
||||
if processedEndpoint.UIConfig.HideHostname {
|
||||
for errIdx, errorString := range result.Errors {
|
||||
result.Errors[errIdx] = strings.ReplaceAll(errorString, result.Hostname, "<redacted>")
|
||||
}
|
||||
result.Hostname = "" // remove it from the result so it doesn't get exposed
|
||||
}
|
||||
if e.UIConfig.HidePort && len(result.port) > 0 {
|
||||
if processedEndpoint.UIConfig.HidePort && len(result.port) > 0 {
|
||||
for errIdx, errorString := range result.Errors {
|
||||
result.Errors[errIdx] = strings.ReplaceAll(errorString, result.port, "<redacted>")
|
||||
}
|
||||
result.port = ""
|
||||
}
|
||||
if e.UIConfig.HideConditions {
|
||||
if processedEndpoint.UIConfig.HideConditions {
|
||||
result.ConditionResults = nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// preprocessWithContext creates a copy of the endpoint with context placeholders replaced
|
||||
func (e *Endpoint) preprocessWithContext(result *Result, context *gontext.Gontext) *Endpoint {
|
||||
// Create a deep copy of the endpoint
|
||||
processed := &Endpoint{}
|
||||
*processed = *e
|
||||
var err error
|
||||
// Replace context placeholders in URL
|
||||
if processed.URL, err = replaceContextPlaceholders(e.URL, context); err != nil {
|
||||
result.AddError(err.Error())
|
||||
}
|
||||
// Replace context placeholders in Body
|
||||
if processed.Body, err = replaceContextPlaceholders(e.Body, context); err != nil {
|
||||
result.AddError(err.Error())
|
||||
}
|
||||
// Replace context placeholders in Headers
|
||||
if e.Headers != nil {
|
||||
processed.Headers = make(map[string]string)
|
||||
for k, v := range e.Headers {
|
||||
if processed.Headers[k], err = replaceContextPlaceholders(v, context); err != nil {
|
||||
result.AddError(err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
return processed
|
||||
}
|
||||
|
||||
// replaceContextPlaceholders replaces [CONTEXT].path placeholders with actual values
|
||||
func replaceContextPlaceholders(input string, ctx *gontext.Gontext) (string, error) {
|
||||
if ctx == nil {
|
||||
return input, nil
|
||||
}
|
||||
var contextErrors []string
|
||||
contextRegex := regexp.MustCompile(`\[CONTEXT\]\.[\w\.]+`)
|
||||
result := contextRegex.ReplaceAllStringFunc(input, func(match string) string {
|
||||
// Extract the path after [CONTEXT].
|
||||
path := strings.TrimPrefix(match, "[CONTEXT].")
|
||||
value, err := ctx.Get(path)
|
||||
if err != nil {
|
||||
contextErrors = append(contextErrors, fmt.Sprintf("path '%s' not found", path))
|
||||
return match // Keep placeholder for error reporting
|
||||
}
|
||||
return fmt.Sprintf("%v", value)
|
||||
})
|
||||
if len(contextErrors) > 0 {
|
||||
return result, fmt.Errorf("context placeholder resolution failed: %s", strings.Join(contextErrors, ", "))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (e *Endpoint) getParsedBody() string {
|
||||
body := e.Body
|
||||
body = strings.ReplaceAll(body, "[ENDPOINT_NAME]", e.Name)
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/TwiN/gatus/v5/config/endpoint/dns"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint/ssh"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint/ui"
|
||||
"github.com/TwiN/gatus/v5/config/gontext"
|
||||
"github.com/TwiN/gatus/v5/config/maintenance"
|
||||
"github.com/TwiN/gatus/v5/test"
|
||||
)
|
||||
@@ -932,3 +933,352 @@ func TestEndpoint_needsToRetrieveIP(t *testing.T) {
|
||||
t.Error("expected true, got false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndpoint_preprocessWithContext(t *testing.T) {
|
||||
// Import the gontext package for creating test contexts
|
||||
// This test thoroughly exercises the replaceContextPlaceholders function
|
||||
tests := []struct {
|
||||
name string
|
||||
endpoint *Endpoint
|
||||
context map[string]interface{}
|
||||
expectedURL string
|
||||
expectedBody string
|
||||
expectedHeaders map[string]string
|
||||
expectedErrorCount int
|
||||
expectedErrorContains []string
|
||||
}{
|
||||
{
|
||||
name: "successful_url_replacement",
|
||||
endpoint: &Endpoint{
|
||||
URL: "https://api.example.com/users/[CONTEXT].userId",
|
||||
Body: "",
|
||||
},
|
||||
context: map[string]interface{}{
|
||||
"userId": "12345",
|
||||
},
|
||||
expectedURL: "https://api.example.com/users/12345",
|
||||
expectedBody: "",
|
||||
expectedErrorCount: 0,
|
||||
},
|
||||
{
|
||||
name: "successful_body_replacement",
|
||||
endpoint: &Endpoint{
|
||||
URL: "https://api.example.com",
|
||||
Body: `{"userId": "[CONTEXT].userId", "action": "update"}`,
|
||||
},
|
||||
context: map[string]interface{}{
|
||||
"userId": "67890",
|
||||
},
|
||||
expectedURL: "https://api.example.com",
|
||||
expectedBody: `{"userId": "67890", "action": "update"}`,
|
||||
expectedErrorCount: 0,
|
||||
},
|
||||
{
|
||||
name: "successful_header_replacement",
|
||||
endpoint: &Endpoint{
|
||||
URL: "https://api.example.com",
|
||||
Body: "",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "Bearer [CONTEXT].token",
|
||||
"X-User-ID": "[CONTEXT].userId",
|
||||
},
|
||||
},
|
||||
context: map[string]interface{}{
|
||||
"token": "abc123token",
|
||||
"userId": "user123",
|
||||
},
|
||||
expectedURL: "https://api.example.com",
|
||||
expectedBody: "",
|
||||
expectedHeaders: map[string]string{
|
||||
"Authorization": "Bearer abc123token",
|
||||
"X-User-ID": "user123",
|
||||
},
|
||||
expectedErrorCount: 0,
|
||||
},
|
||||
{
|
||||
name: "multiple_placeholders_in_url",
|
||||
endpoint: &Endpoint{
|
||||
URL: "https://[CONTEXT].host/api/v[CONTEXT].version/users/[CONTEXT].userId",
|
||||
Body: "",
|
||||
},
|
||||
context: map[string]interface{}{
|
||||
"host": "api.example.com",
|
||||
"version": "2",
|
||||
"userId": "12345",
|
||||
},
|
||||
expectedURL: "https://api.example.com/api/v2/users/12345",
|
||||
expectedBody: "",
|
||||
expectedErrorCount: 0,
|
||||
},
|
||||
{
|
||||
name: "nested_context_path",
|
||||
endpoint: &Endpoint{
|
||||
URL: "https://api.example.com/users/[CONTEXT].user.id",
|
||||
Body: `{"name": "[CONTEXT].user.name"}`,
|
||||
},
|
||||
context: map[string]interface{}{
|
||||
"user": map[string]interface{}{
|
||||
"id": "nested123",
|
||||
"name": "John Doe",
|
||||
},
|
||||
},
|
||||
expectedURL: "https://api.example.com/users/nested123",
|
||||
expectedBody: `{"name": "John Doe"}`,
|
||||
expectedErrorCount: 0,
|
||||
},
|
||||
{
|
||||
name: "url_context_not_found",
|
||||
endpoint: &Endpoint{
|
||||
URL: "https://api.example.com/users/[CONTEXT].missingUserId",
|
||||
Body: "",
|
||||
},
|
||||
context: map[string]interface{}{
|
||||
"userId": "12345", // different key
|
||||
},
|
||||
expectedURL: "https://api.example.com/users/[CONTEXT].missingUserId",
|
||||
expectedBody: "",
|
||||
expectedErrorCount: 1,
|
||||
expectedErrorContains: []string{"path 'missingUserId' not found"},
|
||||
},
|
||||
{
|
||||
name: "body_context_not_found",
|
||||
endpoint: &Endpoint{
|
||||
URL: "https://api.example.com",
|
||||
Body: `{"userId": "[CONTEXT].missingUserId"}`,
|
||||
},
|
||||
context: map[string]interface{}{
|
||||
"userId": "12345", // different key
|
||||
},
|
||||
expectedURL: "https://api.example.com",
|
||||
expectedBody: `{"userId": "[CONTEXT].missingUserId"}`,
|
||||
expectedErrorCount: 1,
|
||||
expectedErrorContains: []string{"path 'missingUserId' not found"},
|
||||
},
|
||||
{
|
||||
name: "header_context_not_found",
|
||||
endpoint: &Endpoint{
|
||||
URL: "https://api.example.com",
|
||||
Body: "",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "Bearer [CONTEXT].missingToken",
|
||||
},
|
||||
},
|
||||
context: map[string]interface{}{
|
||||
"token": "validtoken", // different key
|
||||
},
|
||||
expectedURL: "https://api.example.com",
|
||||
expectedBody: "",
|
||||
expectedHeaders: map[string]string{
|
||||
"Authorization": "Bearer [CONTEXT].missingToken",
|
||||
},
|
||||
expectedErrorCount: 1,
|
||||
expectedErrorContains: []string{"path 'missingToken' not found"},
|
||||
},
|
||||
{
|
||||
name: "multiple_missing_context_paths",
|
||||
endpoint: &Endpoint{
|
||||
URL: "https://[CONTEXT].missingHost/users/[CONTEXT].missingUserId",
|
||||
Body: `{"token": "[CONTEXT].missingToken"}`,
|
||||
},
|
||||
context: map[string]interface{}{
|
||||
"validKey": "validValue",
|
||||
},
|
||||
expectedURL: "https://[CONTEXT].missingHost/users/[CONTEXT].missingUserId",
|
||||
expectedBody: `{"token": "[CONTEXT].missingToken"}`,
|
||||
expectedErrorCount: 2, // 1 for URL (both placeholders), 1 for Body
|
||||
expectedErrorContains: []string{
|
||||
"path 'missingHost' not found",
|
||||
"path 'missingUserId' not found",
|
||||
"path 'missingToken' not found",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mixed_valid_and_invalid_placeholders",
|
||||
endpoint: &Endpoint{
|
||||
URL: "https://api.example.com/users/[CONTEXT].userId/posts/[CONTEXT].missingPostId",
|
||||
Body: `{"userId": "[CONTEXT].userId", "action": "[CONTEXT].missingAction"}`,
|
||||
},
|
||||
context: map[string]interface{}{
|
||||
"userId": "12345",
|
||||
},
|
||||
expectedURL: "https://api.example.com/users/12345/posts/[CONTEXT].missingPostId",
|
||||
expectedBody: `{"userId": "12345", "action": "[CONTEXT].missingAction"}`,
|
||||
expectedErrorCount: 2,
|
||||
expectedErrorContains: []string{
|
||||
"path 'missingPostId' not found",
|
||||
"path 'missingAction' not found",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nil_context",
|
||||
endpoint: &Endpoint{
|
||||
URL: "https://api.example.com/users/[CONTEXT].userId",
|
||||
Body: "",
|
||||
},
|
||||
context: nil,
|
||||
expectedURL: "https://api.example.com/users/[CONTEXT].userId",
|
||||
expectedBody: "",
|
||||
expectedErrorCount: 0,
|
||||
},
|
||||
{
|
||||
name: "empty_context",
|
||||
endpoint: &Endpoint{
|
||||
URL: "https://api.example.com/users/[CONTEXT].userId",
|
||||
Body: "",
|
||||
},
|
||||
context: map[string]interface{}{},
|
||||
expectedURL: "https://api.example.com/users/[CONTEXT].userId",
|
||||
expectedBody: "",
|
||||
expectedErrorCount: 1,
|
||||
expectedErrorContains: []string{"path 'userId' not found"},
|
||||
},
|
||||
{
|
||||
name: "special_characters_in_context_values",
|
||||
endpoint: &Endpoint{
|
||||
URL: "https://api.example.com/search?q=[CONTEXT].query",
|
||||
Body: "",
|
||||
},
|
||||
context: map[string]interface{}{
|
||||
"query": "hello world & special chars!",
|
||||
},
|
||||
expectedURL: "https://api.example.com/search?q=hello world & special chars!",
|
||||
expectedBody: "",
|
||||
expectedErrorCount: 0,
|
||||
},
|
||||
{
|
||||
name: "numeric_context_values",
|
||||
endpoint: &Endpoint{
|
||||
URL: "https://api.example.com/users/[CONTEXT].userId/limit/[CONTEXT].limit",
|
||||
Body: "",
|
||||
},
|
||||
context: map[string]interface{}{
|
||||
"userId": 12345,
|
||||
"limit": 100,
|
||||
},
|
||||
expectedURL: "https://api.example.com/users/12345/limit/100",
|
||||
expectedBody: "",
|
||||
expectedErrorCount: 0,
|
||||
},
|
||||
{
|
||||
name: "boolean_context_values",
|
||||
endpoint: &Endpoint{
|
||||
URL: "https://api.example.com",
|
||||
Body: `{"enabled": [CONTEXT].enabled, "active": [CONTEXT].active}`,
|
||||
},
|
||||
context: map[string]interface{}{
|
||||
"enabled": true,
|
||||
"active": false,
|
||||
},
|
||||
expectedURL: "https://api.example.com",
|
||||
expectedBody: `{"enabled": true, "active": false}`,
|
||||
expectedErrorCount: 0,
|
||||
},
|
||||
{
|
||||
name: "no_context_placeholders",
|
||||
endpoint: &Endpoint{
|
||||
URL: "https://api.example.com/health",
|
||||
Body: `{"status": "check"}`,
|
||||
Headers: map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
context: map[string]interface{}{
|
||||
"userId": "12345",
|
||||
},
|
||||
expectedURL: "https://api.example.com/health",
|
||||
expectedBody: `{"status": "check"}`,
|
||||
expectedHeaders: map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
expectedErrorCount: 0,
|
||||
},
|
||||
{
|
||||
name: "deeply_nested_context_path",
|
||||
endpoint: &Endpoint{
|
||||
URL: "https://api.example.com/users/[CONTEXT].response.data.user.id",
|
||||
Body: "",
|
||||
},
|
||||
context: map[string]interface{}{
|
||||
"response": map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"user": map[string]interface{}{
|
||||
"id": "deep123",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedURL: "https://api.example.com/users/deep123",
|
||||
expectedBody: "",
|
||||
expectedErrorCount: 0,
|
||||
},
|
||||
{
|
||||
name: "invalid_nested_context_path",
|
||||
endpoint: &Endpoint{
|
||||
URL: "https://api.example.com/users/[CONTEXT].response.missing.path",
|
||||
Body: "",
|
||||
},
|
||||
context: map[string]interface{}{
|
||||
"response": map[string]interface{}{
|
||||
"data": "value",
|
||||
},
|
||||
},
|
||||
expectedURL: "https://api.example.com/users/[CONTEXT].response.missing.path",
|
||||
expectedBody: "",
|
||||
expectedErrorCount: 1,
|
||||
expectedErrorContains: []string{"path 'response.missing.path' not found"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Import gontext package for creating context
|
||||
var ctx *gontext.Gontext
|
||||
if tt.context != nil {
|
||||
ctx = gontext.New(tt.context)
|
||||
}
|
||||
// Create a new Result to capture errors
|
||||
result := &Result{}
|
||||
// Call preprocessWithContext
|
||||
processed := tt.endpoint.preprocessWithContext(result, ctx)
|
||||
// Verify URL
|
||||
if processed.URL != tt.expectedURL {
|
||||
t.Errorf("URL mismatch:\nexpected: %s\nactual: %s", tt.expectedURL, processed.URL)
|
||||
}
|
||||
// Verify Body
|
||||
if processed.Body != tt.expectedBody {
|
||||
t.Errorf("Body mismatch:\nexpected: %s\nactual: %s", tt.expectedBody, processed.Body)
|
||||
}
|
||||
// Verify Headers
|
||||
if tt.expectedHeaders != nil {
|
||||
if processed.Headers == nil {
|
||||
t.Error("Expected headers but got nil")
|
||||
} else {
|
||||
for key, expectedValue := range tt.expectedHeaders {
|
||||
if actualValue, exists := processed.Headers[key]; !exists {
|
||||
t.Errorf("Expected header %s not found", key)
|
||||
} else if actualValue != expectedValue {
|
||||
t.Errorf("Header %s mismatch:\nexpected: %s\nactual: %s", key, expectedValue, actualValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Verify error count
|
||||
if len(result.Errors) != tt.expectedErrorCount {
|
||||
t.Errorf("Error count mismatch:\nexpected: %d\nactual: %d\nerrors: %v", tt.expectedErrorCount, len(result.Errors), result.Errors)
|
||||
}
|
||||
// Verify error messages contain expected strings
|
||||
if tt.expectedErrorContains != nil {
|
||||
actualErrors := strings.Join(result.Errors, " ")
|
||||
for _, expectedError := range tt.expectedErrorContains {
|
||||
if !strings.Contains(actualErrors, expectedError) {
|
||||
t.Errorf("Expected error containing '%s' not found in: %v", expectedError, result.Errors)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Verify original endpoint is not modified
|
||||
if tt.endpoint.URL != ((&Endpoint{URL: tt.endpoint.URL, Body: tt.endpoint.Body, Headers: tt.endpoint.Headers}).URL) {
|
||||
t.Error("Original endpoint was modified")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint/heartbeat"
|
||||
"github.com/TwiN/gatus/v5/config/key"
|
||||
"github.com/TwiN/gatus/v5/config/maintenance"
|
||||
)
|
||||
|
||||
@@ -82,7 +83,7 @@ func (externalEndpoint *ExternalEndpoint) DisplayName() string {
|
||||
|
||||
// Key returns the unique key for the Endpoint
|
||||
func (externalEndpoint *ExternalEndpoint) Key() string {
|
||||
return ConvertGroupAndEndpointNameToKey(externalEndpoint.Group, externalEndpoint.Name)
|
||||
return key.ConvertGroupAndNameToKey(externalEndpoint.Group, externalEndpoint.Name)
|
||||
}
|
||||
|
||||
// ToEndpoint converts the ExternalEndpoint to an Endpoint
|
||||
|
||||
@@ -2,24 +2,379 @@ package endpoint
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint/heartbeat"
|
||||
"github.com/TwiN/gatus/v5/config/maintenance"
|
||||
)
|
||||
|
||||
func TestExternalEndpoint_ToEndpoint(t *testing.T) {
|
||||
externalEndpoint := &ExternalEndpoint{
|
||||
Name: "name",
|
||||
Group: "group",
|
||||
func TestExternalEndpoint_ValidateAndSetDefaults(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
endpoint *ExternalEndpoint
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "valid-external-endpoint",
|
||||
endpoint: &ExternalEndpoint{
|
||||
Name: "test-endpoint",
|
||||
Group: "test-group",
|
||||
Token: "valid-token",
|
||||
},
|
||||
wantErr: nil,
|
||||
},
|
||||
{
|
||||
name: "valid-external-endpoint-with-heartbeat",
|
||||
endpoint: &ExternalEndpoint{
|
||||
Name: "test-endpoint",
|
||||
Token: "valid-token",
|
||||
Heartbeat: heartbeat.Config{
|
||||
Interval: 30 * time.Second,
|
||||
},
|
||||
},
|
||||
wantErr: nil,
|
||||
},
|
||||
{
|
||||
name: "missing-token",
|
||||
endpoint: &ExternalEndpoint{
|
||||
Name: "test-endpoint",
|
||||
Group: "test-group",
|
||||
},
|
||||
wantErr: ErrExternalEndpointWithNoToken,
|
||||
},
|
||||
{
|
||||
name: "empty-token",
|
||||
endpoint: &ExternalEndpoint{
|
||||
Name: "test-endpoint",
|
||||
Token: "",
|
||||
},
|
||||
wantErr: ErrExternalEndpointWithNoToken,
|
||||
},
|
||||
{
|
||||
name: "heartbeat-interval-too-low",
|
||||
endpoint: &ExternalEndpoint{
|
||||
Name: "test-endpoint",
|
||||
Token: "valid-token",
|
||||
Heartbeat: heartbeat.Config{
|
||||
Interval: 5 * time.Second, // Less than 10 seconds
|
||||
},
|
||||
},
|
||||
wantErr: ErrExternalEndpointHeartbeatIntervalTooLow,
|
||||
},
|
||||
{
|
||||
name: "heartbeat-interval-exactly-10-seconds",
|
||||
endpoint: &ExternalEndpoint{
|
||||
Name: "test-endpoint",
|
||||
Token: "valid-token",
|
||||
Heartbeat: heartbeat.Config{
|
||||
Interval: 10 * time.Second,
|
||||
},
|
||||
},
|
||||
wantErr: nil,
|
||||
},
|
||||
{
|
||||
name: "heartbeat-interval-zero-is-allowed",
|
||||
endpoint: &ExternalEndpoint{
|
||||
Name: "test-endpoint",
|
||||
Token: "valid-token",
|
||||
Heartbeat: heartbeat.Config{
|
||||
Interval: 0, // Zero means no heartbeat monitoring
|
||||
},
|
||||
},
|
||||
wantErr: nil,
|
||||
},
|
||||
{
|
||||
name: "missing-name",
|
||||
endpoint: &ExternalEndpoint{
|
||||
Group: "test-group",
|
||||
Token: "valid-token",
|
||||
},
|
||||
wantErr: ErrEndpointWithNoName,
|
||||
},
|
||||
}
|
||||
convertedEndpoint := externalEndpoint.ToEndpoint()
|
||||
if externalEndpoint.Name != convertedEndpoint.Name {
|
||||
t.Errorf("expected %s, got %s", externalEndpoint.Name, convertedEndpoint.Name)
|
||||
}
|
||||
if externalEndpoint.Group != convertedEndpoint.Group {
|
||||
t.Errorf("expected %s, got %s", externalEndpoint.Group, convertedEndpoint.Group)
|
||||
}
|
||||
if externalEndpoint.Key() != convertedEndpoint.Key() {
|
||||
t.Errorf("expected %s, got %s", externalEndpoint.Key(), convertedEndpoint.Key())
|
||||
}
|
||||
if externalEndpoint.DisplayName() != convertedEndpoint.DisplayName() {
|
||||
t.Errorf("expected %s, got %s", externalEndpoint.DisplayName(), convertedEndpoint.DisplayName())
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.endpoint.ValidateAndSetDefaults()
|
||||
if tt.wantErr != nil {
|
||||
if err == nil {
|
||||
t.Errorf("Expected error %v, but got none", tt.wantErr)
|
||||
return
|
||||
}
|
||||
if err.Error() != tt.wantErr.Error() {
|
||||
t.Errorf("Expected error %v, got %v", tt.wantErr, err)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, but got %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalEndpoint_IsEnabled(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
enabled *bool
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "nil-enabled-defaults-to-true",
|
||||
enabled: nil,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "explicitly-enabled",
|
||||
enabled: boolPtr(true),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "explicitly-disabled",
|
||||
enabled: boolPtr(false),
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
endpoint := &ExternalEndpoint{
|
||||
Name: "test-endpoint",
|
||||
Token: "test-token",
|
||||
Enabled: tt.enabled,
|
||||
}
|
||||
result := endpoint.IsEnabled()
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %v, got %v", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalEndpoint_DisplayName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
endpoint *ExternalEndpoint
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "with-group",
|
||||
endpoint: &ExternalEndpoint{
|
||||
Name: "test-endpoint",
|
||||
Group: "test-group",
|
||||
},
|
||||
expected: "test-group/test-endpoint",
|
||||
},
|
||||
{
|
||||
name: "without-group",
|
||||
endpoint: &ExternalEndpoint{
|
||||
Name: "test-endpoint",
|
||||
Group: "",
|
||||
},
|
||||
expected: "test-endpoint",
|
||||
},
|
||||
{
|
||||
name: "empty-group-string",
|
||||
endpoint: &ExternalEndpoint{
|
||||
Name: "api-health",
|
||||
Group: "",
|
||||
},
|
||||
expected: "api-health",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := tt.endpoint.DisplayName()
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %q, got %q", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalEndpoint_Key(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
endpoint *ExternalEndpoint
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "with-group",
|
||||
endpoint: &ExternalEndpoint{
|
||||
Name: "test-endpoint",
|
||||
Group: "test-group",
|
||||
},
|
||||
expected: "test-group_test-endpoint",
|
||||
},
|
||||
{
|
||||
name: "without-group",
|
||||
endpoint: &ExternalEndpoint{
|
||||
Name: "test-endpoint",
|
||||
Group: "",
|
||||
},
|
||||
expected: "_test-endpoint",
|
||||
},
|
||||
{
|
||||
name: "special-characters-in-name",
|
||||
endpoint: &ExternalEndpoint{
|
||||
Name: "test endpoint with spaces",
|
||||
Group: "test-group",
|
||||
},
|
||||
expected: "test-group_test-endpoint-with-spaces",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := tt.endpoint.Key()
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %q, got %q", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalEndpoint_ToEndpoint(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
externalEndpoint *ExternalEndpoint
|
||||
}{
|
||||
{
|
||||
name: "complete-external-endpoint",
|
||||
externalEndpoint: &ExternalEndpoint{
|
||||
Enabled: boolPtr(true),
|
||||
Name: "test-endpoint",
|
||||
Group: "test-group",
|
||||
Token: "test-token",
|
||||
Alerts: []*alert.Alert{
|
||||
{
|
||||
Type: alert.TypeSlack,
|
||||
},
|
||||
},
|
||||
MaintenanceWindows: []*maintenance.Config{
|
||||
{
|
||||
Start: "02:00",
|
||||
Duration: time.Hour,
|
||||
},
|
||||
},
|
||||
NumberOfFailuresInARow: 3,
|
||||
NumberOfSuccessesInARow: 5,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "minimal-external-endpoint",
|
||||
externalEndpoint: &ExternalEndpoint{
|
||||
Name: "minimal-endpoint",
|
||||
Token: "minimal-token",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "disabled-external-endpoint",
|
||||
externalEndpoint: &ExternalEndpoint{
|
||||
Enabled: boolPtr(false),
|
||||
Name: "disabled-endpoint",
|
||||
Token: "disabled-token",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "original-test-case",
|
||||
externalEndpoint: &ExternalEndpoint{
|
||||
Name: "name",
|
||||
Group: "group",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := tt.externalEndpoint.ToEndpoint()
|
||||
// Verify all fields are correctly copied
|
||||
if result.Enabled != tt.externalEndpoint.Enabled {
|
||||
t.Errorf("Expected Enabled=%v, got %v", tt.externalEndpoint.Enabled, result.Enabled)
|
||||
}
|
||||
if result.Name != tt.externalEndpoint.Name {
|
||||
t.Errorf("Expected Name=%q, got %q", tt.externalEndpoint.Name, result.Name)
|
||||
}
|
||||
if result.Group != tt.externalEndpoint.Group {
|
||||
t.Errorf("Expected Group=%q, got %q", tt.externalEndpoint.Group, result.Group)
|
||||
}
|
||||
if len(result.Alerts) != len(tt.externalEndpoint.Alerts) {
|
||||
t.Errorf("Expected %d alerts, got %d", len(tt.externalEndpoint.Alerts), len(result.Alerts))
|
||||
}
|
||||
if result.NumberOfFailuresInARow != tt.externalEndpoint.NumberOfFailuresInARow {
|
||||
t.Errorf("Expected NumberOfFailuresInARow=%d, got %d", tt.externalEndpoint.NumberOfFailuresInARow, result.NumberOfFailuresInARow)
|
||||
}
|
||||
if result.NumberOfSuccessesInARow != tt.externalEndpoint.NumberOfSuccessesInARow {
|
||||
t.Errorf("Expected NumberOfSuccessesInARow=%d, got %d", tt.externalEndpoint.NumberOfSuccessesInARow, result.NumberOfSuccessesInARow)
|
||||
}
|
||||
// Original test assertions
|
||||
if tt.externalEndpoint.Key() != result.Key() {
|
||||
t.Errorf("expected %s, got %s", tt.externalEndpoint.Key(), result.Key())
|
||||
}
|
||||
if tt.externalEndpoint.DisplayName() != result.DisplayName() {
|
||||
t.Errorf("expected %s, got %s", tt.externalEndpoint.DisplayName(), result.DisplayName())
|
||||
}
|
||||
// Verify it's a proper Endpoint type
|
||||
if result == nil {
|
||||
t.Error("ToEndpoint() returned nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalEndpoint_ValidationEdgeCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
endpoint *ExternalEndpoint
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "very-long-name",
|
||||
endpoint: &ExternalEndpoint{
|
||||
Name: "this-is-a-very-long-endpoint-name-that-might-cause-issues-in-some-systems-but-should-be-handled-gracefully",
|
||||
Token: "valid-token",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "special-characters-in-name",
|
||||
endpoint: &ExternalEndpoint{
|
||||
Name: "test-endpoint@#$%^&*()",
|
||||
Token: "valid-token",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "unicode-characters-in-name",
|
||||
endpoint: &ExternalEndpoint{
|
||||
Name: "测试端点",
|
||||
Token: "valid-token",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "very-long-token",
|
||||
endpoint: &ExternalEndpoint{
|
||||
Name: "test-endpoint",
|
||||
Token: "very-long-token-that-should-still-be-valid-even-though-it-is-extremely-long-and-might-not-be-practical-in-real-world-scenarios",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.endpoint.ValidateAndSetDefaults()
|
||||
if tt.wantErr && err == nil {
|
||||
t.Error("Expected error but got none")
|
||||
}
|
||||
if !tt.wantErr && err != nil {
|
||||
t.Errorf("Expected no error but got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to create bool pointers
|
||||
func boolPtr(b bool) *bool {
|
||||
return &b
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
package endpoint
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func BenchmarkConvertGroupAndEndpointNameToKey(b *testing.B) {
|
||||
for n := 0; n < b.N; n++ {
|
||||
ConvertGroupAndEndpointNameToKey("group", "name")
|
||||
}
|
||||
}
|
||||
273
config/endpoint/placeholder.go
Normal file
273
config/endpoint/placeholder.go
Normal file
@@ -0,0 +1,273 @@
|
||||
package endpoint
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/TwiN/gatus/v5/config/gontext"
|
||||
"github.com/TwiN/gatus/v5/jsonpath"
|
||||
)
|
||||
|
||||
// Placeholders
|
||||
const (
|
||||
// StatusPlaceholder is a placeholder for a HTTP status.
|
||||
//
|
||||
// Values that could replace the placeholder: 200, 404, 500, ...
|
||||
StatusPlaceholder = "[STATUS]"
|
||||
|
||||
// IPPlaceholder is a placeholder for an IP.
|
||||
//
|
||||
// Values that could replace the placeholder: 127.0.0.1, 10.0.0.1, ...
|
||||
IPPlaceholder = "[IP]"
|
||||
|
||||
// DNSRCodePlaceholder is a placeholder for DNS_RCODE
|
||||
//
|
||||
// Values that could replace the placeholder: NOERROR, FORMERR, SERVFAIL, NXDOMAIN, NOTIMP, REFUSED
|
||||
DNSRCodePlaceholder = "[DNS_RCODE]"
|
||||
|
||||
// ResponseTimePlaceholder is a placeholder for the request response time, in milliseconds.
|
||||
//
|
||||
// Values that could replace the placeholder: 1, 500, 1000, ...
|
||||
ResponseTimePlaceholder = "[RESPONSE_TIME]"
|
||||
|
||||
// BodyPlaceholder is a placeholder for the Body of the response
|
||||
//
|
||||
// Values that could replace the placeholder: {}, {"data":{"name":"john"}}, ...
|
||||
BodyPlaceholder = "[BODY]"
|
||||
|
||||
// ConnectedPlaceholder is a placeholder for whether a connection was successfully established.
|
||||
//
|
||||
// Values that could replace the placeholder: true, false
|
||||
ConnectedPlaceholder = "[CONNECTED]"
|
||||
|
||||
// CertificateExpirationPlaceholder is a placeholder for the duration before certificate expiration, in milliseconds.
|
||||
//
|
||||
// Values that could replace the placeholder: 4461677039 (~52 days)
|
||||
CertificateExpirationPlaceholder = "[CERTIFICATE_EXPIRATION]"
|
||||
|
||||
// DomainExpirationPlaceholder is a placeholder for the duration before the domain expires, in milliseconds.
|
||||
DomainExpirationPlaceholder = "[DOMAIN_EXPIRATION]"
|
||||
|
||||
// ContextPlaceholder is a placeholder for suite context values
|
||||
// Usage: [CONTEXT].path.to.value
|
||||
ContextPlaceholder = "[CONTEXT]"
|
||||
)
|
||||
|
||||
// Functions
|
||||
const (
|
||||
// LengthFunctionPrefix is the prefix for the length function
|
||||
//
|
||||
// Usage: len([BODY].articles) == 10, len([BODY].name) > 5
|
||||
LengthFunctionPrefix = "len("
|
||||
|
||||
// HasFunctionPrefix is the prefix for the has function
|
||||
//
|
||||
// Usage: has([BODY].errors) == true
|
||||
HasFunctionPrefix = "has("
|
||||
|
||||
// PatternFunctionPrefix is the prefix for the pattern function
|
||||
//
|
||||
// Usage: [IP] == pat(192.168.*.*)
|
||||
PatternFunctionPrefix = "pat("
|
||||
|
||||
// AnyFunctionPrefix is the prefix for the any function
|
||||
//
|
||||
// Usage: [IP] == any(1.1.1.1, 1.0.0.1)
|
||||
AnyFunctionPrefix = "any("
|
||||
|
||||
// FunctionSuffix is the suffix for all functions
|
||||
FunctionSuffix = ")"
|
||||
)
|
||||
|
||||
// Other constants
|
||||
const (
|
||||
// InvalidConditionElementSuffix is the suffix that will be appended to an invalid condition
|
||||
InvalidConditionElementSuffix = "(INVALID)"
|
||||
)
|
||||
|
||||
// functionType represents the type of function wrapper
|
||||
type functionType int
|
||||
|
||||
const (
|
||||
// Note that not all functions are handled here. Only len() and has() directly impact the handler
|
||||
// e.g. "len([BODY].name) > 0" vs pat() or any(), which would be used like "[BODY].name == pat(john*)"
|
||||
|
||||
noFunction functionType = iota
|
||||
functionLen
|
||||
functionHas
|
||||
)
|
||||
|
||||
// ResolvePlaceholder resolves all types of placeholders to their string values.
|
||||
//
|
||||
// Supported placeholders:
|
||||
// - [STATUS]: HTTP status code (e.g., "200", "404")
|
||||
// - [IP]: IP address from the response (e.g., "127.0.0.1")
|
||||
// - [RESPONSE_TIME]: Response time in milliseconds (e.g., "250")
|
||||
// - [DNS_RCODE]: DNS response code (e.g., "NOERROR", "NXDOMAIN")
|
||||
// - [CONNECTED]: Connection status (e.g., "true", "false")
|
||||
// - [CERTIFICATE_EXPIRATION]: Certificate expiration time in milliseconds
|
||||
// - [DOMAIN_EXPIRATION]: Domain expiration time in milliseconds
|
||||
// - [BODY]: Full response body
|
||||
// - [BODY].path: JSONPath expression on response body (e.g., [BODY].status, [BODY].data[0].name)
|
||||
// - [CONTEXT].path: Suite context values (e.g., [CONTEXT].user_id, [CONTEXT].session_token)
|
||||
//
|
||||
// Function wrappers:
|
||||
// - len(placeholder): Returns the length of the resolved value
|
||||
// - has(placeholder): Returns "true" if the placeholder exists and is non-empty, "false" otherwise
|
||||
//
|
||||
// Examples:
|
||||
// - ResolvePlaceholder("[STATUS]", result, nil) → "200"
|
||||
// - ResolvePlaceholder("len([BODY].items)", result, nil) → "5" (for JSON array with 5 items)
|
||||
// - ResolvePlaceholder("has([CONTEXT].user_id)", result, ctx) → "true" (if context has user_id)
|
||||
// - ResolvePlaceholder("[BODY].user.name", result, nil) → "john" (for {"user":{"name":"john"}})
|
||||
//
|
||||
// Case-insensitive: All placeholder names are handled case-insensitively, but paths preserve original case.
|
||||
func ResolvePlaceholder(placeholder string, result *Result, ctx *gontext.Gontext) (string, error) {
|
||||
placeholder = strings.TrimSpace(placeholder)
|
||||
originalPlaceholder := placeholder
|
||||
|
||||
// Extract function wrapper if present
|
||||
fn, innerPlaceholder := extractFunctionWrapper(placeholder)
|
||||
placeholder = innerPlaceholder
|
||||
|
||||
// Handle CONTEXT placeholders
|
||||
uppercasePlaceholder := strings.ToUpper(placeholder)
|
||||
if strings.HasPrefix(uppercasePlaceholder, ContextPlaceholder) && ctx != nil {
|
||||
return resolveContextPlaceholder(placeholder, fn, originalPlaceholder, ctx)
|
||||
}
|
||||
|
||||
// Handle basic placeholders (try uppercase first for backward compatibility)
|
||||
switch uppercasePlaceholder {
|
||||
case StatusPlaceholder:
|
||||
return formatWithFunction(strconv.Itoa(result.HTTPStatus), fn), nil
|
||||
case IPPlaceholder:
|
||||
return formatWithFunction(result.IP, fn), nil
|
||||
case ResponseTimePlaceholder:
|
||||
return formatWithFunction(strconv.FormatInt(result.Duration.Milliseconds(), 10), fn), nil
|
||||
case DNSRCodePlaceholder:
|
||||
return formatWithFunction(result.DNSRCode, fn), nil
|
||||
case ConnectedPlaceholder:
|
||||
return formatWithFunction(strconv.FormatBool(result.Connected), fn), nil
|
||||
case CertificateExpirationPlaceholder:
|
||||
return formatWithFunction(strconv.FormatInt(result.CertificateExpiration.Milliseconds(), 10), fn), nil
|
||||
case DomainExpirationPlaceholder:
|
||||
return formatWithFunction(strconv.FormatInt(result.DomainExpiration.Milliseconds(), 10), fn), nil
|
||||
case BodyPlaceholder:
|
||||
body := strings.TrimSpace(string(result.Body))
|
||||
if fn == functionHas {
|
||||
return strconv.FormatBool(len(body) > 0), nil
|
||||
}
|
||||
if fn == functionLen {
|
||||
// For len([BODY]), we need to check if it's JSON and get the actual length
|
||||
// Use jsonpath to evaluate the root element
|
||||
_, resolvedLength, err := jsonpath.Eval("", result.Body)
|
||||
if err == nil {
|
||||
return strconv.Itoa(resolvedLength), nil
|
||||
}
|
||||
// Fall back to string length if not valid JSON
|
||||
return strconv.Itoa(len(body)), nil
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// Handle JSONPath expressions on BODY (including array indexing)
|
||||
if strings.HasPrefix(uppercasePlaceholder, BodyPlaceholder+".") || strings.HasPrefix(uppercasePlaceholder, BodyPlaceholder+"[") {
|
||||
return resolveJSONPathPlaceholder(placeholder, fn, originalPlaceholder, result)
|
||||
}
|
||||
|
||||
// Not a recognized placeholder
|
||||
if fn != noFunction {
|
||||
if fn == functionHas {
|
||||
return "false", nil
|
||||
}
|
||||
// For len() with unrecognized placeholder, return with INVALID suffix
|
||||
return originalPlaceholder + " " + InvalidConditionElementSuffix, nil
|
||||
}
|
||||
|
||||
// Return the original placeholder if we can't resolve it
|
||||
// This allows for literal string comparisons
|
||||
return originalPlaceholder, nil
|
||||
}
|
||||
|
||||
// extractFunctionWrapper detects and extracts function wrappers (len, has)
|
||||
func extractFunctionWrapper(placeholder string) (functionType, string) {
|
||||
if strings.HasPrefix(placeholder, LengthFunctionPrefix) && strings.HasSuffix(placeholder, FunctionSuffix) {
|
||||
inner := strings.TrimSuffix(strings.TrimPrefix(placeholder, LengthFunctionPrefix), FunctionSuffix)
|
||||
return functionLen, inner
|
||||
}
|
||||
if strings.HasPrefix(placeholder, HasFunctionPrefix) && strings.HasSuffix(placeholder, FunctionSuffix) {
|
||||
inner := strings.TrimSuffix(strings.TrimPrefix(placeholder, HasFunctionPrefix), FunctionSuffix)
|
||||
return functionHas, inner
|
||||
}
|
||||
return noFunction, placeholder
|
||||
}
|
||||
|
||||
// resolveJSONPathPlaceholder handles [BODY].path and [BODY][index] placeholders
|
||||
func resolveJSONPathPlaceholder(placeholder string, fn functionType, originalPlaceholder string, result *Result) (string, error) {
|
||||
// Extract the path after [BODY] (case insensitive)
|
||||
uppercasePlaceholder := strings.ToUpper(placeholder)
|
||||
path := ""
|
||||
if strings.HasPrefix(uppercasePlaceholder, BodyPlaceholder) {
|
||||
path = placeholder[len(BodyPlaceholder):]
|
||||
} else {
|
||||
path = strings.TrimPrefix(placeholder, BodyPlaceholder)
|
||||
}
|
||||
// Remove leading dot if present
|
||||
path = strings.TrimPrefix(path, ".")
|
||||
resolvedValue, resolvedLength, err := jsonpath.Eval(path, result.Body)
|
||||
if fn == functionHas {
|
||||
return strconv.FormatBool(err == nil), nil
|
||||
}
|
||||
if err != nil {
|
||||
return originalPlaceholder + " " + InvalidConditionElementSuffix, nil
|
||||
}
|
||||
if fn == functionLen {
|
||||
return strconv.Itoa(resolvedLength), nil
|
||||
}
|
||||
return resolvedValue, nil
|
||||
}
|
||||
|
||||
// resolveContextPlaceholder handles [CONTEXT] placeholder resolution
|
||||
func resolveContextPlaceholder(placeholder string, fn functionType, originalPlaceholder string, ctx *gontext.Gontext) (string, error) {
|
||||
contextPath := strings.TrimPrefix(placeholder, ContextPlaceholder)
|
||||
contextPath = strings.TrimPrefix(contextPath, ".")
|
||||
if contextPath == "" {
|
||||
if fn == functionHas {
|
||||
return "false", nil
|
||||
}
|
||||
return originalPlaceholder + " " + InvalidConditionElementSuffix, nil
|
||||
}
|
||||
value, err := ctx.Get(contextPath)
|
||||
if fn == functionHas {
|
||||
return strconv.FormatBool(err == nil), nil
|
||||
}
|
||||
if err != nil {
|
||||
return originalPlaceholder + " " + InvalidConditionElementSuffix, nil
|
||||
}
|
||||
if fn == functionLen {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
return strconv.Itoa(len(v)), nil
|
||||
case []interface{}:
|
||||
return strconv.Itoa(len(v)), nil
|
||||
case map[string]interface{}:
|
||||
return strconv.Itoa(len(v)), nil
|
||||
default:
|
||||
return strconv.Itoa(len(fmt.Sprintf("%v", v))), nil
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%v", value), nil
|
||||
}
|
||||
|
||||
// formatWithFunction applies len/has functions to any value
|
||||
func formatWithFunction(value string, fn functionType) string {
|
||||
switch fn {
|
||||
case functionHas:
|
||||
return strconv.FormatBool(value != "")
|
||||
case functionLen:
|
||||
return strconv.Itoa(len(value))
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
125
config/endpoint/placeholder_test.go
Normal file
125
config/endpoint/placeholder_test.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package endpoint
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/config/gontext"
|
||||
)
|
||||
|
||||
func TestResolvePlaceholder(t *testing.T) {
|
||||
result := &Result{
|
||||
HTTPStatus: 200,
|
||||
IP: "127.0.0.1",
|
||||
Duration: 250 * time.Millisecond,
|
||||
DNSRCode: "NOERROR",
|
||||
Connected: true,
|
||||
CertificateExpiration: 30 * 24 * time.Hour,
|
||||
DomainExpiration: 365 * 24 * time.Hour,
|
||||
Body: []byte(`{"status":"success","items":[1,2,3],"user":{"name":"john","id":123}}`),
|
||||
}
|
||||
|
||||
ctx := gontext.New(map[string]interface{}{
|
||||
"user_id": "abc123",
|
||||
"session_token": "xyz789",
|
||||
"array_data": []interface{}{"a", "b", "c"},
|
||||
"nested": map[string]interface{}{
|
||||
"value": "test",
|
||||
},
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
placeholder string
|
||||
expected string
|
||||
}{
|
||||
// Basic placeholders
|
||||
{"status", "[STATUS]", "200"},
|
||||
{"ip", "[IP]", "127.0.0.1"},
|
||||
{"response-time", "[RESPONSE_TIME]", "250"},
|
||||
{"dns-rcode", "[DNS_RCODE]", "NOERROR"},
|
||||
{"connected", "[CONNECTED]", "true"},
|
||||
{"certificate-expiration", "[CERTIFICATE_EXPIRATION]", "2592000000"},
|
||||
{"domain-expiration", "[DOMAIN_EXPIRATION]", "31536000000"},
|
||||
{"body", "[BODY]", `{"status":"success","items":[1,2,3],"user":{"name":"john","id":123}}`},
|
||||
|
||||
// Case insensitive placeholders
|
||||
{"status-lowercase", "[status]", "200"},
|
||||
{"ip-mixed-case", "[Ip]", "127.0.0.1"},
|
||||
|
||||
// Function wrappers on basic placeholders
|
||||
{"len-status", "len([STATUS])", "3"},
|
||||
{"len-ip", "len([IP])", "9"},
|
||||
{"has-status", "has([STATUS])", "true"},
|
||||
{"has-empty", "has()", "false"},
|
||||
|
||||
// JSONPath expressions
|
||||
{"body-status", "[BODY].status", "success"},
|
||||
{"body-user-name", "[BODY].user.name", "john"},
|
||||
{"body-user-id", "[BODY].user.id", "123"},
|
||||
{"len-body-items", "len([BODY].items)", "3"},
|
||||
{"body-array-index", "[BODY].items[0]", "1"},
|
||||
{"has-body-status", "has([BODY].status)", "true"},
|
||||
{"has-body-missing", "has([BODY].missing)", "false"},
|
||||
|
||||
// Context placeholders
|
||||
{"context-user-id", "[CONTEXT].user_id", "abc123"},
|
||||
{"context-session-token", "[CONTEXT].session_token", "xyz789"},
|
||||
{"context-nested", "[CONTEXT].nested.value", "test"},
|
||||
{"len-context-array", "len([CONTEXT].array_data)", "3"},
|
||||
{"has-context-user-id", "has([CONTEXT].user_id)", "true"},
|
||||
{"has-context-missing", "has([CONTEXT].missing)", "false"},
|
||||
|
||||
// Invalid placeholders
|
||||
{"unknown-placeholder", "[UNKNOWN]", "[UNKNOWN]"},
|
||||
{"len-unknown", "len([UNKNOWN])", "len([UNKNOWN]) (INVALID)"},
|
||||
{"has-unknown", "has([UNKNOWN])", "false"},
|
||||
{"invalid-jsonpath", "[BODY].invalid.path", "[BODY].invalid.path (INVALID)"},
|
||||
|
||||
// Literal strings
|
||||
{"literal-string", "literal", "literal"},
|
||||
{"number-string", "123", "123"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
actual, err := ResolvePlaceholder(test.placeholder, result, ctx)
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
if actual != test.expected {
|
||||
t.Errorf("expected '%s', got '%s'", test.expected, actual)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePlaceholderWithoutContext(t *testing.T) {
|
||||
result := &Result{
|
||||
HTTPStatus: 404,
|
||||
Body: []byte(`{"error":"not found"}`),
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
placeholder string
|
||||
expected string
|
||||
}{
|
||||
{"status-without-context", "[STATUS]", "404"},
|
||||
{"body-without-context", "[BODY].error", "not found"},
|
||||
{"context-without-context", "[CONTEXT].user_id", "[CONTEXT].user_id"},
|
||||
{"has-context-without-context", "has([CONTEXT].user_id)", "false"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
actual, err := ResolvePlaceholder(test.placeholder, result, nil)
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
if actual != test.expected {
|
||||
t.Errorf("expected '%s', got '%s'", test.expected, actual)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Result of the evaluation of a Endpoint
|
||||
// Result of the evaluation of an Endpoint
|
||||
type Result struct {
|
||||
// HTTPStatus is the HTTP response status code
|
||||
HTTPStatus int `json:"status,omitempty"`
|
||||
@@ -54,6 +54,13 @@ type Result struct {
|
||||
// Below is used only for the UI and is not persisted in the storage //
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
port string `yaml:"-"` // used for endpoints[].ui.hide-port
|
||||
|
||||
///////////////////////////////////
|
||||
// BELOW IS ONLY USED FOR SUITES //
|
||||
///////////////////////////////////
|
||||
// Name of the endpoint (ONLY USED FOR SUITES)
|
||||
// Group is not needed because it's inherited from the suite
|
||||
Name string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
// AddError adds an error to the result's list of errors.
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package endpoint
|
||||
|
||||
import "github.com/TwiN/gatus/v5/config/key"
|
||||
|
||||
// Status contains the evaluation Results of an Endpoint
|
||||
// This is essentially a DTO
|
||||
type Status struct {
|
||||
// Name of the endpoint
|
||||
Name string `json:"name,omitempty"`
|
||||
@@ -30,7 +33,7 @@ func NewStatus(group, name string) *Status {
|
||||
return &Status{
|
||||
Name: name,
|
||||
Group: group,
|
||||
Key: ConvertGroupAndEndpointNameToKey(group, name),
|
||||
Key: key.ConvertGroupAndNameToKey(group, name),
|
||||
Results: make([]*Result, 0),
|
||||
Events: make([]*Event, 0),
|
||||
Uptime: NewUptime(),
|
||||
|
||||
121
config/gontext/gontext.go
Normal file
121
config/gontext/gontext.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package gontext
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrGontextPathNotFound is returned when a gontext path doesn't exist
|
||||
ErrGontextPathNotFound = errors.New("gontext path not found")
|
||||
)
|
||||
|
||||
// Gontext holds values that can be shared between endpoints in a suite
|
||||
type Gontext struct {
|
||||
mu sync.RWMutex
|
||||
values map[string]interface{}
|
||||
}
|
||||
|
||||
// New creates a new gontext with initial values
|
||||
func New(initial map[string]interface{}) *Gontext {
|
||||
if initial == nil {
|
||||
initial = make(map[string]interface{})
|
||||
}
|
||||
// Create a deep copy to avoid external modifications
|
||||
values := make(map[string]interface{})
|
||||
for k, v := range initial {
|
||||
values[k] = deepCopyValue(v)
|
||||
}
|
||||
return &Gontext{
|
||||
values: values,
|
||||
}
|
||||
}
|
||||
|
||||
// Get retrieves a value from the gontext using dot notation
|
||||
func (g *Gontext) Get(path string) (interface{}, error) {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
parts := strings.Split(path, ".")
|
||||
current := interface{}(g.values)
|
||||
for _, part := range parts {
|
||||
switch v := current.(type) {
|
||||
case map[string]interface{}:
|
||||
val, exists := v[part]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("%w: %s", ErrGontextPathNotFound, path)
|
||||
}
|
||||
current = val
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: %s", ErrGontextPathNotFound, path)
|
||||
}
|
||||
}
|
||||
return current, nil
|
||||
}
|
||||
|
||||
// Set stores a value in the gontext using dot notation
|
||||
func (g *Gontext) Set(path string, value interface{}) error {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
parts := strings.Split(path, ".")
|
||||
if len(parts) == 0 {
|
||||
return errors.New("empty path")
|
||||
}
|
||||
// Navigate to the parent of the target
|
||||
current := g.values
|
||||
for i := 0; i < len(parts)-1; i++ {
|
||||
part := parts[i]
|
||||
if next, exists := current[part]; exists {
|
||||
if nextMap, ok := next.(map[string]interface{}); ok {
|
||||
current = nextMap
|
||||
} else {
|
||||
// Path exists but is not a map, create a new map
|
||||
newMap := make(map[string]interface{})
|
||||
current[part] = newMap
|
||||
current = newMap
|
||||
}
|
||||
} else {
|
||||
// Create intermediate maps
|
||||
newMap := make(map[string]interface{})
|
||||
current[part] = newMap
|
||||
current = newMap
|
||||
}
|
||||
}
|
||||
// Set the final value
|
||||
current[parts[len(parts)-1]] = value
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAll returns a copy of all gontext values
|
||||
func (g *Gontext) GetAll() map[string]interface{} {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
|
||||
result := make(map[string]interface{})
|
||||
for k, v := range g.values {
|
||||
result[k] = deepCopyValue(v)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// deepCopyValue creates a deep copy of a value
|
||||
func deepCopyValue(v interface{}) interface{} {
|
||||
switch val := v.(type) {
|
||||
case map[string]interface{}:
|
||||
newMap := make(map[string]interface{})
|
||||
for k, v := range val {
|
||||
newMap[k] = deepCopyValue(v)
|
||||
}
|
||||
return newMap
|
||||
case []interface{}:
|
||||
newSlice := make([]interface{}, len(val))
|
||||
for i, v := range val {
|
||||
newSlice[i] = deepCopyValue(v)
|
||||
}
|
||||
return newSlice
|
||||
default:
|
||||
// For primitive types, return as-is (they're passed by value anyway)
|
||||
return val
|
||||
}
|
||||
}
|
||||
448
config/gontext/gontext_test.go
Normal file
448
config/gontext/gontext_test.go
Normal file
@@ -0,0 +1,448 @@
|
||||
package gontext
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
initial map[string]interface{}
|
||||
expected map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "nil-input",
|
||||
initial: nil,
|
||||
expected: make(map[string]interface{}),
|
||||
},
|
||||
{
|
||||
name: "empty-input",
|
||||
initial: make(map[string]interface{}),
|
||||
expected: make(map[string]interface{}),
|
||||
},
|
||||
{
|
||||
name: "simple-values",
|
||||
initial: map[string]interface{}{
|
||||
"key1": "value1",
|
||||
"key2": 42,
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"key1": "value1",
|
||||
"key2": 42,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nested-values",
|
||||
initial: map[string]interface{}{
|
||||
"user": map[string]interface{}{
|
||||
"id": 123,
|
||||
"name": "John Doe",
|
||||
},
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"user": map[string]interface{}{
|
||||
"id": 123,
|
||||
"name": "John Doe",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := New(tt.initial)
|
||||
if ctx == nil {
|
||||
t.Error("Expected non-nil gontext")
|
||||
}
|
||||
if ctx.values == nil {
|
||||
t.Error("Expected non-nil values map")
|
||||
}
|
||||
|
||||
// Verify deep copy by modifying original
|
||||
if tt.initial != nil {
|
||||
tt.initial["modified"] = "should not appear"
|
||||
if _, exists := ctx.values["modified"]; exists {
|
||||
t.Error("Deep copy failed - original map modification affected gontext")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGontext_Get(t *testing.T) {
|
||||
ctx := New(map[string]interface{}{
|
||||
"simple": "value",
|
||||
"number": 42,
|
||||
"boolean": true,
|
||||
"nested": map[string]interface{}{
|
||||
"level1": map[string]interface{}{
|
||||
"level2": "deep_value",
|
||||
},
|
||||
},
|
||||
"user": map[string]interface{}{
|
||||
"id": 123,
|
||||
"name": "John",
|
||||
"profile": map[string]interface{}{
|
||||
"email": "john@example.com",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
expected interface{}
|
||||
shouldError bool
|
||||
errorType error
|
||||
}{
|
||||
{
|
||||
name: "simple-value",
|
||||
path: "simple",
|
||||
expected: "value",
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "number-value",
|
||||
path: "number",
|
||||
expected: 42,
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "boolean-value",
|
||||
path: "boolean",
|
||||
expected: true,
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "nested-value",
|
||||
path: "nested.level1.level2",
|
||||
expected: "deep_value",
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "user-id",
|
||||
path: "user.id",
|
||||
expected: 123,
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "deep-nested-value",
|
||||
path: "user.profile.email",
|
||||
expected: "john@example.com",
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "non-existent-key",
|
||||
path: "nonexistent",
|
||||
expected: nil,
|
||||
shouldError: true,
|
||||
errorType: ErrGontextPathNotFound,
|
||||
},
|
||||
{
|
||||
name: "non-existent-nested-key",
|
||||
path: "user.nonexistent",
|
||||
expected: nil,
|
||||
shouldError: true,
|
||||
errorType: ErrGontextPathNotFound,
|
||||
},
|
||||
{
|
||||
name: "invalid-nested-path",
|
||||
path: "simple.invalid",
|
||||
expected: nil,
|
||||
shouldError: true,
|
||||
errorType: ErrGontextPathNotFound,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := ctx.Get(tt.path)
|
||||
|
||||
if tt.shouldError {
|
||||
if err == nil {
|
||||
t.Errorf("Expected error but got none")
|
||||
}
|
||||
if tt.errorType != nil && !errors.Is(err, tt.errorType) {
|
||||
t.Errorf("Expected error type %v, got %v", tt.errorType, err)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %v, got %v", tt.expected, result)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGontext_Set(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
value interface{}
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "simple-set",
|
||||
path: "key",
|
||||
value: "value",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "nested-set",
|
||||
path: "user.name",
|
||||
value: "John Doe",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "deep-nested-set",
|
||||
path: "user.profile.email",
|
||||
value: "john@example.com",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "override-primitive-with-nested",
|
||||
path: "existing.new",
|
||||
value: "nested_value",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "empty-path",
|
||||
path: "",
|
||||
value: "value",
|
||||
wantErr: false, // Actually, empty string creates a single part [""], which is valid
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := New(map[string]interface{}{
|
||||
"existing": "primitive",
|
||||
})
|
||||
|
||||
err := ctx.Set(tt.path, tt.value)
|
||||
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Error("Expected error but got none")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify the value was set correctly
|
||||
result, getErr := ctx.Get(tt.path)
|
||||
if getErr != nil {
|
||||
t.Errorf("Error retrieving set value: %v", getErr)
|
||||
return
|
||||
}
|
||||
|
||||
if result != tt.value {
|
||||
t.Errorf("Expected %v, got %v", tt.value, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGontext_SetOverrideBehavior(t *testing.T) {
|
||||
ctx := New(map[string]interface{}{
|
||||
"primitive": "value",
|
||||
"nested": map[string]interface{}{
|
||||
"key": "existing",
|
||||
},
|
||||
})
|
||||
|
||||
// Test overriding primitive with nested structure
|
||||
err := ctx.Set("primitive.new", "nested_value")
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify the primitive was replaced with a nested structure
|
||||
result, err := ctx.Get("primitive.new")
|
||||
if err != nil {
|
||||
t.Errorf("Error getting nested value: %v", err)
|
||||
}
|
||||
if result != "nested_value" {
|
||||
t.Errorf("Expected 'nested_value', got %v", result)
|
||||
}
|
||||
|
||||
// Test overriding existing nested value
|
||||
err = ctx.Set("nested.key", "modified")
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
result, err = ctx.Get("nested.key")
|
||||
if err != nil {
|
||||
t.Errorf("Error getting modified value: %v", err)
|
||||
}
|
||||
if result != "modified" {
|
||||
t.Errorf("Expected 'modified', got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGontext_GetAll(t *testing.T) {
|
||||
initial := map[string]interface{}{
|
||||
"key1": "value1",
|
||||
"key2": 42,
|
||||
"nested": map[string]interface{}{
|
||||
"inner": "value",
|
||||
},
|
||||
}
|
||||
|
||||
ctx := New(initial)
|
||||
|
||||
// Add another value after creation
|
||||
ctx.Set("key3", "value3")
|
||||
|
||||
result := ctx.GetAll()
|
||||
|
||||
// Verify all values are present
|
||||
if result["key1"] != "value1" {
|
||||
t.Errorf("Expected key1=value1, got %v", result["key1"])
|
||||
}
|
||||
if result["key2"] != 42 {
|
||||
t.Errorf("Expected key2=42, got %v", result["key2"])
|
||||
}
|
||||
if result["key3"] != "value3" {
|
||||
t.Errorf("Expected key3=value3, got %v", result["key3"])
|
||||
}
|
||||
|
||||
// Verify nested values
|
||||
nested, ok := result["nested"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Error("Expected nested to be map[string]interface{}")
|
||||
} else if nested["inner"] != "value" {
|
||||
t.Errorf("Expected nested.inner=value, got %v", nested["inner"])
|
||||
}
|
||||
|
||||
// Verify deep copy - modifying returned map shouldn't affect gontext
|
||||
result["key1"] = "modified"
|
||||
original, _ := ctx.Get("key1")
|
||||
if original != "value1" {
|
||||
t.Error("GetAll did not return a deep copy - modification affected original")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGontext_ConcurrentAccess(t *testing.T) {
|
||||
ctx := New(map[string]interface{}{
|
||||
"counter": 0,
|
||||
})
|
||||
|
||||
done := make(chan bool, 10)
|
||||
|
||||
// Start 5 goroutines that read values
|
||||
for i := 0; i < 5; i++ {
|
||||
go func(id int) {
|
||||
for j := 0; j < 100; j++ {
|
||||
_, err := ctx.Get("counter")
|
||||
if err != nil {
|
||||
t.Errorf("Reader %d error: %v", id, err)
|
||||
}
|
||||
}
|
||||
done <- true
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Start 5 goroutines that write values
|
||||
for i := 0; i < 5; i++ {
|
||||
go func(id int) {
|
||||
for j := 0; j < 100; j++ {
|
||||
err := ctx.Set("counter", id*1000+j)
|
||||
if err != nil {
|
||||
t.Errorf("Writer %d error: %v", id, err)
|
||||
}
|
||||
}
|
||||
done <- true
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Wait for all goroutines to complete
|
||||
for i := 0; i < 10; i++ {
|
||||
<-done
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeepCopyValue(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input interface{}
|
||||
}{
|
||||
{
|
||||
name: "primitive-string",
|
||||
input: "test",
|
||||
},
|
||||
{
|
||||
name: "primitive-int",
|
||||
input: 42,
|
||||
},
|
||||
{
|
||||
name: "primitive-bool",
|
||||
input: true,
|
||||
},
|
||||
{
|
||||
name: "simple-map",
|
||||
input: map[string]interface{}{
|
||||
"key": "value",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nested-map",
|
||||
input: map[string]interface{}{
|
||||
"nested": map[string]interface{}{
|
||||
"deep": "value",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "simple-slice",
|
||||
input: []interface{}{"a", "b", "c"},
|
||||
},
|
||||
{
|
||||
name: "mixed-slice",
|
||||
input: []interface{}{
|
||||
"string",
|
||||
42,
|
||||
map[string]interface{}{"nested": "value"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := deepCopyValue(tt.input)
|
||||
|
||||
// For maps and slices, verify it's a different object
|
||||
switch v := tt.input.(type) {
|
||||
case map[string]interface{}:
|
||||
resultMap, ok := result.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Error("Deep copy didn't preserve map type")
|
||||
return
|
||||
}
|
||||
// Modify original to ensure independence
|
||||
v["modified"] = "test"
|
||||
if _, exists := resultMap["modified"]; exists {
|
||||
t.Error("Deep copy failed - maps are not independent")
|
||||
}
|
||||
case []interface{}:
|
||||
resultSlice, ok := result.([]interface{})
|
||||
if !ok {
|
||||
t.Error("Deep copy didn't preserve slice type")
|
||||
return
|
||||
}
|
||||
if len(resultSlice) != len(v) {
|
||||
t.Error("Deep copy didn't preserve slice length")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
package endpoint
|
||||
package key
|
||||
|
||||
import "strings"
|
||||
|
||||
// ConvertGroupAndEndpointNameToKey converts a group and an endpoint to a key
|
||||
func ConvertGroupAndEndpointNameToKey(groupName, endpointName string) string {
|
||||
return sanitize(groupName) + "_" + sanitize(endpointName)
|
||||
// ConvertGroupAndNameToKey converts a group and a name to a key
|
||||
func ConvertGroupAndNameToKey(groupName, name string) string {
|
||||
return sanitize(groupName) + "_" + sanitize(name)
|
||||
}
|
||||
|
||||
func sanitize(s string) string {
|
||||
@@ -16,4 +16,4 @@ func sanitize(s string) string {
|
||||
s = strings.ReplaceAll(s, " ", "-")
|
||||
s = strings.ReplaceAll(s, "#", "-")
|
||||
return s
|
||||
}
|
||||
}
|
||||
11
config/key/key_bench_test.go
Normal file
11
config/key/key_bench_test.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package key
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func BenchmarkConvertGroupAndNameToKey(b *testing.B) {
|
||||
for n := 0; n < b.N; n++ {
|
||||
ConvertGroupAndNameToKey("group", "name")
|
||||
}
|
||||
}
|
||||
@@ -1,33 +1,38 @@
|
||||
package endpoint
|
||||
package key
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestConvertGroupAndEndpointNameToKey(t *testing.T) {
|
||||
func TestConvertGroupAndNameToKey(t *testing.T) {
|
||||
type Scenario struct {
|
||||
GroupName string
|
||||
EndpointName string
|
||||
Name string
|
||||
ExpectedOutput string
|
||||
}
|
||||
scenarios := []Scenario{
|
||||
{
|
||||
GroupName: "Core",
|
||||
EndpointName: "Front End",
|
||||
Name: "Front End",
|
||||
ExpectedOutput: "core_front-end",
|
||||
},
|
||||
{
|
||||
GroupName: "Load balancers",
|
||||
EndpointName: "us-west-2",
|
||||
Name: "us-west-2",
|
||||
ExpectedOutput: "load-balancers_us-west-2",
|
||||
},
|
||||
{
|
||||
GroupName: "a/b test",
|
||||
EndpointName: "a",
|
||||
Name: "a",
|
||||
ExpectedOutput: "a-b-test_a",
|
||||
},
|
||||
{
|
||||
GroupName: "",
|
||||
Name: "name",
|
||||
ExpectedOutput: "_name",
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.ExpectedOutput, func(t *testing.T) {
|
||||
output := ConvertGroupAndEndpointNameToKey(scenario.GroupName, scenario.EndpointName)
|
||||
output := ConvertGroupAndNameToKey(scenario.GroupName, scenario.Name)
|
||||
if output != scenario.ExpectedOutput {
|
||||
t.Errorf("expected '%s', got '%s'", scenario.ExpectedOutput, output)
|
||||
}
|
||||
55
config/suite/result.go
Normal file
55
config/suite/result.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package suite
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
)
|
||||
|
||||
// Result represents the result of a suite execution
|
||||
type Result struct {
|
||||
// Name of the suite
|
||||
Name string `json:"name,omitempty"`
|
||||
|
||||
// Group of the suite
|
||||
Group string `json:"group,omitempty"`
|
||||
|
||||
// Success indicates whether all required endpoints succeeded
|
||||
Success bool `json:"success"`
|
||||
|
||||
// Timestamp is when the suite execution started
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
|
||||
// Duration is how long the entire suite execution took
|
||||
Duration time.Duration `json:"duration"`
|
||||
|
||||
// EndpointResults contains the results of each endpoint execution
|
||||
EndpointResults []*endpoint.Result `json:"endpointResults"`
|
||||
|
||||
// Context is the final state of the context after all endpoints executed
|
||||
Context map[string]interface{} `json:"-"`
|
||||
|
||||
// Errors contains any suite-level errors
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
}
|
||||
|
||||
// AddError adds an error to the suite result
|
||||
func (r *Result) AddError(err string) {
|
||||
r.Errors = append(r.Errors, err)
|
||||
}
|
||||
|
||||
// CalculateSuccess determines if the suite execution was successful
|
||||
func (r *Result) CalculateSuccess() {
|
||||
r.Success = true
|
||||
// Check if any endpoints failed (all endpoints are required)
|
||||
for _, epResult := range r.EndpointResults {
|
||||
if !epResult.Success {
|
||||
r.Success = false
|
||||
break
|
||||
}
|
||||
}
|
||||
// Also check for suite-level errors
|
||||
if len(r.Errors) > 0 {
|
||||
r.Success = false
|
||||
}
|
||||
}
|
||||
214
config/suite/suite.go
Normal file
214
config/suite/suite.go
Normal file
@@ -0,0 +1,214 @@
|
||||
package suite
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"github.com/TwiN/gatus/v5/config/gontext"
|
||||
"github.com/TwiN/gatus/v5/config/key"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrSuiteWithNoName is the error returned when a suite has no name
|
||||
ErrSuiteWithNoName = errors.New("suite must have a name")
|
||||
|
||||
// ErrSuiteWithNoEndpoints is the error returned when a suite has no endpoints
|
||||
ErrSuiteWithNoEndpoints = errors.New("suite must have at least one endpoint")
|
||||
|
||||
// ErrSuiteWithDuplicateEndpointNames is the error returned when a suite has duplicate endpoint names
|
||||
ErrSuiteWithDuplicateEndpointNames = errors.New("suite cannot have duplicate endpoint names")
|
||||
|
||||
// ErrSuiteWithInvalidTimeout is the error returned when a suite has an invalid timeout
|
||||
ErrSuiteWithInvalidTimeout = errors.New("suite timeout must be positive")
|
||||
|
||||
// DefaultInterval is the default interval for suite execution
|
||||
DefaultInterval = 10 * time.Minute
|
||||
|
||||
// DefaultTimeout is the default timeout for suite execution
|
||||
DefaultTimeout = 5 * time.Minute
|
||||
)
|
||||
|
||||
// Suite is a collection of endpoints that are executed sequentially with shared context
|
||||
type Suite struct {
|
||||
// Name of the suite. Must be unique.
|
||||
Name string `yaml:"name"`
|
||||
|
||||
// Group the suite belongs to. Used for grouping multiple suites together.
|
||||
Group string `yaml:"group,omitempty"`
|
||||
|
||||
// Enabled defines whether the suite is enabled
|
||||
Enabled *bool `yaml:"enabled,omitempty"`
|
||||
|
||||
// Interval is the duration to wait between suite executions
|
||||
Interval time.Duration `yaml:"interval,omitempty"`
|
||||
|
||||
// Timeout is the maximum duration for the entire suite execution
|
||||
Timeout time.Duration `yaml:"timeout,omitempty"`
|
||||
|
||||
// InitialContext holds initial values that can be referenced by endpoints
|
||||
InitialContext map[string]interface{} `yaml:"context,omitempty"`
|
||||
|
||||
// Endpoints in the suite (executed sequentially)
|
||||
Endpoints []*endpoint.Endpoint `yaml:"endpoints"`
|
||||
}
|
||||
|
||||
// IsEnabled returns whether the suite is enabled
|
||||
func (s *Suite) IsEnabled() bool {
|
||||
if s.Enabled == nil {
|
||||
return true
|
||||
}
|
||||
return *s.Enabled
|
||||
}
|
||||
|
||||
// Key returns a unique key for the suite
|
||||
func (s *Suite) Key() string {
|
||||
return key.ConvertGroupAndNameToKey(s.Group, s.Name)
|
||||
}
|
||||
|
||||
// ValidateAndSetDefaults validates the suite configuration and sets default values
|
||||
func (s *Suite) ValidateAndSetDefaults() error {
|
||||
// Validate name
|
||||
if len(s.Name) == 0 {
|
||||
return ErrSuiteWithNoName
|
||||
}
|
||||
// Validate endpoints
|
||||
if len(s.Endpoints) == 0 {
|
||||
return ErrSuiteWithNoEndpoints
|
||||
}
|
||||
// Check for duplicate endpoint names
|
||||
endpointNames := make(map[string]bool)
|
||||
for _, ep := range s.Endpoints {
|
||||
if endpointNames[ep.Name] {
|
||||
return fmt.Errorf("%w: duplicate endpoint name '%s'", ErrSuiteWithDuplicateEndpointNames, ep.Name)
|
||||
}
|
||||
endpointNames[ep.Name] = true
|
||||
// Suite endpoints inherit the group from the suite
|
||||
ep.Group = s.Group
|
||||
// Validate each endpoint
|
||||
if err := ep.ValidateAndSetDefaults(); err != nil {
|
||||
return fmt.Errorf("invalid endpoint '%s': %w", ep.Name, err)
|
||||
}
|
||||
}
|
||||
// Set default interval
|
||||
if s.Interval == 0 {
|
||||
s.Interval = DefaultInterval
|
||||
}
|
||||
// Set default timeout
|
||||
if s.Timeout == 0 {
|
||||
s.Timeout = DefaultTimeout
|
||||
}
|
||||
// Validate timeout
|
||||
if s.Timeout < 0 {
|
||||
return ErrSuiteWithInvalidTimeout
|
||||
}
|
||||
// Initialize context if nil
|
||||
if s.InitialContext == nil {
|
||||
s.InitialContext = make(map[string]interface{})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Execute executes all endpoints in the suite sequentially with context sharing
|
||||
func (s *Suite) Execute() *Result {
|
||||
start := time.Now()
|
||||
// Initialize context from suite configuration
|
||||
ctx := gontext.New(s.InitialContext)
|
||||
// Create suite result
|
||||
result := &Result{
|
||||
Name: s.Name,
|
||||
Group: s.Group,
|
||||
Success: true,
|
||||
Timestamp: start,
|
||||
EndpointResults: make([]*endpoint.Result, 0, len(s.Endpoints)),
|
||||
}
|
||||
// Set up timeout for the entire suite execution
|
||||
timeoutChan := time.After(s.Timeout)
|
||||
// Execute each endpoint sequentially
|
||||
suiteHasFailed := false
|
||||
for _, ep := range s.Endpoints {
|
||||
// Skip non-always-run endpoints if suite has already failed
|
||||
if suiteHasFailed && !ep.AlwaysRun {
|
||||
continue
|
||||
}
|
||||
// Check timeout
|
||||
select {
|
||||
case <-timeoutChan:
|
||||
result.AddError(fmt.Sprintf("suite execution timed out after %v", s.Timeout))
|
||||
result.Success = false
|
||||
break
|
||||
default:
|
||||
}
|
||||
// Execute endpoint with context
|
||||
epStartTime := time.Now()
|
||||
epResult := ep.EvaluateHealthWithContext(ctx)
|
||||
epDuration := time.Since(epStartTime)
|
||||
// Set endpoint name, timestamp, and duration on the result
|
||||
epResult.Name = ep.Name
|
||||
epResult.Timestamp = epStartTime
|
||||
epResult.Duration = epDuration
|
||||
// Store values from the endpoint result if configured (always store, even on failure)
|
||||
if ep.Store != nil {
|
||||
_, err := StoreResultValues(ctx, ep.Store, epResult)
|
||||
if err != nil {
|
||||
epResult.AddError(fmt.Sprintf("failed to store values: %v", err))
|
||||
}
|
||||
}
|
||||
result.EndpointResults = append(result.EndpointResults, epResult)
|
||||
// Mark suite as failed on any endpoint failure
|
||||
if !epResult.Success {
|
||||
result.Success = false
|
||||
suiteHasFailed = true
|
||||
}
|
||||
}
|
||||
result.Context = ctx.GetAll()
|
||||
result.Duration = time.Since(start)
|
||||
result.CalculateSuccess()
|
||||
return result
|
||||
}
|
||||
|
||||
// StoreResultValues extracts values from an endpoint result and stores them in the gontext
|
||||
func StoreResultValues(ctx *gontext.Gontext, mappings map[string]string, result *endpoint.Result) (map[string]interface{}, error) {
|
||||
if mappings == nil || len(mappings) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
storedValues := make(map[string]interface{})
|
||||
for contextKey, placeholder := range mappings {
|
||||
value, err := extractValueForStorage(placeholder, result)
|
||||
if err != nil {
|
||||
// Continue storing other values even if one fails
|
||||
storedValues[contextKey] = fmt.Sprintf("ERROR: %v", err)
|
||||
continue
|
||||
}
|
||||
if err := ctx.Set(contextKey, value); err != nil {
|
||||
return storedValues, fmt.Errorf("failed to store %s: %w", contextKey, err)
|
||||
}
|
||||
storedValues[contextKey] = value
|
||||
}
|
||||
return storedValues, nil
|
||||
}
|
||||
|
||||
// extractValueForStorage extracts a value from an endpoint result for storage in context
|
||||
func extractValueForStorage(placeholder string, result *endpoint.Result) (interface{}, error) {
|
||||
// Use the unified ResolvePlaceholder function (no context needed for extraction)
|
||||
resolved, err := endpoint.ResolvePlaceholder(placeholder, result, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Try to parse as number or boolean to store as proper types
|
||||
// Try int first for whole numbers
|
||||
if num, err := strconv.ParseInt(resolved, 10, 64); err == nil {
|
||||
return num, nil
|
||||
}
|
||||
// Then try float for decimals
|
||||
if num, err := strconv.ParseFloat(resolved, 64); err == nil {
|
||||
return num, nil
|
||||
}
|
||||
// Then try boolean
|
||||
if boolVal, err := strconv.ParseBool(resolved); err == nil {
|
||||
return boolVal, nil
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
26
config/suite/suite_status.go
Normal file
26
config/suite/suite_status.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package suite
|
||||
|
||||
// Status represents the status of a suite
|
||||
type Status struct {
|
||||
// Name of the suite
|
||||
Name string `json:"name,omitempty"`
|
||||
|
||||
// Group the suite is a part of. Used for grouping multiple suites together on the front end.
|
||||
Group string `json:"group,omitempty"`
|
||||
|
||||
// Key of the Suite
|
||||
Key string `json:"key"`
|
||||
|
||||
// Results is the list of suite execution results
|
||||
Results []*Result `json:"results"`
|
||||
}
|
||||
|
||||
// NewStatus creates a new Status for a given Suite
|
||||
func NewStatus(s *Suite) *Status {
|
||||
return &Status{
|
||||
Name: s.Name,
|
||||
Group: s.Group,
|
||||
Key: s.Key(),
|
||||
Results: []*Result{},
|
||||
}
|
||||
}
|
||||
449
config/suite/suite_test.go
Normal file
449
config/suite/suite_test.go
Normal file
@@ -0,0 +1,449 @@
|
||||
package suite
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"github.com/TwiN/gatus/v5/config/gontext"
|
||||
)
|
||||
|
||||
func TestSuite_ValidateAndSetDefaults(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
suite *Suite
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid-suite",
|
||||
suite: &Suite{
|
||||
Name: "test-suite",
|
||||
Endpoints: []*endpoint.Endpoint{
|
||||
{
|
||||
Name: "endpoint1",
|
||||
URL: "https://example.org",
|
||||
Conditions: []endpoint.Condition{
|
||||
endpoint.Condition("[STATUS] == 200"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "suite-without-name",
|
||||
suite: &Suite{
|
||||
Endpoints: []*endpoint.Endpoint{
|
||||
{
|
||||
Name: "endpoint1",
|
||||
URL: "https://example.org",
|
||||
Conditions: []endpoint.Condition{
|
||||
endpoint.Condition("[STATUS] == 200"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "suite-without-endpoints",
|
||||
suite: &Suite{
|
||||
Name: "test-suite",
|
||||
Endpoints: []*endpoint.Endpoint{},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "suite-with-duplicate-endpoint-names",
|
||||
suite: &Suite{
|
||||
Name: "test-suite",
|
||||
Endpoints: []*endpoint.Endpoint{
|
||||
{
|
||||
Name: "duplicate",
|
||||
URL: "https://example.org",
|
||||
Conditions: []endpoint.Condition{
|
||||
endpoint.Condition("[STATUS] == 200"),
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "duplicate",
|
||||
URL: "https://example.com",
|
||||
Conditions: []endpoint.Condition{
|
||||
endpoint.Condition("[STATUS] == 200"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.suite.ValidateAndSetDefaults()
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Suite.ValidateAndSetDefaults() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
// Check defaults were set
|
||||
if err == nil {
|
||||
if tt.suite.Interval == 0 {
|
||||
t.Errorf("Expected Interval to be set to default, got 0")
|
||||
}
|
||||
if tt.suite.Timeout == 0 {
|
||||
t.Errorf("Expected Timeout to be set to default, got 0")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuite_IsEnabled(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
enabled *bool
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "nil-defaults-to-true",
|
||||
enabled: nil,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "explicitly-enabled",
|
||||
enabled: boolPtr(true),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "explicitly-disabled",
|
||||
enabled: boolPtr(false),
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
s := &Suite{Enabled: tt.enabled}
|
||||
if got := s.IsEnabled(); got != tt.want {
|
||||
t.Errorf("Suite.IsEnabled() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuite_Key(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
suite *Suite
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "with-group",
|
||||
suite: &Suite{
|
||||
Name: "test-suite",
|
||||
Group: "test-group",
|
||||
},
|
||||
want: "test-group_test-suite",
|
||||
},
|
||||
{
|
||||
name: "without-group",
|
||||
suite: &Suite{
|
||||
Name: "test-suite",
|
||||
Group: "",
|
||||
},
|
||||
want: "_test-suite",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.suite.Key(); got != tt.want {
|
||||
t.Errorf("Suite.Key() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuite_DefaultValues(t *testing.T) {
|
||||
s := &Suite{
|
||||
Name: "test",
|
||||
Endpoints: []*endpoint.Endpoint{
|
||||
{
|
||||
Name: "endpoint1",
|
||||
URL: "https://example.org",
|
||||
Conditions: []endpoint.Condition{
|
||||
endpoint.Condition("[STATUS] == 200"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
err := s.ValidateAndSetDefaults()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if s.Interval != DefaultInterval {
|
||||
t.Errorf("Expected Interval to be %v, got %v", DefaultInterval, s.Interval)
|
||||
}
|
||||
if s.Timeout != DefaultTimeout {
|
||||
t.Errorf("Expected Timeout to be %v, got %v", DefaultTimeout, s.Timeout)
|
||||
}
|
||||
if s.InitialContext == nil {
|
||||
t.Error("Expected InitialContext to be initialized, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to create bool pointers
|
||||
func boolPtr(b bool) *bool {
|
||||
return &b
|
||||
}
|
||||
|
||||
func TestStoreResultValues(t *testing.T) {
|
||||
ctx := gontext.New(nil)
|
||||
// Create a mock result
|
||||
result := &endpoint.Result{
|
||||
HTTPStatus: 200,
|
||||
IP: "192.168.1.1",
|
||||
Duration: 100 * time.Millisecond,
|
||||
Body: []byte(`{"status": "OK", "value": 42}`),
|
||||
Connected: true,
|
||||
}
|
||||
// Define store mappings
|
||||
mappings := map[string]string{
|
||||
"response_code": "[STATUS]",
|
||||
"server_ip": "[IP]",
|
||||
"response_time": "[RESPONSE_TIME]",
|
||||
"status": "[BODY].status",
|
||||
"value": "[BODY].value",
|
||||
"connected": "[CONNECTED]",
|
||||
}
|
||||
// Store values
|
||||
stored, err := StoreResultValues(ctx, mappings, result)
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error storing values: %v", err)
|
||||
}
|
||||
// Verify stored values
|
||||
if stored["response_code"] != int64(200) {
|
||||
t.Errorf("Expected response_code=200, got %v", stored["response_code"])
|
||||
}
|
||||
if stored["server_ip"] != "192.168.1.1" {
|
||||
t.Errorf("Expected server_ip=192.168.1.1, got %v", stored["server_ip"])
|
||||
}
|
||||
if stored["status"] != "OK" {
|
||||
t.Errorf("Expected status=OK, got %v", stored["status"])
|
||||
}
|
||||
if stored["value"] != int64(42) { // Now parsed as int64 for whole numbers
|
||||
t.Errorf("Expected value=42, got %v", stored["value"])
|
||||
}
|
||||
if stored["connected"] != true {
|
||||
t.Errorf("Expected connected=true, got %v", stored["connected"])
|
||||
}
|
||||
// Verify values are in context
|
||||
val, err := ctx.Get("status")
|
||||
if err != nil || val != "OK" {
|
||||
t.Errorf("Expected status=OK in context, got %v, err=%v", val, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuite_ExecuteWithAlwaysRunEndpoints(t *testing.T) {
|
||||
suite := &Suite{
|
||||
Name: "test-suite",
|
||||
Endpoints: []*endpoint.Endpoint{
|
||||
{
|
||||
Name: "create-resource",
|
||||
URL: "https://example.org",
|
||||
Conditions: []endpoint.Condition{
|
||||
endpoint.Condition("[STATUS] == 200"),
|
||||
},
|
||||
Store: map[string]string{
|
||||
"created_id": "[BODY]",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "failing-endpoint",
|
||||
URL: "https://example.org",
|
||||
Conditions: []endpoint.Condition{
|
||||
endpoint.Condition("[STATUS] != 200"), // This will fail
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "cleanup-resource",
|
||||
URL: "https://example.org",
|
||||
Conditions: []endpoint.Condition{
|
||||
endpoint.Condition("[STATUS] == 200"),
|
||||
},
|
||||
AlwaysRun: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := suite.ValidateAndSetDefaults(); err != nil {
|
||||
t.Fatalf("suite validation failed: %v", err)
|
||||
}
|
||||
result := suite.Execute()
|
||||
if result.Success {
|
||||
t.Error("expected suite to fail due to middle endpoint failure")
|
||||
}
|
||||
if len(result.EndpointResults) != 3 {
|
||||
t.Errorf("expected 3 endpoint results, got %d", len(result.EndpointResults))
|
||||
}
|
||||
if result.EndpointResults[0].Name != "create-resource" {
|
||||
t.Errorf("expected first endpoint to be 'create-resource', got '%s'", result.EndpointResults[0].Name)
|
||||
}
|
||||
if result.EndpointResults[1].Name != "failing-endpoint" {
|
||||
t.Errorf("expected second endpoint to be 'failing-endpoint', got '%s'", result.EndpointResults[1].Name)
|
||||
}
|
||||
if result.EndpointResults[1].Success {
|
||||
t.Error("expected failing-endpoint to fail")
|
||||
}
|
||||
if result.EndpointResults[2].Name != "cleanup-resource" {
|
||||
t.Errorf("expected third endpoint to be 'cleanup-resource', got '%s'", result.EndpointResults[2].Name)
|
||||
}
|
||||
if !result.EndpointResults[2].Success {
|
||||
t.Error("expected cleanup endpoint to succeed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuite_ExecuteWithoutAlwaysRunEndpoints(t *testing.T) {
|
||||
suite := &Suite{
|
||||
Name: "test-suite",
|
||||
Endpoints: []*endpoint.Endpoint{
|
||||
{
|
||||
Name: "create-resource",
|
||||
URL: "https://example.org",
|
||||
Conditions: []endpoint.Condition{
|
||||
endpoint.Condition("[STATUS] == 200"),
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "failing-endpoint",
|
||||
URL: "https://example.org",
|
||||
Conditions: []endpoint.Condition{
|
||||
endpoint.Condition("[STATUS] != 200"), // This will fail
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "skipped-endpoint",
|
||||
URL: "https://example.org",
|
||||
Conditions: []endpoint.Condition{
|
||||
endpoint.Condition("[STATUS] == 200"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := suite.ValidateAndSetDefaults(); err != nil {
|
||||
t.Fatalf("suite validation failed: %v", err)
|
||||
}
|
||||
result := suite.Execute()
|
||||
if result.Success {
|
||||
t.Error("expected suite to fail due to middle endpoint failure")
|
||||
}
|
||||
if len(result.EndpointResults) != 2 {
|
||||
t.Errorf("expected 2 endpoint results (execution should stop after failure), got %d", len(result.EndpointResults))
|
||||
}
|
||||
if result.EndpointResults[0].Name != "create-resource" {
|
||||
t.Errorf("expected first endpoint to be 'create-resource', got '%s'", result.EndpointResults[0].Name)
|
||||
}
|
||||
if result.EndpointResults[1].Name != "failing-endpoint" {
|
||||
t.Errorf("expected second endpoint to be 'failing-endpoint', got '%s'", result.EndpointResults[1].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResult_AddError(t *testing.T) {
|
||||
result := &Result{
|
||||
Name: "test-suite",
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
if len(result.Errors) != 0 {
|
||||
t.Errorf("Expected 0 errors initially, got %d", len(result.Errors))
|
||||
}
|
||||
result.AddError("first error")
|
||||
if len(result.Errors) != 1 {
|
||||
t.Errorf("Expected 1 error after AddError, got %d", len(result.Errors))
|
||||
}
|
||||
if result.Errors[0] != "first error" {
|
||||
t.Errorf("Expected 'first error', got '%s'", result.Errors[0])
|
||||
}
|
||||
result.AddError("second error")
|
||||
if len(result.Errors) != 2 {
|
||||
t.Errorf("Expected 2 errors after second AddError, got %d", len(result.Errors))
|
||||
}
|
||||
if result.Errors[1] != "second error" {
|
||||
t.Errorf("Expected 'second error', got '%s'", result.Errors[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResult_CalculateSuccess(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
endpointResults []*endpoint.Result
|
||||
errors []string
|
||||
expectedSuccess bool
|
||||
}{
|
||||
{
|
||||
name: "no-endpoints-no-errors",
|
||||
endpointResults: []*endpoint.Result{},
|
||||
errors: []string{},
|
||||
expectedSuccess: true,
|
||||
},
|
||||
{
|
||||
name: "all-endpoints-successful-no-errors",
|
||||
endpointResults: []*endpoint.Result{
|
||||
{Success: true},
|
||||
{Success: true},
|
||||
},
|
||||
errors: []string{},
|
||||
expectedSuccess: true,
|
||||
},
|
||||
{
|
||||
name: "second-endpoint-failed-no-errors",
|
||||
endpointResults: []*endpoint.Result{
|
||||
{Success: true},
|
||||
{Success: false},
|
||||
},
|
||||
errors: []string{},
|
||||
expectedSuccess: false,
|
||||
},
|
||||
{
|
||||
name: "first-endpoint-failed-no-errors",
|
||||
endpointResults: []*endpoint.Result{
|
||||
{Success: false},
|
||||
{Success: true},
|
||||
},
|
||||
errors: []string{},
|
||||
expectedSuccess: false,
|
||||
},
|
||||
{
|
||||
name: "all-endpoints-successful-with-errors",
|
||||
endpointResults: []*endpoint.Result{
|
||||
{Success: true},
|
||||
{Success: true},
|
||||
},
|
||||
errors: []string{"suite level error"},
|
||||
expectedSuccess: false,
|
||||
},
|
||||
{
|
||||
name: "endpoint-failed-and-errors",
|
||||
endpointResults: []*endpoint.Result{
|
||||
{Success: true},
|
||||
{Success: false},
|
||||
},
|
||||
errors: []string{"suite level error"},
|
||||
expectedSuccess: false,
|
||||
},
|
||||
{
|
||||
name: "no-endpoints-with-errors",
|
||||
endpointResults: []*endpoint.Result{},
|
||||
errors: []string{"configuration error"},
|
||||
expectedSuccess: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := &Result{
|
||||
Name: "test-suite",
|
||||
Timestamp: time.Now(),
|
||||
EndpointResults: tt.endpointResults,
|
||||
Errors: tt.errors,
|
||||
}
|
||||
result.CalculateSuccess()
|
||||
if result.Success != tt.expectedSuccess {
|
||||
t.Errorf("Expected success=%v, got %v", tt.expectedSuccess, result.Success)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user