diff --git a/.gitignore b/.gitignore index 2a8ac54..9fec30a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ bin/ .vscode/ +__debug_* \ No newline at end of file diff --git a/data/config.db b/data/config.db index 403b913..8d9b397 100644 --- a/data/config.db +++ b/data/config.db Binary files differ diff --git a/data/lds.yaml b/data/lds.yaml index 81b5897..afa64e3 100644 --- a/data/lds.yaml +++ b/data/lds.yaml @@ -1,37 +1,5 @@ resources: - "@type": type.googleapis.com/envoy.config.listener.v3.Listener - name: http_listener - address: - socket_address: { address: 0.0.0.0, port_value: 10000 } - filter_chains: - - filters: - - name: envoy.filters.network.http_connection_manager - typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager - stat_prefix: ingress_http - codec_type: AUTO - route_config: - name: ingress_generic_insecure - virtual_hosts: - - name: http_to_https - domains: ["*"] - routes: - - match: { prefix : "/.well-known/acme-challenge"} - route: { cluster: _acme_renewer } - - match: { prefix: "/" } - redirect: { https_redirect: true } - - name: video_insecure - domains: ["video.jerxie.com" , "video.local:10000"] - routes: - - match: { prefix : "/.well-known/acme-challenge"} - route: { cluster: _acme_renewer } - - match: { prefix : "/"} - route: { cluster: _nas_video } - http_filters: - - name: envoy.filters.http.router - typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router -- "@type": type.googleapis.com/envoy.config.listener.v3.Listener name: https_listener address: socket_address: { address: 0.0.0.0, port_value: 10001 } diff --git a/internal/USECASE.md b/internal/USECASE.md new file mode 100644 index 0000000..369fde0 --- /dev/null +++ b/internal/USECASE.md @@ -0,0 +1,28 @@ + +# Append Filter Chain to Listener + +curl -X POST http://localhost:8080/append-filter-chain -H "Content-Type: application/json" -d '{ + "listener_name": "https_listener", + "yaml": "filters:\n - name: envoy.filters.network.http_connection_manager\n typed_config:\n \"@type\": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager\n stat_prefix: ingress_http\n codec_type: AUTO\n route_config:\n virtual_hosts:\n - name: baby_service\n domains: [\"baby.jerxie.com\"]\n routes:\n - match: { prefix: \"/\" }\n route: { cluster: \"_baby_buddy\"}\n http_filters:\n - name: envoy.filters.http.router\n typed_config:\n \"@type\": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router\nfilter_chain_match:\n server_names: [\"baby.jerxie.com\"]\ntransport_socket:\n name: envoy.transport_sockets.tls\n typed_config:\n \"@type\": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext\n common_tls_context:\n tls_certificates:\n - certificate_chain: { filename: \"/etc/certs/downstream/baby.jerxie.com/fullchain.pem\" }\n private_key: { filename: \"/etc/certs/downstream/baby.jerxie.com/privkey.pem\" }" +}' + +# Add a new listener + +# Update a pre-existing filter chain +curl -X POST http://localhost:8080/add-listener -H "Content-Type: application/json" -d '{ + "yaml": "listener yaml..." +}' + + +curl -X POST http://localhost:8080/update-filter-chain -H "Content-Type: application/json" -d '{ + "listener_name": "https_listener", + "yaml": "filters:\n - name: envoy.filters.network.http_connection_manager\n typed_config:\n \"@type\": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager\n stat_prefix: ingress_http_updated\n codec_type: AUTO\n route_config:\n virtual_hosts:\n - name: baby_service_updated\n domains: [\"baby.jerxie.com\"]\n routes:\n - match: { prefix: \"/\" }\n route: { cluster: \"_baby_buddy_updated\"}\n http_filters:\n - name: envoy.filters.http.router\n typed_config:\n \"@type\": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router\nfilter_chain_match:\n server_names: [\"baby.jerxie.com\"]\ntransport_socket:\n name: envoy.transport_sockets.tls\n typed_config:\n \"@type\": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext\n common_tls_context:\n tls_certificates:\n - certificate_chain: { filename: \"/etc/certs/downstream/baby.jerxie.com/fullchain34.pem\" }\n private_key: { filename: \"/etc/certs/downstream/baby.jerxie.com/privkey43.pem\" }" +}' + + +# Delete a filter chain + +curl -X POST http://localhost:8080/remove-filter-chain -H "Content-Type: application/json" -d '{ + "listener_name": "https_listener", + "domains" : ["baby1.jerxie.com"] +}' \ No newline at end of file diff --git a/internal/api.go b/internal/api.go index 44bad17..87c9807 100644 --- a/internal/api.go +++ b/internal/api.go @@ -29,14 +29,14 @@ // Cluster Handlers mux.HandleFunc("/add-cluster", func(w http.ResponseWriter, r *http.Request) { - api.addResourceHandler(w, r, resourcev3.ClusterType, func(req interface{}) types.Resource { + api.addResourcesHandler(w, r, resourcev3.ClusterType, func(req interface{}) []types.Resource { cr := req.(*internalapi.AddClusterRequest) - cl, er := snapshot.LoadClusterFromYAML(context.TODO(), cr.YAML) + cls, er := snapshot.LoadResourceFromYAML(context.TODO(), cr.YAML, resourcev3.ClusterType) if er != nil { http.Error(w, "failed to load cluster", http.StatusBadRequest) return nil } - return cl + return cls }) }) mux.HandleFunc("/disable-cluster", func(w http.ResponseWriter, r *http.Request) { @@ -51,14 +51,14 @@ // Listener Handlers mux.HandleFunc("/add-listener", func(w http.ResponseWriter, r *http.Request) { - api.addResourceHandler(w, r, resourcev3.ListenerType, func(req interface{}) types.Resource { + api.addResourcesHandler(w, r, resourcev3.ListenerType, func(req interface{}) []types.Resource { lr := req.(*internalapi.AddListenerRequest) - ls, err := snapshot.LoadListenerFromYAML(context.TODO(), lr.YAML) + lss, err := snapshot.LoadResourceFromYAML(context.TODO(), lr.YAML, resourcev3.ListenerType) if err != nil { http.Error(w, "failed to load listener", http.StatusBadRequest) return nil } - return ls + return lss }) }) mux.HandleFunc("/disable-listener", func(w http.ResponseWriter, r *http.Request) { @@ -79,6 +79,10 @@ api.updateFilterChainHandler(w, r) }) + mux.HandleFunc("/remove-filter-chain", func(w http.ResponseWriter, r *http.Request) { + api.removeFilterChainHandler(w, r) + }) + // Query / List Handlers mux.HandleFunc("/list-clusters", func(w http.ResponseWriter, r *http.Request) { api.listResourceHandler(w, r, resourcev3.ClusterType) diff --git a/internal/api/types.go b/internal/api/types.go index 5425675..fb34ae6 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -50,6 +50,12 @@ YAML string `json:"yaml"` } +// RemoveFilterChainRequest defines payload to remove a filter chain to a given listener +type RemoveFilterChainRequest struct { + ListenerName string `json:"listener_name"` + Domains []string `json:"domains"` +} + // RemoveListenerRequest defines payload to remove a listener (Not explicitly used in handlers, but included for completeness) type RemoveListenerRequest struct { Name string `json:"name"` diff --git a/internal/api_handlers.go b/internal/api_handlers.go index f711bb5..7332f08 100644 --- a/internal/api_handlers.go +++ b/internal/api_handlers.go @@ -125,8 +125,10 @@ // ---------------- Generic REST Handlers ---------------- +// ---------------- Generic REST Handlers ---------------- + // addResourceHandler handles adding a resource (Cluster, Listener) to the cache and persisting it. -func (api *API) addResourceHandler(w http.ResponseWriter, r *http.Request, typ resourcev3.Type, createFn func(interface{}) types.Resource) { +func (api *API) addResourcesHandler(w http.ResponseWriter, r *http.Request, typ resourcev3.Type, createFn func(interface{}) []types.Resource) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return @@ -151,19 +153,47 @@ return } - res := createFn(req) - if err := api.Manager.AddResourceToSnapshot(res, typ); err != nil { - http.Error(w, fmt.Sprintf("failed to add resource: %v", err), http.StatusInternalServerError) + resources := createFn(req) + // Check if any resources were created + if len(resources) == 0 { + http.Error(w, "create function returned no resources", http.StatusInternalServerError) return } + + // --- FIX: Initialize array to store names --- + addedNames := make([]string, 0, len(resources)) + + for _, r := range resources { + if err := api.Manager.AddResourceToSnapshot(r, typ); err != nil { + http.Error(w, fmt.Sprintf("failed to add resource: %v", err), http.StatusInternalServerError) + return + } + + // --- FIX: Collect name during iteration --- + if nameable, ok := r.(interface{ GetName() string }); ok { + addedNames = append(addedNames, nameable.GetName()) + } + } + // Persist immediately using DeleteLogical (mark as disabled in DB) if err := api.Manager.FlushCacheToDB(context.Background(), storage.DeleteLogical); err != nil { http.Error(w, fmt.Sprintf("failed to persist resource to DB: %v", err), http.StatusInternalServerError) return } + // --- FIX: Encode the array of names in the response --- w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(map[string]string{"name": res.(interface{ GetName() string }).GetName()}) + response := map[string]interface{}{ + "status": "created", + "names": addedNames, + } + + // Fallback if no names were collected (e.g., resource type doesn't implement GetName()) + if len(addedNames) == 0 { + response["names"] = fmt.Sprintf("failed to collect names for %d resources", len(resources)) + } + + json.NewEncoder(w).Encode(response) } // disableResourceHandler handles disabling a resource (logical removal from cache/DB). @@ -304,6 +334,24 @@ w.WriteHeader(http.StatusOK) } +func (api *API) removeFilterChainHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var req internalapi.RemoveFilterChainRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.ListenerName == "" { + http.Error(w, "listener name required", http.StatusBadRequest) + return + } + ctx := context.Background() + if err := api.Manager.RemoveFilterChainFromListener(ctx, req.ListenerName, req.Domains); err != nil { + http.Error(w, fmt.Sprintf("failed to remove filter chain: %v", err), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) +} + // ---------------- Query / List Handlers ---------------- // listResourceHandler returns a list of enabled and disabled resources of a given type. diff --git a/internal/snapshot/resource_crud.go b/internal/snapshot/resource_crud.go index 3bcee29..35d24e6 100644 --- a/internal/snapshot/resource_crud.go +++ b/internal/snapshot/resource_crud.go @@ -207,6 +207,105 @@ return nil } +// RemoveFilterChainFromListener loads the current listener from the cache, removes the +// FilterChain that matches the provided ServerNames from the listener's list of FilterChains, +// and updates the cache with the new snapshot. +func (sm *SnapshotManager) RemoveFilterChainFromListener(ctx context.Context, listenerName string, serverNames []string) error { + log := internallog.LogFromContext(ctx) + + // 1. Validate input and get the current Listener from the cache + if len(serverNames) == 0 { + return fmt.Errorf("failed to get server names from filter chain") + } + + // Use ServerNames for matching, consistent with UpdateFilterChainOfListener + if len(serverNames) == 0 { + return fmt.Errorf("target filter chain match must specify at least one ServerName for targeted removal") + } + + resource, err := sm.GetResourceFromCache(listenerName, resourcev3.ListenerType) + if err != nil { + return fmt.Errorf("failed to get listener '%s' from cache: %w", listenerName, err) + } + + listener, ok := resource.(*listenerv3.Listener) + if !ok { + return fmt.Errorf("resource '%s' is not a Listener type", listenerName) + } + + // 2. Iterate and attempt to find and remove the matching filter chain + foundMatch := false + var updatedChains []*listenerv3.FilterChain // New slice for chains to keep + + for _, existingChain := range listener.FilterChains { + existingServerNames := existingChain.GetFilterChainMatch().GetServerNames() + + // Use the provided ServerNamesEqual for matching + if ServerNamesEqual(existingServerNames, serverNames) { + // Match found! DO NOT append this chain, effectively removing it. + foundMatch = true + log.Debugf("Removing filter chain with match: %v from listener '%s'", serverNames, listenerName) + continue + } + + // Keep the existing chain if it does not match + updatedChains = append(updatedChains, existingChain) + } + + // 3. Handle the result + if !foundMatch { + return fmt.Errorf("no existing filter chain found on listener '%s' with matching server names: %v", + listenerName, serverNames) + } + + // 4. Update the listener with the new slice of filter chains + listener.FilterChains = updatedChains + + // 5. Get current snapshot to extract all resources for the new snapshot + snap, err := sm.Cache.GetSnapshot(sm.NodeID) + if err != nil { + return fmt.Errorf("failed to get snapshot for modification: %w", err) + } + + resources := sm.getAllResourcesFromSnapshot(snap) + + // Replace the old listener with the modified one in the resource list + listenerList, ok := resources[resourcev3.ListenerType] + if !ok { + return fmt.Errorf("listener resource type not present in snapshot") + } + + foundAndReplaced := false + for i, res := range listenerList { + if namer, ok := res.(interface{ GetName() string }); ok && namer.GetName() == listenerName { + listenerList[i] = listener // Replace with the modified listener + foundAndReplaced = true + break + } + } + + if !foundAndReplaced { + // Should not happen if GetResourceFromCache succeeded. + return fmt.Errorf("failed to locate listener '%s' in current resource list for replacement during removal", listenerName) + } + + // 6. Create and set the new snapshot + version := fmt.Sprintf("listener-remove-chain-%s-%d", listenerName, time.Now().UnixNano()) + newSnap, err := cachev3.NewSnapshot(version, resources) + if err != nil { + return fmt.Errorf("failed to create new snapshot: %w", err) + } + + if err := sm.Cache.SetSnapshot(ctx, sm.NodeID, newSnap); err != nil { + return fmt.Errorf("failed to set new snapshot: %w", err) + } + // Assume FlushCacheToDB is a necessary final step after snapshot update + sm.FlushCacheToDB(ctx, storage.DeleteLogical) + log.Infof("Successfully removed filter chain (match: %v) from listener '%s'", serverNames, listenerName) + + return nil +} + // AddResourceToSnapshot adds any resource to the snapshot dynamically func (sm *SnapshotManager) AddResourceToSnapshot(resource types.Resource, typ resourcev3.Type) error { snap, err := sm.Cache.GetSnapshot(sm.NodeID) @@ -233,7 +332,7 @@ } newSnap, _ := cachev3.NewSnapshot( - "snap-generic-"+resourceNamer.GetName(), + "snap-generic-"+resourceNamer.GetName()+"-"+time.Now().Format(time.RFC3339), resources, ) return sm.Cache.SetSnapshot(context.TODO(), sm.NodeID, newSnap) diff --git a/internal/snapshot/resource_io.go b/internal/snapshot/resource_io.go index 3e5a090..6f4154a 100644 --- a/internal/snapshot/resource_io.go +++ b/internal/snapshot/resource_io.go @@ -41,56 +41,33 @@ return nil } -// LoadSnapshotFromFile reads a YAML/JSON file, parses it, and returns a map of xDS resources. -func LoadSnapshotFromFile(context context.Context, filePath string) (map[resourcev3.Type][]types.Resource, error) { - log := internallog.LogFromContext(context) - - // Read the file - data, err := os.ReadFile(filePath) - if err != nil { - return nil, fmt.Errorf("failed to read file: %w", err) +// LoadAndUnmarshal takes raw data (e.g., from a file or string) and unmarshals +// it into the target interface using YAML/JSON rules. +func LoadAndUnmarshal(data []byte, target interface{}) error { + if err := yaml.Unmarshal(data, target); err != nil { + return fmt.Errorf("failed to unmarshal data: %w", err) } + return nil +} - var raw interface{} - if err := yaml.Unmarshal(data, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal YAML/JSON file %s: %w", filePath, err) - } +// ProcessFn is a function type that defines the processing logic to be applied +// to a potential resource node found during the walk. +// It returns a non-nil error if processing fails and should stop the walk. +type ProcessFn func(ctx context.Context, node map[string]interface{}) error - resources := make(map[resourcev3.Type][]types.Resource) - +// WalkAndProcess traverses an arbitrary Go data structure (unmarshaled from YAML/JSON) +// and applies the provided ProcessFn to every map[string]interface{} node it finds. +func WalkAndProcess(ctx context.Context, raw interface{}, processFn ProcessFn) error { var walk func(node interface{}) error walk = func(node interface{}) error { switch v := node.(type) { case map[string]interface{}: - if typStr, ok := v["@type"].(string); ok { - typ := resourcev3.Type(typStr) - - // only process known top-level xDS resources - var resource types.Resource - var newResource bool - - switch typ { - case resourcev3.ClusterType: - resource = &clusterv3.Cluster{} - newResource = true - case resourcev3.ListenerType: - resource = &listenerv3.Listener{} - newResource = true - // ... other types ... - default: - log.Warnf("unsupported resource type: %s", typ) - // Skip nested or unsupported types - } - - if newResource { - if err := unmarshalYamlNodeToProto(v, resource); err != nil { - return fmt.Errorf("failed to unmarshal %s from file: %w", typ, err) - } - resources[typ] = append(resources[typ], resource) - } + // Apply the custom processing function to the current map node + if err := processFn(ctx, v); err != nil { + return err } - // recurse into children + // Recurse into children for _, child := range v { if err := walk(child); err != nil { return err @@ -107,13 +84,97 @@ return nil } - if err := walk(raw); err != nil { + return walk(raw) +} + +// makeResourceProcessor creates the specific ProcessFn needed for the xDS resource logic. +func makeResourceProcessor( + log internallog.Logger, + resources map[resourcev3.Type][]types.Resource, +) ProcessFn { + return func(_ context.Context, v map[string]interface{}) error { + if typStr, ok := v["@type"].(string); ok { + typ := resourcev3.Type(typStr) + + // only process known top-level xDS resources + var resource types.Resource + var newResource bool + + switch typ { + case resourcev3.ClusterType: + resource = &clusterv3.Cluster{} + newResource = true + case resourcev3.ListenerType: + resource = &listenerv3.Listener{} + newResource = true + // ... other types ... + default: + log.Warnf("unsupported resource type: %s", typ) + // Skip nested or unsupported types + } + + if newResource { + // NOTE: unmarshalYamlNodeToProto must be available in this scope + if err := unmarshalYamlNodeToProto(v, resource); err != nil { + return fmt.Errorf("failed to unmarshal %s from file: %w", typ, err) + } + resources[typ] = append(resources[typ], resource) + } + } + return nil + } +} + +// LoadSnapshotFromFile reads a YAML/JSON file, parses it, and returns a map of xDS resources. +func LoadSnapshotFromFile(context context.Context, filePath string) (map[resourcev3.Type][]types.Resource, error) { + log := internallog.LogFromContext(context) + + // Read the file (Step 1: Read) + data, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("failed to read file: %w", err) + } + + // Unmarshal data (Step 2: Generic Unmarshal) + var raw interface{} + if err := LoadAndUnmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("failed to unmarshal file %s: %w", filePath, err) + } + + resources := make(map[resourcev3.Type][]types.Resource) + processor := makeResourceProcessor(log, resources) + + // Walk and Process (Step 3: Generic Walk with specific processor) + if err := WalkAndProcess(context, raw, processor); err != nil { return nil, err } return resources, nil } +// SaveSnapshotToFile marshals the current cache snapshot to JSON and writes it to a file. +func SaveSnapshotToFile(snap cache.ResourceSnapshot, filePath string) error { + + out := make(map[string][]interface{}) + + // Iterate over all known types + clusterTypeResources := snap.GetResources(resourcev3.ClusterType) + for _, r := range clusterTypeResources { + out[resourcev3.ClusterType] = append(out[resourcev3.ClusterType], r) + } + listenerTypeResources := snap.GetResources(resourcev3.ListenerType) + for _, r := range listenerTypeResources { + out[resourcev3.ListenerType] = append(out[resourcev3.ListenerType], r) + } + + data, err := json.MarshalIndent(out, "", " ") + if err != nil { + return err + } + + return os.WriteFile(filePath, data, 0644) +} + // LoadFilterChainFromYAML unmarshals a YAML string representing an Envoy Listener FilterChain // configuration into a listenerv3.FilterChain protobuf message using protojson pipeline. func LoadFilterChainFromYAML(ctx context.Context, yamlStr string) (*listenerv3.FilterChain, error) { @@ -144,85 +205,39 @@ return rawChain, nil } -// SaveSnapshotToFile marshals the current cache snapshot to JSON and writes it to a file. -func SaveSnapshotToFile(snap cache.ResourceSnapshot, filePath string) error { - - out := make(map[string][]interface{}) - - // Iterate over all known types - clusterTypeResources := snap.GetResources(resourcev3.ClusterType) - for _, r := range clusterTypeResources { - out[resourcev3.ClusterType] = append(out[resourcev3.ClusterType], r) - } - listenerTypeResources := snap.GetResources(resourcev3.ListenerType) - for _, r := range listenerTypeResources { - out[resourcev3.ListenerType] = append(out[resourcev3.ListenerType], r) - } - - data, err := json.MarshalIndent(out, "", " ") - if err != nil { - return err - } - - return os.WriteFile(filePath, data, 0644) -} - -// The package imports and existing helper functions (unmarshalYamlNodeToProto, LoadSnapshotFromFile, etc.) are assumed to be present. - -// LoadClusterFromYAML unmarshals a YAML string representing an Envoy Cluster -// configuration into a clusterv3.Cluster protobuf message. -func LoadClusterFromYAML(ctx context.Context, yamlStr string) (*clusterv3.Cluster, error) { +// LoadResourceFromYAML unmarshals a YAML string representing a single xDS resource +// (like a Cluster or a Listener) into the appropriate Protobuf message. +// +// It leverages the existing WalkAndProcess logic to find and unmarshal the resource. +func LoadResourceFromYAML(ctx context.Context, yamlStr string, expectedType resourcev3.Type) ([]types.Resource, error) { log := internallog.LogFromContext(ctx) // 1. Unmarshal YAML into a generic Go map - var rawClusterMap map[string]interface{} - if err := yaml.Unmarshal([]byte(yamlStr), &rawClusterMap); err != nil { - log.Errorf("Failed to unmarshal YAML for Cluster: %v", err) - return nil, fmt.Errorf("failed to unmarshal YAML into generic map for Cluster: %w", err) - } - if rawClusterMap == nil { - return nil, fmt.Errorf("failed to unmarshal YAML: input for Cluster was empty or invalid") + var raw interface{} + if err := LoadAndUnmarshal([]byte(yamlStr), &raw); err != nil { + return nil, fmt.Errorf("failed to unmarshal YAML into generic structure: %w", err) } - // 2. Unmarshal the generic map into the Protobuf struct using the helper - cluster := &clusterv3.Cluster{} - if err := unmarshalYamlNodeToProto(rawClusterMap, cluster); err != nil { - return nil, fmt.Errorf("failed to unmarshal YAML into Cluster using protojson: %w", err) + // This map will hold the one resource we expect to find. + resources := make(map[resourcev3.Type][]types.Resource) + + // 2. Use the standard resource processor to walk the structure. + processor := makeResourceProcessor(log, resources) + + // 3. Walk and Process + if err := WalkAndProcess(ctx, raw, processor); err != nil { + return nil, fmt.Errorf("failed to walk and process YAML: %w", err) } - // Sanity check: ensure the cluster has a name - if cluster.Name == "" { - return nil, fmt.Errorf("cluster loaded but has an empty name") + // 4. Validate and return the resource + resourceList, ok := resources[expectedType] + if !ok || len(resourceList) == 0 { + // Only return an error if the expected type was not found. + return nil, fmt.Errorf("no resource of expected type %s found in YAML", expectedType) } - - return cluster, nil -} - -// LoadListenerFromYAML unmarshals a YAML string representing an Envoy Listener -// configuration into a listenerv3.Listener protobuf message. -func LoadListenerFromYAML(ctx context.Context, yamlStr string) (*listenerv3.Listener, error) { - log := internallog.LogFromContext(ctx) - - // 1. Unmarshal YAML into a generic Go map - var rawListenerMap map[string]interface{} - if err := yaml.Unmarshal([]byte(yamlStr), &rawListenerMap); err != nil { - log.Errorf("Failed to unmarshal YAML for Listener: %v", err) - return nil, fmt.Errorf("failed to unmarshal YAML into generic map for Listener: %w", err) + if len(resourceList) > 1 { + // Warn if multiple resources were found, but return the first one as expected. + log.Warnf("found %d resources of type %s, expected 1; returning the first", len(resourceList), expectedType) } - if rawListenerMap == nil { - return nil, fmt.Errorf("failed to unmarshal YAML: input for Listener was empty or invalid") - } - - // 2. Unmarshal the generic map into the Protobuf struct using the helper - listener := &listenerv3.Listener{} - if err := unmarshalYamlNodeToProto(rawListenerMap, listener); err != nil { - return nil, fmt.Errorf("failed to unmarshal YAML into Listener using protojson: %w", err) - } - - // Sanity check: ensure the listener has a name - if listener.Name == "" { - return nil, fmt.Errorf("listener loaded but has an empty name") - } - - return listener, nil + return resourceList, nil } diff --git a/static/clusters.js b/static/clusters.js index fce66a8..89c6fc3 100644 --- a/static/clusters.js +++ b/static/clusters.js @@ -1,13 +1,3 @@ -// clusters.js - -// Import general modal functions from modals.js -// import { showModal } from './modals.js'; - -// Import config-specific modal launchers from data_fetchers.js -import { showClusterConfigModal } from './data_fetchers.js'; -// We also need 'showModal' if it's used directly in clusters.js for error handling. -// If it is, import it from modals.js: -import { showModal } from './modals.js'; import {API_BASE_URL, configStore} from './global.js' // ========================================================================= // CLUSTER UTILITIES diff --git a/static/data_fetchers.js b/static/data_fetchers.js index db0193c..1f66740 100644 --- a/static/data_fetchers.js +++ b/static/data_fetchers.js @@ -1,6 +1,6 @@ // data_fetchers.js import { API_BASE_URL, configStore } from './global.js'; -import { showModal, switchTab } from './modals.js'; +import { showConfigModal, switchTab } from './modals.js'; import { listListeners } from './listeners.js'; // Will be imported later // ========================================================================= @@ -89,7 +89,7 @@ if (!jsonData) { const errorMsg = 'Filter Chain configuration not found in memory.'; console.error(errorMsg); - showModal(`🚨 Error: ${title}`, { error: errorMsg }, errorMsg); + showConfigModal(`🚨 Error: ${title}`, { error: errorMsg }, errorMsg); return; } @@ -120,14 +120,14 @@ defaultTab = 'json'; // Switch to JSON tab if YAML generation failed } - showModal(title, jsonData, yamlData, defaultTab); + showConfigModal(title, jsonData, yamlData, defaultTab); } export async function showClusterConfigModal(clusterName) { const config = configStore.clusters[clusterName]; if (!config) { - showModal(`🚨 Error: Cluster Not Found`, { name: clusterName, error: 'Configuration data missing from memory.' }, 'Error: Cluster not in memory.'); + showConfigModal(`🚨 Error: Cluster Not Found`, { name: clusterName, error: 'Configuration data missing from memory.' }, 'Error: Cluster not in memory.'); return; } @@ -148,10 +148,35 @@ } } - showModal(`Full Config for Cluster: ${clusterName}`, config, yamlData); + showConfigModal(`Full Config for Cluster: ${clusterName}`, config, yamlData); } +export async function showListenerConfigModal(listenerName) { + const config = configStore.listeners[listenerName]; + if (!config) { + showConfigModal(`🚨 Error: Listener Not Found`, { name: listenerName, error: 'Configuration data missing from memory.' }, 'Error: Listener not in memory.'); + return; + } + let yamlData = configStore.listeners[listenerName]?.yaml || 'Loading YAML...'; + + if (yamlData === 'Loading YAML...') { + try { + const response = await fetch(`${API_BASE_URL}/get-listener?name=${listenerName}&format=yaml`); + if (!response.ok) { + yamlData = `Error fetching YAML: ${response.status} ${response.statusText}`; + } else { + yamlData = await response.text(); + configStore.listeners[listenerName].yaml = yamlData; // Store YAML + } + } catch (error) { + console.error("Failed to fetch YAML listener config:", error); + yamlData = `Network Error fetching YAML: ${error.message}`; + } + } + + showConfigModal(`Full Config for Listener: ${listenerName}`, config, yamlData); +} // ========================================================================= // FILTER CHAIN ADDITION LOGIC (NEW) diff --git a/static/global.js b/static/global.js index 80b1944..79a1772 100644 --- a/static/global.js +++ b/static/global.js @@ -26,7 +26,7 @@ * @param {object} jsonData - The configuration data object (used for JSON tab). * @param {string} yamlData - The configuration data as a YAML string. */ -function showModal(title, jsonData, yamlData) { +function showConfigModal(title, jsonData, yamlData) { document.getElementById('modal-title').textContent = title; // Populate JSON content @@ -132,7 +132,7 @@ if (!jsonData) { const errorMsg = 'Filter Chain configuration not found in memory.'; console.error(errorMsg); - showModal(`🚨 Error: ${title}`, { error: errorMsg }, errorMsg); + showConfigModal(`🚨 Error: ${title}`, { error: errorMsg }, errorMsg); return; } @@ -180,7 +180,7 @@ // The previous code: yamlData = extractFilterChainByDomain(yamlData, domainName) || yamlData; // is no longer needed. - showModal(title, jsonData, yamlData); + showConfigModal(title, jsonData, yamlData); } @@ -255,7 +255,7 @@ async function showClusterConfigModal(clusterName) { const config = configStore.clusters[clusterName]; if (!config) { - showModal(`🚨 Error: Cluster Not Found`, { name: clusterName, error: 'Configuration data missing from memory.' }, 'Error: Cluster not in memory.'); + showConfigModal(`🚨 Error: Cluster Not Found`, { name: clusterName, error: 'Configuration data missing from memory.' }, 'Error: Cluster not in memory.'); return; } @@ -278,13 +278,13 @@ } // Pass JSON object from memory and authoritative YAML from API/memory - showModal(`Full Config for Cluster: ${clusterName}`, config, yamlData); + showConfigModal(`Full Config for Cluster: ${clusterName}`, config, yamlData); } async function showListenerConfigModal(listenerName) { const config = configStore.listeners[listenerName]; if (!config) { - showModal(`🚨 Error: Listener Not Found`, { name: listenerName, error: 'Configuration data missing from memory.' }, 'Error: Listener not in memory.'); + showConfigModal(`🚨 Error: Listener Not Found`, { name: listenerName, error: 'Configuration data missing from memory.' }, 'Error: Listener not in memory.'); return; } @@ -307,7 +307,7 @@ } // Pass JSON object from memory and authoritative YAML from API/memory - showModal(`Full Config for Listener: ${listenerName}`, config, yamlData); + showConfigModal(`Full Config for Listener: ${listenerName}`, config, yamlData); } // ========================================================================= diff --git a/static/index.html b/static/index.html index a01ddf7..689adc3 100644 --- a/static/index.html +++ b/static/index.html @@ -26,6 +26,7 @@ +