Skip to content

Commit 0fa2851

Browse files
zhengxiexieoz-agent
andcommitted
EAS: refactor server into pkg/eas/server with lifecycle, dispatch, and hardening
- Split cmd_eas/main.go into pkg/eas/server/ (server.go, handler.go, response.go, table.go) - Replace handler switch/case with generic map-based resource dispatch - Implement manager.Runnable + LeaderElectionRunnable lifecycle integration - Use Kubernetes StatusError for standardized error responses - Add HTTP method gating (GET/HEAD only) with 405 for non-read methods - Tighten namespaced path parsing to reject invalid extra segments - Extract shared SplitPathSegments/LastPathSegment into pkg/util/path.go - Add comprehensive tests for handlers, responses, server lifecycle, and path utils Co-Authored-By: Oz <oz-agent@warp.dev> Change-Id: I8d0cc4c8e0b55e6d3abf630a66bbe9be18d3b687
1 parent 07b287b commit 0fa2851

15 files changed

Lines changed: 1139 additions & 563 deletions

cmd_eas/main.go

Lines changed: 30 additions & 546 deletions
Large diffs are not rendered by default.

pkg/eas/converter/converter.go

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1212

1313
easv1alpha1 "github.com/vmware-tanzu/nsx-operator/pkg/apis/eas/v1alpha1"
14+
"github.com/vmware-tanzu/nsx-operator/pkg/util"
1415
)
1516

1617
// ConvertVpcIpAddressBlocks converts NSX VpcIpAddressBlocks to K8s VPCIPAddressUsage.
@@ -166,10 +167,10 @@ func convertIpPoolRanges(nsxRanges []model.IpPoolRange) []easv1alpha1.IpPoolRang
166167

