Hedgerules Architecture#
Overview#
Hedgerules is a Go CLI tool that manages CloudFront Functions and Key Value Store (KVS) data for Hugo static sites deployed to AWS CloudFront + S3. It replaces the existing Python scripts (kvs-request.py, kvs-response.py) and adds CloudFront Function management.
The tool reads a built Hugo site, extracts redirect rules and custom header definitions, validates them against CloudFront KVS constraints, and syncs the data to AWS. It also deploys the CloudFront Functions that consume this KVS data at the edge.
1. Package / Directory Layout#
hedgerules/
cmd/
hedgerules/
main.go # Entry point, CLI flags, command dispatch, orchestration
internal/
hugo/
directories.go # Scan Hugo output dirs for index redirects
redirects.go # Parse _hedge_redirects.txt, merge redirects
headers.go # Parse _hedge_headers.json
kvs/
types.go # Entry, Data, SyncPlan types
validate.go # KVS constraint validation
sync.go # Diff + sync logic (put/delete)
functions/
embed.go # go:embed for JS function code, BuildFunctionCode
deploy.go # Create/update CloudFront Functions via API
viewer-request.js # CloudFront Function: redirects + index rewrite
viewer-response.js # CloudFront Function: custom response headers (cascade)
hedgerules.toml # Example config file
go.mod
go.sumPackage responsibilities#
| Package | Responsibility |
|---|---|
cmd/hedgerules |
CLI entry point, flag parsing, TOML config loading, command dispatch |
internal/hugo |
Parse Hugo build output: directories, _hedge_redirects.txt, _hedge_headers.json; merge redirects |
internal/kvs |
KVS data types, validation against constraints, diff-and-sync to AWS |
internal/functions |
Embed JS source files, inject variables (kvsId, debugHeaders), deploy to CloudFront Functions API |
internal/retry |
AWS throttle detection (IsThrottle) and retry loop with exponential backoff (Do) |
2. CLI Command Structure#
Use stdlib flag package only. No third-party CLI framework. The tool has a small surface area (one primary command) and doesn’t justify a dependency like cobra.
hedgerules deploy [flags]
hedgerules versionhedgerules deploy#
The primary command. Does everything: parse, validate, sync KVS, deploy functions.
hedgerules deploy \
--output-dir public/ \
--redirects-kvs-name mysite-redirects \
--headers-kvs-name mysite-headers \
--request-function-name mysite-viewer-request \
--response-function-name mysite-viewer-response \
--dry-runFlags:
| Flag | Required | Description |
|---|---|---|
--output-dir |
Yes | Hugo build output directory (e.g. public/) |
--redirects-kvs-name |
Yes | CloudFront KVS name for redirect data |
--headers-kvs-name |
Yes | CloudFront KVS name for header data |
--request-function-name |
Yes | CloudFront Function name for viewer-request |
--response-function-name |
Yes | CloudFront Function name for viewer-response |
--dry-run |
No | Parse and validate only, print plan, don’t mutate AWS |
--config |
No | Path to config file (default: hedgerules.toml in current directory) |
--region |
No | AWS region override |
String flags accept @FILE syntax: if the value starts with @, the rest is treated as a file path and the flag value is read from that file (whitespace trimmed). For example, --region @/run/secrets/aws-region.
hedgerules version#
Print version and exit.
Subcommand dispatch#
Use Go’s os.Args to dispatch:
func main() {
if len(os.Args) < 2 {
usage()
os.Exit(1)
}
switch os.Args[1] {
case "deploy":
runDeploy(os.Args[2:])
case "version":
fmt.Println(version)
default:
usage()
os.Exit(1)
}
}3. Key Types and Interfaces#
Core data types#
// internal/kvs/types.go
// Entry is a single key-value pair destined for CloudFront KVS.
type Entry struct {
Key string
Value string
}
// Data holds all entries for a single KVS, with validation methods.
type Data struct {
Entries []Entry
}
// SyncPlan describes what operations are needed to bring KVS to desired state.
type SyncPlan struct {
Puts []Entry // Keys to add or update
Deletes []string // Keys to remove
}Validation#
// internal/kvs/validate.go
const (
MaxKeyBytes = 512
MaxEntryBytes = 1024 // key + value
MaxTotalBytes = 5_242_880 // 5 MB
)
// ValidationError describes a single constraint violation.
type ValidationError struct {
Key string
Message string
}
// Validate checks all KVS constraints. Returns nil if valid.
func (d *KVSData) Validate() []ValidationErrorHugo parsing#
// internal/hugo/directories.go
// ScanDirectories walks outputDir and returns index redirect entries
// (e.g., /blog -> /blog/).
func ScanDirectories(outputDir string) ([]kvs.Entry, error)
// internal/hugo/redirects.go
// ParseRedirects reads _hedge_redirects.txt and returns redirect entries.
func ParseRedirects(outputDir string) ([]kvs.Entry, error)
// MergeRedirects merges directory redirects with file redirects.
// File redirects take precedence.
func MergeRedirects(dirEntries, fileEntries []kvs.Entry) []kvs.Entry
// internal/hugo/headers.go
// ParseHeaders reads _hedge_headers.json and returns header entries.
// Each entry's value is newline-delimited "Header-Name: value" strings.
func ParseHeaders(outputDir string) ([]kvs.Entry, error)AWS operations#
// internal/kvs/sync.go
// ComputeSyncPlan compares desired state against existing KVS keys.
func ComputeSyncPlan(desired *Data, existingKeys map[string]string) *SyncPlan
// Sync applies a SyncPlan to a CloudFront KVS. Returns error on failure.
// Uses UpdateKeys batch API for efficiency.
func Sync(ctx context.Context, client KVSClient, kvsARN, etag string, plan *SyncPlan, maxRetries int) error
// internal/functions/deploy.go
// DeployFunction creates or updates a CloudFront Function with the given
// JS source code and KVS association. It publishes the function.
func DeployFunction(ctx context.Context, client CFClient, name string, code []byte, kvsARN string, maxRetries int) errorInterfaces for testability#
// internal/kvs/sync.go
// KVSClient abstracts the CloudFront KeyValueStore API for testing.
type KVSClient interface {
DescribeKeyValueStore(ctx context.Context, params *cfkvs.DescribeKeyValueStoreInput, ...) (*cfkvs.DescribeKeyValueStoreOutput, error)
ListKeys(ctx context.Context, params *cfkvs.ListKeysInput, ...) (*cfkvs.ListKeysOutput, error)
UpdateKeys(ctx context.Context, params *cfkvs.UpdateKeysInput, ...) (*cfkvs.UpdateKeysOutput, error)
}
// internal/functions/deploy.go
// CFClient abstracts the CloudFront Functions API for testing.
type CFClient interface {
DescribeFunction(ctx context.Context, params *cf.DescribeFunctionInput, ...) (*cf.DescribeFunctionOutput, error)
CreateFunction(ctx context.Context, params *cf.CreateFunctionInput, ...) (*cf.CreateFunctionOutput, error)
UpdateFunction(ctx context.Context, params *cf.UpdateFunctionInput, ...) (*cf.UpdateFunctionOutput, error)
PublishFunction(ctx context.Context, params *cf.PublishFunctionInput, ...) (*cf.PublishFunctionOutput, error)
}4. Data Flow#
Hugo build output (public/)
|
v
+-----+------+--------+
| | |
v v v
Scan dirs Parse Parse
for index _hedge_ _hedge_headers.json
redirects redirects.txt
| | |
v v v
[]Entry []Entry []Entry
| | |
+------+------+ |
| |
v |
Merge redirects |
(file overrides dirs) |
| |
v v
Data Data
(redirects) (headers)
| |
v v
Validate Validate
| |
+-------+--------+
|
(if --dry-run: print plan and exit)
|
+-------+--------+
| |
v v
Fetch existing Fetch existing
redirect KVS header KVS
keys keys
| |
v v
ComputeSyncPlan ComputeSyncPlan
| |
v v
Sync to KVS Sync to KVS
| |
+-------+--------+
|
v
Deploy viewer-request Deploy viewer-response
CloudFront Function CloudFront Function
(with redirect KVS ID) (with header KVS ID +
debugHeaders flag)Merge precedence for redirects#
Directory-derived index redirects (/blog -> /blog/) are generated first. Then _redirects file entries are applied on top, overriding any conflicts. This matches the existing Python behavior.
Sequential execution#
The redirect KVS sync and header KVS sync run sequentially (sync redirects, then sync headers, then deploy functions). This keeps the code simple and avoids error-handling complexity from concurrency.
5. AWS API Interactions#
Services used#
| Service | SDK Package | Operations |
|---|---|---|
| CloudFront | github.com/aws/aws-sdk-go-v2/service/cloudfront |
ListKeyValueStores, DescribeFunction, CreateFunction, UpdateFunction, PublishFunction |
| CloudFront KVS | github.com/aws/aws-sdk-go-v2/service/cloudfrontkeyvaluestore |
DescribeKeyValueStore, ListKeys, UpdateKeys |
KVS sync strategy#
Use UpdateKeys batch API (not individual PutKey/DeleteKey). This is an atomic operation that handles puts and deletes in a single call with ETag-based optimistic concurrency.
// Simplified flow
resp, err := kvsClient.DescribeKeyValueStore(ctx, &cfkvs.DescribeKeyValueStoreInput{
KvsARN: &kvsARN,
})
etag := resp.ETag
// ... compute puts and deletes ...
_, err = kvsClient.UpdateKeys(ctx, &cfkvs.UpdateKeysInput{
KvsARN: &kvsARN,
IfMatch: etag,
Puts: puts, // []types.PutKeyRequestListItem
Deletes: deletes, // []types.DeleteKeyRequestListItem
})This is a significant improvement over the Python scripts, which make individual PutKey/DeleteKey calls in a loop and must track ETag changes after each call.
CloudFront Function deployment strategy#
- Call
DescribeFunctionto check if function exists. - If not found, call
CreateFunctionwith the JS code and KVS association. - If found, call
UpdateFunctionwith new code, using the returned ETag. - Call
PublishFunctionto make it live (fromDEVELOPMENTtoLIVEstage).
KVS ARN resolution#
Resolve KVS name to ARN by calling ListKeyValueStores and matching by name, same as the Python scripts. Cache the result for the duration of the command.
6. Embedding CloudFront Function JS Code#
Use Go 1.16+ embed package to compile JS source files into the binary.
// internal/functions/embed.go
package functions
import "embed"
//go:embed viewer-request.js
var ViewerRequestJS []byte
//go:embed viewer-response.js
var ViewerResponseJS []byteThe JS files live in internal/functions/ alongside embed.go. Since go:embed paths are relative to the source file, this is the simplest arrangement — no build steps, no symlinks.
Injecting variables#
At deploy time, the Go code prepends runtime variables to the JS source:
func BuildFunctionCode(jsSource []byte, kvsID string, debugHeaders bool) []byte {
header := fmt.Sprintf("var kvsId = '%s';\nvar debugHeaders = %v;\n", kvsID, debugHeaders)
return append([]byte(header), jsSource...)
}This injects:
kvsId— the CloudFront KVS ARN for KVS lookupsdebugHeaders— whether to emitx-hedgerules-*debug response headers (off by default)
7. Configuration Approach#
Config file format: TOML#
Use TOML for the config file. It’s the format Hugo uses, so Hedgerules users are already familiar with it.
File: hedgerules.toml (in project root, next to Hugo config).
# hedgerules.toml
output-dir = "public/"
region = "us-east-1"
redirects-kvs-name = "mysite-redirects"
headers-kvs-name = "mysite-headers"
viewer-request-name = "mysite-viewer-request"
viewer-response-name = "mysite-viewer-response"
# debug-headers = falseConfig resolution order (lowest to highest priority)#
- Defaults (none currently needed)
- Config file (
hedgerules.toml, or--configflag) - CLI flags
Config file parsing#
Use github.com/BurntSushi/toml. It’s the standard Go TOML library, lightweight, no transitive dependencies.
Config struct#
// cmd/hedgerules/main.go
type config struct {
OutputDir string `toml:"output-dir"`
Region string `toml:"region"`
RedirectsKVSName string `toml:"redirects-kvs-name"`
HeadersKVSName string `toml:"headers-kvs-name"`
ViewerRequestName string `toml:"viewer-request-name"`
ViewerResponseName string `toml:"viewer-response-name"`
DebugHeaders bool `toml:"debug-headers"`
MaxRetries int `toml:"max-retries"`
}8. Error Handling Strategy#
Principles#
- Fail fast: Validate all data before making any AWS API calls. If validation fails, exit before mutating anything.
- Wrap errors with context: Use
fmt.Errorf("scanning directories in %s: %w", dir, err)consistently. - No partial deploys on validation failure: The
deploycommand validates both redirect and header data before syncing either. - Structured exit codes: 0 = success, 1 = validation error, 2 = AWS API error, other = unexpected.
Validation errors#
Collect all validation errors before reporting, rather than stopping at the first one. This lets users fix all issues in one pass.
errs := redirectData.Validate()
errs = append(errs, headerData.Validate()...)
if len(errs) > 0 {
for _, e := range errs {
fmt.Fprintf(os.Stderr, "validation error: %s: %s\n", e.Key, e.Message)
}
os.Exit(1)
}AWS API errors#
- Throttling / rate limiting: Automatically retried with exponential backoff (1s, 2s, 4s… capped at 30s, with jitter). Up to
max-retriesattempts (default 10). Each retry prints a message to stderr. Detected by AWS error code viainternal/retry.IsThrottle. - ETag conflicts: If
UpdateKeysfails withConflictException, returned immediately (not retried). The user should re-run — the sync is convergent. - Not found: If KVS name doesn’t resolve, print a clear message suggesting the user check the name and that the KVS exists.
- Auth errors: Returned immediately without retrying. Let the AWS SDK error message pass through; don’t wrap these excessively.
Logging#
Use fmt.Fprintf(os.Stderr, ...) for status/progress messages and fmt.Println(...) for structured output. No logging framework needed.
9. Dependency List#
Direct dependencies#
| Module | Purpose |
|---|---|
github.com/aws/aws-sdk-go-v2 |
AWS SDK core |
github.com/aws/aws-sdk-go-v2/config |
AWS SDK shared config loading |
github.com/aws/aws-sdk-go-v2/service/cloudfront |
CloudFront Functions + KVS listing API |
github.com/aws/aws-sdk-go-v2/service/cloudfrontkeyvaluestore |
CloudFront KVS data API |
github.com/aws/smithy-go |
AWS error type interface for throttle detection |
github.com/BurntSushi/toml |
TOML config file parsing |
Standard library only (no external dep needed)#
| Need | stdlib solution |
|---|---|
| CLI flag parsing | flag |
JSON parsing (_hedge_headers.json) |
encoding/json |
| File walking (directory scan) | io/fs, filepath.WalkDir |
| Embedding JS | embed |
| Context | context |
| Testing | testing |
Go version#
Require Go 1.21+ (for slices, maps, slog availability if needed later).
Design Decisions Summary#
| Decision | Choice | Rationale |
|---|---|---|
| CLI framework | stdlib flag |
Small command surface, no need for cobra |
| AWS SDK | aws-sdk-go-v2 | Current/maintained SDK, required for CloudFront KVS |
| KVS sync | Batch UpdateKeys |
Atomic, one API call vs N individual calls |
| Config format | TOML | Hugo users know it, lightweight parser |
| JS embedding | go:embed |
No build step, compiled into binary |
| Concurrency | Sequential | Simple, no parallel complexity |
| Error strategy | Validate all first, fail fast | No partial deploys |
| Throttle handling | Retry with exponential backoff | CloudFront rate limits are transient; convergent sync makes retries safe |
| Logging | stderr/stdout, no framework | Minimal dependencies |
Relationship to Existing Examples#
The existing examples/micahrlweb/ directory demonstrates the full AWS setup with CloudFormation. Hedgerules replaces the Python scripts and the Jinja-templated CloudFormation function code deployment:
| Current (examples) | Hedgerules replacement |
|---|---|
kvs-request.py |
hedgerules deploy (redirect parsing + KVS sync) |
kvs-response.py |
hedgerules deploy (header parsing + KVS sync) |
CFN template Jinja for JS + const kvsId |
internal/functions embeds JS, prepends kvsId, deploys via API |
| Two separate scripts, manual KVS name args | One command, one config file |
The CloudFormation template in the example manages the Distribution, S3 buckets, and other infrastructure. Hedgerules intentionally does not manage these - it only manages the Functions and KVS data. Users create the Distribution and KVS resources out of band (via CloudFormation, Terraform, or console).
Important: Hedgerules does create/update CloudFront Functions, but expects the KVS to already exist. The KVS is typically created by the same infrastructure tool that creates the Distribution, since the Distribution’s FunctionAssociations reference both the Function and the KVS.
Updates 20260208#
The following changes were decided on 2026-02-08. Each item needs to be reflected across the codebase (Go code, JS functions, Hugo theme, docs, and tests).
Terminology rename#
_hedge_headers.jsonis now_hedge_headers.json. All references in code, config, docs, templates, and tests must be updated.
Features re-added (previously cut)#
Redirect chain following— Remains cut. The browser follows multiple 301s natively; chain resolution is unnecessary complexity.{/path}token substitution — Support{/path}tokens in header values. The viewer-response function substitutes the request path into header values at the edge.- Extension wildcard matching (
*.xml) — Match headers by file extension in addition to exact path and directory. The viewer-response function needs an extension-based KVS lookup. - Full hierarchical header cascade — Replace the simplified exact-path + root fallback with a full cascade: root
/→ directory → extension → exact path. More specific matches override less specific ones. This affects both the Go CLI (how header entries are organized into KVS) and viewer-response.js (lookup order).
Debug headers#
- Re-added as
x-hedgerules-*(previouslyx-mrldbg-*, previously cut entirely). - Off by default. Enabled via variable injection into the CloudFront Function code at deploy time, using the same
BuildFunctionCodemechanism that already injects the KVS name. - The Go CLI needs a config/flag to toggle debug header injection. The viewer-response.js function needs conditional debug header logic gated on the injected variable.