Newer
Older
EnvoyControlPlane / internal / snapshot / resource_io.go
package snapshot

import (
	"context"
	"encoding/json"
	"fmt"
	"os"

	clusterv3 "github.com/envoyproxy/go-control-plane/envoy/config/cluster/v3"
	listenerv3 "github.com/envoyproxy/go-control-plane/envoy/config/listener/v3"
	_ "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/network/http_connection_manager/v3"

	"github.com/envoyproxy/go-control-plane/pkg/cache/types"
	"github.com/envoyproxy/go-control-plane/pkg/cache/v3"
	resourcev3 "github.com/envoyproxy/go-control-plane/pkg/resource/v3"
	"google.golang.org/protobuf/encoding/protojson"

	yaml "gopkg.in/yaml.v3"

	internallog "envoy-control-plane/internal/log"
)

// YamlResources is a helper struct to unmarshal the common Envoy YAML file structure
type YamlResources struct {
	Resources []yaml.Node `yaml:"resources"`
}

// unmarshalYamlNodeToProto takes a generic map representation of a YAML/JSON object
// and unmarshals it into the given Protobuf resource pointer using protojson.
func unmarshalYamlNodeToProto(node map[string]interface{}, resource types.Resource) error {
	// 1. Remove the standard Protobuf type marker (if present) before marshaling to JSON.
	delete(node, "@type")

	// 2. Marshal the generic map into JSON bytes.
	jsonBytes, err := json.Marshal(node)
	if err != nil {
		return fmt.Errorf("failed to marshal resource node to JSON: %w", err)
	}

	// 3. Unmarshal the JSON bytes into the target Protobuf struct.
	if err := protojson.Unmarshal(jsonBytes, resource); err != nil {
		return fmt.Errorf("failed to unmarshal into proto: %w", err)
	}
	return nil
}

// 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
}

// 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

// 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{}:
			// Apply the custom processing function to the current map node
			if err := processFn(ctx, v); err != nil {
				return err
			}

			// Recurse into children
			for _, child := range v {
				if err := walk(child); err != nil {
					return err
				}
			}

		case []interface{}:
			for _, item := range v {
				if err := walk(item); err != nil {
					return err
				}
			}
		}
		return 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) {
	log := internallog.LogFromContext(ctx)

	// 1. Unmarshal YAML into a generic Go map
	var rawChainMap map[string]interface{}
	if err := yaml.Unmarshal([]byte(yamlStr), &rawChainMap); err != nil {
		log.Errorf("Failed to unmarshal YAML: %v", err)
		return nil, fmt.Errorf("failed to unmarshal YAML into generic map: %w", err)
	}
	if rawChainMap == nil {
		return nil, fmt.Errorf("failed to unmarshal YAML: input was empty or invalid")
	}

	// 2. Unmarshal the generic map into the Protobuf struct using the helper
	rawChain := &listenerv3.FilterChain{}
	if err := unmarshalYamlNodeToProto(rawChainMap, rawChain); err != nil {
		return nil, fmt.Errorf("failed to unmarshal YAML into FilterChain using protojson: %w", err)
	}

	// Check if the FilterChain contains any filters (optional but good sanity check)
	if len(rawChain.Filters) == 0 {
		return nil, fmt.Errorf("filter chain loaded but contains no network filters")
	}

	// Return the single FilterChain object.
	return rawChain, nil
}

// 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 raw interface{}
	if err := LoadAndUnmarshal([]byte(yamlStr), &raw); err != nil {
		return nil, fmt.Errorf("failed to unmarshal YAML into generic structure: %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)
	}

	// 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)
	}
	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)
	}
	return resourceList, nil
}