167168
func ipBlockUsageName(intentPath string, index int) string {
168169
if intentPath != "" {
169-
parts := splitPath(intentPath)
170+
parts := util.SplitPathSegments(intentPath)
170171
if len(parts) > 0 {
171172
name := parts[len(parts)-1]
172-
// If path contains projects, prefix with project name.
173+
// If a path contains projects, prefix with the project name.
173174
// e.g., /orgs/default/projects/Dev_project/infra/ip-blocks/xxx -> Dev_project:xxx
174175
for i, p := range parts {
175176
if p == "projects" && i+1 < len(parts) {
@@ -189,16 +190,6 @@ func ipBlockUsageName(intentPath string, index int) string {
189190
return fmt.Sprintf("ipblock-%d", index)
190191
}
191192

192-
func splitPath(path string) []string {
193-
var parts []string
194-
for _, p := range strings.Split(path, "/") {
195-
if p != "" {
196-
parts = append(parts, p)
197-
}
198-
}
199-
return parts
200-
}
201-
202193
// DerefString safely dereferences a *string, returning empty string if nil.
203194
func DerefString(s *string) string {
204195
if s != nil {

pkg/eas/server/handler.go

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
/* Copyright © 2024 Broadcom, Inc. All Rights Reserved.
2+
SPDX-License-Identifier: Apache-2.0 */
3+
4+
package server
5+
6+
import (
7+
"context"
8+
"fmt"
9+
"net/http"
10+
"sort"
11+
"strings"
12+
13+
apierrors "k8s.io/apimachinery/pkg/api/errors"
14+
15+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
16+
"k8s.io/apimachinery/pkg/runtime/schema"
17+
18+
easv1alpha1 "github.com/vmware-tanzu/nsx-operator/pkg/apis/eas/v1alpha1"
19+
"github.com/vmware-tanzu/nsx-operator/pkg/logger"
20+
)
21+
22+
const readMethodsAllowHeader = "GET, HEAD"
23+
24+
// resourceHandler groups the dispatch functions for a single EAS resource type.
25+
type resourceHandler struct {
26+
kind string
27+
namespaced func(w http.ResponseWriter, r *http.Request, ns, name string)
28+
clusterWide func(w http.ResponseWriter, r *http.Request)
29+
}
30+
31+
// resourceOps captures type-specific operations needed to serve a resource.
32+
// Item is the singular resource type; List is the corresponding list type.
33+
type resourceOps[Item any, List any] struct {
34+
kind string
35+
get func(context.Context, string, string) (*Item, error) // nil → list-and-filter fallback
36+
list func(context.Context, string) (*List, error)
37+
items func(*List) *[]Item // pointer to the Items field for read/append
38+
newList func() *List // empty list with correct TypeMeta
39+
columns []metav1.TableColumnDefinition
40+
toRow func(*Item) metav1.TableRow
41+
getName func(*Item) string
42+
}
43+
44+
func easListTypeMeta(kind string) metav1.TypeMeta {
45+
return metav1.TypeMeta{APIVersion: easv1alpha1.GroupVersion.String(), Kind: kind}
46+
}
47+
48+
func findItemByName[Item any](items []Item, name string, getName func(*Item) string) (*Item, bool) {
49+
for i := range items {
50+
if getName(&items[i]) == name {
51+
return &items[i], true
52+
}
53+
}
54+
return nil, false
55+
}
56+
57+
func tableRowsForItems[Item any](items []Item, toRow func(*Item) metav1.TableRow) []metav1.TableRow {
58+
rows := make([]metav1.TableRow, 0, len(items))
59+
for i := range items {
60+
rows = append(rows, toRow(&items[i]))
61+
}
62+
return rows
63+
}
64+
65+
// registerResource builds namespaced and cluster-wide handlers from generic ops.
66+
func registerResource[Item any, List any](s *EASServer, name string, ops resourceOps[Item, List]) {
67+
s.handlers[name] = resourceHandler{
68+
kind: ops.kind,
69+
namespaced: func(w http.ResponseWriter, r *http.Request, ns, resName string) {
70+
ctx := r.Context()
71+
// Direct Get when available.
72+
if resName != "" && ops.get != nil {
73+
result, err := ops.get(ctx, ns, resName)
74+
if err != nil {
75+
writeError(w, http.StatusInternalServerError, err.Error())
76+
return
77+
}
78+
writeResponse(w, r, result, ops.columns, []metav1.TableRow{ops.toRow(result)})
79+
return
80+
}
81+
// List (or list-and-filter when direct Get is unavailable).
82+
listResult, err := ops.list(ctx, ns)
83+
if err != nil {
84+
writeError(w, http.StatusInternalServerError, err.Error())
85+
return
86+
}
87+
all := *ops.items(listResult)
88+
if resName != "" {
89+
if item, ok := findItemByName(all, resName, ops.getName); ok {
90+
writeResponse(w, r, item, ops.columns, []metav1.TableRow{ops.toRow(item)})
91+
return
92+
}
93+
writeError(w, http.StatusNotFound, fmt.Sprintf("%s %q not found in namespace %q", name, resName, ns))
94+
return
95+
}
96+
writeResponse(w, r, listResult, ops.columns, tableRowsForItems(all, ops.toRow))
97+
},
98+
clusterWide: func(w http.ResponseWriter, r *http.Request) {
99+
if !allowReadMethod(w, r, name) {
100+
return
101+
}
102+
log := logger.Log
103+
namespaces := s.vpcProvider.ListAllVPCNamespaces()
104+
log.Info("Cluster-wide list", "resource", name, "namespaceCount", len(namespaces))
105+
ctx := r.Context()
106+
merged := ops.newList()
107+
for _, ns := range namespaces {
108+
result, err := ops.list(ctx, ns)
109+
if err != nil {
110+
log.Error(err, "Failed to list "+name, "namespace", ns)
111+
continue
112+
}
113+
dst := ops.items(merged)
114+
*dst = append(*dst, *ops.items(result)...)
115+
}
116+
all := *ops.items(merged)
117+
log.Info("Cluster-wide list complete", "resource", name, "totalItems", len(all))
118+
writeResponse(w, r, merged, ops.columns, tableRowsForItems(all, ops.toRow))
119+
},
120+
}
121+
}
122+
123+
// initHandlers builds the resource → handler dispatch table.
124+
func (s *EASServer) initHandlers() {
125+
s.handlers = make(map[string]resourceHandler)
126+
127+
registerResource(s, "vpcipaddressusages", resourceOps[easv1alpha1.VPCIPAddressUsage, easv1alpha1.VPCIPAddressUsageList]{
128+
kind: "VPCIPAddressUsage",
129+
get: s.vpcIPUsage.Get, list: s.vpcIPUsage.List,
130+
items: func(l *easv1alpha1.VPCIPAddressUsageList) *[]easv1alpha1.VPCIPAddressUsage { return &l.Items },
131+
newList: func() *easv1alpha1.VPCIPAddressUsageList {
132+
return &easv1alpha1.VPCIPAddressUsageList{TypeMeta: easListTypeMeta("VPCIPAddressUsageList")}
133+
},
134+
columns: vpcIPUsageColumns,
135+
toRow: func(item *easv1alpha1.VPCIPAddressUsage) metav1.TableRow {
136+
return tableRow(item.Name, item.Namespace, vpcIPBlocksSummary(item))
137+
},
138+
getName: func(item *easv1alpha1.VPCIPAddressUsage) string { return item.Name },
139+
})
140+
141+
registerResource(s, "ipblockusages", resourceOps[easv1alpha1.IPBlockUsage, easv1alpha1.IPBlockUsageList]{
142+
kind: "IPBlockUsage",
143+
list: s.ipBlockUsage.List, // no direct Get; uses list-and-filter
144+
items: func(l *easv1alpha1.IPBlockUsageList) *[]easv1alpha1.IPBlockUsage { return &l.Items },
145+
newList: func() *easv1alpha1.IPBlockUsageList {
146+
return &easv1alpha1.IPBlockUsageList{TypeMeta: easListTypeMeta("IPBlockUsageList")}
147+
},
148+
columns: ipBlockUsageColumns,
149+
toRow: func(item *easv1alpha1.IPBlockUsage) metav1.TableRow {
150+
return tableRow(item.Name, item.Namespace, ipBlockRangesSummary(item.Spec.UsedIpRanges), ipBlockRangesSummary(item.Spec.AvailableIpRanges))
151+
},
152+
getName: func(item *easv1alpha1.IPBlockUsage) string { return item.Name },
153+
})
154+
155+
registerResource(s, "subnetippools", resourceOps[easv1alpha1.SubnetIPPools, easv1alpha1.SubnetIPPoolsList]{
156+
kind: "SubnetIPPools",
157+
get: s.subnetIPPools.Get, list: s.subnetIPPools.List,
158+
items: func(l *easv1alpha1.SubnetIPPoolsList) *[]easv1alpha1.SubnetIPPools { return &l.Items },
159+
newList: func() *easv1alpha1.SubnetIPPoolsList {
160+
return &easv1alpha1.SubnetIPPoolsList{TypeMeta: easListTypeMeta("SubnetIPPoolsList")}
161+
},
162+
columns: subnetIPPoolsColumns,
163+
toRow: func(item *easv1alpha1.SubnetIPPools) metav1.TableRow {
164+
return tableRow(item.Name, item.Namespace, subnetIPPoolsSummary(item))
165+
},
166+
getName: func(item *easv1alpha1.SubnetIPPools) string { return item.Name },
167+
})
168+
169+
registerResource(s, "subnetdhcpserverconfigstats", resourceOps[easv1alpha1.SubnetDHCPServerConfigStats, easv1alpha1.SubnetDHCPServerConfigStatsList]{
170+
kind: "SubnetDHCPServerConfigStats",
171+
get: s.subnetDHCPStats.Get, list: s.subnetDHCPStats.List,
172+
items: func(l *easv1alpha1.SubnetDHCPServerConfigStatsList) *[]easv1alpha1.SubnetDHCPServerConfigStats {
173+
return &l.Items
174+
},
175+
newList: func() *easv1alpha1.SubnetDHCPServerConfigStatsList {
176+
return &easv1alpha1.SubnetDHCPServerConfigStatsList{TypeMeta: easListTypeMeta("SubnetDHCPServerConfigStatsList")}
177+
},
178+
columns: subnetDHCPColumns,
179+
toRow: func(item *easv1alpha1.SubnetDHCPServerConfigStats) metav1.TableRow {
180+
return tableRow(item.Name, item.Namespace, subnetDHCPStatsSummary(item))
181+
},
182+
getName: func(item *easv1alpha1.SubnetDHCPServerConfigStats) string { return item.Name },
183+
})
184+
}
185+
186+
// registerRoutes sets up all HTTP routes.
187+
func (s *EASServer) registerRoutes(mux *http.ServeMux) {
188+
s.initHandlers()
189+
190+
// API discovery endpoint.
191+
mux.HandleFunc(APIBasePath, s.handleAPIResourceList)
192+
193+
// Cluster-wide list (kubectl get <resource> -A).
194+
for res, h := range s.handlers {
195+
mux.HandleFunc(APIBasePath+"/"+res, func(w http.ResponseWriter, r *http.Request) {
196+
h.clusterWide(w, r)
197+
})
198+
}
199+
200+
// Namespaced GET/LIST.
201+
mux.HandleFunc(APIBasePath+"/namespaces/", s.handleNamespacedResource)
202+
}
203+
204+
// handleAPIResourceList returns the API resource list for discovery.
205+
func (s *EASServer) handleAPIResourceList(w http.ResponseWriter, r *http.Request) {
206+
if !allowReadMethod(w, r, "apiresources") {
207+
return
208+
}
209+
log := logger.Log
210+
log.Debug("API discovery request", "path", r.URL.Path)
211+
212+
resources := make([]metav1.APIResource, 0, len(s.handlers))
213+
for name, h := range s.handlers {
214+
resources = append(resources, metav1.APIResource{
215+
Name: name, Namespaced: true, Kind: h.kind, Verbs: metav1.Verbs{"get", "list"},
216+
})
217+
}
218+
sort.Slice(resources, func(i, j int) bool { return resources[i].Name < resources[j].Name })
219+
220+
writeJSON(w, http.StatusOK, &metav1.APIResourceList{
221+
TypeMeta: metav1.TypeMeta{Kind: "APIResourceList", APIVersion: metav1.Unversioned.String()},
222+
GroupVersion: easv1alpha1.GroupVersion.String(),
223+
APIResources: resources,
224+
})
225+
}
226+
227+
// handleNamespacedResource routes namespaced GET/LIST to the registered handler.
228+
func (s *EASServer) handleNamespacedResource(w http.ResponseWriter, r *http.Request) {
229+
log := logger.Log
230+
path := strings.TrimPrefix(r.URL.Path, APIBasePath+"/namespaces/")
231+
namespace, resource, name, ok := parseNamespacedResourcePath(path)
232+
if !ok {
233+
writeError(w, http.StatusNotFound, "invalid resource path")
234+
return
235+
}
236+
237+
h, ok := s.handlers[resource]
238+
if !ok {
239+
writeError(w, http.StatusNotFound, fmt.Sprintf("unknown resource: %s", resource))
240+
return
241+
}
242+
if !allowReadMethod(w, r, resource) {
243+
return
244+
}
245+
246+
log.Info("Handling namespaced request", "resource", resource, "namespace", namespace, "name", name)
247+
h.namespaced(w, r, namespace, name)
248+
}
249+
250+
func allowReadMethod(w http.ResponseWriter, r *http.Request, resource string) bool {
251+
if r.Method == http.MethodGet || r.Method == http.MethodHead {
252+
return true
253+
}
254+
w.Header().Set("Allow", readMethodsAllowHeader)
255+
writeAPIError(w, apierrors.NewMethodNotSupported(schema.GroupResource{
256+
Group: easv1alpha1.GroupVersion.Group,
257+
Resource: resource,
258+
}, r.Method))
259+
return false
260+
}
261+
262+
func parseNamespacedResourcePath(path string) (namespace, resource, name string, ok bool) {
263+
trimmed := strings.Trim(path, "/")
264+
if trimmed == "" {
265+
return "", "", "", false
266+
}
267+
268+
parts := strings.Split(trimmed, "/")
269+
if len(parts) != 2 && len(parts) != 3 {
270+
return "", "", "", false
271+
}
272+
if parts[0] == "" || parts[1] == "" {
273+
return "", "", "", false
274+
}
275+
276+
namespace = parts[0]
277+
resource = parts[1]
278+
if len(parts) == 3 {
279+
if parts[2] == "" {
280+
return "", "", "", false
281+
}
282+
name = parts[2]
283+
}
284+
285+
return namespace, resource, name, true
286+
}

0 commit comments

Comments
 (0)