From d150d0f0ddd1a488584797f2f7898c7765cc7207 Mon Sep 17 00:00:00 2001 From: mac Date: Wed, 22 Jul 2026 14:59:54 +0800 Subject: [PATCH] feat(business-line): auto bind Wayne namespace on create --- server/internal/handler/business_line.go | 46 +++++++- server/internal/service/wayne_role_binding.go | 100 ++++++++++++++++++ .../service/wayne_role_binding_test.go | 69 ++++++++++++ 3 files changed, 214 insertions(+), 1 deletion(-) diff --git a/server/internal/handler/business_line.go b/server/internal/handler/business_line.go index 208a5c5..2d62079 100644 --- a/server/internal/handler/business_line.go +++ b/server/internal/handler/business_line.go @@ -144,9 +144,53 @@ func (h *BusinessLineHandler) Create(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + req.Name = strings.TrimSpace(req.Name) + if req.Name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "business line name is required"}) + return + } + + var existing model.BusinessLine + if err := h.db.Where("name = ?", req.Name).First(&existing).Error; err == nil { + c.JSON(http.StatusConflict, gin.H{"error": "business line already exists"}) + return + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if h.wayne == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "wayne native api is not configured"}) + return + } + namespace, err := h.wayne.EnsureNamespace(c.Request.Context(), req.Name) + if err != nil { + writeWayneRoleBindingError(c, nil, err) + return + } + if namespace == nil || namespace.ID == 0 { + c.JSON(http.StatusBadGateway, gin.H{"error": "wayne namespace response is invalid"}) + return + } + if strings.TrimSpace(namespace.Name) == "" { + namespace.Name = req.Name + } + if strings.TrimSpace(namespace.KubeNamespace) == "" { + namespace.KubeNamespace = req.Name + } item := model.BusinessLine{Name: req.Name} - if err := h.db.Create(&item).Error; err != nil { + if err := h.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Create(&item).Error; err != nil { + return err + } + return tx.Create(&model.BusinessLineWayneNamespace{ + BusinessLineID: item.ID, + WayneNamespaceID: namespace.ID, + WayneNamespaceName: namespace.Name, + KubeNamespace: namespace.KubeNamespace, + }).Error + }); err != nil { c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) return } diff --git a/server/internal/service/wayne_role_binding.go b/server/internal/service/wayne_role_binding.go index 7e2d081..347f667 100644 --- a/server/internal/service/wayne_role_binding.go +++ b/server/internal/service/wayne_role_binding.go @@ -53,6 +53,13 @@ type WayneRoleGroup struct { Type int `json:"type"` } +type WayneNamespace struct { + ID uint64 `json:"id"` + Name string `json:"name"` + KubeNamespace string `json:"kubeNamespace"` + MetaData string `json:"metaData,omitempty"` +} + type WayneOperatorPermissions struct { Create bool `json:"create"` Update bool `json:"update"` @@ -115,6 +122,77 @@ func (s *WayneRoleBindingService) ListNamespaces(ctx context.Context) (*WayneRol return s.callRaw(ctx, http.MethodGet, "/api/v1/namespaces", nil) } +func (s *WayneRoleBindingService) EnsureNamespace(ctx context.Context, name string) (*WayneNamespace, error) { + name = strings.TrimSpace(name) + if name == "" { + return nil, fmt.Errorf("wayne namespace name is required") + } + namespace, err := s.FindNamespaceByName(ctx, name) + if err != nil { + return nil, err + } + if namespace != nil { + return namespace, nil + } + return s.CreateNamespace(ctx, WayneNamespace{ + Name: name, + KubeNamespace: name, + MetaData: "{}", + }) +} + +func (s *WayneRoleBindingService) FindNamespaceByName(ctx context.Context, name string) (*WayneNamespace, error) { + name = strings.TrimSpace(name) + if name == "" { + return nil, fmt.Errorf("wayne namespace name is required") + } + values := url.Values{} + values.Set("name", name) + values.Set("pageNo", "1") + values.Set("pageSize", "500") + result, err := s.callRaw(ctx, http.MethodGet, "/api/v1/namespaces?"+values.Encode(), nil) + if err != nil { + return nil, err + } + namespaces, err := parseWayneNamespaces(result.Body) + if err != nil { + return nil, err + } + for _, namespace := range namespaces { + if strings.EqualFold(strings.TrimSpace(namespace.Name), name) { + return &namespace, nil + } + } + return nil, nil +} + +func (s *WayneRoleBindingService) CreateNamespace(ctx context.Context, namespace WayneNamespace) (*WayneNamespace, error) { + namespace.Name = strings.TrimSpace(namespace.Name) + namespace.KubeNamespace = strings.TrimSpace(namespace.KubeNamespace) + if namespace.Name == "" { + return nil, fmt.Errorf("wayne namespace name is required") + } + if namespace.KubeNamespace == "" { + namespace.KubeNamespace = namespace.Name + } + if strings.TrimSpace(namespace.MetaData) == "" { + namespace.MetaData = "{}" + } + body, err := json.Marshal(namespace) + if err != nil { + return nil, err + } + result, err := s.callRaw(ctx, http.MethodPost, "/api/v1/namespaces", body) + if err != nil { + return nil, err + } + created, err := parseWayneNamespace(result.Body) + if err != nil { + return nil, err + } + return &created, nil +} + func (s *WayneRoleBindingService) ListGroups(ctx context.Context, groupType *int) (*WayneRoleBindingResult, error) { path := "/api/v1/groups" if groupType != nil { @@ -615,6 +693,28 @@ func parseWayneResourceIDs(body []byte) ([]uint64, error) { return resourceIDsFromItems(wrapped.Data.List), nil } +func parseWayneNamespace(body []byte) (WayneNamespace, error) { + var wrapped struct { + Data WayneNamespace `json:"data"` + } + if err := json.Unmarshal(body, &wrapped); err != nil { + return WayneNamespace{}, err + } + return wrapped.Data, nil +} + +func parseWayneNamespaces(body []byte) ([]WayneNamespace, error) { + var wrapped struct { + Data struct { + List []WayneNamespace `json:"list"` + } `json:"data"` + } + if err := json.Unmarshal(body, &wrapped); err != nil { + return nil, err + } + return wrapped.Data.List, nil +} + func resourceIDsFromItems(items []struct { ID uint64 `json:"id"` }) []uint64 { diff --git a/server/internal/service/wayne_role_binding_test.go b/server/internal/service/wayne_role_binding_test.go index 3c56bf9..9e5d8a8 100644 --- a/server/internal/service/wayne_role_binding_test.go +++ b/server/internal/service/wayne_role_binding_test.go @@ -81,6 +81,75 @@ func TestParseWayneUsersSupportsNativePageList(t *testing.T) { } } +func TestWayneRoleBindingServiceEnsureNamespaceUsesExisting(t *testing.T) { + var got []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = append(got, r.Method+" "+r.URL.RequestURI()) + switch r.URL.RequestURI() { + case "/login/db": + _, _ = w.Write([]byte(`{"data":{"token":"` + testJWT(time.Now().Add(time.Hour)) + `"}}`)) + case "/api/v1/namespaces?name=kodo&pageNo=1&pageSize=500": + assertBearer(t, r) + _, _ = w.Write([]byte(`{"data":{"list":[{"id":12,"name":"kodo","kubeNamespace":"kodo-prod"}]}}`)) + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.RequestURI()) + } + })) + defer server.Close() + + svc := NewWayneRoleBindingService(testWayneConfig(server.URL), nil) + namespace, err := svc.EnsureNamespace(context.Background(), "kodo") + if err != nil { + t.Fatalf("EnsureNamespace() error = %v", err) + } + if namespace.ID != 12 || namespace.Name != "kodo" || namespace.KubeNamespace != "kodo-prod" { + t.Fatalf("namespace = %#v", namespace) + } + if strings.Join(got, "\n") != strings.Join([]string{ + "POST /login/db", + "GET /api/v1/namespaces?name=kodo&pageNo=1&pageSize=500", + }, "\n") { + t.Fatalf("requests:\n%s", strings.Join(got, "\n")) + } +} + +func TestWayneRoleBindingServiceEnsureNamespaceCreatesMissing(t *testing.T) { + var createBody WayneNamespace + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.RequestURI() { + case "/login/db": + _, _ = w.Write([]byte(`{"data":{"token":"` + testJWT(time.Now().Add(time.Hour)) + `"}}`)) + case "/api/v1/namespaces?name=las&pageNo=1&pageSize=500": + assertBearer(t, r) + _, _ = w.Write([]byte(`{"data":{"list":[]}}`)) + case "/api/v1/namespaces": + if r.Method != http.MethodPost { + t.Fatalf("method = %s, want POST", r.Method) + } + assertBearer(t, r) + if err := json.Unmarshal(readTestBody(t, r), &createBody); err != nil { + t.Fatalf("invalid create body: %v", err) + } + _, _ = w.Write([]byte(`{"data":{"id":21,"name":"las","kubeNamespace":"las"}}`)) + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.RequestURI()) + } + })) + defer server.Close() + + svc := NewWayneRoleBindingService(testWayneConfig(server.URL), nil) + namespace, err := svc.EnsureNamespace(context.Background(), "las") + if err != nil { + t.Fatalf("EnsureNamespace() error = %v", err) + } + if namespace.ID != 21 || namespace.Name != "las" || namespace.KubeNamespace != "las" { + t.Fatalf("namespace = %#v", namespace) + } + if createBody.Name != "las" || createBody.KubeNamespace != "las" || createBody.MetaData != "{}" { + t.Fatalf("create body = %#v", createBody) + } +} + func TestWayneRoleBindingServiceCreateAndDeleteNativeBindings(t *testing.T) { tests := []struct { name string