Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 20 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,12 @@ Maintaining a Gatus config by hand stops scaling once you have more than a handf

## Resource support

| Resource | Group / Version | Parent (annotation inheritance) | URL shape |
| ---------------- | ------------------------------ | ------------------------------- | ----------------------------------------- |
| **Ingress** | `networking.k8s.io/v1` | `IngressClass` | `http(s)://<host><path>` |
| **Service** | `v1` | — | `<proto>://<name>.<namespace>.svc:<port>` |
| **HTTPRoute** | `gateway.networking.k8s.io/v1` | `Gateway` | `https://<host><path>` |
| **IngressRoute** | `traefik.io/v1alpha1` | — | `http(s)://<host><path>` |
| Resource | Group / Version | Parent (annotation inheritance) | URL shape |
| ---------------- | ------------------------------ | ------------------------------- | ----------------------------------------------------------- |
| **Ingress** | `networking.k8s.io/v1` | `IngressClass` | `http(s)://<host><path>` |
| **Service** | `v1` | — | `<proto>://<name>.<namespace>.svc.<cluster-domain>.:<port>` |
| **HTTPRoute** | `gateway.networking.k8s.io/v1` | `Gateway` | `https://<host><path>` |
| **IngressRoute** | `traefik.io/v1alpha1` | — | `http(s)://<host><path>` |

## Quick start

Expand Down Expand Up @@ -138,13 +138,14 @@ Use these to disambiguate endpoints across resource kinds — Gatus rejects dupl

#### Output & runtime

| Flag | Default | Description |
| ---------------------- | ------------------------------------ | ---------------------------------------------------- |
| `--output` | `/config/gatus-sidecar.yaml` | Destination YAML file (written atomically). |
| `--default-interval` | `1m` | Probe interval when not overridden by an annotation. |
| `--annotation-config` | `gatus.home-operations.com/endpoint` | Annotation key for YAML template overrides. |
| `--annotation-enabled` | `gatus.home-operations.com/enabled` | Annotation key for the on/off gate. |
| `--log-level` | `info` | `debug` \| `info` \| `warn` \| `error`. |
| Flag | Default | Description |
| ---------------------- | ------------------------------------ | ----------------------------------------------------- |
| `--output` | `/config/gatus-sidecar.yaml` | Destination YAML file (written atomically). |
| `--default-interval` | `1m` | Probe interval when not overridden by an annotation. |
| `--annotation-config` | `gatus.home-operations.com/endpoint` | Annotation key for YAML template overrides. |
| `--annotation-enabled` | `gatus.home-operations.com/enabled` | Annotation key for the on/off gate. |
| `--cluster-domain` | `cluster.local` | Cluster DNS domain used to qualify Service endpoints. |
| `--log-level` | `info` | `debug` \| `info` \| `warn` \| `error`. |

### Annotations

Expand Down Expand Up @@ -184,12 +185,12 @@ the child for per-route conditions.

### URL derivation

| Resource | Host | Scheme | Path |
| ---------------- | ---------------------------------------- | ------------------------------------------------------ | -------------------------------------------------------------- |
| **Ingress** | First rule with `host` | `https` if TLS covers that host, else `http` | First non-`/` path under the first rule's HTTP block |
| **HTTPRoute** | `spec.hostnames[0]` | `https` (always) | First `Exact`/`PathPrefix` match value (regex matches skipped) |
| **Service** | `<name>.<namespace>.svc` | First port's protocol, lowercased (`tcp://`, `udp://`) | — |
| **IngressRoute** | First `Host(\`...\`)`in a route's`match` | `https` if `spec.tls` is set, else `http` | First `Path(\`...\`)`/`PathPrefix(\`...\`)`in the same`match` |
| Resource | Host | Scheme | Path |
| ---------------- | ------------------------------------------ | ------------------------------------------------------ | -------------------------------------------------------------- |
| **Ingress** | First rule with `host` | `https` if TLS covers that host, else `http` | First non-`/` path under the first rule's HTTP block |
| **HTTPRoute** | `spec.hostnames[0]` | `https` (always) | First `Exact`/`PathPrefix` match value (regex matches skipped) |
| **Service** | `<name>.<namespace>.svc.<cluster-domain>.` | First port's protocol, lowercased (`tcp://`, `udp://`) | — |
| **IngressRoute** | First `Host(\`...\`)`in a route's`match` | `https` if `spec.tls` is set, else `http` | First `Path(\`...\`)`/`PathPrefix(\`...\`)`in the same`match` |

