33c9c39676
Add an optional remote atomization backend (the Anypreta "Issue Atomizer"
API) for the Subdivide flow, selected by ATOMIZER_REMOTE_BASE_URL /
ATOMIZER_REMOTE_API_KEY. When set, POST /v1/atomize is served by the remote
/v1/atomize-issue endpoint; its two-level issue→features→tasks model is mapped
onto our one-level task→children model by treating each Feature as a child and
its `estimation` as the effort coefficient (renormalized to sum to 1, even
split as a reported fallback when estimations are absent).
Extend and Summarize have no counterpart in the remote API and always stay on
ATOMIZER_BASE_URL, so the §5.1 service must keep running; readiness now probes
both backends. The mandatory `system` object is derived from the customer,
with a generic component tree (an empty tree makes the service return zero
features).
extsvc also learns the remote's flat error envelope ({error_code,message} and
FastAPI {detail}) and never swallows an unrecognized 4xx body.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
217 lines
6.6 KiB
Go
217 lines
6.6 KiB
Go
// Package config loads and validates all application configuration from the
|
|
// environment (optionally seeded from a .env file). The app fails fast at
|
|
// startup on invalid configuration rather than misbehaving later.
|
|
package config
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"net/netip"
|
|
"net/url"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type SMTP struct {
|
|
Host string
|
|
Port int
|
|
User string
|
|
Password string
|
|
From string
|
|
}
|
|
|
|
func (s SMTP) Enabled() bool { return s.Host != "" }
|
|
|
|
type OIDC struct {
|
|
IssuerURL string
|
|
ClientID string
|
|
ClientSecret string
|
|
}
|
|
|
|
func (o OIDC) Enabled() bool {
|
|
return o.IssuerURL != "" && o.ClientID != "" && o.ClientSecret != ""
|
|
}
|
|
|
|
type Config struct {
|
|
AppBaseURL string
|
|
// AppInternalURL is how the external services reach the app from inside
|
|
// the compose network (callback URLs, signed attachment URLs). Defaults
|
|
// to AppBaseURL.
|
|
AppInternalURL string
|
|
TrustedProxyCIDRs []netip.Prefix
|
|
AppPort int
|
|
MongoURI string
|
|
MongoDB string
|
|
MongoUsername string
|
|
MongoPassword string
|
|
SessionSecret string
|
|
CredentialsEncKey []byte // 32 bytes, decoded
|
|
MaxUploadMB int
|
|
|
|
AdminEmail string
|
|
AdminInitialPassword string
|
|
|
|
AtomizerBaseURL string
|
|
AtomizerToken string
|
|
AtomizerTimeout time.Duration
|
|
// AtomizerRemoteBaseURL / AtomizerRemoteAPIKey enable the Anypreta Issue
|
|
// Atomizer as the Subdivide backend. Both must be set; Extend and
|
|
// Summarize stay on ATOMIZER_BASE_URL either way.
|
|
AtomizerRemoteBaseURL string
|
|
AtomizerRemoteAPIKey string
|
|
WorkPerformerBaseURL string
|
|
WorkPerformerToken string
|
|
WorkPerformerHTTPTimeout time.Duration
|
|
|
|
SMTP SMTP
|
|
OIDC OIDC
|
|
|
|
CookieSecureMode string // auto | true | false
|
|
AtomizeMaxConcurrency int
|
|
}
|
|
|
|
// CookieSecure resolves the Secure cookie flag: explicit true/false, or in
|
|
// auto mode derived from the APP_BASE_URL scheme.
|
|
func (c *Config) CookieSecure() bool {
|
|
switch c.CookieSecureMode {
|
|
case "true":
|
|
return true
|
|
case "false":
|
|
return false
|
|
default:
|
|
return strings.HasPrefix(c.AppBaseURL, "https://")
|
|
}
|
|
}
|
|
|
|
func getenv(key, def string) string {
|
|
if v, ok := os.LookupEnv(key); ok {
|
|
return v
|
|
}
|
|
return def
|
|
}
|
|
|
|
// Load reads configuration from the environment and validates it. All
|
|
// problems are reported together so a broken deployment is fixed in one pass.
|
|
func Load() (*Config, error) {
|
|
var errs []error
|
|
fail := func(format string, a ...any) { errs = append(errs, fmt.Errorf(format, a...)) }
|
|
|
|
atoi := func(key, def string) int {
|
|
raw := getenv(key, def)
|
|
n, err := strconv.Atoi(raw)
|
|
if err != nil {
|
|
fail("%s: %q is not an integer", key, raw)
|
|
}
|
|
return n
|
|
}
|
|
|
|
c := &Config{
|
|
AppBaseURL: strings.TrimRight(getenv("APP_BASE_URL", "http://localhost:8787"), "/"),
|
|
AppPort: atoi("APP_PORT", "8787"),
|
|
MongoURI: getenv("MONGO_URI", "mongodb://localhost:27017"),
|
|
MongoDB: getenv("MONGO_DB", "bountyboard"),
|
|
MongoUsername: os.Getenv("MONGO_USERNAME"),
|
|
MongoPassword: os.Getenv("MONGO_PASSWORD"),
|
|
SessionSecret: os.Getenv("SESSION_SECRET"),
|
|
MaxUploadMB: atoi("MAX_UPLOAD_MB", "25"),
|
|
AdminEmail: strings.ToLower(strings.TrimSpace(getenv("ADMIN_EMAIL", ""))),
|
|
AdminInitialPassword: os.Getenv("ADMIN_INITIAL_PASSWORD"),
|
|
|
|
AtomizerBaseURL: strings.TrimRight(getenv("ATOMIZER_BASE_URL", "http://atomizer-mock:8090"), "/"),
|
|
AtomizerToken: os.Getenv("ATOMIZER_TOKEN"),
|
|
AtomizerRemoteBaseURL: strings.TrimRight(os.Getenv("ATOMIZER_REMOTE_BASE_URL"), "/"),
|
|
AtomizerRemoteAPIKey: os.Getenv("ATOMIZER_REMOTE_API_KEY"),
|
|
WorkPerformerBaseURL: strings.TrimRight(getenv("WORK_PERFORMER_BASE_URL", "http://work-performer:8091"), "/"),
|
|
WorkPerformerToken: os.Getenv("WORK_PERFORMER_TOKEN"),
|
|
|
|
SMTP: SMTP{
|
|
Host: os.Getenv("SMTP_HOST"),
|
|
Port: atoi("SMTP_PORT", "587"),
|
|
User: os.Getenv("SMTP_USER"),
|
|
Password: os.Getenv("SMTP_PASSWORD"),
|
|
From: getenv("SMTP_FROM", "bountyboard@example.com"),
|
|
},
|
|
OIDC: OIDC{
|
|
IssuerURL: os.Getenv("OIDC_ISSUER_URL"),
|
|
ClientID: os.Getenv("OIDC_CLIENT_ID"),
|
|
ClientSecret: os.Getenv("OIDC_CLIENT_SECRET"),
|
|
},
|
|
|
|
CookieSecureMode: getenv("COOKIE_SECURE", "auto"),
|
|
AtomizeMaxConcurrency: atoi("ATOMIZE_MAX_CONCURRENCY", "3"),
|
|
}
|
|
c.AppInternalURL = strings.TrimRight(getenv("APP_INTERNAL_URL", c.AppBaseURL), "/")
|
|
c.AtomizerTimeout = time.Duration(atoi("ATOMIZER_TIMEOUT_SEC", "120")) * time.Second
|
|
c.WorkPerformerHTTPTimeout = time.Duration(atoi("WORK_PERFORMER_HTTP_TIMEOUT_SEC", "30")) * time.Second
|
|
|
|
if c.AppPort < 1 || c.AppPort > 65535 {
|
|
fail("APP_PORT: %d out of range", c.AppPort)
|
|
}
|
|
if u, err := url.Parse(c.AppBaseURL); err != nil || u.Scheme == "" || u.Host == "" {
|
|
fail("APP_BASE_URL: %q is not an absolute URL", c.AppBaseURL)
|
|
}
|
|
for _, name := range []string{"ATOMIZER_BASE_URL", "WORK_PERFORMER_BASE_URL"} {
|
|
v := c.AtomizerBaseURL
|
|
if name == "WORK_PERFORMER_BASE_URL" {
|
|
v = c.WorkPerformerBaseURL
|
|
}
|
|
if u, err := url.Parse(v); err != nil || u.Scheme == "" || u.Host == "" {
|
|
fail("%s: %q is not an absolute URL", name, v)
|
|
}
|
|
}
|
|
if c.AtomizerRemoteBaseURL != "" {
|
|
if u, err := url.Parse(c.AtomizerRemoteBaseURL); err != nil || u.Scheme == "" || u.Host == "" {
|
|
fail("ATOMIZER_REMOTE_BASE_URL: %q is not an absolute URL", c.AtomizerRemoteBaseURL)
|
|
}
|
|
if c.AtomizerRemoteAPIKey == "" {
|
|
fail("ATOMIZER_REMOTE_API_KEY: required when ATOMIZER_REMOTE_BASE_URL is set")
|
|
}
|
|
}
|
|
if len(c.SessionSecret) < 16 {
|
|
fail("SESSION_SECRET: must be at least 16 characters of random data")
|
|
}
|
|
switch raw := os.Getenv("CREDENTIALS_ENC_KEY"); raw {
|
|
case "":
|
|
fail("CREDENTIALS_ENC_KEY: required; generate with `openssl rand -base64 32`")
|
|
default:
|
|
key, err := base64.StdEncoding.DecodeString(raw)
|
|
if err != nil || len(key) != 32 {
|
|
fail("CREDENTIALS_ENC_KEY: must be base64 of exactly 32 bytes")
|
|
}
|
|
c.CredentialsEncKey = key
|
|
}
|
|
if c.MaxUploadMB < 1 {
|
|
fail("MAX_UPLOAD_MB: must be >= 1")
|
|
}
|
|
if c.AtomizeMaxConcurrency < 1 {
|
|
fail("ATOMIZE_MAX_CONCURRENCY: must be >= 1")
|
|
}
|
|
switch c.CookieSecureMode {
|
|
case "auto", "true", "false":
|
|
default:
|
|
fail("COOKIE_SECURE: %q must be auto, true or false", c.CookieSecureMode)
|
|
}
|
|
if raw := strings.TrimSpace(os.Getenv("TRUSTED_PROXY_CIDRS")); raw != "" {
|
|
for cidr := range strings.SplitSeq(raw, ",") {
|
|
cidr = strings.TrimSpace(cidr)
|
|
if cidr == "" {
|
|
continue
|
|
}
|
|
p, err := netip.ParsePrefix(cidr)
|
|
if err != nil {
|
|
fail("TRUSTED_PROXY_CIDRS: %q is not a CIDR", cidr)
|
|
continue
|
|
}
|
|
c.TrustedProxyCIDRs = append(c.TrustedProxyCIDRs, p.Masked())
|
|
}
|
|
}
|
|
|
|
if len(errs) > 0 {
|
|
return nil, fmt.Errorf("invalid configuration:\n%w", errors.Join(errs...))
|
|
}
|
|
return c, nil
|
|
}
|