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// HostOwner returns the app and service serving a canonical host.
68func (h *HostIndex) HostOwner(host string) (app, service string, ok bool) {
69 entry, found := h.Lookup(host)
70 if !found {
71 return "", "", false
72 }
73 return entry.App, entry.Service, true
74}
75
76// LookupHost implements proxy.TargetProvider over the host index.
77// The proxy package owns the interface; apptraffic implements it to
78// keep app-state interpretation out of the proxy.
79func (h *HostIndex) LookupHost(host string) (domain.AppBackend, bool) {
80 if h == nil {
81 return domain.AppBackend{}, false
82 }
83 entry, ok := h.Lookup(host)
84 if !ok {
85 return domain.AppBackend{}, false
86 }
87 return entry.Backend, true
88}
89
90// AppHosts returns the sorted served hosts with resolved backends.
91// Unresolved entries (no recorded bind) are excluded: fail closed.
92// It implements out.AppHostSource: the shared read model for traffic,
93// PKI and public-TLS consumers.
94func (h *HostIndex) AppHosts() []out.AppHost {
95 if h == nil {
96 return nil
97 }
98 h.mu.RLock()
99 defer h.mu.RUnlock()
100 hosts := make([]out.AppHost, 0, len(h.byHost))
101 for host, entry := range h.byHost {
102 if !entry.Backend.Resolved() {
103 continue
104 }
105 hosts = append(hosts, out.AppHost{
106 Host: host,
107 App: entry.App,
108 Service: entry.Service,
109 TLSMode: entry.TLSMode,
110 })
111 }
112 sort.Slice(hosts, func(i, j int) bool { return hosts[i].Host < hosts[j].Host })
113 return hosts
114}
115
116// L4Entries returns resolved TCP and UDP projections.
117func (h *HostIndex) L4Entries() []RouteEntry {
118 if h == nil {
119 return nil
120 }
121 h.mu.RLock()
122 defer h.mu.RUnlock()
123 entries := make([]RouteEntry, 0, len(h.entries))
124 for _, entry := range h.entries {
125 if (entry.Kind == "tcp" || entry.Kind == "udp") && entry.Backend.Resolved() {
126 entries = append(entries, entry)
127 }
128 }
129 return entries
130}
131
132// Activator re-projects active app state into the host index.
133// It is the coordinated traffic activation for the deployment engine:
134// after each deployment the engine rebuilds the host index from ACTIVE
135// state so the proxy only serves current backends.
136type Activator struct {
137 log zerowrap.Logger
138}
139
140// NewActivator wires activation with its logger.
141func NewActivator(log zerowrap.Logger) *Activator {
142 return &Activator{log: log}
143}
144
145// RebuildHostIndex re-projects every app's ACTIVE state into the host
146// index. The daemon calls it after each activation and at boot.
147// STOPPED-intent apps are skipped: their backends must not stay
148// routable merely because ACTIVE retains their effective spec.
149func (a *Activator) RebuildHostIndex(
150 ctx context.Context,
151 index *HostIndex,
152 state out.AppStateReader,
153 entrypoints map[string]EntrypointPolicy,
154) error {
155 apps, err := state.ListApps(ctx)
156 if err != nil {
157 return fmt.Errorf("apptraffic: list apps for host index: %w", err)
158 }
159 actives := make(map[string]domain.AppActive, len(apps))
160 for _, app := range apps {
161 intent, err := state.LoadIntent(ctx, app)
162 if err != nil {
163 return fmt.Errorf("apptraffic: load intent for host index: %w", err)
164 }
165 if intent.Stopped {
166 continue
167 }
168 active, ok, err := state.LoadActive(ctx, app)
169 if err != nil {
170 return fmt.Errorf("apptraffic: load active for host index: %w", err)
171 }
172 if !ok {
173 continue
174 }
175 actives[app] = active
176 }
177 projections, err := ProjectAll(actives, entrypoints)
178 if err != nil {
179 return err
180 }
181 var entries []RouteEntry
182 for _, app := range sortedKeys(projections) {
183 entries = append(entries, projections[app]...)
184 }
185 index.Replace(entries)
186 a.log.Info().Int("hosts", len(entries)).Msg("apptraffic: host index rebuilt")
187 return nil
188}
189
190// AppStateReader is the ACTIVE/intent subset read paths need.
191// Narrower than out.AppState so read paths never gain write access.
192// (Re-exported alias: the canonical definition lives in out.)
193
194func sortedKeys(projections map[string][]RouteEntry) []string {
195 apps := make([]string, 0, len(projections))
196 for app := range projections {
197 apps = append(apps, app)
198 }
199 sort.Strings(apps)
200 return apps
201}
202func ProjectAll(
203 apps map[string]domain.AppActive,
204 entrypoints map[string]EntrypointPolicy,
205) (map[string][]RouteEntry, error) {
206 names := make([]string, 0, len(apps))
207 for app := range apps {
208 names = append(names, app)
209 }
210 sort.Strings(names)
211 projections := make(map[string][]RouteEntry, len(apps))
212 for _, app := range names {
213 entries, err := Project(app, apps[app], entrypoints)
214 if err != nil {
215 return nil, err
216 }
217 projections[app] = entries
218 }
219 return projections, nil
220}