@@ -18,11 +18,13 @@ package hash
1818
1919import (
2020 "crypto/sha256"
21+ "encoding/base64"
2122 "encoding/hex"
2223 "encoding/json"
2324 "fmt"
2425 "reflect"
2526 "sort"
27+ "strings"
2628
2729 "github.com/oracle/oci-go-sdk/v65/core"
2830 "github.com/pkg/errors"
@@ -139,10 +141,6 @@ func computeProjectedHash(projected *comparableLaunchDetails) (string, error) {
139141 return hex .EncodeToString (sum [:]), nil
140142}
141143
142- func normalizeLaunchDetails (in * core.InstanceConfigurationLaunchInstanceDetails ) * comparableLaunchDetails {
143- return projectLaunchDetails (in , in )
144- }
145-
146144func projectLaunchDetails (in , mask * core.InstanceConfigurationLaunchInstanceDetails ) * comparableLaunchDetails {
147145 if in == nil {
148146 return nil
@@ -170,14 +168,16 @@ func projectLaunchDetails(in, mask *core.InstanceConfigurationLaunchInstanceDeta
170168 }
171169}
172170
173- // normalizeMetadata filters instance metadata to exclude fields like user_data
171+ // normalizeMetadata returns a copy of the metadata map with user_data excluded.
172+ // Bootstrap data (user_data) changes are tracked separately via the
173+ // BootstrapDataHashAnnotation to avoid conflating infrastructure config
174+ // changes with bootstrap secret changes.
174175func normalizeMetadata (md map [string ]string ) map [string ]string {
175176 if md == nil {
176177 return nil
177178 }
178179 output := make (map [string ]string , len (md ))
179180 for k , v := range md {
180- // exclude user_data
181181 if k == "user_data" {
182182 continue
183183 }
@@ -189,16 +189,85 @@ func normalizeMetadata(md map[string]string) map[string]string {
189189 return output
190190}
191191
192- // hashChanged returns true if the two hashes are different, indicating a configuration change
193- func hashChanged (hash1 , hash2 string ) bool {
194- return hash1 != hash2
192+ // ComputeUserDataHash computes a SHA-256 hash of the raw user_data value from
193+ // instance metadata.
194+ func ComputeUserDataHash (metadata map [string ]string ) string {
195+ ud , ok := metadata ["user_data" ]
196+ if ! ok {
197+ return ""
198+ }
199+ sum := sha256 .Sum256 ([]byte (ud ))
200+ return hex .EncodeToString (sum [:])
201+ }
202+
203+ // ComputeUserDataHashIgnoringKubeadmToken computes a SHA-256 hash of user_data
204+ // with kubeadm discovery token lines normalized away. This lets callers detect
205+ // token-only rotation separately from substantive bootstrap changes.
206+ func ComputeUserDataHashIgnoringKubeadmToken (metadata map [string ]string ) string {
207+ ud , ok := metadata ["user_data" ]
208+ if ! ok {
209+ return ""
210+ }
211+ sum := sha256 .Sum256 ([]byte (normalizeUserDataForHash (ud )))
212+ return hex .EncodeToString (sum [:])
213+ }
214+
215+ func normalizeUserDataForHash (userData string ) string {
216+ decoded , err := base64 .StdEncoding .DecodeString (userData )
217+ if err != nil {
218+ return userData
219+ }
220+
221+ normalized := string (decoded )
222+ if ! strings .Contains (normalized , "kind: JoinConfiguration" ) || ! strings .Contains (normalized , "bootstrapToken:" ) {
223+ return normalized
224+ }
225+ return normalizeKubeadmDiscoveryBootstrapToken (normalized )
226+ }
227+
228+ // normalizeKubeadmDiscoveryBootstrapToken rewrites only
229+ // JoinConfiguration.discovery.bootstrapToken.token values to a fixed sentinel.
230+ // Why: CABPK rotates this token frequently; we want a token-insensitive hash for
231+ // classification/observability. Reconcile still uses the raw user_data hash for
232+ // drift decisions, so non-token bootstrap changes are not ignored.
233+ func normalizeKubeadmDiscoveryBootstrapToken (cloudInitData string ) string {
234+ lines := strings .Split (cloudInitData , "\n " )
235+ discoveryIndent := - 1
236+ bootstrapTokenIndent := - 1
237+
238+ for i , line := range lines {
239+ trimmed := strings .TrimSpace (line )
240+ if trimmed == "" || strings .HasPrefix (trimmed , "#" ) {
241+ continue
242+ }
243+
244+ indent := leadingIndentWidth (line )
245+
246+ if bootstrapTokenIndent >= 0 && indent <= bootstrapTokenIndent {
247+ bootstrapTokenIndent = - 1
248+ }
249+ if discoveryIndent >= 0 && indent <= discoveryIndent {
250+ discoveryIndent = - 1
251+ }
252+
253+ if trimmed == "discovery:" {
254+ discoveryIndent = indent
255+ continue
256+ }
257+ if discoveryIndent >= 0 && indent > discoveryIndent && trimmed == "bootstrapToken:" {
258+ bootstrapTokenIndent = indent
259+ continue
260+ }
261+ if bootstrapTokenIndent >= 0 && indent > bootstrapTokenIndent && strings .HasPrefix (trimmed , "token:" ) {
262+ lines [i ] = line [:indent ] + "token: <redacted>"
263+ }
264+ }
265+
266+ return strings .Join (lines , "\n " )
195267}
196268
197- // launchDetailsEqual returns true if two launch details are equivalent after normalization
198- func launchDetailsEqual (ld1 , ld2 * core.InstanceConfigurationLaunchInstanceDetails ) bool {
199- normalized1 := normalizeLaunchDetails (ld1 )
200- normalized2 := normalizeLaunchDetails (ld2 )
201- return reflect .DeepEqual (normalized1 , normalized2 )
269+ func leadingIndentWidth (s string ) int {
270+ return len (s ) - len (strings .TrimLeft (s , " \t " ))
202271}
203272
204273func projectCreateVnicDetails (in , mask * core.InstanceConfigurationCreateVnicDetails ) * comparableCreateVnicDetails {
0 commit comments