> Trivial paths (empty, `/`, non-rooted) are dropped so the URL stays bare.
>
Expand Down
1 change: 1 addition & 0 deletions charts/gatus-sidecar/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ Kubernetes: `>=1.34.0-0`
| serviceAccount.name | string | `""` | ServiceAccount name; generated from the release name if empty. |
| sidecar.annotationConfig | string | `""` | Annotation key for the per-resource YAML config override (--annotation-config); empty uses the sidecar default. |
| sidecar.annotationEnabled | string | `""` | Annotation key for enabling/disabling per-resource processing (--annotation-enabled); empty uses the sidecar default. |
| sidecar.clusterDomain | string | `""` | Cluster DNS domain used to fully qualify generated Service endpoint hostnames (--cluster-domain); empty uses the sidecar default (cluster.local). |
| sidecar.defaultInterval | string | `"1m"` | Default probe interval for generated endpoints (--default-interval). |
| sidecar.enabled | bool | `true` | Run the gatus-sidecar as a native sidecar (init container with restartPolicy: Always). |
| sidecar.extraArgs | list | `[]` | Extra raw flags appended to the sidecar args, e.g. `["--foo=bar"]`. |
Expand Down
6 changes: 5 additions & 1 deletion charts/gatus-sidecar/templates/_helpers.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,8 @@ lands in the shared volume gatus reads.
sidecar flag list, rendered as a YAML sequence, derived from the structured
sidecar.* values (see internal/config/config.go for the authoritative flags).
Always emits --output, --default-interval, --probe-paths and --log-level; emits
--namespace / --annotation-config / --annotation-enabled only when non-empty;
--namespace / --cluster-domain / --annotation-config / --annotation-enabled only
when non-empty;
emits --gateway-name / --ingress-class once per list item; per kind emits
--enable-<kind> / --auto-<kind> / --prefix-<kind> as configured; then appends
sidecar.extraArgs verbatim.
Expand All @@ -132,6 +133,9 @@ sidecar.extraArgs verbatim.
{{- with $s.namespace }}
- --namespace={{ . }}
{{- end }}
{{- with $s.clusterDomain }}
- --cluster-domain={{ . }}
{{- end }}
{{- with $s.annotationConfig }}
- --annotation-config={{ . }}
{{- end }}
Expand Down
8 changes: 8 additions & 0 deletions charts/gatus-sidecar/tests/deployment_test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,10 @@ tests:
- contains:
path: spec.template.spec.initContainers[0].args
content: --enable-service
# empty clusterDomain leaves the flag to the sidecar default.
- notContains:
path: spec.template.spec.initContainers[0].args
content: --cluster-domain=cluster.local
# ingress is off by default, so neither its enable nor auto flag is emitted.
- notContains:
path: spec.template.spec.initContainers[0].args
Expand All @@ -193,6 +197,7 @@ tests:
template: deployment.tpl
set:
sidecar.namespace: monitoring
sidecar.clusterDomain: k8s.example
sidecar.annotationConfig: example.com/config
sidecar.annotationEnabled: example.com/enabled
sidecar.gatewayNames: [gw-a, gw-b]
Expand All @@ -203,6 +208,9 @@ tests:
- contains:
path: spec.template.spec.initContainers[0].args
content: --namespace=monitoring
- contains:
path: spec.template.spec.initContainers[0].args
content: --cluster-domain=k8s.example
- contains:
path: spec.template.spec.initContainers[0].args
content: --annotation-config=example.com/config
Expand Down
6 changes: 6 additions & 0 deletions charts/gatus-sidecar/values.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -759,6 +759,12 @@
"title": "annotationEnabled",
"type": "string"
},
"clusterDomain": {
"default": "",
"description": "Cluster DNS domain used to fully qualify generated Service endpoint hostnames (--cluster-domain); empty uses the sidecar default (cluster.local).",
"title": "clusterDomain",
"type": "string"
},
"defaultInterval": {
"default": "1m",
"description": "Default probe interval for generated endpoints (--default-interval).",
Expand Down
2 changes: 2 additions & 0 deletions charts/gatus-sidecar/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ sidecar:
defaultInterval: 1m
# -- Include paths from match rules in probe URLs (--probe-paths); false probes bare hostnames.
probePaths: true
# -- Cluster DNS domain used to fully qualify generated Service endpoint hostnames (--cluster-domain); empty uses the sidecar default (cluster.local).
clusterDomain: ""
# -- Annotation key for the per-resource YAML config override (--annotation-config); empty uses the sidecar default.
annotationConfig: ""
# -- Annotation key for enabling/disabling per-resource processing (--annotation-enabled); empty uses the sidecar default.
Expand Down
3 changes: 3 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const (
DefaultInterval = time.Minute
DefaultTemplateAnnotation = "gatus.home-operations.com/endpoint"
DefaultEnabledAnnotation = "gatus.home-operations.com/enabled"
DefaultClusterDomain = "cluster.local"
DefaultLogLevel = "info"
)

Expand Down Expand Up @@ -56,6 +57,7 @@ type Config struct {
Output string
DefaultInterval time.Duration
ProbePaths bool
ClusterDomain string

TemplateAnnotation string
EnabledAnnotation string
Expand Down Expand Up @@ -85,6 +87,7 @@ func Load(name string, args []string, errOut io.Writer) (*Config, error) {
fs.StringVar(&cfg.Output, "output", DefaultOutputPath, "File to write generated YAML")
fs.DurationVar(&cfg.DefaultInterval, "default-interval", DefaultInterval, "Default interval value for endpoints")
fs.BoolVar(&cfg.ProbePaths, "probe-paths", true, "Include paths from Ingress/HTTPRoute/IngressRoute match rules in probe URLs; set false to probe bare hostnames")
fs.StringVar(&cfg.ClusterDomain, "cluster-domain", DefaultClusterDomain, "Cluster DNS domain used to fully qualify generated Service endpoint hostnames")
fs.StringVar(&cfg.TemplateAnnotation, "annotation-config", DefaultTemplateAnnotation, "Annotation key for YAML config override")
fs.StringVar(&cfg.EnabledAnnotation, "annotation-enabled", DefaultEnabledAnnotation, "Annotation key for enabling/disabling resource processing")

Expand Down
7 changes: 7 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ func TestLoad_Defaults(t *testing.T) {
if cfg.DefaultInterval != DefaultInterval {
t.Errorf("DefaultInterval = %v, want %v", cfg.DefaultInterval, DefaultInterval)
}
if cfg.ClusterDomain != DefaultClusterDomain {
t.Errorf("ClusterDomain = %q, want %q", cfg.ClusterDomain, DefaultClusterDomain)
}
if cfg.TemplateAnnotation != DefaultTemplateAnnotation {
t.Errorf("TemplateAnnotation = %q, want %q", cfg.TemplateAnnotation, DefaultTemplateAnnotation)
}
Expand All @@ -40,6 +43,7 @@ func TestLoad_AllFlags(t *testing.T) {
"--auto-ingress=true",
"--output=/tmp/foo.yaml",
"--default-interval=30s",
"--cluster-domain=k8s.example",
"--annotation-config=k1",
"--annotation-enabled=k2",
}
Expand All @@ -61,6 +65,9 @@ func TestLoad_AllFlags(t *testing.T) {
if cfg.DefaultInterval != 30*time.Second {
t.Errorf("DefaultInterval = %v", cfg.DefaultInterval)
}
if cfg.ClusterDomain != "k8s.example" {
t.Errorf("ClusterDomain = %q", cfg.ClusterDomain)
}
if cfg.TemplateAnnotation != "k1" || cfg.EnabledAnnotation != "k2" {
t.Errorf("annotation flags incorrect: %+v", cfg)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/k8s/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ func (c *Controller) reconcile(ctx context.Context, key string, flush bool) erro
return c.removeEndpoint(endpointKey, namespace, name, "not-matched", flush)
}

probeURL := c.resource.URL(obj)
probeURL := c.resource.URL(obj, c.cfg)
if probeURL == "" {
// Per-resync per-resource; common for headless Services.
c.log.Debug("resource has no derivable URL", "namespace", namespace, "name", name)
Expand Down
2 changes: 1 addition & 1 deletion internal/k8s/controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ func matchesEnabledAnnotation(obj metav1.Object, cfg *config.Config) bool {
return err == nil && enabled
}

func (f fakeResource) URL(obj metav1.Object) string {
func (f fakeResource) URL(obj metav1.Object, _ *config.Config) string {
if f.urlFn != nil {
return f.urlFn(obj)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/k8s/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ type Resource interface {
Matches(obj metav1.Object, cfg *config.Config) bool

// URL returns the URL gatus should probe, or "" if none can be derived.
URL(obj metav1.Object) string
URL(obj metav1.Object, cfg *config.Config) string

DefaultConditions() []string

Expand Down
2 changes: 1 addition & 1 deletion internal/resources/httproute.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ func (HTTPRoute) Matches(obj metav1.Object, cfg *config.Config) bool {
return matchesAnnotation(obj, cfg.AutoEnabled(config.KindHTTPRoute), cfg)
}

func (HTTPRoute) URL(obj metav1.Object) string {
func (HTTPRoute) URL(obj metav1.Object, _ *config.Config) string {
route, ok := obj.(*gatewayv1.HTTPRoute)
if !ok {
return ""
Expand Down
2 changes: 1 addition & 1 deletion internal/resources/httproute_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ func TestHTTPRoute_URL(t *testing.T) {
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := (HTTPRoute{}).URL(tt.in); got != tt.want {
if got := (HTTPRoute{}).URL(tt.in, nil); got != tt.want {
t.Errorf("URL() = %q, want %q", got, tt.want)
}
})
Expand Down
2 changes: 1 addition & 1 deletion internal/resources/ingress.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ func (Ingress) Matches(obj metav1.Object, cfg *config.Config) bool {
return matchesAnnotation(obj, cfg.AutoEnabled(config.KindIngress), cfg)
}

func (Ingress) URL(obj metav1.Object) string {
func (Ingress) URL(obj metav1.Object, _ *config.Config) string {
ing, ok := obj.(*networkingv1.Ingress)
if !ok {
return ""
Expand Down
2 changes: 1 addition & 1 deletion internal/resources/ingress_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ func TestIngress_URL(t *testing.T) {
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := (Ingress{}).URL(tt.in); got != tt.want {
if got := (Ingress{}).URL(tt.in, nil); got != tt.want {
t.Errorf("URL() = %q, want %q", got, tt.want)
}
})
Expand Down
2 changes: 1 addition & 1 deletion internal/resources/ingressroute.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ func (IngressRoute) Matches(obj metav1.Object, cfg *config.Config) bool {
return matchesAnnotation(obj, cfg.AutoEnabled(config.KindIngressRoute), cfg)
}

func (IngressRoute) URL(obj metav1.Object) string {
func (IngressRoute) URL(obj metav1.Object, _ *config.Config) string {
u, ok := obj.(*unstructured.Unstructured)
if !ok {
return ""
Expand Down
2 changes: 1 addition & 1 deletion internal/resources/ingressroute_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ func TestIngressRoute_URL(t *testing.T) {
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := (IngressRoute{}).URL(tt.obj); got != tt.want {
if got := (IngressRoute{}).URL(tt.obj, nil); got != tt.want {
t.Errorf("URL() = %q, want %q", got, tt.want)
}
})
Expand Down
10 changes: 8 additions & 2 deletions internal/resources/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,20 @@ func (Service) Matches(obj metav1.Object, cfg *config.Config) bool {
return matchesAnnotation(obj, cfg.AutoEnabled(config.KindService), cfg)
}

func (Service) URL(obj metav1.Object) string {
// URL fully qualifies the in-cluster hostname and roots it with a trailing dot.
// <service>.<namespace>.svc is not resolvable on its own; it only works when the
// pod's search list happens to append the cluster domain. Rooting the name keeps
// resolution to a single query whatever the pod's ndots is, and stops the bare
// .svc form escaping to the public resolvers under ndots:1.
func (Service) URL(obj metav1.Object, cfg *config.Config) string {
Comment thread
onedr0p marked this conversation as resolved.
Resolved
svc, ok := obj.(*corev1.Service)
if !ok || len(svc.Spec.Ports) == 0 {
return ""
}
port := svc.Spec.Ports[0]
protocol := strings.ToLower(string(cmp.Or(port.Protocol, corev1.ProtocolTCP)))
return fmt.Sprintf("%s://%s.%s.svc:%d", protocol, svc.Name, svc.Namespace, port.Port)
domain := cmp.Or(strings.Trim(cfg.ClusterDomain, "."), config.DefaultClusterDomain)
return fmt.Sprintf("%s://%s.%s.svc.%s.:%d", protocol, svc.Name, svc.Namespace, domain, port.Port)
}

func (Service) DefaultConditions() []string { return tcpDefaultConditions }
Expand Down
24 changes: 15 additions & 9 deletions internal/resources/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,23 +22,29 @@ func makeService(name, ns string, port int32, protocol corev1.Protocol) *corev1.
func TestService_URL(t *testing.T) {
t.Parallel()
cases := []struct {
name string
svc metav1.Object
want string
name string
svc metav1.Object
domain string
want string
}{
{"tcp", makeService("a", "ns", 8080, corev1.ProtocolTCP), "tcp://a.ns.svc:8080"},
{"udp", makeService("dns", "kube-system", 53, corev1.ProtocolUDP), "udp://dns.kube-system.svc:53"},
{"tcp", makeService("a", "ns", 8080, corev1.ProtocolTCP), "cluster.local", "tcp://a.ns.svc.cluster.local.:8080"},
{"udp", makeService("dns", "kube-system", 53, corev1.ProtocolUDP), "cluster.local", "udp://dns.kube-system.svc.cluster.local.:53"},
{"custom cluster domain", makeService("a", "ns", 80, corev1.ProtocolTCP), "k8s.example", "tcp://a.ns.svc.k8s.example.:80"},
{"empty cluster domain falls back to the default", makeService("a", "ns", 80, corev1.ProtocolTCP), "", "tcp://a.ns.svc.cluster.local.:80"},
{"dots-only cluster domain falls back to the default", makeService("a", "ns", 80, corev1.ProtocolTCP), ".", "tcp://a.ns.svc.cluster.local.:80"},
{"trailing dot on the configured domain is not doubled", makeService("a", "ns", 80, corev1.ProtocolTCP), "cluster.local.", "tcp://a.ns.svc.cluster.local.:80"},
{"default protocol", &corev1.Service{
ObjectMeta: metav1.ObjectMeta{Name: "a", Namespace: "n"},
Spec: corev1.ServiceSpec{Ports: []corev1.ServicePort{{Port: 80}}},
}, "tcp://a.n.svc:80"},
{"no ports", &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: "a"}}, ""},
{"wrong type", &corev1.Pod{}, ""},
}, "cluster.local", "tcp://a.n.svc.cluster.local.:80"},
{"no ports", &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: "a"}}, "cluster.local", ""},
{"wrong type", &corev1.Pod{}, "cluster.local", ""},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := (Service{}).URL(tt.svc); got != tt.want {
cfg := &config.Config{ClusterDomain: tt.domain}
if got := (Service{}).URL(tt.svc, cfg); got != tt.want {
t.Errorf("URL() = %q, want %q", got, tt.want)
}
})
Expand Down
4 changes: 2 additions & 2 deletions test/e2e/e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,10 @@ func TestE2E(t *testing.T) {
t.Run("emits expected endpoints", func(t *testing.T) {
// Ingress path /api/v1 is captured in the URL (#33).
h.waitForEndpointURL("ing/e2e-ingress", "https://e2e-ingress.example.com/api/v1")
h.waitForEndpointURL("svc/e2e-service", "tcp://e2e-service.e2e.svc:8080")
h.waitForEndpointURL("svc/e2e-service", "tcp://e2e-service.e2e.svc.cluster.local.:8080")
// Service e2e-ingress collides with Ingress e2e-ingress by name;
// prefixes keep them distinct in Gatus (#59).
h.waitForEndpointURL("svc/e2e-ingress", "tcp://e2e-ingress.e2e.svc:9090")
h.waitForEndpointURL("svc/e2e-ingress", "tcp://e2e-ingress.e2e.svc.cluster.local.:9090")
h.waitForEndpointURL("e2e-route", "https://e2e-route.example.com")

eps := h.endpoints()
Expand Down
Loading