Skip to content
Open
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
3 changes: 2 additions & 1 deletion .github/workflows/deploy-app-gateway.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ jobs:
- name: Write app-gateway env file
uses: appleboy/ssh-action@v0.1.10
env:
ENV_LIST: OS_AUTH_URL,OS_USERNAME,OS_PASSWORD,OS_PROJECT_NAME,OS_USER_DOMAIN_NAME,CF_ACCESS_CLIENT_ID,CF_ACCESS_CLIENT_SECRET,DB_DRIVER,DB_DSN,APP_GATEWAY_PORT,RCP_NS_PROXY_SOCK,RCP_APP_GW_FIXED_NETWORK,RCP_APP_GW_READ_TIMEOUT,RCP_APP_GW_SHUTDOWN_TIMEOUT,RCP_APP_GW_LOG_LEVEL
ENV_LIST: OS_AUTH_URL,OS_USERNAME,OS_PASSWORD,OS_PROJECT_NAME,OS_USER_DOMAIN_NAME,CF_ACCESS_CLIENT_ID,CF_ACCESS_CLIENT_SECRET,DB_DRIVER,DB_DSN,APP_GATEWAY_PORT,RCP_NS_PROXY_SOCK,RCP_APP_GW_FIXED_NETWORK,RCP_APP_GW_READ_TIMEOUT,RCP_APP_GW_SHUTDOWN_TIMEOUT,RCP_APP_GW_FIXED_IP_CACHE_TTL,RCP_APP_GW_LOG_LEVEL
OS_AUTH_URL: ${{ secrets.OS_AUTH_URL }}
OS_USERNAME: ${{ secrets.OS_USERNAME }}
OS_PASSWORD: ${{ secrets.OS_PASSWORD }}
Expand All @@ -77,6 +77,7 @@ jobs:
RCP_APP_GW_FIXED_NETWORK: ${{ secrets.RCP_APP_GW_FIXED_NETWORK }}
RCP_APP_GW_READ_TIMEOUT: ${{ secrets.RCP_APP_GW_READ_TIMEOUT || '30s' }}
RCP_APP_GW_SHUTDOWN_TIMEOUT: ${{ secrets.RCP_APP_GW_SHUTDOWN_TIMEOUT || '30s' }}
RCP_APP_GW_FIXED_IP_CACHE_TTL: ${{ secrets.RCP_APP_GW_FIXED_IP_CACHE_TTL || '5m' }}
RCP_APP_GW_LOG_LEVEL: ${{ secrets.RCP_APP_GW_LOG_LEVEL || 'info' }}
with:
host: ${{ secrets.SERVER_HOST }}
Expand Down
6 changes: 6 additions & 0 deletions cmd/app-gateway/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ type Config struct {
DBDSN string
ReadTimeout time.Duration
ShutdownTimeout time.Duration
FixedIPCacheTTL time.Duration
LogLevel string
}

