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: 36 additions & 0 deletions .github/workflows/general.yml
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,39 @@ jobs:
env:
COVERALLS_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: goveralls -coverprofile=cover.out -service=github

smoke:
# Full end-to-end smoke matrix (Phase A gateway-only + Phase B oauth +
# MySQL). Builds the binary, runs `altair new`, spawns it as a
# subprocess, and asserts forwarding + proxy options + the oauth
# auth/scope/body matrix. ~10s wall time on the runner; MySQL via the
# services: block (no docker-compose / no DinD).
# Spec: docs/superpowers/specs/2026-04-23-altair-smoke-test-design.md
name: Smoke (e2e)
runs-on: ubuntu-latest
needs: verify
services:
mysql:
image: mysql:5.7
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: altair_development
ports:
- '3306:3306'
options: >-
--health-cmd="mysqladmin ping -uroot -proot"
--health-interval=10s
--health-timeout=5s
--health-retries=10
steps:
- uses: actions/checkout@v6

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: stable
check-latest: true
cache: true

- name: Smoke Test
run: make smoke
11 changes: 10 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,16 @@ export VERSION ?= $(shell git show -q --format=%h)
export IMAGE ?= kodefluence/altair

test:
go test -race -cover -coverprofile=cover.out $$(go list ./... | grep -Ev "altair$$|core|mock|interfaces|testhelper")
go test -race -cover -coverprofile=cover.out $$(go list ./... | grep -Ev "altair$$|core|mock|interfaces|testhelper|/e2e")

# End-to-end smoke test. Builds the binary, scaffolds via `altair new`,
# spawns a real altair subprocess against an in-process echo upstream, and
# exercises the proxy path (forwarding, headers, timeout, body cap).
# Build-tagged so `make test` stays fast; opt in with `make smoke`.
# Phase A is gateway-only (no MySQL); Phase B will add the oauth+MySQL
# matrix per docs/superpowers/specs/2026-04-23-altair-smoke-test-design.md.
smoke:
go test -tags=e2e -count=1 -v -timeout=5m ./e2e/...

mock_metric:
mockgen -source core/metric.go -destination mock/mock_metric.go -package mock
Expand Down
4 changes: 4 additions & 0 deletions adapter/app_config.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package adapter

import (
"time"

"github.com/kodefluence/altair/core"
"github.com/kodefluence/altair/entity"
)
Expand All @@ -17,6 +19,8 @@ func (a *appConfig) Port() int { return a.c.Port() }
func (a *appConfig) BasicAuthUsername() string { return a.c.BasicAuthUsername() }
func (a *appConfig) BasicAuthPassword() string { return a.c.BasicAuthPassword() }
func (a *appConfig) ProxyHost() string { return a.c.ProxyHost() }
func (a *appConfig) UpstreamTimeout() time.Duration { return a.c.UpstreamTimeout() }
func (a *appConfig) MaxRequestBodySize() int64 { return a.c.MaxRequestBodySize() }
func (a *appConfig) PluginExists(pluginName string) bool { return a.c.PluginExists(pluginName) }
func (a *appConfig) Plugins() []string { return a.c.Plugins() }
func (a *appConfig) AutoMigrate() bool { return a.c.AutoMigrate() }
Expand Down
9 changes: 8 additions & 1 deletion altair.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import (
pluginlist "github.com/kodefluence/altair/module/plugin_list"
"github.com/kodefluence/altair/module/projectgenerator"
"github.com/kodefluence/altair/module/router"
routerusecase "github.com/kodefluence/altair/module/router/usecase"
"github.com/kodefluence/altair/plugin"
)

