Skip to content

Commit d8d89d0

Browse files
angrycubblink-so[bot]ethanndickson
authored
feat: add configurable HTTP headers to provider (#320)
Adds a `headers` map attribute to the provider configuration for injecting arbitrary HTTP headers into all API requests. Falls back to the `CODER_HEADER` environment variable (CSV `key=value` format, matching the Coder CLI) when `headers` is not set in HCL; explicit HCL config takes precedence. The primary use case is setting `X-Coder-Bypass-Ratelimit: true` for Owner-role accounts to avoid hitting Coder's file endpoint rate limit during heavy Terraform runs. Unit tests cover HCL config, env var fallback, HCL-overrides-env, invalid env var, and no-headers cases. An integration test spins up a real Coder instance with rate limits enabled and verifies that a request burst without the header hits a 429 while the same burst with the header succeeds. --------- Co-authored-by: blink-so[bot] <211532188+blink-so[bot]@users.noreply.github.com> Co-authored-by: ethan <ethanndickson@gmail.com>
1 parent 6697442 commit d8d89d0

7 files changed

Lines changed: 363 additions & 12 deletions

File tree

docs/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,5 +84,6 @@ resource "coderd_template" "example" {
8484
### Optional
8585

8686
- `default_organization_id` (String) Default organization ID to use when creating resources. Defaults to the first organization the token has access to.
87+
- `headers` (Map of String) Additional HTTP headers to include in all API requests. Provide as a map of header names to values. For example, set `X-Coder-Bypass-Ratelimit` to `"true"` to bypass rate limits (requires Owner role). Can also be specified with the `CODER_HEADER` environment variable as comma-separated `key=value` pairs (CSV format, matching the coder CLI).
8788
- `token` (String) API token for communicating with the deployment. Most resource types require elevated permissions. Defaults to `$CODER_SESSION_TOKEN`.
8889
- `url` (String) URL to the Coder deployment. Defaults to `$CODER_URL`.

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ require (
66
cdr.dev/slog/v3 v3.0.0-rc1
77
github.com/coder/coder/v2 v2.31.2
88
github.com/coder/retry v1.5.1
9+
github.com/coder/serpent v0.14.0
910
github.com/coder/websocket v1.8.14
1011
github.com/docker/docker v28.5.2+incompatible
1112
github.com/docker/go-connections v0.6.0
@@ -63,7 +64,6 @@ require (
6364
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
6465
github.com/cloudflare/circl v1.6.3 // indirect
6566
github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0 // indirect
66-
github.com/coder/serpent v0.14.0 // indirect
6767
github.com/coder/terraform-provider-coder/v2 v2.13.1 // indirect
6868
github.com/containerd/errdefs v1.0.0 // indirect
6969
github.com/containerd/errdefs/pkg v0.3.0 // indirect

go.sum

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,8 +120,6 @@ github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w
120120
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
121121
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
122122
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
123-
github.com/coder/coder/v2 v2.31.1 h1:yLU9ScPYJzelN9EkPEKfOVvAspX45TBwOu4tVBGzaU4=
124-
github.com/coder/coder/v2 v2.31.1/go.mod h1:XSfk1tKVr5Y2un+DJ1KeBvtvTMVwbAxjG8sWWW6NWQc=
125123
github.com/coder/coder/v2 v2.31.2 h1:xReEruuvOGB3NXr+uT53HL8MpunvDridX/UniTrEUn8=
126124
github.com/coder/coder/v2 v2.31.2/go.mod h1:XSfk1tKVr5Y2un+DJ1KeBvtvTMVwbAxjG8sWWW6NWQc=
127125
github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0 h1:3A0ES21Ke+FxEM8CXx9n47SZOKOpgSE1bbJzlE4qPVs=

integration/headers_test.go

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
package integration
2+
3+
import (
4+
"archive/tar"
5+
"bytes"
6+
"context"
7+
"errors"
8+
"net/http"
9+
"os"
10+
"strconv"
11+
"testing"
12+
"time"
13+
14+
"github.com/coder/coder/v2/codersdk"
15+
"github.com/stretchr/testify/require"
16+
)
17+
18+
// createMinimalTar creates a small valid tar archive for uploading.
19+
func createMinimalTar(t *testing.T) *bytes.Buffer {
20+
t.Helper()
21+
var buf bytes.Buffer
22+
tw := tar.NewWriter(&buf)
23+
content := []byte("# test file")
24+
err := tw.WriteHeader(&tar.Header{
25+
Name: "main.tf",
26+
Mode: 0o644,
27+
Size: int64(len(content)),
28+
})
29+
require.NoError(t, err)
30+
_, err = tw.Write(content)
31+
require.NoError(t, err)
32+
require.NoError(t, tw.Close())
33+
return &buf
34+
}
35+
36+
// TestHeadersBypassRateLimit verifies that the X-Coder-Bypass-Ratelimit header
37+
// allows an Owner to exceed the files endpoint rate limit (12 req/min).
38+
//
39+
// This test starts a Coder instance with rate limits ENABLED, then:
40+
// 1. Confirms that rapid file GETs without the bypass header hit a 429.
41+
// 2. Confirms that the same burst with the bypass header succeeds.
42+
func TestHeadersBypassRateLimit(t *testing.T) {
43+
t.Parallel()
44+
if os.Getenv("TF_ACC") == "1" {
45+
t.Skip("Skipping integration tests during tf acceptance tests")
46+
}
47+
if testing.Short() {
48+
t.Skip("Skipping integration test in short mode")
49+
}
50+
51+
timeoutStr := os.Getenv("TIMEOUT_MINS")
52+
if timeoutStr == "" {
53+
timeoutStr = "10"
54+
}
55+
timeoutMins, err := strconv.Atoi(timeoutStr)
56+
require.NoError(t, err, "invalid value specified for timeout")
57+
ctx, cancel := context.WithTimeout(t.Context(), time.Duration(timeoutMins)*time.Minute)
58+
t.Cleanup(cancel)
59+
60+
// Start Coder WITH rate limits enabled (no CODER_DANGEROUS_DISABLE_RATE_LIMITS).
61+
client := StartCoder(ctx, t, "headers-ratelimit", EnableRateLimits)
62+
63+
// Upload a small file so we have something to GET.
64+
uploadResp, err := client.Upload(ctx, "application/x-tar", createMinimalTar(t))
65+
require.NoError(t, err, "upload file")
66+
fileID := uploadResp.ID
67+
68+
// The files endpoint rate limit is 12 requests per minute.
69+
// Fire 15 rapid GETs without the bypass header -- we expect at least one 429.
70+
const burstCount = 15
71+
72+
t.Run("WithoutBypass", func(t *testing.T) {
73+
t.Parallel()
74+
subCtx, subCancel := context.WithTimeout(context.Background(), 2*time.Minute)
75+
defer subCancel()
76+
77+
got429 := false
78+
for range burstCount {
79+
_, _, err := client.Download(subCtx, fileID)
80+
if err != nil {
81+
var sdkErr *codersdk.Error
82+
if errors.As(err, &sdkErr) && sdkErr.StatusCode() == http.StatusTooManyRequests {
83+
got429 = true
84+
break
85+
}
86+
}
87+
}
88+
require.True(t, got429, "expected to hit 429 rate limit within %d requests", burstCount)
89+
})
90+
91+
t.Run("WithBypass", func(t *testing.T) {
92+
t.Parallel()
93+
subCtx, subCancel := context.WithTimeout(context.Background(), 2*time.Minute)
94+
defer subCancel()
95+
96+
// Create a new client with the bypass header set.
97+
bypassClient := codersdk.New(client.URL)
98+
bypassClient.SetSessionToken(client.SessionToken())
99+
bypassClient.HTTPClient.Transport = &codersdk.HeaderTransport{
100+
Transport: http.DefaultTransport,
101+
Header: http.Header{
102+
"X-Coder-Bypass-Ratelimit": []string{"true"},
103+
},
104+
}
105+
106+
// Same burst, but with bypass -- all should succeed.
107+
for i := range burstCount {
108+
_, _, err := bypassClient.Download(subCtx, fileID)
109+
require.NoError(t, err, "request %d should not be rate limited with bypass header", i+1)
110+
}
111+
})
112+
}

integration/integration.go

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,11 @@ import (
2323
// Using the pattern from
2424
// https://dave.cheney.net/2014/10/17/functional-options-for-friendly-apis
2525
type coderOptions struct {
26-
useLicense bool
27-
image string
28-
version string
29-
experiments string
26+
useLicense bool
27+
enableRateLimits bool
28+
image string
29+
version string
30+
experiments string
3031
}
3132

3233
func UseLicense(opts *coderOptions) {
@@ -48,6 +49,10 @@ func CoderExperiments(experiments string) func(opts *coderOptions) {
4849
}
4950
}
5051

52+
func EnableRateLimits(opts *coderOptions) {
53+
opts.enableRateLimits = true
54+
}
55+
5156
func StartCoder(ctx context.Context, t *testing.T, name string, options ...func(*coderOptions)) *codersdk.Client {
5257
// Start with the defaults.
5358
opts := coderOptions{
@@ -97,10 +102,12 @@ func StartCoder(ctx context.Context, t *testing.T, name string, options ...func(
97102
require.NoError(t, err, "pull coder image")
98103

99104
env := []string{
100-
"CODER_HTTP_ADDRESS=0.0.0.0:3000", // Listen on all interfaces inside the container.
101-
"CODER_ACCESS_URL=http://localhost:3000", // Avoid creating try.coder.app URLs.
102-
"CODER_TELEMETRY_ENABLE=false", // Avoid creating noise.
103-
"CODER_DANGEROUS_DISABLE_RATE_LIMITS=true", // Avoid hitting file rate limit in tests.
105+
"CODER_HTTP_ADDRESS=0.0.0.0:3000", // Listen on all interfaces inside the container.
106+
"CODER_ACCESS_URL=http://localhost:3000", // Avoid creating try.coder.app URLs.
107+
"CODER_TELEMETRY_ENABLE=false", // Avoid creating noise.
108+
}
109+
if !opts.enableRateLimits {
110+
env = append(env, "CODER_DANGEROUS_DISABLE_RATE_LIMITS=true")
104111
}
105112
if opts.experiments != "" {
106113
env = append(env, "CODER_EXPERIMENTS="+opts.experiments)

internal/provider/provider.go

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ package provider
33
import (
44
"context"
55
"fmt"
6+
"net/http"
67
"net/url"
78
"os"
89
"strings"
910
"sync/atomic"
1011

1112
"cdr.dev/slog/v3"
13+
"github.com/coder/serpent"
1214
"github.com/google/uuid"
1315
"github.com/hashicorp/terraform-plugin-framework/datasource"
1416
"github.com/hashicorp/terraform-plugin-framework/function"
@@ -80,7 +82,8 @@ type CoderdProviderModel struct {
8082
URL types.String `tfsdk:"url"`
8183
Token types.String `tfsdk:"token"`
8284

83-
DefaultOrganizationID UUID `tfsdk:"default_organization_id"`
85+
DefaultOrganizationID UUID `tfsdk:"default_organization_id"`
86+
Headers types.Map `tfsdk:"headers"`
8487
}
8588

8689
func (p *CoderdProvider) Metadata(ctx context.Context, req provider.MetadataRequest, resp *provider.MetadataResponse) {
@@ -110,6 +113,14 @@ This provider is only compatible with Coder version [2.10.1](https://github.com/
110113
CustomType: UUIDType,
111114
Optional: true,
112115
},
116+
"headers": schema.MapAttribute{
117+
MarkdownDescription: "Additional HTTP headers to include in all API requests. " +
118+
"Provide as a map of header names to values. " +
119+
"For example, set `X-Coder-Bypass-Ratelimit` to `\"true\"` to bypass rate limits (requires Owner role). " +
120+
"Can also be specified with the `CODER_HEADER` environment variable as comma-separated `key=value` pairs (CSV format, matching the coder CLI).",
121+
ElementType: types.StringType,
122+
Optional: true,
123+
},
113124
},
114125
}
115126
}
@@ -156,6 +167,39 @@ func (p *CoderdProvider) Configure(ctx context.Context, req provider.ConfigureRe
156167
client := codersdk.New(url)
157168
client.SetLogger(slog.Make(tfslog{}).Leveled(slog.LevelDebug))
158169
client.SetSessionToken(data.Token.ValueString())
170+
171+
// Apply custom headers from the provider configuration or CODER_HEADERS env var.
172+
httpHeaders := make(http.Header)
173+
if !data.Headers.IsNull() && !data.Headers.IsUnknown() {
174+
headerMap := make(map[string]string)
175+
resp.Diagnostics.Append(data.Headers.ElementsAs(ctx, &headerMap, false)...)
176+
if resp.Diagnostics.HasError() {
177+
return
178+
}
179+
for k, v := range headerMap {
180+
httpHeaders.Set(k, v)
181+
}
182+
} else if headersEnv, ok := os.LookupEnv("CODER_HEADER"); ok && headersEnv != "" {
183+
var sa serpent.StringArray
184+
if err := sa.Set(headersEnv); err != nil {
185+
resp.Diagnostics.AddError("headers", fmt.Sprintf("invalid CODER_HEADER value: %s", err))
186+
return
187+
}
188+
for _, entry := range sa.Value() {
189+
parts := strings.SplitN(entry, "=", 2)
190+
if len(parts) != 2 {
191+
resp.Diagnostics.AddError("headers", fmt.Sprintf("invalid CODER_HEADER entry %q, expected key=value", entry))
192+
return
193+
}
194+
httpHeaders.Set(strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]))
195+
}
196+
}
197+
if len(httpHeaders) > 0 {
198+
client.HTTPClient.Transport = &codersdk.HeaderTransport{
199+
Transport: client.HTTPClient.Transport,
200+
Header: httpHeaders,
201+
}
202+
}
159203
if data.DefaultOrganizationID.IsNull() {
160204
user, err := client.User(ctx, codersdk.Me)
161205
if err != nil {

0 commit comments

Comments
 (0)