Expand Down Expand Up @@ -54,6 +55,10 @@ func LoadConfig(getenv func(string) string) (*Config, error) {
if err != nil {
return nil, err
}
fixedIPCacheTTL, err := utils.EnvPositiveDuration(getenv, "RCP_APP_GW_FIXED_IP_CACHE_TTL", 5*time.Minute)
if err != nil {
return nil, err
}
logLevel, err := utils.EnvLogLevel(getenv, "RCP_APP_GW_LOG_LEVEL", "info")
if err != nil {
return nil, err
Expand All @@ -67,6 +72,7 @@ func LoadConfig(getenv func(string) string) (*Config, error) {
DBDSN: dbDSN,
ReadTimeout: readTimeout,
ShutdownTimeout: shutdownTimeout,
FixedIPCacheTTL: fixedIPCacheTTL,
LogLevel: logLevel,
}, nil
}
Expand Down
23 changes: 15 additions & 8 deletions cmd/app-gateway/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,21 +32,25 @@ func TestLoadConfigDefaults(t *testing.T) {
if cfg.ShutdownTimeout != 30*time.Second {
t.Fatalf("ShutdownTimeout got %v", cfg.ShutdownTimeout)
}
if cfg.FixedIPCacheTTL != 5*time.Minute {
t.Fatalf("FixedIPCacheTTL got %v", cfg.FixedIPCacheTTL)
}
if cfg.LogLevel != "info" {
t.Fatalf("LogLevel got %q", cfg.LogLevel)
}
}

func TestLoadConfigUsesExplicitValues(t *testing.T) {
cfg, err := LoadConfig(envFromMap(map[string]string{
"APP_GATEWAY_PORT": "19090",
"RCP_NS_PROXY_SOCK": "/tmp/ns-proxy.sock",
"DB_DRIVER": "postgres",
"DB_DSN": "host=db dbname=rcp",
"RCP_APP_GW_FIXED_NETWORK": "tenant-net",
"RCP_APP_GW_READ_TIMEOUT": "5s",
"RCP_APP_GW_SHUTDOWN_TIMEOUT": "7s",
"RCP_APP_GW_LOG_LEVEL": "debug",
"APP_GATEWAY_PORT": "19090",
"RCP_NS_PROXY_SOCK": "/tmp/ns-proxy.sock",
"DB_DRIVER": "postgres",
"DB_DSN": "host=db dbname=rcp",
"RCP_APP_GW_FIXED_NETWORK": "tenant-net",
"RCP_APP_GW_READ_TIMEOUT": "5s",
"RCP_APP_GW_SHUTDOWN_TIMEOUT": "7s",
"RCP_APP_GW_FIXED_IP_CACHE_TTL": "9s",
"RCP_APP_GW_LOG_LEVEL": "debug",
}))
if err != nil {
t.Fatalf("unexpected err: %v", err)
Expand All @@ -63,6 +67,9 @@ func TestLoadConfigUsesExplicitValues(t *testing.T) {
if cfg.ReadTimeout != 5*time.Second || cfg.ShutdownTimeout != 7*time.Second {
t.Fatalf("unexpected timeouts: %+v", cfg)
}
if cfg.FixedIPCacheTTL != 9*time.Second {
t.Fatalf("FixedIPCacheTTL got %v", cfg.FixedIPCacheTTL)
}
if cfg.LogLevel != "debug" {
t.Fatalf("LogLevel got %q", cfg.LogLevel)
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/app-gateway/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ func main() {
fatal("openstack compute client: %v", err)
}

app := NewServer(cfg, log, newRepo(db), resolver)
app := NewServer(cfg, log, newRepo(db), newCachedFixedIPResolver(resolver, cfg.FixedIPCacheTTL))
httpSrv := &http.Server{
Addr: cfg.Listen,
Handler: app,
Expand Down
52 changes: 52 additions & 0 deletions cmd/app-gateway/resolver_cache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package main

import (
"context"
"sync"
"time"
)

type cachedFixedIPResolver struct {
next fixedIPResolver
ttl time.Duration
now func() time.Time
mu sync.RWMutex
cache map[string]fixedIPCacheEntry
}

type fixedIPCacheEntry struct {
ip string
expiresAt time.Time
}

func newCachedFixedIPResolver(next fixedIPResolver, ttl time.Duration) *cachedFixedIPResolver {
return &cachedFixedIPResolver{
next: next,
ttl: ttl,
now: time.Now,
cache: make(map[string]fixedIPCacheEntry),
}
}

func (c *cachedFixedIPResolver) ResolveFixedIPv4(ctx context.Context, openstackID string) (string, error) {
now := c.now()
c.mu.RLock()
entry, ok := c.cache[openstackID]
c.mu.RUnlock()
if ok && now.Before(entry.expiresAt) {
return entry.ip, nil
}

ip, err := c.next.ResolveFixedIPv4(ctx, openstackID)
if err != nil {
if ok && entry.ip != "" {
return entry.ip, nil
}
return "", err
}

c.mu.Lock()
c.cache[openstackID] = fixedIPCacheEntry{ip: ip, expiresAt: now.Add(c.ttl)}
c.mu.Unlock()
return ip, nil
}
105 changes: 105 additions & 0 deletions cmd/app-gateway/resolver_cache_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package main

import (
"context"
"errors"
"testing"
"time"
)

type countingResolver struct {
ip string
err error
calls int
}

func (c *countingResolver) ResolveFixedIPv4(context.Context, string) (string, error) {
c.calls++
if c.err != nil {
return "", c.err
}
return c.ip, nil
}

func TestCachedFixedIPResolverCachesSuccessfulLookup(t *testing.T) {
next := &countingResolver{ip: "10.0.0.8"}
resolver := newCachedFixedIPResolver(next, time.Minute)

for range 2 {
got, err := resolver.ResolveFixedIPv4(context.Background(), "os-1")
if err != nil {
t.Fatalf("ResolveFixedIPv4 returned error: %v", err)
}
if got != "10.0.0.8" {
t.Fatalf("ip got %q", got)
}
}

if next.calls != 1 {
t.Fatalf("underlying resolver calls got %d", next.calls)
}
}

func TestCachedFixedIPResolverDoesNotCacheErrors(t *testing.T) {
next := &countingResolver{err: errors.New("nova timeout")}
resolver := newCachedFixedIPResolver(next, time.Minute)

for range 2 {
_, err := resolver.ResolveFixedIPv4(context.Background(), "os-1")
if err == nil {
t.Fatalf("expected error")
}
}

if next.calls != 2 {
t.Fatalf("underlying resolver calls got %d", next.calls)
}
}

func TestCachedFixedIPResolverRefreshesAfterTTL(t *testing.T) {
next := &countingResolver{ip: "10.0.0.8"}
resolver := newCachedFixedIPResolver(next, time.Minute)
now := time.Date(2026, 6, 5, 12, 0, 0, 0, time.UTC)
resolver.now = func() time.Time { return now }

if _, err := resolver.ResolveFixedIPv4(context.Background(), "os-1"); err != nil {
t.Fatalf("ResolveFixedIPv4 returned error: %v", err)
}

next.ip = "10.0.0.7"
now = now.Add(time.Minute + time.Second)
got, err := resolver.ResolveFixedIPv4(context.Background(), "os-1")
if err != nil {
t.Fatalf("ResolveFixedIPv4 returned error: %v", err)
}
if got != "10.0.0.7" {
t.Fatalf("ip got %q", got)
}
if next.calls != 2 {
t.Fatalf("underlying resolver calls got %d", next.calls)
}
}

func TestCachedFixedIPResolverUsesStaleIPOnRefreshError(t *testing.T) {
next := &countingResolver{ip: "10.0.0.8"}
resolver := newCachedFixedIPResolver(next, time.Minute)
now := time.Date(2026, 6, 5, 12, 0, 0, 0, time.UTC)
resolver.now = func() time.Time { return now }

if _, err := resolver.ResolveFixedIPv4(context.Background(), "os-1"); err != nil {
t.Fatalf("ResolveFixedIPv4 returned error: %v", err)
}

next.err = errors.New("nova timeout")
now = now.Add(time.Minute + time.Second)
got, err := resolver.ResolveFixedIPv4(context.Background(), "os-1")
if err != nil {
t.Fatalf("ResolveFixedIPv4 returned error: %v", err)
}
if got != "10.0.0.8" {
t.Fatalf("ip got %q", got)
}
if next.calls != 2 {
t.Fatalf("underlying resolver calls got %d", next.calls)
}
}
3 changes: 3 additions & 0 deletions deploy/systemd/app-gateway.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ DB_DSN=host=localhost port=5432 user=rcp password=secret dbname=rcp sslmode=disa
# ns-proxy Unix socket 경로.
RCP_NS_PROXY_SOCK=/run/rcp/ns-proxy.sock

# Nova fixed IP 조회 결과 캐시 TTL. 만료 후 조회 실패 시 마지막 성공 IP를 fallback으로 사용합니다.
RCP_APP_GW_FIXED_IP_CACHE_TTL=5m

# fixed IP 조회용 OpenStack 인증 설정.
OS_AUTH_URL=https://openstack.example.com:5000/v3
OS_USERNAME=return
Expand Down