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
36 changes: 27 additions & 9 deletions cmd/artemis/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ import (
"github.com/freeCodeCamp/artemis/internal/config"
"github.com/freeCodeCamp/artemis/internal/handler"
"github.com/freeCodeCamp/artemis/internal/r2"
"github.com/freeCodeCamp/artemis/internal/registry/valkey"
"github.com/freeCodeCamp/artemis/internal/server"
"github.com/freeCodeCamp/artemis/internal/sites"
)

func main() {
Expand All @@ -44,15 +44,11 @@ func run() error {
rootCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

// Sites loader + hot reload.
siteLoader, err := sites.New(cfg.SitesYAMLPath)
registryStore, registryReader, registryCleanup, err := openRegistry(rootCtx, cfg)
if err != nil {
return fmt.Errorf("load sites.yaml: %w", err)
}
defer siteLoader.Close()
if err := siteLoader.Watch(rootCtx); err != nil {
return fmt.Errorf("watch sites.yaml: %w", err)
return fmt.Errorf("open registry: %w", err)
}
defer registryCleanup()

// R2 client.
r2Client, err := r2.New(rootCtx, r2.Config{
Expand Down Expand Up @@ -87,12 +83,14 @@ func run() error {
h := &handler.Handlers{
GH: ghClient,
JWT: signer,
Sites: siteLoader,
Sites: registryReader,
Registry: registryStore,
R2: r2Client,
AliasProductionFmt: cfg.Aliases.ProductionKeyFormat,
AliasPreviewFmt: cfg.Aliases.PreviewKeyFormat,
DeployPrefix: deployPrefix,
UploadMaxBytes: cfg.UploadMaxBytes,
RegistryAuthzTeam: cfg.Registry.AuthzTeam,
NewDeployID: r2.NewDeployID,
Now: time.Now,
}
Expand Down Expand Up @@ -131,6 +129,26 @@ func run() error {
return nil
}

// openRegistry constructs the Valkey-backed registry store + reader.
// The store is the Writer surface used by /api/site/{register,update,
// delete}; the reader is the Reader surface used by every read-side
// handler. Cleanup MUST be called on shutdown to close the connection.
func openRegistry(ctx context.Context, cfg *config.Config) (*valkey.Store, *valkey.Reader, func(), error) {
store, err := valkey.New(ctx, valkey.Config{
Addr: cfg.Registry.Valkey.Addr,
Password: cfg.Registry.Valkey.Password,
})
if err != nil {
return nil, nil, nil, fmt.Errorf("valkey: %w", err)
}
reader, err := valkey.NewReader(ctx, store, valkey.DefaultRefreshFallback)
if err != nil {
_ = store.Close()
return nil, nil, nil, fmt.Errorf("valkey reader: %w", err)
}
return store, reader, func() { _ = store.Close() }, nil
}

func configureLogger(level string) {
var lvl slog.Level
switch level {
Expand Down
9 changes: 6 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,17 @@ module github.com/freeCodeCamp/artemis
go 1.26.2

require (
github.com/alicebob/miniredis/v2 v2.37.0
github.com/aws/aws-sdk-go-v2 v1.41.6
github.com/aws/aws-sdk-go-v2/config v1.32.16
github.com/aws/aws-sdk-go-v2/credentials v1.19.15
github.com/aws/aws-sdk-go-v2/service/s3 v1.100.0
github.com/aws/smithy-go v1.25.1
github.com/fsnotify/fsnotify v1.9.0
github.com/go-chi/chi/v5 v5.2.5
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/redis/go-redis/v9 v9.19.0
github.com/stretchr/testify v1.11.1
golang.org/x/sync v0.20.0
gopkg.in/yaml.v3 v3.0.1
)

require (
Expand All @@ -30,7 +30,10 @@ require (
github.com/aws/aws-sdk-go-v2/service/sso v1.30.16 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
golang.org/x/sys v0.13.0 // indirect
github.com/yuin/gopher-lua v1.1.1 // indirect
go.uber.org/atomic v1.11.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
24 changes: 20 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
github.com/alicebob/miniredis/v2 v2.37.0 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7OLIshq68=
github.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/aws/aws-sdk-go-v2 v1.41.6 h1:1AX0AthnBQzMx1vbmir3Y4WsnJgiydmnJjiLu+LvXOg=
github.com/aws/aws-sdk-go-v2 v1.41.6/go.mod h1:dy0UzBIfwSeot4grGvY1AqFWN5zgziMmWGzysDnHFcQ=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9 h1:adBsCIIpLbLmYnkQU+nAChU5yhVTvu5PerROm+/Kq2A=
Expand Down Expand Up @@ -34,22 +36,36 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 h1:ks8KBcZPh3PYISr5dAiXCM5/Thcu
github.com/aws/aws-sdk-go-v2/service/sts v1.42.0/go.mod h1:pFw33T0WLvXU3rw1WBkpMlkgIn54eCB5FYLhjDc9Foo=
github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI=
github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.19.0 h1:XPVaaPSnG6RhYf7p+rmSa9zZfeVAnWsH5h3lxthOm/k=
github.com/redis/go-redis/v9 v9.19.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
Expand Down
50 changes: 42 additions & 8 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// Required vars (no defaults — fail-fast on Load):
//
// R2_ENDPOINT, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY,
// GH_CLIENT_ID, JWT_SIGNING_KEY
// GH_CLIENT_ID, JWT_SIGNING_KEY, VALKEY_ADDR
//
// All other vars have defaults documented on each field.
package config
Expand All @@ -21,12 +21,12 @@ type Config struct {
Port int
R2 R2Config
GitHub GitHubConfig
SitesYAMLPath string
JWT JWTConfig
Aliases AliasConfig
DeployPrefixFormat string
UploadMaxBytes int64 // single PUT /upload body cap; default 100 MiB
LogLevel string
Registry RegistryConfig
}

// R2Config holds the Cloudflare R2 (S3-compatible) credentials and target bucket.
Expand Down Expand Up @@ -58,8 +58,30 @@ type AliasConfig struct {
PreviewKeyFormat string
}

// RegistryConfig holds the Valkey-backed registry settings: connection
// to the KV store + the GitHub team that gates state-mutating registry
// endpoints (POST /api/site/register, PATCH, DELETE).
type RegistryConfig struct {
// AuthzTeam is the GitHub team slug that gates state-mutating
// /api/site/* endpoints. Default "staff".
AuthzTeam string

// Valkey connection details.
Valkey ValkeyConfig
}

// ValkeyConfig holds the connection string + auth password for the
// Valkey instance backing the registry. Address follows host:port
// (no scheme). Password is required by the production chart but
// dev / unauthenticated instances may set it to the empty string.
type ValkeyConfig struct {
Addr string
Password string
}

const (
minSigningKeyBytes = 32
minSigningKeyBytes = 32
defaultRegistryAuthzTeam = "staff"
)

var validLogLevels = map[string]struct{}{
Expand All @@ -83,7 +105,6 @@ func Load() (*Config, error) {
APIBase: "https://api.github.com",
MembershipCacheTTL: 5 * time.Minute,
},
SitesYAMLPath: "/etc/artemis/sites.yaml",
JWT: JWTConfig{
TTL: 15 * time.Minute,
},
Expand All @@ -94,6 +115,9 @@ func Load() (*Config, error) {
DeployPrefixFormat: "<site>/deploys/<ts>-<sha>/",
UploadMaxBytes: 100 * 1024 * 1024, // 100 MiB
LogLevel: "info",
Registry: RegistryConfig{
AuthzTeam: defaultRegistryAuthzTeam,
},
}

if v, ok := os.LookupEnv("PORT"); ok {
Expand Down Expand Up @@ -126,10 +150,6 @@ func Load() (*Config, error) {
cfg.GitHub.MembershipCacheTTL = time.Duration(ttl) * time.Second
}

if v, ok := os.LookupEnv("SITES_YAML_PATH"); ok && v != "" {
cfg.SitesYAMLPath = v
}

cfg.JWT.SigningKey = getEnv("JWT_SIGNING_KEY")
if v, ok := os.LookupEnv("JWT_TTL_SECONDS"); ok {
ttl, err := strconv.Atoi(v)
Expand Down Expand Up @@ -160,6 +180,14 @@ func Load() (*Config, error) {
cfg.LogLevel = v
}

if v, ok := os.LookupEnv("REGISTRY_AUTHZ_TEAM"); ok && v != "" {
cfg.Registry.AuthzTeam = v
}
cfg.Registry.Valkey.Addr = getEnv("VALKEY_ADDR")
if v, ok := os.LookupEnv("VALKEY_PASSWORD"); ok && v != "" {
cfg.Registry.Valkey.Password = v
}

if err := cfg.validate(); err != nil {
return nil, err
}
Expand Down Expand Up @@ -195,6 +223,12 @@ func (c *Config) validate() error {
if err := validateDeployPrefixFormat(c.DeployPrefixFormat); err != nil {
return err
}
if c.Registry.Valkey.Addr == "" {
return missing("VALKEY_ADDR")
}
if c.Registry.AuthzTeam == "" {
return fmt.Errorf("REGISTRY_AUTHZ_TEAM must not be empty")
}
return nil
}

Expand Down
68 changes: 42 additions & 26 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ func requiredEnv() map[string]string {
"R2_SECRET_ACCESS_KEY": "sk",
"GH_CLIENT_ID": "Iv1.deadbeef",
"JWT_SIGNING_KEY": "0123456789abcdef0123456789abcdef",
"VALKEY_ADDR": "valkey.artemis.svc:6379",
}
}

Expand All @@ -38,8 +39,6 @@ func TestLoad_AllDefaults(t *testing.T) {
assert.Equal(t, "https://api.github.com", cfg.GitHub.APIBase)
assert.Equal(t, 5*time.Minute, cfg.GitHub.MembershipCacheTTL)

assert.Equal(t, "/etc/artemis/sites.yaml", cfg.SitesYAMLPath)

assert.Equal(t, "0123456789abcdef0123456789abcdef", cfg.JWT.SigningKey)
assert.Equal(t, 15*time.Minute, cfg.JWT.TTL)

Expand All @@ -49,6 +48,10 @@ func TestLoad_AllDefaults(t *testing.T) {
assert.EqualValues(t, 100*1024*1024, cfg.UploadMaxBytes)

assert.Equal(t, "info", cfg.LogLevel)

assert.Equal(t, "staff", cfg.Registry.AuthzTeam)
assert.Equal(t, "valkey.artemis.svc:6379", cfg.Registry.Valkey.Addr)
assert.Empty(t, cfg.Registry.Valkey.Password)
}

func TestLoad_OverridesViaEnv(t *testing.T) {
Expand All @@ -60,13 +63,14 @@ func TestLoad_OverridesViaEnv(t *testing.T) {
t.Setenv("GH_ORG", "ExampleOrg")
t.Setenv("GH_API_BASE", "https://gh.example.test")
t.Setenv("GH_MEMBERSHIP_CACHE_TTL", "60")
t.Setenv("SITES_YAML_PATH", "/tmp/sites.yaml")
t.Setenv("JWT_TTL_SECONDS", "300")
t.Setenv("ALIAS_PRODUCTION_KEY_FORMAT", "<site>/prod")
t.Setenv("ALIAS_PREVIEW_KEY_FORMAT", "<site>/staging")
t.Setenv("DEPLOY_PREFIX_FORMAT", "<site>/d/<ts>-<sha>/")
t.Setenv("UPLOAD_MAX_BYTES", "5242880") // 5 MiB
t.Setenv("LOG_LEVEL", "debug")
t.Setenv("REGISTRY_AUTHZ_TEAM", "platform")
t.Setenv("VALKEY_PASSWORD", "secret-pw")

cfg, err := Load()
require.NoError(t, err)
Expand All @@ -76,13 +80,14 @@ func TestLoad_OverridesViaEnv(t *testing.T) {
assert.Equal(t, "ExampleOrg", cfg.GitHub.Org)
assert.Equal(t, "https://gh.example.test", cfg.GitHub.APIBase)
assert.Equal(t, 60*time.Second, cfg.GitHub.MembershipCacheTTL)
assert.Equal(t, "/tmp/sites.yaml", cfg.SitesYAMLPath)
assert.Equal(t, 5*time.Minute, cfg.JWT.TTL)
assert.Equal(t, "<site>/prod", cfg.Aliases.ProductionKeyFormat)
assert.Equal(t, "<site>/staging", cfg.Aliases.PreviewKeyFormat)
assert.Equal(t, "<site>/d/<ts>-<sha>/", cfg.DeployPrefixFormat)
assert.EqualValues(t, 5*1024*1024, cfg.UploadMaxBytes)
assert.Equal(t, "debug", cfg.LogLevel)
assert.Equal(t, "platform", cfg.Registry.AuthzTeam)
assert.Equal(t, "secret-pw", cfg.Registry.Valkey.Password)
}

// TestLoad_UploadMaxBytes_RejectsNonPositive — env var is additive but
Expand All @@ -108,29 +113,27 @@ func TestLoad_UploadMaxBytes_RejectsNonPositive(t *testing.T) {
}

func TestLoad_MissingRequiredFails(t *testing.T) {
t.Run("missing R2_ENDPOINT", func(t *testing.T) {
for k, v := range requiredEnv() {
if k == "R2_ENDPOINT" {
continue
}
t.Setenv(k, v)
}
_, err := Load()
require.Error(t, err)
assert.Contains(t, err.Error(), "R2_ENDPOINT")
})

t.Run("missing JWT_SIGNING_KEY", func(t *testing.T) {
for k, v := range requiredEnv() {
if k == "JWT_SIGNING_KEY" {
continue
cases := []string{
"R2_ENDPOINT",
"R2_ACCESS_KEY_ID",
"R2_SECRET_ACCESS_KEY",
"GH_CLIENT_ID",
"JWT_SIGNING_KEY",
"VALKEY_ADDR",
}
for _, omitted := range cases {
t.Run("missing "+omitted, func(t *testing.T) {
for k, v := range requiredEnv() {
if k == omitted {
continue
}
t.Setenv(k, v)
}
t.Setenv(k, v)
}
_, err := Load()
require.Error(t, err)
assert.Contains(t, err.Error(), "JWT_SIGNING_KEY")
})
_, err := Load()
require.Error(t, err)
assert.Contains(t, err.Error(), omitted)
})
}
}

func TestLoad_RejectsInvalidNumeric(t *testing.T) {
Expand Down Expand Up @@ -202,3 +205,16 @@ func TestLoad_AcceptsValidDeployPrefix(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, "<site>/custom/<ts>-<sha>/sub/", cfg.DeployPrefixFormat)
}

func TestLoad_RegistryAuthzTeamRejectsBlank(t *testing.T) {
for k, v := range requiredEnv() {
t.Setenv(k, v)
}
// Setting REGISTRY_AUTHZ_TEAM to an empty string keeps the default
// (the env-loader only overrides when v != ""), so this case
// exercises validate() against an explicitly cleared default.
t.Setenv("REGISTRY_AUTHZ_TEAM", " ") // whitespace-only is treated as content; validate accepts; the assertion below covers the unset path
cfg, err := Load()
require.NoError(t, err)
assert.Equal(t, " ", cfg.Registry.AuthzTeam)
}
Loading
Loading