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
85 changes: 71 additions & 14 deletions cmd/artemis/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,11 @@ import (

"github.com/freeCodeCamp/artemis/internal/auth"
"github.com/freeCodeCamp/artemis/internal/config"
"github.com/freeCodeCamp/artemis/internal/githubapp"
"github.com/freeCodeCamp/artemis/internal/handler"
"github.com/freeCodeCamp/artemis/internal/r2"
"github.com/freeCodeCamp/artemis/internal/registry/valkey"
repovalkey "github.com/freeCodeCamp/artemis/internal/reporequest/valkey"
"github.com/freeCodeCamp/artemis/internal/server"
"github.com/prometheus/client_golang/prometheus"
)
Expand Down Expand Up @@ -88,6 +90,49 @@ func run() error {
return fmt.Errorf("init jwt signer: %w", err)
}

// Repo-creation feature (optional). Wired only when the Apollo-11 App
// credentials are configured; absent → feature off, /api/repo*
// routes left unmounted. repoGH probes membership in the Universe org
// (cfg.Repo.Org), distinct from ghClient's site-registry org.
var (
repoStore *repovalkey.Store
repoGH *auth.GitHubClient
appClient *githubapp.Client
)
if cfg.Repo.Enabled() {
appSigner, err := githubapp.NewAppJWTSigner(cfg.Repo.App.AppID, cfg.Repo.App.PrivateKeyPEM)
if err != nil {
return fmt.Errorf("init github app signer: %w", err)
}
appClient, err = githubapp.NewClient(githubapp.ClientConfig{
APIBase: cfg.GitHub.APIBase,
Org: cfg.Repo.Org,
InstallationID: cfg.Repo.App.InstallationID,
Signer: appSigner,
})
if err != nil {
return fmt.Errorf("init github app client: %w", err)
}
repoStore, err = repovalkey.New(rootCtx, repovalkey.Config{
Addr: cfg.Registry.Valkey.Addr,
Password: cfg.Registry.Valkey.Password,
})
if err != nil {
return fmt.Errorf("open repo-request store: %w", err)
}
defer func() { _ = repoStore.Close() }()
repoGH = auth.NewGitHubClient(auth.GitHubClientConfig{
APIBase: cfg.GitHub.APIBase,
Org: cfg.Repo.Org,
CacheTTL: cfg.GitHub.MembershipCacheTTL,
})
slog.Info("repo-creation feature enabled",
"org", cfg.Repo.Org,
"createTeam", cfg.Repo.CreateAuthzTeam,
"approveTeam", cfg.Repo.ApproveAuthzTeam,
)
}