Expand Down Expand Up @@ -318,7 +319,13 @@ func runAPI() error {

reportMigrationDrift(pluginBearer, dbBearer)

compiler, forwarder := router.Provide(pluginModule.Controller().ListDownstream(), pluginModule.Controller().ListMetric())
compiler, forwarder := router.Provide(
pluginModule.Controller().ListDownstream(),
pluginModule.Controller().ListMetric(),
routerusecase.WithUpstreamTimeout(appConfig.UpstreamTimeout()),
routerusecase.WithProxyHost(appConfig.ProxyHost()),
routerusecase.WithMaxRequestBodySize(appConfig.MaxRequestBodySize()),
)
routeObjects, err := compiler.Compile("./routes")
if err != nil {
log.Error().
Expand Down
95 changes: 88 additions & 7 deletions cfg/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"io"
"os"
"strconv"
"strings"
"time"

"gopkg.in/yaml.v2"

Expand All @@ -14,14 +16,70 @@ import (
"github.com/kodefluence/altair/entity"
)

// defaultUpstreamTimeout caps how long a single upstream request may take.
// Mirrors module/router/usecase.defaultUpstreamTimeout — kept in sync so a
// missing app.yml field and a missing WithUpstreamTimeout option produce the
// same effective timeout. 30s is conservative enough for most APIs while
// still bounded enough to prevent goroutine leaks from hung upstreams.
const defaultUpstreamTimeout = 30 * time.Second

type app struct{}

// baseProxy carries the canonical (v1.0+) proxy block. Kept as its own type
// so an absent block (zero value) is distinguishable via the Host field.
type baseProxy struct {
Host string `yaml:"host"`
UpstreamTimeout string `yaml:"upstream_timeout"`
MaxRequestBodySize string `yaml:"max_request_body_size"`
}

// parseByteSize accepts an integer (bytes) or an integer suffixed with B,
// KB, MB, or GB (binary multipliers — KB = 1024, MB = 1024², GB = 1024³).
// Empty string is "no cap" (returns 0). Anything else is a hard error so
// typos in the YAML can't silently disable the limit.
func parseByteSize(s string) (int64, error) {
s = strings.TrimSpace(s)
if s == "" {
return 0, nil
}

multiplier := int64(1)
upper := strings.ToUpper(s)
switch {
case strings.HasSuffix(upper, "GB"):
multiplier = 1024 * 1024 * 1024
s = s[:len(s)-2]
case strings.HasSuffix(upper, "MB"):
multiplier = 1024 * 1024
s = s[:len(s)-2]
case strings.HasSuffix(upper, "KB"):
multiplier = 1024
s = s[:len(s)-2]
case strings.HasSuffix(upper, "B"):
s = s[:len(s)-1]
}

n, err := strconv.ParseInt(strings.TrimSpace(s), 10, 64)
if err != nil {
return 0, fmt.Errorf("invalid byte size %q: %w", s, err)
}
if n < 0 {
return 0, fmt.Errorf("byte size must be non-negative, got %d", n)
}
return n * multiplier, nil
}

type baseAppConfig struct {
Version string `yaml:"version"`
Plugins []string `yaml:"plugins"`
Port string `yaml:"port"`
ProxyHost string `yaml:"proxy_host"`
AutoMigrate bool `yaml:"auto_migrate"`
Version string `yaml:"version"`
Plugins []string `yaml:"plugins"`
Port string `yaml:"port"`
// Legacy top-level proxy_host (pre-nested-block schema). Kept for
// backward compatibility with existing deployments; new apps generated
// by `altair new` use the `proxy:` block. If both are set, `proxy.host`
// wins so operators can stage a migration without an outage.
ProxyHost string `yaml:"proxy_host"`
Proxy baseProxy `yaml:"proxy"`
AutoMigrate bool `yaml:"auto_migrate"`
Authorization struct {
Username string `yaml:"username"`
Password string `yaml:"password"`
Expand Down Expand Up @@ -78,11 +136,34 @@ func (a *app) Compile(configPath string) (core.AppConfig, error) {
appConfigOption.Port = port
}

if config.ProxyHost == "" {
// Resolve proxy host: nested block wins over the legacy top-level
// field; default to www.local.host if neither is set.
switch {
case config.Proxy.Host != "":
appConfigOption.ProxyHost = config.Proxy.Host
case config.ProxyHost != "":
appConfigOption.ProxyHost = config.ProxyHost
default:
appConfigOption.ProxyHost = "www.local.host"
}

if config.Proxy.UpstreamTimeout == "" {
appConfigOption.UpstreamTimeout = defaultUpstreamTimeout
} else {
appConfigOption.ProxyHost = config.ProxyHost
d, err := time.ParseDuration(config.Proxy.UpstreamTimeout)
if err != nil {
return nil, fmt.Errorf("invalid proxy.upstream_timeout %q: %w", config.Proxy.UpstreamTimeout, err)
}
appConfigOption.UpstreamTimeout = d
}

// max_request_body_size defaults to 0 (unlimited) so existing
// deployments that don't set it continue to behave as before.
bodyCap, err := parseByteSize(config.Proxy.MaxRequestBodySize)
if err != nil {
return nil, fmt.Errorf("invalid proxy.max_request_body_size %q: %w", config.Proxy.MaxRequestBodySize, err)
}
appConfigOption.MaxRequestBodySize = bodyCap

appConfigOption.Plugins = config.Plugins
appConfigOption.AutoMigrate = config.AutoMigrate
Expand Down
68 changes: 68 additions & 0 deletions cfg/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cfg_test
import (
"fmt"
"testing"
"time"

"github.com/stretchr/testify/assert"

Expand Down Expand Up @@ -106,6 +107,73 @@ func TestApp(t *testing.T) {
})
})

t.Run("Default upstream timeout is 30s when proxy block omitted", func(t *testing.T) {
configPath := "./app_default_timeout/"
fileName := "app.yml"

testhelper.GenerateTempTestFiles(configPath, AppConfigNormal, fileName, 0666)

appConfig, err := cfg.App().Compile(fmt.Sprintf("%s%s", configPath, fileName))
assert.Nil(t, err)
assert.Equal(t, 30*time.Second, appConfig.UpstreamTimeout())

testhelper.RemoveTempTestFiles(configPath)
})

t.Run("Nested proxy block parses host, upstream_timeout, max_request_body_size", func(t *testing.T) {
configPath := "./app_nested_proxy/"
fileName := "app.yml"

testhelper.GenerateTempTestFiles(configPath, AppConfigWithNestedProxyBlock, fileName, 0666)

appConfig, err := cfg.App().Compile(fmt.Sprintf("%s%s", configPath, fileName))
assert.Nil(t, err)
assert.Equal(t, "www.altair.id", appConfig.ProxyHost())
assert.Equal(t, 5*time.Second, appConfig.UpstreamTimeout())
assert.Equal(t, int64(1024*1024), appConfig.MaxRequestBodySize())

testhelper.RemoveTempTestFiles(configPath)
})

t.Run("Default max_request_body_size is 0 (unlimited) when omitted", func(t *testing.T) {
configPath := "./app_default_body_size/"
fileName := "app.yml"

testhelper.GenerateTempTestFiles(configPath, AppConfigNormal, fileName, 0666)

appConfig, err := cfg.App().Compile(fmt.Sprintf("%s%s", configPath, fileName))
assert.Nil(t, err)
assert.Equal(t, int64(0), appConfig.MaxRequestBodySize())

testhelper.RemoveTempTestFiles(configPath)
})

t.Run("Invalid max_request_body_size returns error", func(t *testing.T) {
configPath := "./app_invalid_body_size/"
fileName := "app.yml"

testhelper.GenerateTempTestFiles(configPath, AppConfigWithInvalidBodySize, fileName, 0666)

appConfig, err := cfg.App().Compile(fmt.Sprintf("%s%s", configPath, fileName))
assert.NotNil(t, err)
assert.Nil(t, appConfig)

testhelper.RemoveTempTestFiles(configPath)
})

t.Run("Invalid upstream_timeout duration string returns error", func(t *testing.T) {
configPath := "./app_invalid_timeout/"
fileName := "app.yml"

testhelper.GenerateTempTestFiles(configPath, AppConfigWithInvalidUpstreamTimeout, fileName, 0666)

appConfig, err := cfg.App().Compile(fmt.Sprintf("%s%s", configPath, fileName))
assert.NotNil(t, err)
assert.Nil(t, appConfig)

testhelper.RemoveTempTestFiles(configPath)
})

t.Run("Empty authorization username", func(t *testing.T) {
t.Run("Return error", func(t *testing.T) {
configPath := "./app_empty_username/"
Expand Down
34 changes: 34 additions & 0 deletions cfg/example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,40 @@ authorization:
plugins:
- oauth`

var AppConfigWithNestedProxyBlock = `
version: 1.0
proxy:
host: www.altair.id
upstream_timeout: 5s
max_request_body_size: 1MB
authorization:
username: altair
password: secret
plugins:
- oauth`

var AppConfigWithInvalidBodySize = `
version: 1.0
proxy:
host: www.altair.id
max_request_body_size: not-a-size
authorization:
username: altair
password: secret
plugins:
- oauth`

var AppConfigWithInvalidUpstreamTimeout = `
version: 1.0
proxy:
host: www.altair.id
upstream_timeout: not-a-duration
authorization:
username: altair
password: secret
plugins:
- oauth`

var AppConfigWithCustomPort = `
version: 1.0
port: 7001
Expand Down
2 changes: 2 additions & 0 deletions core/cfg.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ type AppConfig interface {
BasicAuthUsername() string
BasicAuthPassword() string
ProxyHost() string
UpstreamTimeout() time.Duration
MaxRequestBodySize() int64
PluginExists(pluginName string) bool
Plugins() []string
AutoMigrate() bool
Expand Down
8 changes: 0 additions & 8 deletions core/migrator.go

This file was deleted.

13 changes: 0 additions & 13 deletions core/provider.go

This file was deleted.

Loading
Loading