diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 1d13d018..203a8498 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -25,4 +25,4 @@ jobs: uses: actions/checkout@ff7abcd0c3c05ccf6adc123a8cd1fd4fb30fb493 # v4.1.3 - name: 'Dependency Review' - uses: actions/dependency-review-action@da24556b548a50705dd671f47852072ea4c105d9 # v4.7.1 + uses: actions/dependency-review-action@bc41886e18ea39df68b1b1245f4184881938e050 # v4.7.2 diff --git a/fleetconfig-controller/api/v1alpha1/fleetconfig_types.go b/fleetconfig-controller/api/v1alpha1/fleetconfig_types.go index 96e8f49f..82ff3d9b 100644 --- a/fleetconfig-controller/api/v1alpha1/fleetconfig_types.go +++ b/fleetconfig-controller/api/v1alpha1/fleetconfig_types.go @@ -458,6 +458,11 @@ type JoinedSpoke struct { // +kubebuilder:default:={} // +optional EnabledAddons []string `json:"enabledAddons,omitempty"` + + // KlusterletHash is a hash of spoke.klusterlet.values + // +kubebuilder:default:="" + // +optional + KlusterletHash string `json:"klusterletHash,omitempty"` } // GetName returns the name of the joined spoke cluster. diff --git a/fleetconfig-controller/build/Dockerfile.base b/fleetconfig-controller/build/Dockerfile.base index 0dde5d0f..adf00d89 100644 --- a/fleetconfig-controller/build/Dockerfile.base +++ b/fleetconfig-controller/build/Dockerfile.base @@ -38,7 +38,7 @@ ARG ARCH RUN apk update && apk add --no-cache bash curl # Install clusteradm -ARG CLUSTERADM_VERSION=1.0.1 +ARG CLUSTERADM_VERSION=1.0.2 RUN curl -L https://raw.githubusercontent.com/open-cluster-management-io/clusteradm/main/install.sh | bash -s -- ${CLUSTERADM_VERSION} ## Stage 3: Compress binaries with upx to reduce image size diff --git a/fleetconfig-controller/build/Dockerfile.devspace b/fleetconfig-controller/build/Dockerfile.devspace index e504e62f..43f250bb 100644 --- a/fleetconfig-controller/build/Dockerfile.devspace +++ b/fleetconfig-controller/build/Dockerfile.devspace @@ -16,7 +16,7 @@ RUN apk add --no-cache bash curl python3 py3-pip RUN go install github.com/go-delve/delve/cmd/dlv@latest # Install clusteradm -ARG CLUSTERADM_VERSION=1.0.1 +ARG CLUSTERADM_VERSION=1.0.2 RUN curl -L https://raw.githubusercontent.com/open-cluster-management-io/clusteradm/main/install.sh | bash -s -- ${CLUSTERADM_VERSION} # Install aws-iam-authenticator if building for EKS diff --git a/fleetconfig-controller/build/Dockerfile.eks b/fleetconfig-controller/build/Dockerfile.eks index f5c7fffe..de1efc3e 100644 --- a/fleetconfig-controller/build/Dockerfile.eks +++ b/fleetconfig-controller/build/Dockerfile.eks @@ -38,7 +38,7 @@ ARG ARCH RUN apk update && apk add --no-cache bash curl # Install clusteradm -ARG CLUSTERADM_VERSION=1.0.1 +ARG CLUSTERADM_VERSION=1.0.2 RUN curl -L https://raw.githubusercontent.com/open-cluster-management-io/clusteradm/main/install.sh | bash -s -- ${CLUSTERADM_VERSION} # Install aws-iam-authenticator diff --git a/fleetconfig-controller/build/Dockerfile.gke b/fleetconfig-controller/build/Dockerfile.gke index e7a681c6..3ca29b86 100644 --- a/fleetconfig-controller/build/Dockerfile.gke +++ b/fleetconfig-controller/build/Dockerfile.gke @@ -37,7 +37,7 @@ ARG ARCH RUN apk update && apk add --no-cache bash curl # Install clusteradm -ARG CLUSTERADM_VERSION=1.0.1 +ARG CLUSTERADM_VERSION=1.0.2 RUN curl -L https://raw.githubusercontent.com/open-cluster-management-io/clusteradm/main/install.sh | bash -s -- ${CLUSTERADM_VERSION} ## Stage 3: Compress binaries with upx to reduce image size diff --git a/fleetconfig-controller/charts/fleetconfig-controller/crds/fleetconfig.open-cluster-management.io_fleetconfigs.yaml b/fleetconfig-controller/charts/fleetconfig-controller/crds/fleetconfig.open-cluster-management.io_fleetconfigs.yaml index 1f80450b..ff46d3ba 100644 --- a/fleetconfig-controller/charts/fleetconfig-controller/crds/fleetconfig.open-cluster-management.io_fleetconfigs.yaml +++ b/fleetconfig-controller/charts/fleetconfig-controller/crds/fleetconfig.open-cluster-management.io_fleetconfigs.yaml @@ -2691,6 +2691,10 @@ spec: items: type: string type: array + klusterletHash: + default: "" + description: KlusterletHash is a hash of spoke.klusterlet.values + type: string kubeconfig: description: Kubeconfig details for the Spoke cluster. properties: diff --git a/fleetconfig-controller/go.mod b/fleetconfig-controller/go.mod index 42155fcc..906a9885 100644 --- a/fleetconfig-controller/go.mod +++ b/fleetconfig-controller/go.mod @@ -5,6 +5,7 @@ go 1.24.4 require ( github.com/Masterminds/semver v1.5.0 github.com/go-logr/logr v1.4.3 + github.com/mitchellh/hashstructure/v2 v2.0.2 github.com/onsi/ginkgo/v2 v2.23.4 github.com/onsi/gomega v1.37.0 github.com/openshift/build-machinery-go v0.0.0-20250602125535-1b6d00b8c37c diff --git a/fleetconfig-controller/go.sum b/fleetconfig-controller/go.sum index 8e790219..f8a248a8 100644 --- a/fleetconfig-controller/go.sum +++ b/fleetconfig-controller/go.sum @@ -142,6 +142,8 @@ github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4 github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/moby/buildkit v0.20.0 h1:aF5RujjQ310Pn6SLL/wQYIrSsPXy0sQ5KvWifwq1h8Y= diff --git a/fleetconfig-controller/hack/.versions.env b/fleetconfig-controller/hack/.versions.env index ed754803..99d57843 100644 --- a/fleetconfig-controller/hack/.versions.env +++ b/fleetconfig-controller/hack/.versions.env @@ -1,4 +1,4 @@ AWSIAMAUTH_VERSION=0.7.2 CERT_MANAGER_VERSION=v1.18.1 -CLUSTERADM_VERSION=1.0.1 +CLUSTERADM_VERSION=1.0.2 OCM_VERSION=1.0.0 diff --git a/fleetconfig-controller/internal/controller/v1alpha1/hub.go b/fleetconfig-controller/internal/controller/v1alpha1/hub.go index 32bcfc3a..6206d1b2 100644 --- a/fleetconfig-controller/internal/controller/v1alpha1/hub.go +++ b/fleetconfig-controller/internal/controller/v1alpha1/hub.go @@ -243,12 +243,16 @@ func hubNeedsUpgrade(ctx context.Context, fc *v1alpha1.FleetConfig, operatorC *o if err != nil { return false, fmt.Errorf("failed to detect bundleVersion from clustermanager spec: %w", err) } + desiredBundleVersion, err := version.Normalize(fc.Spec.Hub.ClusterManager.Source.BundleVersion) + if err != nil { + return false, err + } logger.V(0).Info("found clustermanager bundleVersions", "activeBundleVersion", activeBundleVersion, - "desiredBundleVersion", fc.Spec.Hub.ClusterManager.Source.BundleVersion, + "desiredBundleVersion", desiredBundleVersion, ) - return activeBundleVersion == fc.Spec.Hub.ClusterManager.Source.BundleVersion, nil + return activeBundleVersion != desiredBundleVersion, nil } // getClusterManager retrieves the ClusterManager resource from the Hub cluster diff --git a/fleetconfig-controller/internal/controller/v1alpha1/spoke.go b/fleetconfig-controller/internal/controller/v1alpha1/spoke.go index 4d59bc00..547e6862 100644 --- a/fleetconfig-controller/internal/controller/v1alpha1/spoke.go +++ b/fleetconfig-controller/internal/controller/v1alpha1/spoke.go @@ -27,6 +27,7 @@ import ( "github.com/open-cluster-management-io/lab/fleetconfig-controller/api/v1alpha1" exec_utils "github.com/open-cluster-management-io/lab/fleetconfig-controller/internal/exec" "github.com/open-cluster-management-io/lab/fleetconfig-controller/internal/file" + "github.com/open-cluster-management-io/lab/fleetconfig-controller/internal/hash" "github.com/open-cluster-management-io/lab/fleetconfig-controller/internal/kube" "github.com/open-cluster-management-io/lab/fleetconfig-controller/internal/version" "github.com/open-cluster-management-io/lab/fleetconfig-controller/pkg/common" @@ -78,8 +79,7 @@ func handleSpokes(ctx context.Context, kClient client.Client, fc *v1alpha1.Fleet } } - allEnabledAddons := make([][]string, len(fc.Spec.Spokes)) - for i, spoke := range fc.Spec.Spokes { + for _, spoke := range fc.Spec.Spokes { logger.V(0).Info("handleSpokes: reconciling spoke cluster", "name", spoke.Name) // check if the spoke has already been joined to the hub @@ -163,11 +163,16 @@ func handleSpokes(ctx context.Context, kClient client.Client, fc *v1alpha1.Fleet logger.V(0).Info("handleSpokes: labeled ManagedCluster as hub-as-spoke", "name", spoke.Name) } - // attempt an upgrade whenever the klusterlet's bundleVersion changes - upgrade, err := spokeNeedsUpgrade(ctx, kClient, spoke) + // attempt an upgrade whenever the klusterlet's bundleVersion or values change + currKlusterletHash, err := hash.ComputeHash(spoke.Klusterlet.Values) + if err != nil { + return fmt.Errorf("failed to compute hash of spoke %s klusterlet values: %w", spoke.Name, err) + } + upgrade, err := spokeNeedsUpgrade(ctx, kClient, spoke, fc.Status.JoinedSpokes, currKlusterletHash) if err != nil { return fmt.Errorf("failed to check if spoke cluster needs upgrade: %w", err) } + if upgrade { if err := upgradeSpoke(ctx, kClient, fc, spoke); err != nil { return fmt.Errorf("failed to upgrade spoke cluster %s: %w", spoke.Name, err) @@ -175,7 +180,6 @@ func handleSpokes(ctx context.Context, kClient client.Client, fc *v1alpha1.Fleet } enabledAddons, err := handleSpokeAddons(ctx, addonClient, spoke, fc) - allEnabledAddons[i] = enabledAddons if err != nil { msg := fmt.Sprintf("failed to enable addons for spoke cluster %s: %s", spoke.Name, err.Error()) fc.SetConditions(true, v1alpha1.NewCondition( @@ -189,27 +193,33 @@ func handleSpokes(ctx context.Context, kClient client.Client, fc *v1alpha1.Fleet "AddonsEnabled", spoke.AddonEnableType(), metav1.ConditionTrue, metav1.ConditionTrue, )) } - } - // Only spokes which are joined, are eligible to be unjoined - for i, spoke := range fc.Spec.Spokes { - joinedCondition := fc.GetCondition(spoke.JoinType()) - if joinedCondition == nil || joinedCondition.Status != metav1.ConditionTrue { - continue - } js := v1alpha1.JoinedSpoke{ Name: spoke.Name, Kubeconfig: spoke.Kubeconfig, PurgeKlusterletOperator: spoke.Klusterlet.PurgeOperator, - EnabledAddons: allEnabledAddons[i], + EnabledAddons: enabledAddons, + KlusterletHash: currKlusterletHash, } joinedSpokes = append(joinedSpokes, js) + } + fc.Status.JoinedSpokes = joinedSpokes return nil } +func getJoinedSpoke(js []v1alpha1.JoinedSpoke, spokeName string) (v1alpha1.JoinedSpoke, bool) { + i := slices.IndexFunc(js, func(s v1alpha1.JoinedSpoke) bool { + return spokeName == s.Name + }) + if i == -1 { + return v1alpha1.JoinedSpoke{}, false + } + return js[i], true +} + func getJoinedCondition(managedCluster *clusterv1.ManagedCluster) *metav1.Condition { if managedCluster == nil || managedCluster.Status.Conditions == nil { return nil @@ -420,11 +430,27 @@ func joinSpoke(ctx context.Context, kClient client.Client, fc *v1alpha1.FleetCon return nil } -// spokeNeedsUpgrade checks if the klusterlet on a Spoke cluster has the desired bundle version -func spokeNeedsUpgrade(ctx context.Context, kClient client.Client, spoke v1alpha1.Spoke) (bool, error) { +// spokeNeedsUpgrade checks if the klusterlet on a Spoke cluster requires an upgrade. Upgrades are required when any of the following are true: +// - The bundle version in the spec does not match the klusterlet's active bundle version +// - The hash of the klusterlet chart values in the spec does not match the hash of the last applied klusterlet chart values +func spokeNeedsUpgrade(ctx context.Context, kClient client.Client, spoke v1alpha1.Spoke, joinedSpokes []v1alpha1.JoinedSpoke, currKlusterletHash string) (bool, error) { logger := log.FromContext(ctx) logger.V(0).Info("spokeNeedsUpgrade", "spokeClusterName", spoke.Name) + hashChanged := false + prevJs, found := getJoinedSpoke(joinedSpokes, spoke.Name) + if found { + hashChanged = prevJs.KlusterletHash != currKlusterletHash + logger.V(2).Info("comparing klusterlet values hash", + "spoke", spoke.Name, + "prevHash", prevJs.KlusterletHash, + "currHash", currKlusterletHash, + ) + } + if hashChanged { + return true, nil + } + if spoke.Klusterlet.Source.BundleVersion == "default" { logger.V(0).Info("klusterlet bundleVersion is default, skipping upgrade") return false, nil @@ -463,12 +489,16 @@ func spokeNeedsUpgrade(ctx context.Context, kClient client.Client, spoke v1alpha if err != nil { return false, fmt.Errorf("failed to detect bundleVersion from klusterlet spec: %w", err) } + desiredBundleVersion, err := version.Normalize(spoke.Klusterlet.Source.BundleVersion) + if err != nil { + return false, err + } logger.V(0).Info("found klusterlet bundleVersions", "activeBundleVersion", activeBundleVersion, - "desiredBundleVersion", spoke.Klusterlet.Source.BundleVersion, + "desiredBundleVersion", desiredBundleVersion, ) - return activeBundleVersion == spoke.Klusterlet.Source.BundleVersion, nil + return activeBundleVersion != desiredBundleVersion, nil } // upgradeSpoke upgrades the Spoke cluster's klusterlet to the specified version diff --git a/fleetconfig-controller/internal/hash/hash.go b/fleetconfig-controller/internal/hash/hash.go new file mode 100644 index 00000000..a7696aa9 --- /dev/null +++ b/fleetconfig-controller/internal/hash/hash.go @@ -0,0 +1,22 @@ +// Package hash provides hashing utilities. +package hash + +import ( + "strconv" + + "github.com/mitchellh/hashstructure/v2" +) + +// ComputeHash computes the hash value of an arbitrary object +func ComputeHash(obj any) (string, error) { + opts := &hashstructure.HashOptions{ + ZeroNil: true, + } + // compute a hash value of any object + hash, err := hashstructure.Hash(obj, hashstructure.FormatV2, opts) + if err != nil { + return "", err + } + hashStr := strconv.FormatUint(hash, 16) + return hashStr, nil +} diff --git a/fleetconfig-controller/internal/version/version.go b/fleetconfig-controller/internal/version/version.go index f85444ad..15e5ef8e 100644 --- a/fleetconfig-controller/internal/version/version.go +++ b/fleetconfig-controller/internal/version/version.go @@ -44,3 +44,12 @@ func LowestBundleVersion(ctx context.Context, bundleSpecs []string) (string, err return semvers[0].String(), nil } + +// Normalize returns a semver string with the leading `v` prefix stripped off +func Normalize(v string) (string, error) { + sv, err := semver.NewVersion(v) + if err != nil { + return "", err + } + return sv.String(), nil +} diff --git a/fleetconfig-controller/internal/version/version_test.go b/fleetconfig-controller/internal/version/version_test.go index e27a96df..e57c493c 100644 --- a/fleetconfig-controller/internal/version/version_test.go +++ b/fleetconfig-controller/internal/version/version_test.go @@ -50,3 +50,43 @@ func TestLowestBundleVersion(t *testing.T) { }) } } + +func TestNormalize(t *testing.T) { + tests := []struct { + name string + version string + want string + wantErr bool + }{ + { + name: "valid version with v prefix", + version: "v1.2.3", + want: "1.2.3", + wantErr: false, + }, + { + name: "valid version without v prefix", + version: "1.2.3", + want: "1.2.3", + wantErr: false, + }, + { + name: "invalid version string", + version: "invalid-version", + want: "", + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Normalize(tt.version) + if (err != nil) != tt.wantErr { + t.Errorf("Normalize(%v) error = %v, wantErr %v", tt.version, err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("Normalize(%v) = %v, want %v", tt.version, got, tt.want) + } + }) + } +} diff --git a/fleetconfig-controller/vendor/github.com/mitchellh/hashstructure/v2/LICENSE b/fleetconfig-controller/vendor/github.com/mitchellh/hashstructure/v2/LICENSE new file mode 100644 index 00000000..a3866a29 --- /dev/null +++ b/fleetconfig-controller/vendor/github.com/mitchellh/hashstructure/v2/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016 Mitchell Hashimoto + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/fleetconfig-controller/vendor/github.com/mitchellh/hashstructure/v2/README.md b/fleetconfig-controller/vendor/github.com/mitchellh/hashstructure/v2/README.md new file mode 100644 index 00000000..21f36be1 --- /dev/null +++ b/fleetconfig-controller/vendor/github.com/mitchellh/hashstructure/v2/README.md @@ -0,0 +1,76 @@ +# hashstructure [![GoDoc](https://godoc.org/github.com/mitchellh/hashstructure?status.svg)](https://godoc.org/github.com/mitchellh/hashstructure) + +hashstructure is a Go library for creating a unique hash value +for arbitrary values in Go. + +This can be used to key values in a hash (for use in a map, set, etc.) +that are complex. The most common use case is comparing two values without +sending data across the network, caching values locally (de-dup), and so on. + +## Features + + * Hash any arbitrary Go value, including complex types. + + * Tag a struct field to ignore it and not affect the hash value. + + * Tag a slice type struct field to treat it as a set where ordering + doesn't affect the hash code but the field itself is still taken into + account to create the hash value. + + * Optionally, specify a custom hash function to optimize for speed, collision + avoidance for your data set, etc. + + * Optionally, hash the output of `.String()` on structs that implement fmt.Stringer, + allowing effective hashing of time.Time + + * Optionally, override the hashing process by implementing `Hashable`. + +## Installation + +Standard `go get`: + +``` +$ go get github.com/mitchellh/hashstructure/v2 +``` + +**Note on v2:** It is highly recommended you use the "v2" release since this +fixes some significant hash collisions issues from v1. In practice, we used +v1 for many years in real projects at HashiCorp and never had issues, but it +is highly dependent on the shape of the data you're hashing and how you use +those hashes. + +When using v2+, you can still generate weaker v1 hashes by using the +`FormatV1` format when calling `Hash`. + +## Usage & Example + +For usage and examples see the [Godoc](http://godoc.org/github.com/mitchellh/hashstructure). + +A quick code example is shown below: + +```go +type ComplexStruct struct { + Name string + Age uint + Metadata map[string]interface{} +} + +v := ComplexStruct{ + Name: "mitchellh", + Age: 64, + Metadata: map[string]interface{}{ + "car": true, + "location": "California", + "siblings": []string{"Bob", "John"}, + }, +} + +hash, err := hashstructure.Hash(v, hashstructure.FormatV2, nil) +if err != nil { + panic(err) +} + +fmt.Printf("%d", hash) +// Output: +// 2307517237273902113 +``` diff --git a/fleetconfig-controller/vendor/github.com/mitchellh/hashstructure/v2/errors.go b/fleetconfig-controller/vendor/github.com/mitchellh/hashstructure/v2/errors.go new file mode 100644 index 00000000..44b89514 --- /dev/null +++ b/fleetconfig-controller/vendor/github.com/mitchellh/hashstructure/v2/errors.go @@ -0,0 +1,22 @@ +package hashstructure + +import ( + "fmt" +) + +// ErrNotStringer is returned when there's an error with hash:"string" +type ErrNotStringer struct { + Field string +} + +// Error implements error for ErrNotStringer +func (ens *ErrNotStringer) Error() string { + return fmt.Sprintf("hashstructure: %s has hash:\"string\" set, but does not implement fmt.Stringer", ens.Field) +} + +// ErrFormat is returned when an invalid format is given to the Hash function. +type ErrFormat struct{} + +func (*ErrFormat) Error() string { + return "format must be one of the defined Format values in the hashstructure library" +} diff --git a/fleetconfig-controller/vendor/github.com/mitchellh/hashstructure/v2/hashstructure.go b/fleetconfig-controller/vendor/github.com/mitchellh/hashstructure/v2/hashstructure.go new file mode 100644 index 00000000..3dc0eb74 --- /dev/null +++ b/fleetconfig-controller/vendor/github.com/mitchellh/hashstructure/v2/hashstructure.go @@ -0,0 +1,482 @@ +package hashstructure + +import ( + "encoding/binary" + "fmt" + "hash" + "hash/fnv" + "reflect" + "time" +) + +// HashOptions are options that are available for hashing. +type HashOptions struct { + // Hasher is the hash function to use. If this isn't set, it will + // default to FNV. + Hasher hash.Hash64 + + // TagName is the struct tag to look at when hashing the structure. + // By default this is "hash". + TagName string + + // ZeroNil is flag determining if nil pointer should be treated equal + // to a zero value of pointed type. By default this is false. + ZeroNil bool + + // IgnoreZeroValue is determining if zero value fields should be + // ignored for hash calculation. + IgnoreZeroValue bool + + // SlicesAsSets assumes that a `set` tag is always present for slices. + // Default is false (in which case the tag is used instead) + SlicesAsSets bool + + // UseStringer will attempt to use fmt.Stringer always. If the struct + // doesn't implement fmt.Stringer, it'll fall back to trying usual tricks. + // If this is true, and the "string" tag is also set, the tag takes + // precedence (meaning that if the type doesn't implement fmt.Stringer, we + // panic) + UseStringer bool +} + +// Format specifies the hashing process used. Different formats typically +// generate different hashes for the same value and have different properties. +type Format uint + +const ( + // To disallow the zero value + formatInvalid Format = iota + + // FormatV1 is the format used in v1.x of this library. This has the + // downsides noted in issue #18 but allows simultaneous v1/v2 usage. + FormatV1 + + // FormatV2 is the current recommended format and fixes the issues + // noted in FormatV1. + FormatV2 + + formatMax // so we can easily find the end +) + +// Hash returns the hash value of an arbitrary value. +// +// If opts is nil, then default options will be used. See HashOptions +// for the default values. The same *HashOptions value cannot be used +// concurrently. None of the values within a *HashOptions struct are +// safe to read/write while hashing is being done. +// +// The "format" is required and must be one of the format values defined +// by this library. You should probably just use "FormatV2". This allows +// generated hashes uses alternate logic to maintain compatibility with +// older versions. +// +// Notes on the value: +// +// * Unexported fields on structs are ignored and do not affect the +// hash value. +// +// * Adding an exported field to a struct with the zero value will change +// the hash value. +// +// For structs, the hashing can be controlled using tags. For example: +// +// struct { +// Name string +// UUID string `hash:"ignore"` +// } +// +// The available tag values are: +// +// * "ignore" or "-" - The field will be ignored and not affect the hash code. +// +// * "set" - The field will be treated as a set, where ordering doesn't +// affect the hash code. This only works for slices. +// +// * "string" - The field will be hashed as a string, only works when the +// field implements fmt.Stringer +// +func Hash(v interface{}, format Format, opts *HashOptions) (uint64, error) { + // Validate our format + if format <= formatInvalid || format >= formatMax { + return 0, &ErrFormat{} + } + + // Create default options + if opts == nil { + opts = &HashOptions{} + } + if opts.Hasher == nil { + opts.Hasher = fnv.New64() + } + if opts.TagName == "" { + opts.TagName = "hash" + } + + // Reset the hash + opts.Hasher.Reset() + + // Create our walker and walk the structure + w := &walker{ + format: format, + h: opts.Hasher, + tag: opts.TagName, + zeronil: opts.ZeroNil, + ignorezerovalue: opts.IgnoreZeroValue, + sets: opts.SlicesAsSets, + stringer: opts.UseStringer, + } + return w.visit(reflect.ValueOf(v), nil) +} + +type walker struct { + format Format + h hash.Hash64 + tag string + zeronil bool + ignorezerovalue bool + sets bool + stringer bool +} + +type visitOpts struct { + // Flags are a bitmask of flags to affect behavior of this visit + Flags visitFlag + + // Information about the struct containing this field + Struct interface{} + StructField string +} + +var timeType = reflect.TypeOf(time.Time{}) + +func (w *walker) visit(v reflect.Value, opts *visitOpts) (uint64, error) { + t := reflect.TypeOf(0) + + // Loop since these can be wrapped in multiple layers of pointers + // and interfaces. + for { + // If we have an interface, dereference it. We have to do this up + // here because it might be a nil in there and the check below must + // catch that. + if v.Kind() == reflect.Interface { + v = v.Elem() + continue + } + + if v.Kind() == reflect.Ptr { + if w.zeronil { + t = v.Type().Elem() + } + v = reflect.Indirect(v) + continue + } + + break + } + + // If it is nil, treat it like a zero. + if !v.IsValid() { + v = reflect.Zero(t) + } + + // Binary writing can use raw ints, we have to convert to + // a sized-int, we'll choose the largest... + switch v.Kind() { + case reflect.Int: + v = reflect.ValueOf(int64(v.Int())) + case reflect.Uint: + v = reflect.ValueOf(uint64(v.Uint())) + case reflect.Bool: + var tmp int8 + if v.Bool() { + tmp = 1 + } + v = reflect.ValueOf(tmp) + } + + k := v.Kind() + + // We can shortcut numeric values by directly binary writing them + if k >= reflect.Int && k <= reflect.Complex64 { + // A direct hash calculation + w.h.Reset() + err := binary.Write(w.h, binary.LittleEndian, v.Interface()) + return w.h.Sum64(), err + } + + switch v.Type() { + case timeType: + w.h.Reset() + b, err := v.Interface().(time.Time).MarshalBinary() + if err != nil { + return 0, err + } + + err = binary.Write(w.h, binary.LittleEndian, b) + return w.h.Sum64(), err + } + + switch k { + case reflect.Array: + var h uint64 + l := v.Len() + for i := 0; i < l; i++ { + current, err := w.visit(v.Index(i), nil) + if err != nil { + return 0, err + } + + h = hashUpdateOrdered(w.h, h, current) + } + + return h, nil + + case reflect.Map: + var includeMap IncludableMap + if opts != nil && opts.Struct != nil { + if v, ok := opts.Struct.(IncludableMap); ok { + includeMap = v + } + } + + // Build the hash for the map. We do this by XOR-ing all the key + // and value hashes. This makes it deterministic despite ordering. + var h uint64 + for _, k := range v.MapKeys() { + v := v.MapIndex(k) + if includeMap != nil { + incl, err := includeMap.HashIncludeMap( + opts.StructField, k.Interface(), v.Interface()) + if err != nil { + return 0, err + } + if !incl { + continue + } + } + + kh, err := w.visit(k, nil) + if err != nil { + return 0, err + } + vh, err := w.visit(v, nil) + if err != nil { + return 0, err + } + + fieldHash := hashUpdateOrdered(w.h, kh, vh) + h = hashUpdateUnordered(h, fieldHash) + } + + if w.format != FormatV1 { + // Important: read the docs for hashFinishUnordered + h = hashFinishUnordered(w.h, h) + } + + return h, nil + + case reflect.Struct: + parent := v.Interface() + var include Includable + if impl, ok := parent.(Includable); ok { + include = impl + } + + if impl, ok := parent.(Hashable); ok { + return impl.Hash() + } + + // If we can address this value, check if the pointer value + // implements our interfaces and use that if so. + if v.CanAddr() { + vptr := v.Addr() + parentptr := vptr.Interface() + if impl, ok := parentptr.(Includable); ok { + include = impl + } + + if impl, ok := parentptr.(Hashable); ok { + return impl.Hash() + } + } + + t := v.Type() + h, err := w.visit(reflect.ValueOf(t.Name()), nil) + if err != nil { + return 0, err + } + + l := v.NumField() + for i := 0; i < l; i++ { + if innerV := v.Field(i); v.CanSet() || t.Field(i).Name != "_" { + var f visitFlag + fieldType := t.Field(i) + if fieldType.PkgPath != "" { + // Unexported + continue + } + + tag := fieldType.Tag.Get(w.tag) + if tag == "ignore" || tag == "-" { + // Ignore this field + continue + } + + if w.ignorezerovalue { + if innerV.IsZero() { + continue + } + } + + // if string is set, use the string value + if tag == "string" || w.stringer { + if impl, ok := innerV.Interface().(fmt.Stringer); ok { + innerV = reflect.ValueOf(impl.String()) + } else if tag == "string" { + // We only show this error if the tag explicitly + // requests a stringer. + return 0, &ErrNotStringer{ + Field: v.Type().Field(i).Name, + } + } + } + + // Check if we implement includable and check it + if include != nil { + incl, err := include.HashInclude(fieldType.Name, innerV) + if err != nil { + return 0, err + } + if !incl { + continue + } + } + + switch tag { + case "set": + f |= visitFlagSet + } + + kh, err := w.visit(reflect.ValueOf(fieldType.Name), nil) + if err != nil { + return 0, err + } + + vh, err := w.visit(innerV, &visitOpts{ + Flags: f, + Struct: parent, + StructField: fieldType.Name, + }) + if err != nil { + return 0, err + } + + fieldHash := hashUpdateOrdered(w.h, kh, vh) + h = hashUpdateUnordered(h, fieldHash) + } + + if w.format != FormatV1 { + // Important: read the docs for hashFinishUnordered + h = hashFinishUnordered(w.h, h) + } + } + + return h, nil + + case reflect.Slice: + // We have two behaviors here. If it isn't a set, then we just + // visit all the elements. If it is a set, then we do a deterministic + // hash code. + var h uint64 + var set bool + if opts != nil { + set = (opts.Flags & visitFlagSet) != 0 + } + l := v.Len() + for i := 0; i < l; i++ { + current, err := w.visit(v.Index(i), nil) + if err != nil { + return 0, err + } + + if set || w.sets { + h = hashUpdateUnordered(h, current) + } else { + h = hashUpdateOrdered(w.h, h, current) + } + } + + if set && w.format != FormatV1 { + // Important: read the docs for hashFinishUnordered + h = hashFinishUnordered(w.h, h) + } + + return h, nil + + case reflect.String: + // Directly hash + w.h.Reset() + _, err := w.h.Write([]byte(v.String())) + return w.h.Sum64(), err + + default: + return 0, fmt.Errorf("unknown kind to hash: %s", k) + } + +} + +func hashUpdateOrdered(h hash.Hash64, a, b uint64) uint64 { + // For ordered updates, use a real hash function + h.Reset() + + // We just panic if the binary writes fail because we are writing + // an int64 which should never be fail-able. + e1 := binary.Write(h, binary.LittleEndian, a) + e2 := binary.Write(h, binary.LittleEndian, b) + if e1 != nil { + panic(e1) + } + if e2 != nil { + panic(e2) + } + + return h.Sum64() +} + +func hashUpdateUnordered(a, b uint64) uint64 { + return a ^ b +} + +// After mixing a group of unique hashes with hashUpdateUnordered, it's always +// necessary to call hashFinishUnordered. Why? Because hashUpdateUnordered +// is a simple XOR, and calling hashUpdateUnordered on hashes produced by +// hashUpdateUnordered can effectively cancel out a previous change to the hash +// result if the same hash value appears later on. For example, consider: +// +// hashUpdateUnordered(hashUpdateUnordered("A", "B"), hashUpdateUnordered("A", "C")) = +// H("A") ^ H("B")) ^ (H("A") ^ H("C")) = +// (H("A") ^ H("A")) ^ (H("B") ^ H(C)) = +// H(B) ^ H(C) = +// hashUpdateUnordered(hashUpdateUnordered("Z", "B"), hashUpdateUnordered("Z", "C")) +// +// hashFinishUnordered "hardens" the result, so that encountering partially +// overlapping input data later on in a different context won't cancel out. +func hashFinishUnordered(h hash.Hash64, a uint64) uint64 { + h.Reset() + + // We just panic if the writes fail + e1 := binary.Write(h, binary.LittleEndian, a) + if e1 != nil { + panic(e1) + } + + return h.Sum64() +} + +// visitFlag is used as a bitmask for affecting visit behavior +type visitFlag uint + +const ( + visitFlagInvalid visitFlag = iota + visitFlagSet = iota << 1 +) diff --git a/fleetconfig-controller/vendor/github.com/mitchellh/hashstructure/v2/include.go b/fleetconfig-controller/vendor/github.com/mitchellh/hashstructure/v2/include.go new file mode 100644 index 00000000..702d3541 --- /dev/null +++ b/fleetconfig-controller/vendor/github.com/mitchellh/hashstructure/v2/include.go @@ -0,0 +1,22 @@ +package hashstructure + +// Includable is an interface that can optionally be implemented by +// a struct. It will be called for each field in the struct to check whether +// it should be included in the hash. +type Includable interface { + HashInclude(field string, v interface{}) (bool, error) +} + +// IncludableMap is an interface that can optionally be implemented by +// a struct. It will be called when a map-type field is found to ask the +// struct if the map item should be included in the hash. +type IncludableMap interface { + HashIncludeMap(field string, k, v interface{}) (bool, error) +} + +// Hashable is an interface that can optionally be implemented by a struct +// to override the hash value. This value will override the hash value for +// the entire struct. Entries in the struct will not be hashed. +type Hashable interface { + Hash() (uint64, error) +} diff --git a/fleetconfig-controller/vendor/modules.txt b/fleetconfig-controller/vendor/modules.txt index 39232432..78ca40f9 100644 --- a/fleetconfig-controller/vendor/modules.txt +++ b/fleetconfig-controller/vendor/modules.txt @@ -208,6 +208,9 @@ github.com/mailru/easyjson/jwriter # github.com/mitchellh/copystructure v1.2.0 ## explicit; go 1.15 github.com/mitchellh/copystructure +# github.com/mitchellh/hashstructure/v2 v2.0.2 +## explicit; go 1.14 +github.com/mitchellh/hashstructure/v2 # github.com/mitchellh/reflectwalk v1.0.2 ## explicit github.com/mitchellh/reflectwalk