Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,5 +84,6 @@ resource "coderd_template" "example" {
### Optional

- `default_organization_id` (String) Default organization ID to use when creating resources. Defaults to the first organization the token has access to.
- `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).
- `token` (String) API token for communicating with the deployment. Most resource types require elevated permissions. Defaults to `$CODER_SESSION_TOKEN`.
- `url` (String) URL to the Coder deployment. Defaults to `$CODER_URL`.
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ require (
cdr.dev/slog/v3 v3.0.0-rc1
github.com/coder/coder/v2 v2.31.2
github.com/coder/retry v1.5.1
github.com/coder/serpent v0.14.0
github.com/coder/websocket v1.8.14
github.com/docker/docker v28.5.2+incompatible
github.com/docker/go-connections v0.6.0
Expand Down Expand Up @@ -63,7 +64,6 @@ require (
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0 // indirect
github.com/coder/serpent v0.14.0 // indirect
github.com/coder/terraform-provider-coder/v2 v2.13.1 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
Expand Down
2 changes: 0 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,6 @@ github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/coder/coder/v2 v2.31.1 h1:yLU9ScPYJzelN9EkPEKfOVvAspX45TBwOu4tVBGzaU4=
github.com/coder/coder/v2 v2.31.1/go.mod h1:XSfk1tKVr5Y2un+DJ1KeBvtvTMVwbAxjG8sWWW6NWQc=
github.com/coder/coder/v2 v2.31.2 h1:xReEruuvOGB3NXr+uT53HL8MpunvDridX/UniTrEUn8=
github.com/coder/coder/v2 v2.31.2/go.mod h1:XSfk1tKVr5Y2un+DJ1KeBvtvTMVwbAxjG8sWWW6NWQc=
github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0 h1:3A0ES21Ke+FxEM8CXx9n47SZOKOpgSE1bbJzlE4qPVs=
Expand Down
112 changes: 112 additions & 0 deletions integration/headers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package integration

import (
"archive/tar"
"bytes"
"context"
"errors"
"net/http"
"os"
"strconv"
"testing"
"time"

"github.com/coder/coder/v2/codersdk"
"github.com/stretchr/testify/require"
)

// createMinimalTar creates a small valid tar archive for uploading.
func createMinimalTar(t *testing.T) *bytes.Buffer {
t.Helper()
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
content := []byte("# test file")
err := tw.WriteHeader(&tar.Header{
Name: "main.tf",
Mode: 0o644,
Size: int64(len(content)),
})
require.NoError(t, err)
_, err = tw.Write(content)
require.NoError(t, err)
require.NoError(t, tw.Close())
return &buf
}

// TestHeadersBypassRateLimit verifies that the X-Coder-Bypass-Ratelimit header
// allows an Owner to exceed the files endpoint rate limit (12 req/min).
//
// This test starts a Coder instance with rate limits ENABLED, then:
// 1. Confirms that rapid file GETs without the bypass header hit a 429.
// 2. Confirms that the same burst with the bypass header succeeds.
func TestHeadersBypassRateLimit(t *testing.T) {
t.Parallel()
if os.Getenv("TF_ACC") == "1" {
t.Skip("Skipping integration tests during tf acceptance tests")
}
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}

timeoutStr := os.Getenv("TIMEOUT_MINS")
if timeoutStr == "" {
timeoutStr = "10"
}
timeoutMins, err := strconv.Atoi(timeoutStr)
require.NoError(t, err, "invalid value specified for timeout")
ctx, cancel := context.WithTimeout(t.Context(), time.Duration(timeoutMins)*time.Minute)
t.Cleanup(cancel)

// Start Coder WITH rate limits enabled (no CODER_DANGEROUS_DISABLE_RATE_LIMITS).
client := StartCoder(ctx, t, "headers-ratelimit", EnableRateLimits)

// Upload a small file so we have something to GET.
uploadResp, err := client.Upload(ctx, "application/x-tar", createMinimalTar(t))
require.NoError(t, err, "upload file")
fileID := uploadResp.ID

// The files endpoint rate limit is 12 requests per minute.
// Fire 15 rapid GETs without the bypass header -- we expect at least one 429.
const burstCount = 15

t.Run("WithoutBypass", func(t *testing.T) {
t.Parallel()
subCtx, subCancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer subCancel()

got429 := false
for range burstCount {
_, _, err := client.Download(subCtx, fileID)
if err != nil {
var sdkErr *codersdk.Error
if errors.As(err, &sdkErr) && sdkErr.StatusCode() == http.StatusTooManyRequests {
got429 = true
break
}
}
}
require.True(t, got429, "expected to hit 429 rate limit within %d requests", burstCount)
})

t.Run("WithBypass", func(t *testing.T) {
t.Parallel()
subCtx, subCancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer subCancel()

// Create a new client with the bypass header set.
bypassClient := codersdk.New(client.URL)
bypassClient.SetSessionToken(client.SessionToken())
bypassClient.HTTPClient.Transport = &codersdk.HeaderTransport{
Transport: http.DefaultTransport,
Header: http.Header{
"X-Coder-Bypass-Ratelimit": []string{"true"},
},
}

// Same burst, but with bypass -- all should succeed.
for i := range burstCount {
_, _, err := bypassClient.Download(subCtx, fileID)
require.NoError(t, err, "request %d should not be rate limited with bypass header", i+1)
}
})
}
23 changes: 15 additions & 8 deletions integration/integration.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,11 @@ import (
// Using the pattern from
// https://dave.cheney.net/2014/10/17/functional-options-for-friendly-apis
type coderOptions struct {
useLicense bool
image string
version string
experiments string
useLicense bool
enableRateLimits bool
image string
version string
experiments string
}

func UseLicense(opts *coderOptions) {
Expand All @@ -48,6 +49,10 @@ func CoderExperiments(experiments string) func(opts *coderOptions) {
}
}

func EnableRateLimits(opts *coderOptions) {
opts.enableRateLimits = true
}

func StartCoder(ctx context.Context, t *testing.T, name string, options ...func(*coderOptions)) *codersdk.Client {
// Start with the defaults.
opts := coderOptions{
Expand Down Expand Up @@ -97,10 +102,12 @@ func StartCoder(ctx context.Context, t *testing.T, name string, options ...func(
require.NoError(t, err, "pull coder image")

env := []string{
"CODER_HTTP_ADDRESS=0.0.0.0:3000", // Listen on all interfaces inside the container.
"CODER_ACCESS_URL=http://localhost:3000", // Avoid creating try.coder.app URLs.
"CODER_TELEMETRY_ENABLE=false", // Avoid creating noise.
"CODER_DANGEROUS_DISABLE_RATE_LIMITS=true", // Avoid hitting file rate limit in tests.
"CODER_HTTP_ADDRESS=0.0.0.0:3000", // Listen on all interfaces inside the container.
"CODER_ACCESS_URL=http://localhost:3000", // Avoid creating try.coder.app URLs.
"CODER_TELEMETRY_ENABLE=false", // Avoid creating noise.
}
if !opts.enableRateLimits {
env = append(env, "CODER_DANGEROUS_DISABLE_RATE_LIMITS=true")
}
if opts.experiments != "" {
env = append(env, "CODER_EXPERIMENTS="+opts.experiments)
Expand Down
46 changes: 45 additions & 1 deletion internal/provider/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ package provider
import (
"context"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"sync/atomic"

"cdr.dev/slog/v3"
"github.com/coder/serpent"
"github.com/google/uuid"
"github.com/hashicorp/terraform-plugin-framework/datasource"
"github.com/hashicorp/terraform-plugin-framework/function"
Expand Down Expand Up @@ -80,7 +82,8 @@ type CoderdProviderModel struct {
URL types.String `tfsdk:"url"`
Token types.String `tfsdk:"token"`

DefaultOrganizationID UUID `tfsdk:"default_organization_id"`
DefaultOrganizationID UUID `tfsdk:"default_organization_id"`
Headers types.Map `tfsdk:"headers"`
}

func (p *CoderdProvider) Metadata(ctx context.Context, req provider.MetadataRequest, resp *provider.MetadataResponse) {
Expand Down Expand Up @@ -110,6 +113,14 @@ This provider is only compatible with Coder version [2.10.1](https://github.com/
CustomType: UUIDType,
Optional: true,
},
"headers": schema.MapAttribute{
MarkdownDescription: "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).",
ElementType: types.StringType,
Optional: true,
},
},
}
}
Expand Down Expand Up @@ -156,6 +167,39 @@ func (p *CoderdProvider) Configure(ctx context.Context, req provider.ConfigureRe
client := codersdk.New(url)
client.SetLogger(slog.Make(tfslog{}).Leveled(slog.LevelDebug))
client.SetSessionToken(data.Token.ValueString())

// Apply custom headers from the provider configuration or CODER_HEADERS env var.
httpHeaders := make(http.Header)
if !data.Headers.IsNull() && !data.Headers.IsUnknown() {
headerMap := make(map[string]string)
resp.Diagnostics.Append(data.Headers.ElementsAs(ctx, &headerMap, false)...)
if resp.Diagnostics.HasError() {
return
}
for k, v := range headerMap {
httpHeaders.Set(k, v)
}
} else if headersEnv, ok := os.LookupEnv("CODER_HEADER"); ok && headersEnv != "" {
var sa serpent.StringArray
if err := sa.Set(headersEnv); err != nil {
resp.Diagnostics.AddError("headers", fmt.Sprintf("invalid CODER_HEADER value: %s", err))
return
}
for _, entry := range sa.Value() {
parts := strings.SplitN(entry, "=", 2)
if len(parts) != 2 {
resp.Diagnostics.AddError("headers", fmt.Sprintf("invalid CODER_HEADER entry %q, expected key=value", entry))
return
}
httpHeaders.Set(strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]))
}
}
if len(httpHeaders) > 0 {
client.HTTPClient.Transport = &codersdk.HeaderTransport{
Transport: client.HTTPClient.Transport,
Header: httpHeaders,
}
}
if data.DefaultOrganizationID.IsNull() {
user, err := client.User(ctx, codersdk.Me)
if err != nil {
Expand Down
Loading