Skip to content

Commit df1ce70

Browse files
committed
feat(config): support environment variables in check specs
1 parent 98d2d96 commit df1ce70

11 files changed

Lines changed: 564 additions & 64 deletions

File tree

README.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ Example configuration files are available in:
6262
Validate configuration with:
6363

6464
```bash
65-
CONFIG_DIR=./examples go run ./cmd/pulse-validate
65+
API_HTTP_AUTH_TOKEN=dev-token API_HTTP_CUSTOM_HEADER=dev-value CONFIG_DIR=./examples go run ./cmd/pulse-validate
6666
```
6767

6868
Before starting the app, apply migrations:
@@ -74,7 +74,7 @@ go run ./cmd/pulse-migrate up
7474
Run with:
7575

7676
```bash
77-
CONFIG_DIR=./examples go run ./cmd/pulse
77+
API_HTTP_AUTH_TOKEN=dev-token API_HTTP_CUSTOM_HEADER=dev-value CONFIG_DIR=./examples go run ./cmd/pulse
7878
```
7979

8080
Run API with:
@@ -83,6 +83,15 @@ Run API with:
8383
API_LISTEN_ADDR=:8080 INTERNAL_API_ENABLED=true CONFIG_DIR=./examples go run ./cmd/pulse-api
8484
```
8585

86+
Check `spec` values may reference environment variables with `${VAR_NAME}` syntax:
87+
88+
```yaml
89+
headers:
90+
Authorization: "Bearer ${API_HTTP_AUTH_TOKEN}"
91+
```
92+
93+
Referenced variables must be present when the configuration is loaded. Values are resolved at check execution time and are not written back into the loaded config snapshot.
94+
8695
The API process has separate internal and public route groups.
8796
Both are disabled by default and must be enabled explicitly:
8897

examples/checks/api-checks.yaml

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,16 @@ checks:
1212
allowed_buckets: [ minute, hour ]
1313
status_impact: critical
1414
spec:
15-
url: http://localhost:8080
16-
method: GET
15+
url: "${API_HTTP_URL}"
16+
method: "${API_HTTP_METHOD}"
1717
headers:
1818
Accept: application/json
1919
Content-Type: application/json
20-
Authorization: Bearer xxx
21-
X-Custom-Header: yyy
20+
Authorization: "Bearer ${API_HTTP_AUTH_TOKEN}"
21+
X-Custom-Header: "${API_HTTP_CUSTOM_HEADER}"
2222
payload:
23-
key1: value1
24-
key2: value2
23+
key1: "${API_HTTP_PAYLOAD_KEY1}"
24+
key2: "${API_HTTP_PAYLOAD_KEY2}"
2525
follow_redirects: true
2626
success_codes: [ 200 ]
2727
expected_body:

internal/checker/dns/request.go

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,19 @@ func (c *Checker) request(ctx context.Context) error {
1818
ctx, cancel := context.WithTimeout(ctx, c.config.Timeout)
1919
defer cancel()
2020

21+
spec, err := config.ResolveDNSSpecEnv(c.config.Spec)
22+
if err != nil {
23+
return e.NewError(e.ErrInternal, fmt.Sprintf("could not resolve dns spec: %v", err))
24+
}
25+
2126
req := new(mdns.Msg)
2227
req.SetQuestion(
23-
mdns.Fqdn(c.config.Spec.Name),
24-
recordTypeToQType(c.config.Spec.RecordType),
28+
mdns.Fqdn(spec.Name),
29+
recordTypeToQType(spec.RecordType),
2530
)
2631
req.RecursionDesired = true
2732

28-
server, err := resolveServer(c.config.Spec.Server)
33+
server, err := resolveServer(spec.Server)
2934
if err != nil {
3035
return fmt.Errorf("could not resolve dns server: %w", err)
3136
}
@@ -46,19 +51,19 @@ func (c *Checker) request(ctx context.Context) error {
4651
)
4752
}
4853

49-
values, err := collectAnswers(res.Answer, c.config.Spec.RecordType)
54+
values, err := collectAnswers(res.Answer, spec.RecordType)
5055
if err != nil {
5156
return err
5257
}
5358

5459
if len(values) == 0 {
5560
return e.NewError(
5661
e.ErrConstraint,
57-
fmt.Sprintf("no %s records found for %s", c.config.Spec.RecordType, c.config.Spec.Name),
62+
fmt.Sprintf("no %s records found for %s", spec.RecordType, spec.Name),
5863
)
5964
}
6065

61-
if err = checkAnswers(c.config.Spec.Expect, c.config.Spec.RecordType, values); err != nil {
66+
if err = checkAnswers(spec.Expect, spec.RecordType, values); err != nil {
6267
return err
6368
}
6469

internal/checker/grpc/request.go

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,20 @@ import (
1010
healthpb "google.golang.org/grpc/health/grpc_health_v1"
1111
"google.golang.org/grpc/metadata"
1212

13+
"github.com/pixel365/pulse/internal/config"
1314
"github.com/pixel365/pulse/internal/e"
1415
)
1516

1617
func (c *Checker) request(ctx context.Context) error {
1718
ctx, cancel := context.WithTimeout(ctx, c.config.Timeout)
1819
defer cancel()
1920

20-
address := net.JoinHostPort(c.config.Spec.Host, fmt.Sprint(c.config.Spec.Port))
21+
spec, err := config.ResolveGRPCSpecEnv(c.config.Spec)
22+
if err != nil {
23+
return e.NewError(e.ErrInternal, fmt.Sprintf("could not resolve grpc spec: %v", err))
24+
}
25+
26+
address := net.JoinHostPort(spec.Host, fmt.Sprint(spec.Port))
2127
conn, err := grpc.NewClient(
2228
address,
2329
grpc.WithTransportCredentials(insecure.NewCredentials()),
@@ -33,13 +39,13 @@ func (c *Checker) request(ctx context.Context) error {
3339
_ = conn.Close()
3440
}()
3541

36-
if len(c.config.Spec.Metadata) > 0 {
37-
ctx = metadata.NewOutgoingContext(ctx, metadata.New(c.config.Spec.Metadata))
42+
if len(spec.Metadata) > 0 {
43+
ctx = metadata.NewOutgoingContext(ctx, metadata.New(spec.Metadata))
3844
}
3945

4046
service := ""
41-
if c.config.Spec.Request != nil {
42-
service = c.config.Spec.Request.Service
47+
if spec.Request != nil {
48+
service = spec.Request.Service
4349
}
4450

4551
client := healthpb.NewHealthClient(conn)
@@ -50,12 +56,12 @@ func (c *Checker) request(ctx context.Context) error {
5056
return fmt.Errorf("could not execute grpc health check: %w", err)
5157
}
5258

53-
if got := resp.GetStatus().String(); got != string(c.config.Spec.ExpectedHealthStatus) {
59+
if got := resp.GetStatus().String(); got != string(spec.ExpectedHealthStatus) {
5460
return e.NewError(
5561
e.ErrConstraint,
5662
fmt.Sprintf(
5763
"unexpected grpc health status: expected %s, got %s",
58-
c.config.Spec.ExpectedHealthStatus,
64+
spec.ExpectedHealthStatus,
5965
got,
6066
),
6167
)

internal/checker/http/request.go

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,14 @@ func (c *Checker) request(ctx context.Context) error {
1818
ctx, cancel := context.WithTimeout(ctx, c.config.Timeout)
1919
defer cancel()
2020

21+
spec, err := config.ResolveHTTPSpecEnv(c.config.Spec)
22+
if err != nil {
23+
return e.NewError(e.ErrInternal, fmt.Sprintf("could not resolve http spec: %v", err))
24+
}
25+
2126
cl := &h.Client{
2227
CheckRedirect: func(req *h.Request, via []*h.Request) error {
23-
if !c.config.Spec.FollowRedirects {
28+
if !spec.FollowRedirects {
2429
return h.ErrUseLastResponse
2530
}
2631

@@ -32,12 +37,12 @@ func (c *Checker) request(ctx context.Context) error {
3237
},
3338
}
3439

35-
req, err := makeRequest(ctx, c.config)
40+
req, err := makeRequest(ctx, spec)
3641
if err != nil {
3742
return fmt.Errorf("could not make request: %w", err)
3843
}
3944

40-
for k, v := range c.config.Spec.Headers {
45+
for k, v := range spec.Headers {
4146
req.Header.Set(k, v)
4247
}
4348

@@ -50,56 +55,56 @@ func (c *Checker) request(ctx context.Context) error {
5055
_ = res.Body.Close()
5156
}()
5257

53-
if err = checkCode(res.StatusCode, c.config.Spec.SuccessCodes); err != nil {
58+
if err = checkCode(res.StatusCode, spec.SuccessCodes); err != nil {
5459
return fmt.Errorf("could not check response code: %w", err)
5560
}
5661

57-
if err = checkBody(c.config.Spec.ExpectedBody, res.Body); err != nil {
62+
if err = checkBody(spec.ExpectedBody, res.Body); err != nil {
5863
return fmt.Errorf("could not parse response body: %w", err)
5964
}
6065

6166
return nil
6267
}
6368

64-
func makeRequest(ctx context.Context, config Alias) (*h.Request, error) {
69+
func makeRequest(ctx context.Context, spec config.HttpSpec) (*h.Request, error) {
6570
var req *h.Request
6671

67-
switch config.Spec.Method {
72+
switch spec.Method {
6873
case "GET":
69-
fullUrl := config.Spec.URL
70-
if len(config.Spec.Payload) > 0 {
74+
fullUrl := spec.URL
75+
if len(spec.Payload) > 0 {
7176
params := url.Values{}
72-
for k, v := range config.Spec.Payload {
77+
for k, v := range spec.Payload {
7378
params.Add(k, fmt.Sprint(v))
7479
}
7580

7681
fullUrl = fmt.Sprintf("%s?%s", fullUrl, params.Encode())
7782
}
7883

79-
rq, err := h.NewRequestWithContext(ctx, config.Spec.Method, fullUrl, nil)
84+
rq, err := h.NewRequestWithContext(ctx, spec.Method, fullUrl, nil)
8085
if err != nil {
8186
return nil, fmt.Errorf("could not send request: %w", err)
8287
}
8388
req = rq
8489
case "POST":
8590
var payload io.Reader
86-
if len(config.Spec.Payload) > 0 {
87-
data, err := json.Marshal(config.Spec.Payload)
91+
if len(spec.Payload) > 0 {
92+
data, err := json.Marshal(spec.Payload)
8893
if err != nil {
8994
return nil, fmt.Errorf("could not marshal data: %w", err)
9095
}
9196
payload = bytes.NewReader(data)
9297
}
9398

94-
rq, err := h.NewRequestWithContext(ctx, config.Spec.Method, config.Spec.URL, payload)
99+
rq, err := h.NewRequestWithContext(ctx, spec.Method, spec.URL, payload)
95100
if err != nil {
96101
return nil, fmt.Errorf("could not send request: %w", err)
97102
}
98103
req = rq
99104
default:
100105
return nil, e.NewError(
101106
e.ErrInternal,
102-
fmt.Sprintf("unsupported method: %s", config.Spec.Method),
107+
fmt.Sprintf("unsupported method: %s", spec.Method),
103108
)
104109
}
105110

internal/checker/tcp/request.go

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,12 @@ func (c *Checker) request(ctx context.Context) error {
1616
ctx, cancel := context.WithTimeout(ctx, c.config.Timeout)
1717
defer cancel()
1818

19-
address := net.JoinHostPort(c.config.Spec.Host, fmt.Sprint(c.config.Spec.Port))
19+
spec, err := config.ResolveTCPSpecEnv(c.config.Spec)
20+
if err != nil {
21+
return e.NewError(e.ErrInternal, fmt.Sprintf("could not resolve tcp spec: %v", err))
22+
}
23+
24+
address := net.JoinHostPort(spec.Host, fmt.Sprint(spec.Port))
2025
dialer := &net.Dialer{}
2126

2227
conn, err := dialer.DialContext(ctx, "tcp", address)
@@ -34,13 +39,13 @@ func (c *Checker) request(ctx context.Context) error {
3439
}
3540
}
3641

37-
if c.config.Spec.Send != "" {
38-
if _, err = io.WriteString(conn, c.config.Spec.Send); err != nil {
42+
if spec.Send != "" {
43+
if _, err = io.WriteString(conn, spec.Send); err != nil {
3944
return fmt.Errorf("could not write tcp payload: %w", err)
4045
}
4146
}
4247

43-
if err = checkResponse(conn, c.config.Spec.Expect); err != nil {
48+
if err = checkResponse(conn, spec.Expect); err != nil {
4449
return err
4550
}
4651

internal/checker/tls/request.go

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,23 @@ import (
77
"net"
88
"time"
99

10+
"github.com/pixel365/pulse/internal/config"
1011
"github.com/pixel365/pulse/internal/e"
1112
)
1213

1314
func (c *Checker) request(ctx context.Context) error {
1415
ctx, cancel := context.WithTimeout(ctx, c.config.Timeout)
1516
defer cancel()
1617

17-
address := net.JoinHostPort(c.config.Spec.Host, fmt.Sprint(c.config.Spec.Port))
18-
serverName := c.config.Spec.ServerName
18+
spec, err := config.ResolveTLSSpecEnv(c.config.Spec)
19+
if err != nil {
20+
return e.NewError(e.ErrInternal, fmt.Sprintf("could not resolve tls spec: %v", err))
21+
}
22+
23+
address := net.JoinHostPort(spec.Host, fmt.Sprint(spec.Port))
24+
serverName := spec.ServerName
1925
if serverName == "" {
20-
serverName = c.config.Spec.Host
26+
serverName = spec.Host
2127
}
2228

2329
dialer := &ctls.Dialer{
@@ -52,13 +58,13 @@ func (c *Checker) request(ctx context.Context) error {
5258

5359
leaf := state.PeerCertificates[0]
5460
validityLeft := time.Until(leaf.NotAfter)
55-
if validityLeft < c.config.Spec.MinValidity {
61+
if validityLeft < spec.MinValidity {
5662
return e.NewError(
5763
e.ErrConstraint,
5864
fmt.Sprintf(
5965
"certificate validity %s is below required minimum %s",
6066
validityLeft.Truncate(time.Second),
61-
c.config.Spec.MinValidity,
67+
spec.MinValidity,
6268
),
6369
)
6470
}

internal/config/config.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -171,10 +171,6 @@ func decodeTypedCheck[T any](raw Check) (TypedCheck[T], error) {
171171
return typedCheck, fmt.Errorf("failed to unmarshal check spec: %w", err)
172172
}
173173

174-
if err = validateStruct(typedCheck); err != nil {
175-
return typedCheck, fmt.Errorf("failed to validate check spec: %w", err)
176-
}
177-
178174
return typedCheck, nil
179175
}
180176

@@ -188,6 +184,10 @@ func appendTypedCheck[T any](dst map[string]TypedCheck[T], raw Check) error {
188184
return err
189185
}
190186

187+
if err = validateStruct(&typedCheck); err != nil {
188+
return fmt.Errorf("failed to validate check spec: %w", err)
189+
}
190+
191191
dst[raw.ID] = typedCheck
192192

193193
return nil

0 commit comments

Comments
 (0)