diff --git a/controller/internal/config/config.go b/controller/internal/config/config.go new file mode 100644 index 00000000..bf724205 --- /dev/null +++ b/controller/internal/config/config.go @@ -0,0 +1,5 @@ +package config + +func GoSoPath() string { + return "/etc/libgolang.so" +} diff --git a/controller/internal/controller/httpfilterpolicy_controller.go b/controller/internal/controller/httpfilterpolicy_controller.go index 12576ff8..2a6b873d 100644 --- a/controller/internal/controller/httpfilterpolicy_controller.go +++ b/controller/internal/controller/httpfilterpolicy_controller.go @@ -19,6 +19,7 @@ package controller import ( "context" "fmt" + "strings" istiov1b1 "istio.io/client-go/pkg/apis/networking/v1beta1" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -34,7 +35,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/reconcile" mosniov1 "mosn.io/moe/controller/api/v1" - "mosn.io/moe/controller/internal/ir" + "mosn.io/moe/controller/internal/translation" ) // HTTPFilterPolicyReconciler reconciles a HTTPFilterPolicy object @@ -65,7 +66,7 @@ func (r *HTTPFilterPolicyReconciler) Reconcile(ctx context.Context, req ctrl.Req return ctrl.Result{}, fmt.Errorf("failed to list HTTPFilterPolicy: %v", err) } - state := ir.NewInitState(&logger) + state := translation.NewInitState(&logger) for _, policy := range policies.Items { err := validateHTTPFilterPolicy(&policy) @@ -88,11 +89,37 @@ func (r *HTTPFilterPolicyReconciler) Reconcile(ctx context.Context, req ctrl.Req err = validateVirtualService(&virtualService) if err != nil { - logger.Error(err, "invalid VirtualService", "name", virtualService.Name, "namespace", virtualService.Namespace) + logger.Info("unsupported VirtualService", "name", virtualService.Name, "namespace", virtualService.Namespace, "reason", err.Error()) continue } - state.AddPolicyForVirtualService(&policy, &virtualService) + for _, gw := range virtualService.Spec.Gateways { + if gw == "mesh" { + logger.Info("skip unsupported mesh gateway", "name", virtualService.Name, "namespace", virtualService.Namespace) + continue + } + if strings.Contains(gw, "/") { + logger.Info("skip gateway from other namespace", "name", virtualService.Name, "namespace", virtualService.Namespace) + continue + } + + var gateway istiov1b1.Gateway + err = r.Get(ctx, types.NamespacedName{Name: gw, Namespace: req.Namespace}, &gateway) + if err != nil { + if !apierrors.IsNotFound(err) { + return ctrl.Result{}, err + } + continue + } + + err = validateGateway(&gateway) + if err != nil { + logger.Info("unsupported Gateway", "name", gateway.Name, "namespace", gateway.Namespace, "reason", err.Error()) + continue + } + + state.AddPolicyForVirtualService(&policy, &virtualService, &gateway) + } } } diff --git a/controller/internal/controller/validation.go b/controller/internal/controller/validation.go index 42af45bf..29ed3cf1 100644 --- a/controller/internal/controller/validation.go +++ b/controller/internal/controller/validation.go @@ -2,6 +2,7 @@ package controller import ( "errors" + "strings" "google.golang.org/protobuf/encoding/protojson" istiov1b1 "istio.io/client-go/pkg/apis/networking/v1beta1" @@ -41,3 +42,15 @@ func validateVirtualService(vs *istiov1b1.VirtualService) error { } return nil } + +func validateGateway(gw *istiov1b1.Gateway) error { + // TODO: support it + for _, svr := range gw.Spec.Servers { + for _, host := range svr.Hosts { + if strings.ContainsRune(host, '/') { + return errors.New("Gateway has host with namespace is not supported") + } + } + } + return nil +} diff --git a/controller/internal/envoyfilter/envoyfilter.go b/controller/internal/envoyfilter/envoyfilter.go deleted file mode 100644 index 08bfd6ba..00000000 --- a/controller/internal/envoyfilter/envoyfilter.go +++ /dev/null @@ -1,9 +0,0 @@ -package envoyfilter - -func GenerateEnvoyFilters() { - // generate all the EnvoyFilter -} - -func DiffEnvoyFilters() { - // diff with the previous output -} diff --git a/controller/internal/ir/data_plane_state.go b/controller/internal/ir/data_plane_state.go deleted file mode 100644 index 7c80c608..00000000 --- a/controller/internal/ir/data_plane_state.go +++ /dev/null @@ -1,56 +0,0 @@ -package ir - -import ( - "fmt" - - istiov1b1 "istio.io/api/networking/v1beta1" - "k8s.io/apimachinery/pkg/types" - - mosniov1 "mosn.io/moe/controller/api/v1" -) - -type dataPlaneState struct { - Hosts map[string]*hostPolicy -} - -type hostPolicy struct { - Routes map[string]*routePolicy -} - -type routePolicy struct { - Policies []*mosniov1.HTTPFilterPolicy -} - -func genRouteId(id *types.NamespacedName, r *istiov1b1.HTTPRoute, order int) string { - return id.String() + "_" + fmt.Sprintf("%d", order) -} - -func toDataPlaneState(ctx *Ctx, state *InitState) error { - s := &dataPlaneState{ - Hosts: make(map[string]*hostPolicy), - } - for id, vsp := range state.VirtualServices { - spec := &vsp.VirtualService.Spec - routes := make(map[string]*routePolicy) - for i, r := range spec.Http { - routes[genRouteId(&id, r, i)] = &routePolicy{ - Policies: vsp.Policies, - } - } - for _, hostName := range spec.Hosts { - if host, ok := s.Hosts[hostName]; ok { - for name, route := range routes { - if _, ok := host.Routes[name]; !ok { - host.Routes[name] = route - } - } - } else { - s.Hosts[hostName] = &hostPolicy{ - Routes: routes, - } - } - } - } - - return toMergedState(ctx, s) -} diff --git a/controller/internal/ir/final_state.go b/controller/internal/ir/final_state.go deleted file mode 100644 index 0c851155..00000000 --- a/controller/internal/ir/final_state.go +++ /dev/null @@ -1,17 +0,0 @@ -package ir - -import "mosn.io/moe/controller/internal/envoyfilter" - -type finalState struct { -} - -func toFinalState(ctx *Ctx, state *mergedState) error { - envoyfilter.GenerateEnvoyFilters() - envoyfilter.DiffEnvoyFilters() - return publishCustomResources(ctx) -} - -func publishCustomResources(ctx *Ctx) error { - // write the delta to k8s - return nil -} diff --git a/controller/internal/ir/merged_state.go b/controller/internal/ir/merged_state.go deleted file mode 100644 index d343ab0f..00000000 --- a/controller/internal/ir/merged_state.go +++ /dev/null @@ -1,27 +0,0 @@ -package ir - -import ( - mosniov1 "mosn.io/moe/controller/api/v1" -) - -type mergedState struct { - Hosts map[string]*hostPolicy -} - -func toMergedState(ctx *Ctx, state *dataPlaneState) error { - s := &mergedState{ - Hosts: state.Hosts, - } - for _, host := range s.Hosts { - for _, route := range host.Routes { - // TODO: implement merge policy - // According to the https://gateway-api.sigs.k8s.io/geps/gep-713/, - // 1. A Policy targeting a more specific scope wins over a policy targeting a lesser specific scope. - // 2. If multiple polices configure the same plugin, the oldest one (based on creation timestamp) wins. - // 3. If there are multiple oldest polices, the one appearing first in alphabetical order by {namespace}/{name} wins. - route.Policies = []*mosniov1.HTTPFilterPolicy{route.Policies[0]} - } - } - - return toFinalState(ctx, s) -} diff --git a/controller/internal/istio/envoyfilter.go b/controller/internal/istio/envoyfilter.go new file mode 100644 index 00000000..53c05cfa --- /dev/null +++ b/controller/internal/istio/envoyfilter.go @@ -0,0 +1,113 @@ +package istio + +import ( + "encoding/json" + + "google.golang.org/protobuf/types/known/structpb" + istioapi "istio.io/api/networking/v1alpha3" + istiov1a3 "istio.io/client-go/pkg/apis/networking/v1alpha3" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + ctrlcfg "mosn.io/moe/controller/internal/config" + "mosn.io/moe/controller/internal/model" + "mosn.io/moe/pkg/filtermanager" +) + +const ( + DefaultHttpFilter = "htnn-http-filter" +) + +func MustNewStruct(fields map[string]interface{}) *structpb.Struct { + st, err := structpb.NewStruct(fields) + if err != nil { + // NewStruct returns error only when the fields contain non-standard type + panic(err) + } + return st +} + +func DefaultEnvoyFilters() map[string]*istiov1a3.EnvoyFilter { + efs := map[string]*istiov1a3.EnvoyFilter{} + efs[DefaultHttpFilter] = &istiov1a3.EnvoyFilter{ + ObjectMeta: metav1.ObjectMeta{ + Name: DefaultHttpFilter, + }, + Spec: istioapi.EnvoyFilter{ + ConfigPatches: []*istioapi.EnvoyFilter_EnvoyConfigObjectPatch{ + { + ApplyTo: istioapi.EnvoyFilter_HTTP_FILTER, + Match: &istioapi.EnvoyFilter_EnvoyConfigObjectMatch{ + ObjectTypes: &istioapi.EnvoyFilter_EnvoyConfigObjectMatch_Listener{ + Listener: &istioapi.EnvoyFilter_ListenerMatch{ + FilterChain: &istioapi.EnvoyFilter_ListenerMatch_FilterChainMatch{ + Filter: &istioapi.EnvoyFilter_ListenerMatch_FilterMatch{ + Name: "envoy.filters.network.http_connection_manager", + SubFilter: &istioapi.EnvoyFilter_ListenerMatch_SubFilterMatch{ + Name: "envoy.filters.http.router", + }, + }, + }, + }, + }, + }, + Patch: &istioapi.EnvoyFilter_Patch{ + Operation: istioapi.EnvoyFilter_Patch_INSERT_BEFORE, + Value: MustNewStruct(map[string]interface{}{ + "name": "envoy.filters.http.golang", + "typed_config": map[string]interface{}{ + "@type": "type.googleapis.com/envoy.extensions.filters.http.golang.v3alpha.Config", + "library_id": "fm", + "library_path": ctrlcfg.GoSoPath(), + "plugin_name": "fm", + }, + }), + }, + }, + }, + }, + } + return efs +} + +func GenerateHostFilter(host *model.VirtualHost, config *filtermanager.FilterManagerConfig) *istiov1a3.EnvoyFilter { + v := map[string]interface{}{} + // This Marshal/Unmarshal trick works around the type check in MustNewStruct + data, _ := json.Marshal(config) + json.Unmarshal(data, &v) + return &istiov1a3.EnvoyFilter{ + Spec: istioapi.EnvoyFilter{ + ConfigPatches: []*istioapi.EnvoyFilter_EnvoyConfigObjectPatch{ + { + ApplyTo: istioapi.EnvoyFilter_VIRTUAL_HOST, + Match: &istioapi.EnvoyFilter_EnvoyConfigObjectMatch{ + ObjectTypes: &istioapi.EnvoyFilter_EnvoyConfigObjectMatch_RouteConfiguration{ + RouteConfiguration: &istioapi.EnvoyFilter_RouteConfigurationMatch{ + Vhost: &istioapi.EnvoyFilter_RouteConfigurationMatch_VirtualHostMatch{ + Name: host.Name, + }, + }, + }, + }, + Patch: &istioapi.EnvoyFilter_Patch{ + Operation: istioapi.EnvoyFilter_Patch_MERGE, + Value: MustNewStruct(map[string]interface{}{ + "typed_per_filter_config": map[string]interface{}{ + "envoy.filters.http.golang": map[string]interface{}{ + "@type": "type.googleapis.com/envoy.extensions.filters.http.golang.v3alpha.ConfigsPerRoute", + "plugins_config": map[string]interface{}{ + "fm": map[string]interface{}{ + "config": map[string]interface{}{ + "@type": "type.googleapis.com/xds.type.v3.TypedStruct", + "value": v, + }, + }, + }, + }, + }, + }), + }, + }, + }, + }, + } +} diff --git a/controller/internal/istio/envoyfilter_test.go b/controller/internal/istio/envoyfilter_test.go new file mode 100644 index 00000000..5e16f457 --- /dev/null +++ b/controller/internal/istio/envoyfilter_test.go @@ -0,0 +1,24 @@ +package istio + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + istiov1a3 "istio.io/client-go/pkg/apis/networking/v1alpha3" + "sigs.k8s.io/yaml" +) + +func TestDefaultFilters(t *testing.T) { + out := []*istiov1a3.EnvoyFilter{} + for _, ef := range DefaultEnvoyFilters() { + out = append(out, ef) + } + d, _ := yaml.Marshal(out) + actual := string(d) + expFile := filepath.Join("testdata", "default_filters.yml") + d, _ = os.ReadFile(expFile) + want := string(d) + require.Equal(t, want, actual) +} diff --git a/controller/internal/istio/testdata/default_filters.yml b/controller/internal/istio/testdata/default_filters.yml new file mode 100644 index 00000000..d561195a --- /dev/null +++ b/controller/internal/istio/testdata/default_filters.yml @@ -0,0 +1,23 @@ +- metadata: + creationTimestamp: null + name: htnn-http-filter + spec: + configPatches: + - applyTo: HTTP_FILTER + match: + listener: + filterChain: + filter: + name: envoy.filters.network.http_connection_manager + subFilter: + name: envoy.filters.http.router + patch: + operation: INSERT_BEFORE + value: + name: envoy.filters.http.golang + typed_config: + '@type': type.googleapis.com/envoy.extensions.filters.http.golang.v3alpha.Config + library_id: fm + library_path: /etc/libgolang.so + plugin_name: fm + status: {} diff --git a/controller/internal/model/model.go b/controller/internal/model/model.go new file mode 100644 index 00000000..270d2391 --- /dev/null +++ b/controller/internal/model/model.go @@ -0,0 +1,14 @@ +package model + +import "k8s.io/apimachinery/pkg/types" + +type Gateway struct { + NsName *types.NamespacedName + Port uint32 +} + +type VirtualHost struct { + Gateway *Gateway + NsName *types.NamespacedName + Name string +} diff --git a/controller/internal/translation/data_plane_state.go b/controller/internal/translation/data_plane_state.go new file mode 100644 index 00000000..ba0650b6 --- /dev/null +++ b/controller/internal/translation/data_plane_state.go @@ -0,0 +1,106 @@ +package translation + +import ( + "errors" + "fmt" + "net" + "strings" + + istioapi "istio.io/api/networking/v1beta1" + istiov1b1 "istio.io/client-go/pkg/apis/networking/v1beta1" + "k8s.io/apimachinery/pkg/types" + + mosniov1 "mosn.io/moe/controller/api/v1" + "mosn.io/moe/controller/internal/model" +) + +// dataPlaneState converts the init state to the structure used by the data plane +type dataPlaneState struct { + Hosts map[string]*hostPolicy +} + +type hostPolicy struct { + VirtualHost *model.VirtualHost + Routes map[string]*routePolicy + Policies []*mosniov1.HTTPFilterPolicy +} + +type routePolicy struct { + Policies []*mosniov1.HTTPFilterPolicy +} + +func genRouteId(id *types.NamespacedName, r *istioapi.HTTPRoute, order int) string { + return id.String() + "_" + fmt.Sprintf("%d", order) +} + +func hostMatch(gwHost string, host string) bool { + if gwHost == host { + return true + } + if strings.HasPrefix(gwHost, "*") { + return strings.HasSuffix(host, gwHost[1:]) + } + return false +} + +func buildVirtualHost(host string, gws []*istiov1b1.Gateway) *model.VirtualHost { + for _, gw := range gws { + for _, svr := range gw.Spec.Servers { + port := svr.Port.Number + for _, h := range svr.Hosts { + if hostMatch(h, host) { + name := net.JoinHostPort(host, fmt.Sprintf("%d", port)) + return &model.VirtualHost{ + Gateway: &model.Gateway{ + NsName: &types.NamespacedName{ + Namespace: gw.Namespace, + Name: gw.Name, + }, + Port: port, + }, + Name: name, + } + } + } + } + } + return nil +} + +func toDataPlaneState(ctx *Ctx, state *InitState) error { + s := &dataPlaneState{ + Hosts: make(map[string]*hostPolicy), + } + for id, vsp := range state.VirtualServices { + gws := state.VsToGateway[id] + spec := &vsp.VirtualService.Spec + routes := make(map[string]*routePolicy) + for i, r := range spec.Http { + routes[genRouteId(&id, r, i)] = &routePolicy{ + Policies: vsp.Policies, + } + } + for _, hostName := range spec.Hosts { + vh := buildVirtualHost(hostName, gws) + if vh == nil { + err := errors.New("can not build virtual host") + ctx.logger.Error(err, "failed to build virtual host", "hostname", hostName, "virtualservice", id, "gateways", gws) + return err + } + vh.NsName = &id + policy := &hostPolicy{ + VirtualHost: vh, + Routes: routes, + // It is possible that multiple VirtualServices share the same host but with different routes. + // In this case, the host is considered a match condition but not a parent of routes. + // So it is unreasonable to set host level policy to such VirtualServices. We don't + // support this case (VirtualServices share same host & Host level policy attached) for now. + // If people want to add policy to the route under the host, use route level policy instead. + Policies: vsp.Policies, + } + s.Hosts[vh.Name] = policy + } + } + + return toMergedState(ctx, s) +} diff --git a/controller/internal/translation/data_plane_state_test.go b/controller/internal/translation/data_plane_state_test.go new file mode 100644 index 00000000..1c1016f9 --- /dev/null +++ b/controller/internal/translation/data_plane_state_test.go @@ -0,0 +1,19 @@ +package translation + +import "testing" + +func TestHostMatch(t *testing.T) { + matched := []string{"*", "*.com", "*.test.com", "v.test.com"} + mismatched := []string{"a.test.com", "*.t.com"} + + for _, m := range matched { + if !hostMatch(m, "v.test.com") { + t.Errorf("hostMatch(%s, v.test.com) should be true", m) + } + } + for _, m := range mismatched { + if hostMatch(m, "v.test.com") { + t.Errorf("hostMatch(%s, v.test.com) should be false", m) + } + } +} diff --git a/controller/internal/translation/final_state.go b/controller/internal/translation/final_state.go new file mode 100644 index 00000000..185103a5 --- /dev/null +++ b/controller/internal/translation/final_state.go @@ -0,0 +1,74 @@ +package translation + +import ( + "fmt" + "sort" + + istiov1a3 "istio.io/client-go/pkg/apis/networking/v1alpha3" + + "mosn.io/moe/controller/internal/istio" + "mosn.io/moe/controller/internal/model" +) + +func nameFromHost(host *model.VirtualHost) string { + // We use the NsName as the EnvoyFilter name because the host name may contain invalid characters. + // This design also make it easier to reference the original CR with the EnvoyFilter. + // As a result, when a VirtualService or something else has multiple hosts, we hold them in the + // same EnvoyFilter. + // The namespace & name may be overlapped, so we use `--` as separator to reduce the chance + return fmt.Sprintf("htnn-h-%s--%s", host.NsName.Namespace, host.NsName.Name) +} + +// finalState is the end of the translation. We convert the state to EnvoyFilter and write it to k8s. +type finalState struct { +} + +var ( + // FIXME: init current envoy filters when the controller starts + currentEnvoyFilters = map[string]*istiov1a3.EnvoyFilter{} +) + +func diffEnvoyFilters(efs map[string]*istiov1a3.EnvoyFilter) (addOrUpdate []*istiov1a3.EnvoyFilter, del []*istiov1a3.EnvoyFilter) { + for name, curr := range currentEnvoyFilters { + if _, ok := efs[name]; !ok { + del = append(del, curr) + } + } + for _, ef := range efs { + // Let k8s applies them + addOrUpdate = append(addOrUpdate, ef) + } + currentEnvoyFilters = efs + return +} + +func toFinalState(ctx *Ctx, state *mergedState) error { + efs := istio.DefaultEnvoyFilters() + hosts := []*mergedHostPolicy{} + for _, host := range state.Hosts { + if host.Policy != nil { + hosts = append(hosts, host) + } + } + sort.Slice(hosts, func(i, j int) bool { + return hosts[i].VirtualHost.Name < hosts[j].VirtualHost.Name + }) + for _, host := range hosts { + ef := istio.GenerateHostFilter(host.VirtualHost, host.Policy) + name := nameFromHost(host.VirtualHost) + ef.SetName(name) + + if curr, ok := efs[name]; ok { + curr.Spec.ConfigPatches = append(curr.Spec.ConfigPatches, ef.Spec.ConfigPatches...) + } else { + efs[name] = ef + } + } + addOrUpdate, del := diffEnvoyFilters(efs) + return publishCustomResources(ctx, addOrUpdate, del) +} + +func publishCustomResources(ctx *Ctx, addOrUpdate []*istiov1a3.EnvoyFilter, del []*istiov1a3.EnvoyFilter) error { + // write the delta to k8s + return nil +} diff --git a/controller/internal/ir/init_state.go b/controller/internal/translation/init_state.go similarity index 78% rename from controller/internal/ir/init_state.go rename to controller/internal/translation/init_state.go index f9a99f55..4455eeb2 100644 --- a/controller/internal/ir/init_state.go +++ b/controller/internal/translation/init_state.go @@ -1,4 +1,4 @@ -package ir +package translation import ( "context" @@ -15,8 +15,10 @@ type VirtualServicePolicies struct { Policies []*mosniov1.HTTPFilterPolicy } +// InitState is the beginning of our translation. type InitState struct { VirtualServices map[types.NamespacedName]*VirtualServicePolicies + VsToGateway map[types.NamespacedName][]*istiov1b1.Gateway logger *logr.Logger } @@ -24,11 +26,12 @@ type InitState struct { func NewInitState(logger *logr.Logger) *InitState { return &InitState{ VirtualServices: make(map[types.NamespacedName]*VirtualServicePolicies), + VsToGateway: make(map[types.NamespacedName][]*istiov1b1.Gateway), logger: logger, } } -func (s *InitState) AddPolicyForVirtualService(policy *mosniov1.HTTPFilterPolicy, vs *istiov1b1.VirtualService) { +func (s *InitState) AddPolicyForVirtualService(policy *mosniov1.HTTPFilterPolicy, vs *istiov1b1.VirtualService, gw *istiov1b1.Gateway) { nn := types.NamespacedName{ Namespace: vs.ObjectMeta.Namespace, Name: vs.ObjectMeta.Name, @@ -44,6 +47,12 @@ func (s *InitState) AddPolicyForVirtualService(policy *mosniov1.HTTPFilterPolicy } vsp.Policies = append(vsp.Policies, policy.DeepCopy()) + + gws, ok := s.VsToGateway[nn] + if !ok { + gws = make([]*istiov1b1.Gateway, 0) + } + s.VsToGateway[nn] = append(gws, gw.DeepCopy()) } func (s *InitState) Process(original_ctx context.Context) error { diff --git a/controller/internal/translation/merged_state.go b/controller/internal/translation/merged_state.go new file mode 100644 index 00000000..28975997 --- /dev/null +++ b/controller/internal/translation/merged_state.go @@ -0,0 +1,82 @@ +package translation + +import ( + "encoding/json" + "sort" + + "mosn.io/moe/pkg/filtermanager" + + mosniov1 "mosn.io/moe/controller/api/v1" + "mosn.io/moe/controller/internal/model" +) + +// mergedState does the following: +// 1. merge policy among the same level policies +// 2. merge policy among different hierarchies +// 3. transform a plugin to different plugins if needed +type mergedState struct { + Hosts map[string]*mergedHostPolicy +} + +type mergedHostPolicy struct { + VirtualHost *model.VirtualHost + Routes map[string]*mergedRoutePolicy + Policy *filtermanager.FilterManagerConfig +} + +type mergedRoutePolicy struct { + Policy *filtermanager.FilterManagerConfig +} + +func toMergedState(ctx *Ctx, state *dataPlaneState) error { + s := &mergedState{ + Hosts: make(map[string]*mergedHostPolicy), + } + for name, host := range state.Hosts { + mh := &mergedHostPolicy{ + VirtualHost: host.VirtualHost, + Routes: make(map[string]*mergedRoutePolicy), + } + // FIXME: implement merge policy + // According to the https://gateway-api.sigs.k8s.io/geps/gep-713/, + // 1. A Policy targeting a more specific scope wins over a policy targeting a lesser specific scope. + // 2. If multiple polices configure the same plugin, the oldest one (based on creation timestamp) wins. + // 3. If there are multiple oldest polices, the one appearing first in alphabetical order by {namespace}/{name} wins. + mergedPolicy := host.Policies[0] + fmc, err := translateHTTPFilterPolicyToFilterManagerConfig(mergedPolicy) + if err != nil { + return err + } + mh.Policy = fmc + s.Hosts[name] = mh + } + + return toFinalState(ctx, s) +} + +type goPluginConfig struct { + Config interface{} `json:"config"` +} + +func translateHTTPFilterPolicyToFilterManagerConfig(policy *mosniov1.HTTPFilterPolicy) (*filtermanager.FilterManagerConfig, error) { + fmc := &filtermanager.FilterManagerConfig{ + Plugins: []*filtermanager.FilterConfig{}, + } + for name, filter := range policy.Spec.Filters { + cfg := goPluginConfig{} + err := json.Unmarshal(filter.Raw, &cfg) + if err != nil { + // we validated the filter at the beginning, so theorily this should not happen + return nil, err + } + fmc.Plugins = append(fmc.Plugins, &filtermanager.FilterConfig{ + Name: name, + Config: cfg.Config, + }) + } + // FIXME: sort by the user defined order + sort.Slice(fmc.Plugins, func(i, j int) bool { + return fmc.Plugins[i].Name < fmc.Plugins[j].Name + }) + return fmc, nil +} diff --git a/controller/internal/translation/testdata/translation/virtualservice-wildcard-gateway.in.yml b/controller/internal/translation/testdata/translation/virtualservice-wildcard-gateway.in.yml new file mode 100644 index 00000000..ac3aaaee --- /dev/null +++ b/controller/internal/translation/testdata/translation/virtualservice-wildcard-gateway.in.yml @@ -0,0 +1,56 @@ +httpFilterPolicy: + - apiVersion: mosn.io/v1 + kind: HTTPFilterPolicy + metadata: + name: policy + namespace: test + spec: + targetRef: + group: networking.istio.io + kind: VirtualService + name: httpbin + filters: + localReply: + config: + need: true + decode: true +virtualService: + policy: + - apiVersion: networking.istio.io/v1beta1 + kind: VirtualService + metadata: + name: httpbin + namespace: test + spec: + gateways: + - httpbin-gateway + hosts: + - "dev.httpbin.example.com" + http: + - match: + - uri: + prefix: /status + - uri: + prefix: /delay + route: + - destination: + host: httpbin + port: + number: 8000 +gateway: + httpbin: + - apiVersion: networking.istio.io/v1beta1 + kind: Gateway + metadata: + name: httpbin-gateway + namespace: test + spec: + selector: + istio: ingressgateway + servers: + - hosts: + - "*.example.com" + port: + name: http + number: 80 + protocol: HTTP diff --git a/controller/internal/translation/testdata/translation/virtualservice-wildcard-gateway.out.yml b/controller/internal/translation/testdata/translation/virtualservice-wildcard-gateway.out.yml new file mode 100644 index 00000000..5d9edab8 --- /dev/null +++ b/controller/internal/translation/testdata/translation/virtualservice-wildcard-gateway.out.yml @@ -0,0 +1,28 @@ +toUpdate: +- metadata: + creationTimestamp: null + name: htnn-h-test--httpbin + spec: + configPatches: + - applyTo: VIRTUAL_HOST + match: + routeConfiguration: + vhost: + name: dev.httpbin.example.com:80 + patch: + operation: MERGE + value: + typed_per_filter_config: + envoy.filters.http.golang: + '@type': type.googleapis.com/envoy.extensions.filters.http.golang.v3alpha.ConfigsPerRoute + plugins_config: + fm: + config: + '@type': type.googleapis.com/xds.type.v3.TypedStruct + value: + plugins: + - config: + decode: true + need: true + name: localReply + status: {} diff --git a/controller/internal/translation/testdata/translation/virtualservice-wildcard-host.in.yml b/controller/internal/translation/testdata/translation/virtualservice-wildcard-host.in.yml new file mode 100644 index 00000000..9772cbae --- /dev/null +++ b/controller/internal/translation/testdata/translation/virtualservice-wildcard-host.in.yml @@ -0,0 +1,56 @@ +httpFilterPolicy: + - apiVersion: mosn.io/v1 + kind: HTTPFilterPolicy + metadata: + name: policy + namespace: test + spec: + targetRef: + group: networking.istio.io + kind: VirtualService + name: httpbin + filters: + localReply: + config: + need: true + decode: true +virtualService: + policy: + - apiVersion: networking.istio.io/v1beta1 + kind: VirtualService + metadata: + name: httpbin + namespace: test + spec: + gateways: + - httpbin-gateway + hosts: + - "*.httpbin.example.com" + http: + - match: + - uri: + prefix: /status + - uri: + prefix: /delay + route: + - destination: + host: httpbin + port: + number: 8000 +gateway: + httpbin: + - apiVersion: networking.istio.io/v1beta1 + kind: Gateway + metadata: + name: httpbin-gateway + namespace: test + spec: + selector: + istio: ingressgateway + servers: + - hosts: + - "*.httpbin.example.com" + port: + name: http + number: 80 + protocol: HTTP diff --git a/controller/internal/translation/testdata/translation/virtualservice-wildcard-host.out.yml b/controller/internal/translation/testdata/translation/virtualservice-wildcard-host.out.yml new file mode 100644 index 00000000..1d0c0794 --- /dev/null +++ b/controller/internal/translation/testdata/translation/virtualservice-wildcard-host.out.yml @@ -0,0 +1,28 @@ +toUpdate: +- metadata: + creationTimestamp: null + name: htnn-h-test--httpbin + spec: + configPatches: + - applyTo: VIRTUAL_HOST + match: + routeConfiguration: + vhost: + name: '*.httpbin.example.com:80' + patch: + operation: MERGE + value: + typed_per_filter_config: + envoy.filters.http.golang: + '@type': type.googleapis.com/envoy.extensions.filters.http.golang.v3alpha.ConfigsPerRoute + plugins_config: + fm: + config: + '@type': type.googleapis.com/xds.type.v3.TypedStruct + value: + plugins: + - config: + decode: true + need: true + name: localReply + status: {} diff --git a/controller/internal/translation/testdata/translation/virtualservice.in.yml b/controller/internal/translation/testdata/translation/virtualservice.in.yml new file mode 100644 index 00000000..489e932c --- /dev/null +++ b/controller/internal/translation/testdata/translation/virtualservice.in.yml @@ -0,0 +1,76 @@ +httpFilterPolicy: + - apiVersion: mosn.io/v1 + kind: HTTPFilterPolicy + metadata: + name: policy + namespace: default + spec: + targetRef: + group: networking.istio.io + kind: VirtualService + name: httpbin + filters: + animal: + config: + host_name: goldfish +virtualService: + policy: + - apiVersion: networking.istio.io/v1beta1 + kind: VirtualService + metadata: + name: httpbin + namespace: default + spec: + gateways: + - httpbin-gateway + - httpbin-test-gateway + hosts: + - httpbin.example.com + - httpbin.test.com + http: + - match: + - uri: + prefix: /status + - uri: + prefix: /delay + route: + - destination: + host: httpbin + port: + number: 8000 +gateway: + httpbin: + - apiVersion: networking.istio.io/v1beta1 + kind: Gateway + metadata: + name: httpbin-gateway + namespace: default + spec: + selector: + istio: ingressgateway + servers: + - hosts: + - httpbin.example.com + port: + name: http + number: 80 + protocol: HTTP + - apiVersion: networking.istio.io/v1beta1 + kind: Gateway + metadata: + name: httpbin-test-gateway + namespace: default + spec: + selector: + istio: ingressgateway + servers: + - hosts: + - httpbin.test.com + port: + name: http + number: 8080 + protocol: HTTP +envoyFilter: +- metadata: + name: htnn-h-default--httpbin443 + namespace: istio-system diff --git a/controller/internal/translation/testdata/translation/virtualservice.out.yml b/controller/internal/translation/testdata/translation/virtualservice.out.yml new file mode 100644 index 00000000..4a7b302c --- /dev/null +++ b/controller/internal/translation/testdata/translation/virtualservice.out.yml @@ -0,0 +1,54 @@ +toDelete: +- metadata: + creationTimestamp: null + name: htnn-h-default--httpbin443 + namespace: istio-system + spec: {} + status: {} +toUpdate: +- metadata: + creationTimestamp: null + name: htnn-h-default--httpbin + spec: + configPatches: + - applyTo: VIRTUAL_HOST + match: + routeConfiguration: + vhost: + name: httpbin.example.com:80 + patch: + operation: MERGE + value: + typed_per_filter_config: + envoy.filters.http.golang: + '@type': type.googleapis.com/envoy.extensions.filters.http.golang.v3alpha.ConfigsPerRoute + plugins_config: + fm: + config: + '@type': type.googleapis.com/xds.type.v3.TypedStruct + value: + plugins: + - config: + host_name: goldfish + name: animal + - applyTo: VIRTUAL_HOST + match: + routeConfiguration: + vhost: + name: httpbin.test.com:8080 + patch: + operation: MERGE + value: + typed_per_filter_config: + envoy.filters.http.golang: + '@type': type.googleapis.com/envoy.extensions.filters.http.golang.v3alpha.ConfigsPerRoute + plugins_config: + fm: + config: + '@type': type.googleapis.com/xds.type.v3.TypedStruct + value: + plugins: + - config: + host_name: goldfish + name: animal + status: {} diff --git a/controller/internal/ir/ir.go b/controller/internal/translation/translation.go similarity index 94% rename from controller/internal/ir/ir.go rename to controller/internal/translation/translation.go index 3b0f26c9..c328eb56 100644 --- a/controller/internal/ir/ir.go +++ b/controller/internal/translation/translation.go @@ -1,4 +1,4 @@ -package ir +package translation import ( "context" diff --git a/controller/internal/translation/translation_test.go b/controller/internal/translation/translation_test.go new file mode 100644 index 00000000..66ec6848 --- /dev/null +++ b/controller/internal/translation/translation_test.go @@ -0,0 +1,126 @@ +package translation + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/agiledragon/gomonkey/v2" + "github.com/stretchr/testify/require" + "golang.org/x/exp/slices" + istiov1a3 "istio.io/client-go/pkg/apis/networking/v1alpha3" + istiov1b1 "istio.io/client-go/pkg/apis/networking/v1beta1" + "sigs.k8s.io/yaml" + + mosniov1 "mosn.io/moe/controller/api/v1" + "mosn.io/moe/controller/internal/istio" +) + +func testName(inputFile string) string { + _, fileName := filepath.Split(inputFile) + return strings.TrimSuffix(fileName, ".in.yml") +} + +func mustUnmarshal(t *testing.T, fn string, out interface{}) { + input, err := os.ReadFile(fn) + require.NoError(t, err) + require.NoError(t, yaml.UnmarshalStrict(input, out, yaml.DisallowUnknownFields)) +} + +type testInput struct { + HTTPFilterPolicy []*mosniov1.HTTPFilterPolicy `json:"httpFilterPolicy"` + VirtualService map[string][]*istiov1b1.VirtualService `json:"virtualService"` + Gateway map[string][]*istiov1b1.Gateway `json:"gateway"` + EnvoyFilter []*istiov1a3.EnvoyFilter `json:"envoyFilter"` +} + +type testOutput struct { + // we use sigs.k8s.io/yaml which uses JSON under the hover + ToUpdate []*istiov1a3.EnvoyFilter `json:"toUpdate,omitempty"` + ToDelete []*istiov1a3.EnvoyFilter `json:"toDelete,omitempty"` +} + +func TestTranslate(t *testing.T) { + inputFiles, err := filepath.Glob(filepath.Join("testdata", "translation", "*.in.yml")) + require.NoError(t, err) + + var toUpdate []*istiov1a3.EnvoyFilter + var toDel []*istiov1a3.EnvoyFilter + // We can't run the test in parallel as it contains a diff process. So it's safe to store delta to toUpdate + patches := gomonkey.ApplyFunc(publishCustomResources, + func(ctx *Ctx, addOrUpdate []*istiov1a3.EnvoyFilter, del []*istiov1a3.EnvoyFilter) error { + toUpdate = addOrUpdate + toDel = del + return nil + }) + defer patches.Reset() + + for _, inputFile := range inputFiles { + inputFile := inputFile + t.Run(testName(inputFile), func(t *testing.T) { + input := &testInput{} + mustUnmarshal(t, inputFile, input) + + s := NewInitState(nil) + + // set up resources + for _, httpFilterPolicy := range input.HTTPFilterPolicy { + vss := input.VirtualService[httpFilterPolicy.Name] + for _, vs := range vss { + gws := input.Gateway[vs.Name] + for _, gw := range gws { + // fulfill default fields + if httpFilterPolicy.Namespace == "" { + httpFilterPolicy.SetNamespace("default") + } + if vs.Namespace == "" { + vs.SetNamespace("default") + } + if gw.Namespace == "" { + gw.SetNamespace("default") + } + s.AddPolicyForVirtualService(httpFilterPolicy, vs, gw) + } + } + } + + currentEnvoyFilters = map[string]*istiov1a3.EnvoyFilter{} + for _, ef := range input.EnvoyFilter { + currentEnvoyFilters[ef.Namespace+"/"+ef.Name] = ef + } + + err := s.Process(context.Background()) + require.NoError(t, err) + + defaultEnvoyFilters := istio.DefaultEnvoyFilters() + for name := range defaultEnvoyFilters { + found := false + for i, ef := range toUpdate { + if ef.Name == name { + found = true + toUpdate = slices.Delete(toUpdate, i, i+1) + break + } + } + require.True(t, found) + } + + out := &testOutput{ + ToUpdate: toUpdate, + ToDelete: toDel, + } + d, _ := yaml.Marshal(out) + actual := string(d) + + outputFilePath := strings.ReplaceAll(inputFile, ".in.yml", ".out.yml") + d, _ = os.ReadFile(outputFilePath) + want := string(d) + // google/go-cmp is not used here as it will compare unexported fields by default. + // Calling IgnoreUnexported for each types in istio object is too cubmersome so we + // just use string comparison here. + require.Equal(t, want, actual) + }) + } +}