1package apptraffic
2
3import (
4 "context"
5 "fmt"
6 "sort"
7 "sync"
8
9 "github.com/bnema/zerowrap"
10
11 "github.com/bnema/gordon/internal/boundaries/out"
12 "github.com/bnema/gordon/internal/domain"
13)
14
15// HostIndex is the proxy-facing read model: canonical HTTP host to the
16// resolved loopback backend. It derives from ACTIVE app state (bbolt
17// stays authoritative); the daemon rebuilds it after every activation
18// and at boot. Lookups never touch container IPs.
19type HostIndex struct {
20 mu sync.RWMutex
21 byHost map[string]RouteEntry
22 entries []RouteEntry
23}
24
25// NewHostIndex creates an empty host index.
26func NewHostIndex() *HostIndex {
27 return &HostIndex{byHost: map[string]RouteEntry{}}
28}
29
30// Replace installs a rebuilt host map.
31func (h *HostIndex) Replace(entries []RouteEntry) {
32 byHost := make(map[string]RouteEntry, len(entries))
33 for _, entry := range entries {
34 if entry.Kind != "http" || entry.Host == "" {
35 continue
36 }
37 byHost[entry.Host] = entry
38 }
39 h.mu.Lock()
40 defer h.mu.Unlock()
41 h.byHost = byHost
42 h.entries = append([]RouteEntry(nil), entries...)
43}
44
45// Entries returns a copy of the projected entries, for a caller that
46// builds a candidate index and publishes it through another one.
47func (h *HostIndex) Entries() []RouteEntry {
48 if h == nil {
49 return nil
50 }
51 h.mu.RLock()
52 defer h.mu.RUnlock()
53 return append([]RouteEntry(nil), h.entries...)
54}
55
56// Lookup returns the projected entry for a canonical host.
57func (h *HostIndex) Lookup(host string) (RouteEntry, bool) {
58 if h == nil {
59 return RouteEntry{}, false
60 }
61 h.mu.RLock()
62 defer h.mu.RUnlock()
63 entry, ok := h.byHost[host]
64 return entry, ok
65}
66
67// LookupHost implements proxy.TargetProvider over the host index.
68// The proxy package owns the interface; apptraffic implements it to
69// keep app-state interpretation out of the proxy.
70func (h *HostIndex) LookupHost(host string) (domain.AppBackend, bool) {
71 if h == nil {
72 return domain.AppBackend{}, false
73 }
74 entry, ok := h.Lookup(host)
75 if !ok {
76 return domain.AppBackend{}, false
77 }
78 return entry.Backend, true
79}
80
81// AppHosts returns the sorted served hosts with resolved backends.
82// Unresolved entries (no recorded bind) are excluded: fail closed.
83// It implements out.AppHostSource: the shared read model for traffic,
84// PKI and public-TLS consumers.
85func (h *HostIndex) AppHosts() []out.AppHost {
86 if h == nil {
87 return nil
88 }
89 h.mu.RLock()
90 defer h.mu.RUnlock()
91 hosts := make([]out.AppHost, 0, len(h.byHost))
92 for host, entry := range h.byHost {
93 if !entry.Backend.Resolved() {
94 continue
95 }
96 hosts = append(hosts, out.AppHost{
97 Host: host,
98 App: entry.App,
99 Service: entry.Service,
100 TLSMode: entry.TLSMode,
101 })
102 }
103 sort.Slice(hosts, func(i, j int) bool { return hosts[i].Host < hosts[j].Host })
104 return hosts
105}
106
107// L4Entries returns resolved TCP and UDP projections.
108func (h *HostIndex) L4Entries() []RouteEntry {
109 if h == nil {
110 return nil
111 }
112 h.mu.RLock()
113 defer h.mu.RUnlock()
114 entries := make([]RouteEntry, 0, len(h.entries))
115 for _, entry := range h.entries {
116 if (entry.Kind == "tcp" || entry.Kind == "udp") && entry.Backend.Resolved() {
117 entries = append(entries, entry)
118 }
119 }
120 return entries
121}
122
123// Activator re-projects active app state into the host index.
124// It is the coordinated traffic activation for the deployment engine:
125// after each deployment the engine rebuilds the host index from ACTIVE
126// state so the proxy only serves current backends.
127type Activator struct {
128 log zerowrap.Logger
129}
130
131// NewActivator wires activation with its logger.
132func NewActivator(log zerowrap.Logger) *Activator {
133 return &Activator{log: log}
134}
135
136// RebuildHostIndex re-projects every app's ACTIVE state into the host
137// index. The daemon calls it after each activation and at boot.
138// STOPPED-intent apps are skipped: their backends must not stay
139// routable merely because ACTIVE retains their effective spec.
140func (a *Activator) RebuildHostIndex(
141 ctx context.Context,
142 index *HostIndex,
143 state out.AppStateReader,
144 entrypoints map[string]EntrypointPolicy,
145) error {
146 apps, err := state.ListApps(ctx)
147 if err != nil {
148 return fmt.Errorf("apptraffic: list apps for host index: %w", err)
149 }
150 actives := make(map[string]domain.AppActive, len(apps))
151 for _, app := range apps {
152 intent, err := state.LoadIntent(ctx, app)
153 if err != nil {
154 return fmt.Errorf("apptraffic: load intent for host index: %w", err)
155 }
156 if intent.Stopped {
157 continue
158 }
159 active, ok, err := state.LoadActive(ctx, app)
160 if err != nil {
161 return fmt.Errorf("apptraffic: load active for host index: %w", err)
162 }
163 if !ok {
164 continue
165 }
166 actives[app] = active
167 }
168 projections, err := ProjectAll(actives, entrypoints)
169 if err != nil {
170 return err
171 }
172 var entries []RouteEntry
173 for _, app := range sortedKeys(projections) {
174 entries = append(entries, projections[app]...)
175 }
176 index.Replace(entries)
177 a.log.Info().Int("hosts", len(entries)).Msg("apptraffic: host index rebuilt")
178 return nil
179}
180
181// AppStateReader is the ACTIVE/intent subset read paths need.
182// Narrower than out.AppState so read paths never gain write access.
183// (Re-exported alias: the canonical definition lives in out.)
184
185func sortedKeys(projections map[string][]RouteEntry) []string {
186 apps := make([]string, 0, len(projections))
187 for app := range projections {
188 apps = append(apps, app)
189 }
190 sort.Strings(apps)
191 return apps
192}
193func ProjectAll(
194 apps map[string]domain.AppActive,
195 entrypoints map[string]EntrypointPolicy,
196) (map[string][]RouteEntry, error) {
197 names := make([]string, 0, len(apps))
198 for app := range apps {
199 names = append(names, app)
200 }
201 sort.Strings(names)
202 projections := make(map[string][]RouteEntry, len(apps))
203 for _, app := range names {
204 entries, err := Project(app, apps[app], entrypoints)
205 if err != nil {
206 return nil, err
207 }
208 projections[app] = entries
209 }
210 return projections, nil
211}