diff --git a/Makefile b/Makefile index 35e44dbb0..ed6c10df9 100644 --- a/Makefile +++ b/Makefile @@ -90,7 +90,7 @@ IMAGE_BUILD_ARGS_MINIMAL = --target minimal \ all: image -BUILD_BINARIES := nfd-master nfd-worker nfd-topology-updater nfd-gc kubectl-nfd +BUILD_BINARIES := nfd-master nfd-worker nfd-topology-updater nfd-gc kubectl-nfd nfd build-%: $(GO_CMD) build -v -o bin/ $(BUILD_FLAGS) ./cmd/$* diff --git a/cmd/nfd/main.go b/cmd/nfd/main.go new file mode 100644 index 000000000..f8d2517e5 --- /dev/null +++ b/cmd/nfd/main.go @@ -0,0 +1,27 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "sigs.k8s.io/node-feature-discovery/cmd/nfd/subcmd" +) + +const ProgramName = "nfd" + +func main() { + subcmd.Execute() +} diff --git a/cmd/nfd/subcmd/compat/compat.go b/cmd/nfd/subcmd/compat/compat.go new file mode 100644 index 000000000..cf46575dd --- /dev/null +++ b/cmd/nfd/subcmd/compat/compat.go @@ -0,0 +1,36 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package compat + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +var CompatCmd = &cobra.Command{ + Use: "compat", + Short: "Image compatibility commands", +} + +func Execute() { + if err := CompatCmd.Execute(); err != nil { + fmt.Println(err) + os.Exit(1) + } +} diff --git a/cmd/nfd/subcmd/compat/options/platform.go b/cmd/nfd/subcmd/compat/options/platform.go new file mode 100644 index 000000000..9a8f4a5ae --- /dev/null +++ b/cmd/nfd/subcmd/compat/options/platform.go @@ -0,0 +1,70 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package options + +import ( + "fmt" + "runtime" + "strings" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/spf13/cobra" +) + +// PlatformOption represents +type PlatformOption struct { + // PlatformStr contains the raw platform argument provided by the user. + PlatformStr string + // Platform represents the OCI platform specification, built from PlatformStr. + Platform *ocispec.Platform +} + +// Parse takes the PlatformStr argument provided by the user +// to build OCI platform specification. +func (opt *PlatformOption) Parse(*cobra.Command) error { + var pStr string + + if opt.PlatformStr == "" { + return nil + } + + platform := &ocispec.Platform{} + pStr, platform.OSVersion, _ = strings.Cut(opt.PlatformStr, ":") + parts := strings.Split(pStr, "/") + + switch len(parts) { + case 3: + platform.Variant = parts[2] + fallthrough + case 2: + platform.Architecture = parts[1] + case 1: + platform.Architecture = runtime.GOARCH + default: + return fmt.Errorf("failed to parse platform %q: expected format os[/arch[/variant]]", opt.PlatformStr) + } + + platform.OS = parts[0] + if platform.OS == "" { + return fmt.Errorf("invalid platform: OS cannot be empty") + } + if platform.Architecture == "" { + return fmt.Errorf("invalid platform: Architecture cannot be empty") + } + opt.Platform = platform + return nil +} diff --git a/cmd/nfd/subcmd/compat/validate-node.go b/cmd/nfd/subcmd/compat/validate-node.go new file mode 100644 index 000000000..d8a3f7ba7 --- /dev/null +++ b/cmd/nfd/subcmd/compat/validate-node.go @@ -0,0 +1,224 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package compat + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "strings" + "time" + + "github.com/jedib0t/go-pretty/v6/table" + "github.com/jedib0t/go-pretty/v6/text" + "github.com/spf13/cobra" + "oras.land/oras-go/v2/registry" + + "sigs.k8s.io/node-feature-discovery/cmd/nfd/subcmd/compat/options" + artifactcli "sigs.k8s.io/node-feature-discovery/pkg/client-nfd/compat/artifact-client" + nodevalidator "sigs.k8s.io/node-feature-discovery/pkg/client-nfd/compat/node-validator" + "sigs.k8s.io/node-feature-discovery/source" +) + +var ( + image string + tags []string + platform options.PlatformOption + plainHTTP bool + outputJSON bool + + // secrets + readPassword bool + readAccessToken bool + username string + password string + accessToken string +) + +var validateNodeCmd = &cobra.Command{ + Use: "validate-node", + Short: "Perform node validation based on its associated image compatibility artifact", + PreRunE: func(cmd *cobra.Command, args []string) error { + var err error + + if err = platform.Parse(cmd); err != nil { + return err + } + + if readAccessToken && readPassword { + return fmt.Errorf("cannot use --read-access-token and --read-password at the same time") + } else if readAccessToken { + accessToken, err = readStdin() + if err != nil { + return err + } + } else if readPassword { + password, err = readStdin() + if err != nil { + return err + } + } + + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + + ref, err := registry.ParseReference(image) + if err != nil { + return err + } + + sources := map[string]source.FeatureSource{} + for k, v := range source.GetAllFeatureSources() { + if ts, ok := v.(source.SupplementalSource); ok && ts.DisableByDefault() { + continue + } + sources[k] = v + } + + authOpt := artifactcli.WithAuthDefault() + if username != "" && password != "" { + authOpt = artifactcli.WithAuthPassword(username, password) + } else if accessToken != "" { + authOpt = artifactcli.WithAuthToken(accessToken) + } + + ac := artifactcli.New( + &ref, + artifactcli.WithArgs(artifactcli.Args{PlainHttp: plainHTTP}), + artifactcli.WithPlatform(platform.Platform), + authOpt, + ) + + nv := nodevalidator.New( + nodevalidator.WithArgs(&nodevalidator.Args{Tags: tags}), + nodevalidator.WithArtifactClient(ac), + nodevalidator.WithSources(sources), + ) + + out, err := nv.Execute(ctx) + if err != nil { + return err + } + if outputJSON { + b, err := json.Marshal(out) + if err != nil { + return err + } + fmt.Printf("%s", b) + } else { + pprintResult(out) + } + + return nil + }, +} + +func readStdin() (string, error) { + secretRaw, err := io.ReadAll(os.Stdin) + if err != nil { + return "", err + } + secret := strings.TrimSuffix(string(secretRaw), "\n") + secret = strings.TrimSuffix(secret, "\r") + + return secret, nil +} + +func pprintResult(css []*nodevalidator.CompatibilityStatus) { + for i, cs := range css { + fmt.Print(text.Colors{text.FgCyan, text.Bold}.Sprintf("COMPATIBILITY SET #%d ", i+1)) + fmt.Print(text.FgCyan.Sprintf("Weight: %d", cs.Weight)) + if cs.Tag != "" { + fmt.Print(text.FgCyan.Sprintf("; Tag: %s", cs.Tag)) + } + fmt.Println() + fmt.Println(text.FgWhite.Sprintf("Description: %s", cs.Description)) + + for _, r := range cs.Rules { + printTable(r) + } + fmt.Println() + } +} + +func printTable(rs nodevalidator.ProcessedRuleStatus) { + t := table.NewWriter() + t.SetStyle(table.StyleLight) + t.SetOutputMirror(os.Stdout) + t.Style().Format.Header = text.FormatDefault + t.SetAutoIndex(true) + + validTxt := text.BgRed.Sprint(" FAIL ") + if rs.IsMatch { + validTxt = text.BgGreen.Sprint(" OK ") + } + ruleTxt := strings.ToUpper(fmt.Sprintf("rule: %s", rs.Name)) + + t.SetTitle(text.Bold.Sprintf("%s - %s", ruleTxt, validTxt)) + t.AppendHeader(table.Row{"Feature", "Expression", "Matcher Type", "Status"}) + + if mf := rs.MatchedExpressions; len(mf) > 0 { + renderMatchFeatures(t, mf) + } + if ma := rs.MatchedAny; len(ma) > 0 { + for _, elem := range ma { + t.AppendSeparator() + renderMatchFeatures(t, elem.MatchedExpressions) + } + } + t.Render() +} + +func renderMatchFeatures(t table.Writer, matchedExpressions []nodevalidator.MatchedExpression) { + for _, fm := range matchedExpressions { + fullFeatureDomain := fm.Feature + if fm.Name != "" { + fullFeatureDomain = fmt.Sprintf("%s.%s", fm.Feature, fm.Name) + } + + addTableRows(t, fullFeatureDomain, fm.Expression.String(), fm.MatcherType, fm.IsMatch) + } +} + +func addTableRows(t table.Writer, fullFeatureDomain, expression string, matcherType nodevalidator.MatcherType, isMatch bool) { + status := text.FgHiRed.Sprint("FAIL") + if isMatch { + status = text.FgHiGreen.Sprint("OK") + } + t.AppendRow(table.Row{fullFeatureDomain, expression, matcherType, status}) +} + +func init() { + CompatCmd.AddCommand(validateNodeCmd) + validateNodeCmd.Flags().StringVar(&image, "image", "", "the URL of the image containing compatibility metadata") + validateNodeCmd.Flags().StringSliceVar(&tags, "tags", []string{}, "a list of tags that must match the tags set on the compatibility objects") + validateNodeCmd.Flags().StringVar(&platform.PlatformStr, "platform", "", "the artifact platform in the format os[/arch][/variant][:os_version]") + validateNodeCmd.Flags().BoolVar(&plainHTTP, "plain-http", false, "use of HTTP protocol for all registry communications") + validateNodeCmd.Flags().BoolVar(&outputJSON, "output-json", false, "print a JSON object") + validateNodeCmd.Flags().StringVar(&username, "reg-username", "", "registry username") + validateNodeCmd.Flags().BoolVar(&readPassword, "reg-password-stdin", false, "read registry password from stdin") + validateNodeCmd.Flags().BoolVar(&readAccessToken, "reg-token-stdin", false, "read registry access token from stdin") + + if err := validateNodeCmd.MarkFlagRequired("image"); err != nil { + panic(err) + } +} diff --git a/cmd/nfd/subcmd/root.go b/cmd/nfd/subcmd/root.go new file mode 100644 index 000000000..d58f6b982 --- /dev/null +++ b/cmd/nfd/subcmd/root.go @@ -0,0 +1,45 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package subcmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + "sigs.k8s.io/node-feature-discovery/cmd/nfd/subcmd/compat" +) + +// RootCmd represents the base command when called without any subcommands +var RootCmd = &cobra.Command{ + Use: "nfd", + Short: "Node Feature Discovery client", +} + +func init() { + RootCmd.AddCommand(compat.CompatCmd) +} + +// Execute adds all child commands to the root command and sets flags appropriately. +// This is called by main.main(). It only needs to happen once to the rootCmd. +func Execute() { + if err := RootCmd.Execute(); err != nil { + fmt.Println(err) + os.Exit(1) + } +} diff --git a/examples/image-compatibility.yaml b/examples/image-compatibility.yaml new file mode 100644 index 000000000..38cdc34b8 --- /dev/null +++ b/examples/image-compatibility.yaml @@ -0,0 +1,27 @@ +version: v1alpha1 +compatibilities: +- description: "my image requirements" + rules: + - name: "kernel and cpu" + matchFeatures: + - feature: kernel.loadedmodule + matchExpressions: + vfio-pci: {op: Exists} + ip_tables: {op: Exists} + - feature: cpu.model + matchExpressions: + vendor_id: {op: In, value: ["Intel", "AMD"]} + - feature: cpu.cpuid + matchName: {op: InRegexp, value: ["^AVX"]} + - name: "one of available nics" + matchAny: + - matchFeatures: + - feature: pci.device + matchExpressions: + vendor: {op: In, value: ["0eee"]} + class: {op: In, value: ["0200"]} + - matchFeatures: + - feature: pci.device + matchExpressions: + vendor: {op: In, value: ["0fff"]} + class: {op: In, value: ["0200"]} diff --git a/go.mod b/go.mod index a7dd9ecaf..de94b3265 100644 --- a/go.mod +++ b/go.mod @@ -7,11 +7,13 @@ require ( github.com/google/go-cmp v0.6.0 github.com/google/uuid v1.6.0 github.com/jaypipes/ghw v0.13.0 + github.com/jedib0t/go-pretty/v6 v6.6.1 github.com/k8stopologyawareschedwg/noderesourcetopology-api v0.1.2 github.com/k8stopologyawareschedwg/podfingerprint v0.2.2 github.com/klauspost/cpuid/v2 v2.2.9 github.com/onsi/ginkgo/v2 v2.22.0 github.com/onsi/gomega v1.36.1 + github.com/opencontainers/image-spec v1.1.0 github.com/opencontainers/runc v1.2.3 github.com/prometheus/client_golang v1.19.1 github.com/smartystreets/goconvey v1.8.1 @@ -34,6 +36,7 @@ require ( k8s.io/kubernetes v1.32.0 k8s.io/pod-security-admission v0.32.0 k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 + oras.land/oras-go/v2 v2.5.0 sigs.k8s.io/node-feature-discovery/api/nfd v0.0.0-00010101000000-000000000000 sigs.k8s.io/yaml v1.4.0 ) @@ -92,6 +95,7 @@ require ( github.com/jtolds/gls v4.20.0+incompatible // indirect github.com/karrick/godirwalk v1.17.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect github.com/mistifyio/go-zfs v2.1.2-0.20190413222219-f784269be439+incompatible // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/moby/spdystream v0.5.0 // indirect @@ -109,6 +113,7 @@ require ( github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.55.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect + github.com/rivo/uniseg v0.2.0 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/smarty/assertions v1.15.1 // indirect github.com/spf13/pflag v1.0.5 // indirect diff --git a/go.sum b/go.sum index ed282dc86..b928c12ab 100644 --- a/go.sum +++ b/go.sum @@ -131,6 +131,8 @@ github.com/jaypipes/ghw v0.13.0 h1:log8MXuB8hzTNnSktqpXMHc0c/2k/WgjOMSUtnI1RV4= github.com/jaypipes/ghw v0.13.0/go.mod h1:In8SsaDqlb1oTyrbmTC14uy+fbBMvp+xdqX51MidlD8= github.com/jaypipes/pcidb v1.0.1 h1:WB2zh27T3nwg8AE8ei81sNRb9yWBii3JGNJtT7K9Oic= github.com/jaypipes/pcidb v1.0.1/go.mod h1:6xYUz/yYEyOkIkUt2t2J2folIuZ4Yg6uByCGFXMCeE4= +github.com/jedib0t/go-pretty/v6 v6.6.1 h1:iJ65Xjb680rHcikRj6DSIbzCex2huitmc7bDtxYVWyc= +github.com/jedib0t/go-pretty/v6 v6.6.1/go.mod h1:zbn98qrYlh95FIhwwsbIip0LYpwSG8SUOScs+v9/t0E= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jonboulle/clockwork v0.4.0 h1:p4Cf1aMWXnXAUh8lVfewRBx1zaTSYKrKMF2g3ST4RZ4= github.com/jonboulle/clockwork v0.4.0/go.mod h1:xgRqUGwRcjKCO1vbZUEtSLrqKoPSsUpK7fnezOII0kc= @@ -159,6 +161,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mistifyio/go-zfs v2.1.2-0.20190413222219-f784269be439+incompatible h1:aKW/4cBs+yK6gpqU3K/oIwk9Q/XICqd3zOX/UFuvqmk= github.com/mistifyio/go-zfs v2.1.2-0.20190413222219-f784269be439+incompatible/go.mod h1:8AuVvqP/mXw1px98n46wfvcGfQ4ci2FwoAjKYxuo3Z4= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= @@ -207,6 +211,8 @@ github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -412,6 +418,8 @@ k8s.io/pod-security-admission v0.32.0 h1:I+Og0uZIiMpIgTgPrTbW4jlwRI5RWazi8y+jrx1 k8s.io/pod-security-admission v0.32.0/go.mod h1:RvrcY0+5UAoCIJ7BscgDF3nbmXprgfnjTW+byCyXDvA= k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro= k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +oras.land/oras-go/v2 v2.5.0 h1:o8Me9kLY74Vp5uw07QXPiitjsw7qNXi8Twd+19Zf02c= +oras.land/oras-go/v2 v2.5.0/go.mod h1:z4eisnLP530vwIOUOJeBIj0aGI0L1C3d53atvCBqZHg= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.0 h1:CPT0ExVicCzcpeN4baWEV2ko2Z/AsiZgEdwgcfwLgMo= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.0/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8= diff --git a/pkg/client-nfd/compat/artifact-client/client.go b/pkg/client-nfd/compat/artifact-client/client.go new file mode 100644 index 000000000..ef37f1fc3 --- /dev/null +++ b/pkg/client-nfd/compat/artifact-client/client.go @@ -0,0 +1,178 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package compat + +//go:generate mockery --name=ArtifactClient --inpackage + +import ( + "context" + "encoding/json" + "fmt" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + oras "oras.land/oras-go/v2" + "oras.land/oras-go/v2/registry" + "oras.land/oras-go/v2/registry/remote" + "oras.land/oras-go/v2/registry/remote/auth" + "oras.land/oras-go/v2/registry/remote/retry" + "sigs.k8s.io/yaml" + + compatv1alpha1 "sigs.k8s.io/node-feature-discovery/api/image-compatibility/v1alpha1" +) + +// ArtifactClient interface contain set of functions to manipulate compatibility artfact. +type ArtifactClient interface { + // FetchCompatibilitySpec downloads the compatibility specifcation associated with the image. + FetchCompatibilitySpec(ctx context.Context) (*compatv1alpha1.Spec, error) +} + +// Args holds command line arguments. +type Args struct { + PlainHttp bool +} + +// Client represents a client that is reposnible for all artifact operations. +type Client struct { + Args Args + RegReference *registry.Reference + Platform *ocispec.Platform + + orasClient *auth.Client +} + +// New returns a new compatibility spec object. +func New(regReference *registry.Reference, opts ...ArtifactClientOpts) *Client { + c := &Client{ + RegReference: regReference, + } + for _, opt := range opts { + opt.apply(c) + } + return c +} + +// FetchCompatibilitySpec pulls the image compatibility specification associated with the image. +func (c *Client) FetchCompatibilitySpec(ctx context.Context) (*compatv1alpha1.Spec, error) { + repo, err := remote.NewRepository(c.RegReference.String()) + if err != nil { + return nil, err + } + repo.Client = c.orasClient + repo.PlainHTTP = c.Args.PlainHttp + + opts := oras.DefaultResolveOptions + if c.Platform != nil { + opts.TargetPlatform = c.Platform + } + + targetDesc, err := oras.Resolve(ctx, repo, c.RegReference.Reference, opts) + if err != nil { + return nil, err + } + + descs, err := registry.Referrers(ctx, repo, targetDesc, compatv1alpha1.ArtifactType) + if err != nil { + return nil, nil + } else if len(descs) < 1 { + return nil, fmt.Errorf("compatibility artifact not found") + } + artifactDesc := descs[len(descs)-1] + + _, content, err := oras.FetchBytes(ctx, repo.Manifests(), artifactDesc.Digest.String(), oras.DefaultFetchBytesOptions) + if err != nil { + return nil, err + } + + manifest := ocispec.Manifest{} + if err := json.Unmarshal(content, &manifest); err != nil { + return nil, err + } + + // TODO: now it's a lazy check, verify in the future the media types and number of layers + if len(manifest.Layers) < 1 { + return nil, fmt.Errorf("compatibility layer not found") + } + specDesc := manifest.Layers[0] + + _, compatSpecRaw, err := oras.FetchBytes(ctx, repo.Blobs(), specDesc.Digest.String(), oras.DefaultFetchBytesOptions) + if err != nil { + return nil, err + } + + compatSpec := compatv1alpha1.Spec{} + err = yaml.Unmarshal(compatSpecRaw, &compatSpec) + if err != nil { + return nil, err + } + + return &compatSpec, nil +} + +// NodeValidatorOpts applies certain options to the node validator. +type ArtifactClientOpts interface { + apply(*Client) +} + +type artifactClientOpt struct { + f func(*Client) +} + +func (o *artifactClientOpt) apply(nv *Client) { + o.f(nv) +} + +// WithArgs applies arguments to the artifact client. +func WithArgs(args Args) ArtifactClientOpts { + return &artifactClientOpt{f: func(c *Client) { c.Args = args }} +} + +// WithPlatform applies OCI platform spec to the artifact client. +func WithPlatform(platform *ocispec.Platform) ArtifactClientOpts { + return &artifactClientOpt{f: func(c *Client) { c.Platform = platform }} +} + +// WithAuthPassword initializes oras client with user and password. +func WithAuthPassword(username, password string) ArtifactClientOpts { + return &artifactClientOpt{f: func(c *Client) { + c.orasClient = &auth.Client{ + Client: retry.DefaultClient, + Cache: auth.NewCache(), + Credential: auth.StaticCredential(c.RegReference.Registry, auth.Credential{ + Username: username, + Password: password, + }), + } + }} +} + +// WithAuthToken initializes oras client with auth token. +func WithAuthToken(token string) ArtifactClientOpts { + return &artifactClientOpt{f: func(c *Client) { + c.orasClient = &auth.Client{ + Client: retry.DefaultClient, + Cache: auth.NewCache(), + Credential: auth.StaticCredential(c.RegReference.Registry, auth.Credential{ + AccessToken: token, + }), + } + }} +} + +// WithAuthDefault initializes the default oras client that does not authenticate. +func WithAuthDefault() ArtifactClientOpts { + return &artifactClientOpt{f: func(c *Client) { c.orasClient = auth.DefaultClient }} +} diff --git a/pkg/client-nfd/compat/artifact-client/mock_ArtifactClient.go b/pkg/client-nfd/compat/artifact-client/mock_ArtifactClient.go new file mode 100644 index 000000000..3c246ee52 --- /dev/null +++ b/pkg/client-nfd/compat/artifact-client/mock_ArtifactClient.go @@ -0,0 +1,59 @@ +// Code generated by mockery v2.42.0. DO NOT EDIT. + +package compat + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" + v1alpha1 "sigs.k8s.io/node-feature-discovery/api/image-compatibility/v1alpha1" +) + +// MockArtifactClient is an autogenerated mock type for the ArtifactClient type +type MockArtifactClient struct { + mock.Mock +} + +// FetchCompatibilitySpec provides a mock function with given fields: ctx +func (_m *MockArtifactClient) FetchCompatibilitySpec(ctx context.Context) (*v1alpha1.Spec, error) { + ret := _m.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for FetchCompatibilitySpec") + } + + var r0 *v1alpha1.Spec + var r1 error + if rf, ok := ret.Get(0).(func(context.Context) (*v1alpha1.Spec, error)); ok { + return rf(ctx) + } + if rf, ok := ret.Get(0).(func(context.Context) *v1alpha1.Spec); ok { + r0 = rf(ctx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*v1alpha1.Spec) + } + } + + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// NewMockArtifactClient creates a new instance of MockArtifactClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockArtifactClient(t interface { + mock.TestingT + Cleanup(func()) +}) *MockArtifactClient { + mock := &MockArtifactClient{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/pkg/client-nfd/compat/node-validator/node-validator.go b/pkg/client-nfd/compat/node-validator/node-validator.go new file mode 100644 index 000000000..ef907c00f --- /dev/null +++ b/pkg/client-nfd/compat/node-validator/node-validator.go @@ -0,0 +1,203 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package nodevalidator + +import ( + "context" + "slices" + "sort" + + nfdv1alpha1 "sigs.k8s.io/node-feature-discovery/api/nfd/v1alpha1" + "sigs.k8s.io/node-feature-discovery/pkg/apis/nfd/nodefeaturerule" + artifactcli "sigs.k8s.io/node-feature-discovery/pkg/client-nfd/compat/artifact-client" + "sigs.k8s.io/node-feature-discovery/source" + + // register sources + _ "sigs.k8s.io/node-feature-discovery/source/cpu" + _ "sigs.k8s.io/node-feature-discovery/source/kernel" + _ "sigs.k8s.io/node-feature-discovery/source/memory" + _ "sigs.k8s.io/node-feature-discovery/source/network" + _ "sigs.k8s.io/node-feature-discovery/source/pci" + _ "sigs.k8s.io/node-feature-discovery/source/storage" + _ "sigs.k8s.io/node-feature-discovery/source/system" + _ "sigs.k8s.io/node-feature-discovery/source/usb" +) + +// Args holds command line arguments. +type Args struct { + Tags []string +} + +type nodeValidator struct { + args Args + + artifactClient artifactcli.ArtifactClient + sources map[string]source.FeatureSource +} + +// New builds a node validator with specified options. +func New(opts ...NodeValidatorOpts) nodeValidator { + n := nodeValidator{} + for _, opt := range opts { + opt.apply(&n) + } + return n +} + +// Execute pulls the compatibility artifact to compare described features with the ones discovered on the node. +func (nv *nodeValidator) Execute(ctx context.Context) ([]*CompatibilityStatus, error) { + spec, err := nv.artifactClient.FetchCompatibilitySpec(ctx) + if err != nil { + return nil, err + } + + for _, s := range nv.sources { + if err := s.Discover(); err != nil { + return nil, err + } + } + features := source.GetAllFeatures() + + compats := []*CompatibilityStatus{} + for _, c := range spec.Compatibilties { + if len(nv.args.Tags) > 0 && !slices.Contains(nv.args.Tags, c.Tag) { + continue + } + compat := newCompatibilityStatus(&c) + + for _, r := range c.Rules { + ruleOut, err := nodefeaturerule.Execute(&r, features, false) + if err != nil { + return nil, err + } + compat.Rules = append(compat.Rules, evaluateRuleStatus(&r, ruleOut.MatchStatus)) + + // Add the 'rule.matched' feature for backreference functionality + features.InsertAttributeFeatures(nfdv1alpha1.RuleBackrefDomain, nfdv1alpha1.RuleBackrefFeature, ruleOut.Labels) + features.InsertAttributeFeatures(nfdv1alpha1.RuleBackrefDomain, nfdv1alpha1.RuleBackrefFeature, ruleOut.Vars) + } + compats = append(compats, &compat) + } + + return compats, nil +} + +func evaluateRuleStatus(rule *nfdv1alpha1.Rule, matchStatus *nodefeaturerule.MatchStatus) ProcessedRuleStatus { + out := ProcessedRuleStatus{Name: rule.Name, IsMatch: matchStatus.IsMatch} + + evaluateFeatureMatcher := func(featureMatcher, matchedFeatureTerms nfdv1alpha1.FeatureMatcher) []MatchedExpression { + out := []MatchedExpression{} + for _, term := range featureMatcher { + if term.MatchExpressions != nil { + for name, exp := range *term.MatchExpressions { + isMatch := false + + // Check if the expression matches + for _, processedTerm := range matchedFeatureTerms { + if term.Feature != processedTerm.Feature || processedTerm.MatchExpressions == nil { + continue + } + pexp, ok := (*processedTerm.MatchExpressions)[name] + if isMatch = ok && exp.Op == pexp.Op && slices.Equal(exp.Value, pexp.Value); isMatch { + break + } + } + + out = append(out, MatchedExpression{ + Feature: term.Feature, + Name: name, + Expression: exp, + MatcherType: MatchExpressionType, + IsMatch: isMatch, + }) + } + } + + if term.MatchName != nil { + isMatch := false + for _, processedTerm := range matchStatus.MatchedFeaturesTerms { + if term.Feature != processedTerm.Feature || processedTerm.MatchName == nil { + continue + } + isMatch = term.MatchName.Op == processedTerm.MatchName.Op && slices.Equal(term.MatchName.Value, processedTerm.MatchName.Value) + if isMatch { + break + } + } + out = append(out, MatchedExpression{ + Feature: term.Feature, + Name: "", + Expression: term.MatchName, + MatcherType: MatchNameType, + IsMatch: isMatch, + }) + } + } + + // For reproducible output sort by name, feature, expression. + sort.Slice(out, func(i, j int) bool { + if out[i].Feature != out[j].Feature { + return out[i].Feature < out[j].Feature + } + if out[i].Name != out[j].Name { + return out[i].Name < out[j].Name + } + return out[i].Expression.String() < out[j].Expression.String() + }) + + return out + } + + if matchFeatures := rule.MatchFeatures; matchFeatures != nil { + out.MatchedExpressions = evaluateFeatureMatcher(matchFeatures, matchStatus.MatchedFeaturesTerms) + } + + for i, matchAnyElem := range rule.MatchAny { + matchedExpressions := evaluateFeatureMatcher(matchAnyElem.MatchFeatures, matchStatus.MatchAny[i].MatchedFeaturesTerms) + out.MatchedAny = append(out.MatchedAny, MatchAnyElem{MatchedExpressions: matchedExpressions}) + } + + return out +} + +// NodeValidatorOpts applies certain options to the node validator. +type NodeValidatorOpts interface { + apply(*nodeValidator) +} + +type nodeValidatorOpt struct { + f func(*nodeValidator) +} + +func (o *nodeValidatorOpt) apply(nv *nodeValidator) { + o.f(nv) +} + +// WithArgs applies command line arguments to the node validator object. +func WithArgs(args *Args) NodeValidatorOpts { + return &nodeValidatorOpt{f: func(nv *nodeValidator) { nv.args = *args }} +} + +// WithArtifactClient applies the client for all artifact operations. +func WithArtifactClient(cli artifactcli.ArtifactClient) NodeValidatorOpts { + return &nodeValidatorOpt{f: func(nv *nodeValidator) { nv.artifactClient = cli }} +} + +// WithSources applies the list of enabled feature sources. +func WithSources(sources map[string]source.FeatureSource) NodeValidatorOpts { + return &nodeValidatorOpt{f: func(nv *nodeValidator) { nv.sources = sources }} +} diff --git a/pkg/client-nfd/compat/node-validator/node-validator_test.go b/pkg/client-nfd/compat/node-validator/node-validator_test.go new file mode 100644 index 000000000..6977578a9 --- /dev/null +++ b/pkg/client-nfd/compat/node-validator/node-validator_test.go @@ -0,0 +1,418 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package nodevalidator + +import ( + "context" + "testing" + + . "github.com/smartystreets/goconvey/convey" + + compatv1alpha1 "sigs.k8s.io/node-feature-discovery/api/image-compatibility/v1alpha1" + "sigs.k8s.io/node-feature-discovery/api/nfd/v1alpha1" + artifactcli "sigs.k8s.io/node-feature-discovery/pkg/client-nfd/compat/artifact-client" + "sigs.k8s.io/node-feature-discovery/source" + "sigs.k8s.io/node-feature-discovery/source/fake" +) + +func init() { + fs := source.GetConfigurableSource(fake.Name) + fs.SetConfig(fs.NewConfig()) +} + +func TestNodeValidator(t *testing.T) { + ctx := context.Background() + + Convey("With a single compatibility set that contains flags, attributes and instances", t, func() { + spec := &compatv1alpha1.Spec{ + Version: compatv1alpha1.Version, + Compatibilties: []compatv1alpha1.Compatibility{ + { + Description: "Fake compatibility", + Rules: []v1alpha1.Rule{ + { + Name: "fake_1", + MatchFeatures: v1alpha1.FeatureMatcher{ + { + Feature: "fake.flag", + MatchName: &v1alpha1.MatchExpression{Op: v1alpha1.MatchInRegexp, Value: v1alpha1.MatchValue{"^flag"}}, + }, + }, + }, + { + Name: "fake_2", + MatchFeatures: v1alpha1.FeatureMatcher{ + { + Feature: "fake.flag", + MatchExpressions: &v1alpha1.MatchExpressionSet{ + "flag_unknown": &v1alpha1.MatchExpression{Op: v1alpha1.MatchExists}, + }, + }, + { + Feature: "fake.attribute", + MatchExpressions: &v1alpha1.MatchExpressionSet{ + "attr_1": &v1alpha1.MatchExpression{Op: v1alpha1.MatchIn, Value: v1alpha1.MatchValue{"true"}}, + }, + }, + }, + }, + { + Name: "fake_3", + MatchFeatures: v1alpha1.FeatureMatcher{ + { + Feature: "fake.instance", + MatchExpressions: &v1alpha1.MatchExpressionSet{ + "name": &v1alpha1.MatchExpression{Op: v1alpha1.MatchIn, Value: v1alpha1.MatchValue{"instance_1"}}, + "attr_1": &v1alpha1.MatchExpression{Op: v1alpha1.MatchIn, Value: v1alpha1.MatchValue{"true"}}, + }, + }, + { + Feature: "fake.instance", + MatchExpressions: &v1alpha1.MatchExpressionSet{ + "name": &v1alpha1.MatchExpression{Op: v1alpha1.MatchIn, Value: v1alpha1.MatchValue{"instance_2"}}, + "attr_1": &v1alpha1.MatchExpression{Op: v1alpha1.MatchIn, Value: v1alpha1.MatchValue{"false"}}, + }, + }, + }, + }, + { + Name: "fake_4", + MatchAny: []v1alpha1.MatchAnyElem{ + { + MatchFeatures: v1alpha1.FeatureMatcher{ + { + Feature: "fake.instance", + MatchExpressions: &v1alpha1.MatchExpressionSet{ + "name": &v1alpha1.MatchExpression{Op: v1alpha1.MatchIn, Value: v1alpha1.MatchValue{"instance_1"}}, + }, + }, + }, + }, + { + MatchFeatures: v1alpha1.FeatureMatcher{ + { + Feature: "fake.instance", + MatchExpressions: &v1alpha1.MatchExpressionSet{ + "name": &v1alpha1.MatchExpression{Op: v1alpha1.MatchIn, Value: v1alpha1.MatchValue{"instance_unknown"}}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + // The output contains expressions in alphabetical order over the feature, name and expression string. + expectedOutput := []*CompatibilityStatus{ + { + Description: "Fake compatibility", + Rules: []ProcessedRuleStatus{ + { + Name: "fake_1", + IsMatch: true, + MatchedExpressions: []MatchedExpression{ + { + Feature: "fake.flag", + Name: "", + Expression: &v1alpha1.MatchExpression{Op: v1alpha1.MatchInRegexp, Value: v1alpha1.MatchValue{"^flag"}}, + MatcherType: MatchNameType, + IsMatch: true, + }, + }, + }, + { + Name: "fake_2", + IsMatch: false, + MatchedExpressions: []MatchedExpression{ + { + Feature: "fake.attribute", + Name: "attr_1", + Expression: &v1alpha1.MatchExpression{Op: v1alpha1.MatchIn, Value: v1alpha1.MatchValue{"true"}}, + MatcherType: MatchExpressionType, + IsMatch: true, + }, + { + Feature: "fake.flag", + Name: "flag_unknown", + Expression: &v1alpha1.MatchExpression{Op: v1alpha1.MatchExists}, + MatcherType: MatchExpressionType, + IsMatch: false, + }, + }, + }, + { + Name: "fake_3", + IsMatch: false, + MatchedExpressions: []MatchedExpression{ + { + Feature: "fake.instance", + Name: "attr_1", + Expression: &v1alpha1.MatchExpression{Op: v1alpha1.MatchIn, Value: v1alpha1.MatchValue{"false"}}, + MatcherType: MatchExpressionType, + IsMatch: false, + }, + { + Feature: "fake.instance", + Name: "attr_1", + Expression: &v1alpha1.MatchExpression{Op: v1alpha1.MatchIn, Value: v1alpha1.MatchValue{"true"}}, + MatcherType: MatchExpressionType, + IsMatch: true, + }, + { + Feature: "fake.instance", + Name: "name", + Expression: &v1alpha1.MatchExpression{Op: v1alpha1.MatchIn, Value: v1alpha1.MatchValue{"instance_1"}}, + MatcherType: MatchExpressionType, + IsMatch: true, + }, + { + Feature: "fake.instance", + Name: "name", + Expression: &v1alpha1.MatchExpression{Op: v1alpha1.MatchIn, Value: v1alpha1.MatchValue{"instance_2"}}, + MatcherType: MatchExpressionType, + IsMatch: true, + }, + }, + }, + { + Name: "fake_4", + IsMatch: true, + MatchedAny: []MatchAnyElem{ + { + MatchedExpressions: []MatchedExpression{ + { + Feature: "fake.instance", + Name: "name", + Expression: &v1alpha1.MatchExpression{Op: v1alpha1.MatchIn, Value: v1alpha1.MatchValue{"instance_1"}}, + MatcherType: MatchExpressionType, + IsMatch: true, + }, + }, + }, + { + MatchedExpressions: []MatchedExpression{ + { + Feature: "fake.instance", + Name: "name", + Expression: &v1alpha1.MatchExpression{Op: v1alpha1.MatchIn, Value: v1alpha1.MatchValue{"instance_unknown"}}, + MatcherType: MatchExpressionType, + IsMatch: false, + }, + }, + }, + }, + }, + }, + }, + } + + validator := New( + WithArgs(&Args{}), + WithArtifactClient(newMock(ctx, spec)), + WithSources(map[string]source.FeatureSource{fake.Name: source.GetFeatureSource(fake.Name)}), + ) + output, err := validator.Execute(ctx) + + So(err, ShouldBeNil) + So(output, ShouldEqual, expectedOutput) + }) + + Convey("With multiple compatibility sets", t, func() { + spec := &compatv1alpha1.Spec{ + Version: compatv1alpha1.Version, + Compatibilties: []compatv1alpha1.Compatibility{ + { + Tag: "prefered", + Weight: 90, + Description: "Fake compatibility 1", + Rules: []v1alpha1.Rule{ + { + Name: "fake_1", + MatchFeatures: v1alpha1.FeatureMatcher{ + { + Feature: "fake.attribute", + MatchExpressions: &v1alpha1.MatchExpressionSet{ + "attr_1": &v1alpha1.MatchExpression{Op: v1alpha1.MatchIn, Value: v1alpha1.MatchValue{"false"}}, + }, + }, + }, + }, + }, + }, + { + Tag: "fallback", + Weight: 40, + Description: "Fake compatibility 2", + Rules: []v1alpha1.Rule{ + { + Name: "fake_1", + MatchFeatures: v1alpha1.FeatureMatcher{ + { + Feature: "fake.attribute", + MatchExpressions: &v1alpha1.MatchExpressionSet{ + "attr_2": &v1alpha1.MatchExpression{Op: v1alpha1.MatchIn, Value: v1alpha1.MatchValue{"false"}}, + }, + }, + }, + }, + }, + }, + }, + } + + expectedOutput := []*CompatibilityStatus{ + { + Tag: "prefered", + Weight: 90, + Description: "Fake compatibility 1", + Rules: []ProcessedRuleStatus{ + { + Name: "fake_1", + IsMatch: false, + MatchedExpressions: []MatchedExpression{ + { + Feature: "fake.attribute", + Name: "attr_1", + Expression: &v1alpha1.MatchExpression{Op: v1alpha1.MatchIn, Value: v1alpha1.MatchValue{"false"}}, + MatcherType: MatchExpressionType, + IsMatch: false, + }, + }, + }, + }, + }, + { + Tag: "fallback", + Weight: 40, + Description: "Fake compatibility 2", + Rules: []ProcessedRuleStatus{ + { + Name: "fake_1", + IsMatch: true, + MatchedExpressions: []MatchedExpression{ + { + Feature: "fake.attribute", + Name: "attr_2", + Expression: &v1alpha1.MatchExpression{Op: v1alpha1.MatchIn, Value: v1alpha1.MatchValue{"false"}}, + MatcherType: MatchExpressionType, + IsMatch: true, + }, + }, + }, + }, + }, + } + + validator := New( + WithArgs(&Args{}), + WithArtifactClient(newMock(ctx, spec)), + WithSources(map[string]source.FeatureSource{fake.Name: source.GetFeatureSource(fake.Name)}), + ) + output, err := validator.Execute(ctx) + + So(err, ShouldBeNil) + So(output, ShouldEqual, expectedOutput) + }) + + Convey("With compatibility sets filtered out by tags", t, func() { + spec := &compatv1alpha1.Spec{ + Version: compatv1alpha1.Version, + Compatibilties: []compatv1alpha1.Compatibility{ + { + Tag: "prefered", + Weight: 90, + Description: "Fake compatibility 1", + Rules: []v1alpha1.Rule{ + { + Name: "fake_1", + MatchFeatures: v1alpha1.FeatureMatcher{ + { + Feature: "fake.attribute", + MatchExpressions: &v1alpha1.MatchExpressionSet{ + "attr_1": &v1alpha1.MatchExpression{Op: v1alpha1.MatchIn, Value: v1alpha1.MatchValue{"false"}}, + }, + }, + }, + }, + }, + }, + { + Tag: "fallback", + Weight: 40, + Description: "Fake compatibility 2", + Rules: []v1alpha1.Rule{ + { + Name: "fake_1", + MatchFeatures: v1alpha1.FeatureMatcher{ + { + Feature: "fake.attribute", + MatchExpressions: &v1alpha1.MatchExpressionSet{ + "attr_2": &v1alpha1.MatchExpression{Op: v1alpha1.MatchIn, Value: v1alpha1.MatchValue{"false"}}, + }, + }, + }, + }, + }, + }, + }, + } + + expectedOutput := []*CompatibilityStatus{ + { + Tag: "prefered", + Weight: 90, + Description: "Fake compatibility 1", + Rules: []ProcessedRuleStatus{ + { + Name: "fake_1", + IsMatch: false, + MatchedExpressions: []MatchedExpression{ + { + Feature: "fake.attribute", + Name: "attr_1", + Expression: &v1alpha1.MatchExpression{Op: v1alpha1.MatchIn, Value: v1alpha1.MatchValue{"false"}}, + MatcherType: MatchExpressionType, + IsMatch: false, + }, + }, + }, + }, + }, + } + + validator := New( + WithArgs(&Args{ + Tags: []string{"prefered"}, + }), + WithArtifactClient(newMock(ctx, spec)), + WithSources(map[string]source.FeatureSource{fake.Name: source.GetFeatureSource(fake.Name)}), + ) + output, err := validator.Execute(ctx) + + So(err, ShouldBeNil) + So(output, ShouldEqual, expectedOutput) + }) +} + +func newMock(ctx context.Context, result *compatv1alpha1.Spec) *artifactcli.MockArtifactClient { + artifactClient := &artifactcli.MockArtifactClient{} + artifactClient.On("FetchCompatibilitySpec", ctx).Return(result, nil) + return artifactClient +} diff --git a/pkg/client-nfd/compat/node-validator/status.go b/pkg/client-nfd/compat/node-validator/status.go new file mode 100644 index 000000000..68d92e041 --- /dev/null +++ b/pkg/client-nfd/compat/node-validator/status.go @@ -0,0 +1,89 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package nodevalidator + +import ( + compatv1alpha1 "sigs.k8s.io/node-feature-discovery/api/image-compatibility/v1alpha1" + nfdv1alpha1 "sigs.k8s.io/node-feature-discovery/api/nfd/v1alpha1" +) + +// MatcherType represents a type of the used matcher. +type MatcherType string + +const ( + // MatchExpressionType represents a matchExpression type. + MatchExpressionType MatcherType = "matchExpression" + // MatchNameType represents a matchName type. + MatchNameType MatcherType = "matchName" +) + +// CompatibilityStatus represents the state of +// feature matching between the image and the host. +type CompatibilityStatus struct { + // Rules contain information about the matching status + // of all Node Feature Rules. + Rules []ProcessedRuleStatus `json:"rules"` + // Description of the compatibility set. + Description string `json:"description,omitempty"` + // Weight provides information about the priority of the compatibility set. + Weight int `json:"weight,omitempty"` + // Tag provides information about the tag assigned to the compatibility set. + Tag string `json:"tag,omitempty"` +} + +func newCompatibilityStatus(c *compatv1alpha1.Compatibility) CompatibilityStatus { + cs := CompatibilityStatus{ + Description: c.Description, + Weight: c.Weight, + Tag: c.Tag, + } + + return cs +} + +// ProcessedRuleStatus provides information whether the expressions succeeded on the host. +type ProcessedRuleStatus struct { + // Name of the rule. + Name string `json:"name"` + // IsMatch provides information if the rule matches with the host. + IsMatch bool `json:"isMatch"` + + // MatchedExpressions represents the expressions that succeed on the host. + MatchedExpressions []MatchedExpression `json:"matchedExpressions,omitempty"` + // MatchAny represents an array of logical OR conditions between MatchedExpressions entries. + MatchedAny []MatchAnyElem `json:"matchedAny,omitempty"` +} + +// MatchAnyElem represents a single object of MatchAny that contains MatchedExpression entries. +type MatchAnyElem struct { + // MatchedExpressions contains MatchedExpression entries. + MatchedExpressions []MatchedExpression `json:"matchedExpressions"` +} + +// MatchedExpression represent all details about the expression that succeeded on the host. +type MatchedExpression struct { + // Feature which is available to be evaluated on the host. + Feature string `json:"feature"` + // Name of the element. + Name string `json:"name"` + // Expression represents the expression provided by users. + Expression *nfdv1alpha1.MatchExpression `json:"expression"` + // MatcherType represents the matcher type, e.g. MatchExpression, MatchName. + MatcherType MatcherType `json:"matcherType"` + // IsMatch provides information whether the expression suceeded on the host. + IsMatch bool `json:"isMatch"` +}