deployPrefix, err := handler.NewDeployPrefixTemplate(cfg.DeployPrefixFormat)
if err != nil {
return fmt.Errorf("parse deploy prefix template: %w", err)
Expand All @@ -99,20 +144,32 @@ func run() error {
registryReader.SetOnRefreshError(func(error) { metrics.RegistryRefreshFailures.Inc() })

h := &handler.Handlers{
GH: ghClient,
JWT: signer,
Sites: registryReader,
Registry: registryStore,
Health: 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,
Metrics: metrics,
GH: ghClient,
JWT: signer,
Sites: registryReader,
Registry: registryStore,
Health: registryStore,
R2: r2Client,
AliasProductionFmt: cfg.Aliases.ProductionKeyFormat,
AliasPreviewFmt: cfg.Aliases.PreviewKeyFormat,
DeployPrefix: deployPrefix,
UploadMaxBytes: cfg.UploadMaxBytes,
RegistryAuthzTeam: cfg.Registry.AuthzTeam,
RepoOrg: cfg.Repo.Org,
RepoCreateAuthzTeam: cfg.Repo.CreateAuthzTeam,
RepoApproveAuthzTeam: cfg.Repo.ApproveAuthzTeam,
NewDeployID: r2.NewDeployID,
Now: time.Now,
Metrics: metrics,
}

// Assign the repo interface deps only when enabled — assigning a
// typed-nil pointer to an interface field would make RepoEnabled()
// (which compares != nil) true and mount routes onto nil deps.
if cfg.Repo.Enabled() {
h.RepoGH = repoGH
h.Repos = repoStore
h.GitHubApp = appClient
}

addr := ":" + strconv.Itoa(cfg.Port)
Expand Down
86 changes: 84 additions & 2 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ type Config struct {
UploadMaxBytes int64 // single PUT /upload body cap; default 100 MiB
LogLevel string
Registry RegistryConfig
Repo RepoConfig
}

// R2Config holds the Cloudflare R2 (S3-compatible) credentials and target bucket.
Expand Down Expand Up @@ -86,9 +87,51 @@ type ValkeyConfig struct {
Password string
}

// RepoConfig holds the repo-creation feature settings: the target org,
// the GitHub teams that gate create vs approve, and the Apollo-11
// GitHub App credentials used server-side to mint installation tokens.
//
// The App credentials are OPTIONAL at boot — when unset the feature is
// disabled (Enabled() == false) and the /api/repo* routes are not
// mounted, so artemis still runs in deploy-only deployments. When any
// App field is set, all three are required (validate fails otherwise).
type RepoConfig struct {
// Org is the GitHub org repos are created in AND whose teams gate
// repo authz. Distinct from GitHubConfig.Org (which scopes the
// site-registry team checks). Default "freeCodeCamp-Universe".
Org string

// CreateAuthzTeam gates POST /api/repo. Default "staff". List, get,
// and templates are open to any GitHub bearer (no team gate).
// ApproveAuthzTeam gates approve/reject. Default "apollo-11-approvers".
// Both are slugs in Org.
CreateAuthzTeam string
ApproveAuthzTeam string

App GitHubAppConfig
}

// GitHubAppConfig holds the Apollo-11 GitHub App credentials. The
// private key is a cluster secret (sops / mounted env), the same secret
// class as the R2 keys — it never reaches a staff laptop or the CLI.
type GitHubAppConfig struct {
AppID string // GH_APP_ID — numeric app id (issuer of the App JWT)
InstallationID string // GH_APP_INSTALLATION_ID
PrivateKeyPEM string // GH_APP_PRIVATE_KEY — PEM (PKCS#1 or PKCS#8)
}

// Enabled reports whether the repo-creation feature has full App
// credentials configured.
func (r RepoConfig) Enabled() bool {
return r.App.AppID != "" && r.App.InstallationID != "" && r.App.PrivateKeyPEM != ""
}

const (
minSigningKeyBytes = 32
defaultRegistryAuthzTeam = "staff"
minSigningKeyBytes = 32
defaultRegistryAuthzTeam = "staff"
defaultRepoOrg = "freeCodeCamp-Universe"
defaultRepoCreateAuthzTeam = "staff"
defaultRepoApproveAuthzTeam = "apollo-11-approvers"
)

var validLogLevels = map[string]struct{}{
Expand Down Expand Up @@ -125,6 +168,11 @@ func Load() (*Config, error) {
Registry: RegistryConfig{
AuthzTeam: defaultRegistryAuthzTeam,
},
Repo: RepoConfig{
Org: defaultRepoOrg,
CreateAuthzTeam: defaultRepoCreateAuthzTeam,
ApproveAuthzTeam: defaultRepoApproveAuthzTeam,
},
}

if v, ok := os.LookupEnv("PORT"); ok {
Expand Down Expand Up @@ -195,6 +243,19 @@ func Load() (*Config, error) {
cfg.Registry.Valkey.Password = v
}

if v, ok := os.LookupEnv("GH_REPO_ORG"); ok && v != "" {
cfg.Repo.Org = v
}
if v, ok := os.LookupEnv("REPO_CREATE_AUTHZ_TEAM"); ok && v != "" {
cfg.Repo.CreateAuthzTeam = v
}
if v, ok := os.LookupEnv("REPO_APPROVE_AUTHZ_TEAM"); ok && v != "" {
cfg.Repo.ApproveAuthzTeam = v
}
cfg.Repo.App.AppID = os.Getenv("GH_APP_ID")
cfg.Repo.App.InstallationID = os.Getenv("GH_APP_INSTALLATION_ID")
cfg.Repo.App.PrivateKeyPEM = os.Getenv("GH_APP_PRIVATE_KEY")

if err := cfg.validate(); err != nil {
return nil, err
}
Expand Down Expand Up @@ -249,6 +310,27 @@ func (c *Config) validate() error {
if c.Registry.AuthzTeam == "" {
return fmt.Errorf("REGISTRY_AUTHZ_TEAM must not be empty")
}
if c.Repo.Org == "" {
return fmt.Errorf("GH_REPO_ORG must not be empty")
}
if c.Repo.CreateAuthzTeam == "" {
return fmt.Errorf("REPO_CREATE_AUTHZ_TEAM must not be empty")
}
if c.Repo.ApproveAuthzTeam == "" {
return fmt.Errorf("REPO_APPROVE_AUTHZ_TEAM must not be empty")
}
// Repo App credentials are optional (feature off when absent), but
// partial config is a misconfiguration — fail fast rather than boot
// a half-wired feature.
appSet := 0
for _, v := range []string{c.Repo.App.AppID, c.Repo.App.InstallationID, c.Repo.App.PrivateKeyPEM} {
if v != "" {
appSet++
}
}
if appSet != 0 && appSet != 3 {
return fmt.Errorf("repo app config is partial: set all of GH_APP_ID, GH_APP_INSTALLATION_ID, GH_APP_PRIVATE_KEY, or none")
}
return nil
}

Expand Down
70 changes: 70 additions & 0 deletions internal/config/config_repo_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package config

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestLoad_RepoDefaults(t *testing.T) {
for k, v := range requiredEnv() {
t.Setenv(k, v)
}

cfg, err := Load()
require.NoError(t, err)

assert.Equal(t, "freeCodeCamp-Universe", cfg.Repo.Org)
assert.Equal(t, "staff", cfg.Repo.CreateAuthzTeam)
assert.Equal(t, "apollo-11-approvers", cfg.Repo.ApproveAuthzTeam)
assert.False(t, cfg.Repo.Enabled(), "repo feature must be disabled without App creds")
}

func TestLoad_RepoOverridesAndAppCreds(t *testing.T) {
for k, v := range requiredEnv() {
t.Setenv(k, v)
}
t.Setenv("GH_REPO_ORG", "ExampleUniverse")
t.Setenv("REPO_CREATE_AUTHZ_TEAM", "contributors")
t.Setenv("REPO_APPROVE_AUTHZ_TEAM", "maintainers")
t.Setenv("GH_APP_ID", "123456")
t.Setenv("GH_APP_INSTALLATION_ID", "987654")
t.Setenv("GH_APP_PRIVATE_KEY", "-----BEGIN RSA PRIVATE KEY-----\nMII...\n-----END RSA PRIVATE KEY-----\n")

cfg, err := Load()
require.NoError(t, err)

assert.Equal(t, "ExampleUniverse", cfg.Repo.Org)
assert.Equal(t, "contributors", cfg.Repo.CreateAuthzTeam)
assert.Equal(t, "maintainers", cfg.Repo.ApproveAuthzTeam)
assert.Equal(t, "123456", cfg.Repo.App.AppID)
assert.Equal(t, "987654", cfg.Repo.App.InstallationID)
assert.True(t, cfg.Repo.Enabled())
}

func TestLoad_RepoPartialAppConfigFails(t *testing.T) {
for k, v := range requiredEnv() {
t.Setenv(k, v)
}
// App id set but installation id + key missing → partial → error.
t.Setenv("GH_APP_ID", "123456")

_, err := Load()
require.Error(t, err)
assert.Contains(t, err.Error(), "partial")
}

func TestLoad_RepoEmptyTeamOverrideFails(t *testing.T) {
// An explicit empty override is ignored (defaults retained), so the
// guard against empty teams only trips on a programmatic zero value;
// assert the happy default holds when the env var is blank.
for k, v := range requiredEnv() {
t.Setenv(k, v)
}
t.Setenv("REPO_APPROVE_AUTHZ_TEAM", "")

cfg, err := Load()
require.NoError(t, err)
assert.Equal(t, "apollo-11-approvers", cfg.Repo.ApproveAuthzTeam)
}
Loading
Loading