This is an automated email from the ASF dual-hosted git repository.

fanningpj pushed a commit to branch 1.3.x
in repository https://gitbox.apache.org/repos/asf/pekko-management.git


The following commit(s) were added to refs/heads/1.3.x by this push:
     new 44051b9d Add hash suffix on truncation for Kubernetes lease names 
(#708) (#723)
44051b9d is described below

commit 44051b9dc27082b17a77d92d2dbab9923606d74f
Author: PJ Fanning <[email protected]>
AuthorDate: Sat Mar 28 11:01:21 2026 +0100

    Add hash suffix on truncation for Kubernetes lease names (#708) (#723)
    
    * Initial plan
    
    * Add on-truncate-add-hash-length config with SHA-256/base32 hash suffix on 
truncation
    
    
    
    * Add Base32EncodeSpec with edge-case tests; widen base32Encode to 
private[kubernetes]
    
    
    
    * Switch hash suffix to take first N chars; add tests for names differing 
by last 1-2 chars
    
    
    
    * Handle hashLength >= maxLength gracefully; fix test bugs and add 
edge-case tests
    
    
    
    * scalafmt
    
    * Refactor string concatenation to use string interpolation
    
    * Update Base32EncodeSpec.scala
    
    * Update Base32EncodeSpec.scala
    
    * update long hash len logic
    
    * Update MakeDNS1039CompatibleSpec.scala
    
    ---------
    
    Co-authored-by: copilot-swe-agent[bot] 
<[email protected]>
    Co-authored-by: pjfanning <[email protected]>
---
 lease-kubernetes/src/main/resources/reference.conf |  19 ++
 .../lease/kubernetes/AbstractKubernetesLease.scala |  74 +++++++-
 .../lease/kubernetes/KubernetesSettings.scala      |   8 +-
 .../lease/kubernetes/Base32EncodeSpec.scala        | 144 +++++++++++++++
 .../lease/kubernetes/KubernetesSettingsSpec.scala  |  12 ++
 .../kubernetes/MakeDNS1039CompatibleSpec.scala     | 205 +++++++++++++++++++++
 6 files changed, 453 insertions(+), 9 deletions(-)

diff --git a/lease-kubernetes/src/main/resources/reference.conf 
b/lease-kubernetes/src/main/resources/reference.conf
index c1ee5f7a..dc88efbf 100644
--- a/lease-kubernetes/src/main/resources/reference.conf
+++ b/lease-kubernetes/src/main/resources/reference.conf
@@ -53,6 +53,25 @@ pekko.coordination.lease.kubernetes {
     # on the way back from the API server but will be reported as not taken 
and can be safely retried.
     lease-operation-timeout = 5s
 
+    # The maximum length of the lease name after sanitization for DNS 1039 
compatibility.
+    # DNS 1039 labels must be 63 characters or less. Some subsystems of 
Kubernetes cannot
+    # manage longer names, so the default is 63.
+    # You may be able to set this to 253 if you are sure that your Kubernetes 
cluster can handle it, see
+    # 
https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#dns-subdomain-names
+    lease-name-max-length = 63
+
+    # When the lease name needs to be truncated to fit within 
lease-name-max-length, a hash suffix
+    # is appended to help distinguish names that would otherwise truncate to 
the same value.
+    # The suffix is derived from a SHA-256 digest of the original lease name, 
base32-encoded
+    # (lowercase), with the last N characters taken where N is this setting.
+    # Set to 0 to disable hash suffixing on truncation.
+    # We base32 encode the SHA-256 digest to ensure that the suffix is DNS 
1039 compatible, and we
+    # take characters from the start of the hash. This encoded value about 52 
characters long.
+    # If you choose a value greater than about 52 then the full digest will be 
used as the suffix.
+    # If you choose a value greater than the lease-name-max-length then the 
suffix will be used on its
+    # own with no prefix but will still be truncated to fit within 
lease-name-max-length.
+    on-truncate-add-hash-length = 8
+
     # Settings that are specific to retrying requests with 401 responses due 
to possible token rotation
     token-rotation-retry {
       # Number of total attempts to make
diff --git 
a/lease-kubernetes/src/main/scala/org/apache/pekko/coordination/lease/kubernetes/AbstractKubernetesLease.scala
 
b/lease-kubernetes/src/main/scala/org/apache/pekko/coordination/lease/kubernetes/AbstractKubernetesLease.scala
index d8a6a20d..5c6dcd88 100644
--- 
a/lease-kubernetes/src/main/scala/org/apache/pekko/coordination/lease/kubernetes/AbstractKubernetesLease.scala
+++ 
b/lease-kubernetes/src/main/scala/org/apache/pekko/coordination/lease/kubernetes/AbstractKubernetesLease.scala
@@ -13,6 +13,8 @@
 
 package org.apache.pekko.coordination.lease.kubernetes
 
+import java.nio.charset.StandardCharsets
+import java.security.MessageDigest
 import java.text.Normalizer
 import java.util.concurrent.atomic.AtomicBoolean
 import java.util.concurrent.atomic.AtomicInteger
@@ -20,7 +22,6 @@ import scala.concurrent.Future
 import scala.util.{ Failure, Success }
 import scala.annotation.nowarn
 import org.apache.pekko
-import 
pekko.coordination.lease.kubernetes.AbstractKubernetesLease.makeDNS1039Compatible
 import pekko.actor.ExtendedActorSystem
 import pekko.coordination.lease.kubernetes.LeaseActor._
 import pekko.coordination.lease.scaladsl.Lease
@@ -37,11 +38,47 @@ object AbstractKubernetesLease {
   val configPath = "pekko.coordination.lease.kubernetes"
   private val leaseCounter = new AtomicInteger(1)
 
+  // Base32 alphabet (RFC 4648 §6), **lowercased** so every character is a 
valid DNS 1039 label
+  // character.  '=' padding is intentionally omitted: we stream full 5-bit 
groups and emit one
+  // final partial group when bits remain, so the output contains only 
[a-z2-7] characters.
+  private val base32Alphabet = "abcdefghijklmnopqrstuvwxyz234567"
+
+  /**
+   * Base32-encode a byte array using a lowercase alphabet and no '=' padding.
+   * Every output character is in [a-z2-7], making the result safe for use in 
DNS 1039 labels.
+   */
+  private[kubernetes] def base32Encode(bytes: Array[Byte]): String = {
+    val sb = new StringBuilder
+    var buffer = 0
+    var bitsLeft = 0
+    for (b <- bytes) {
+      buffer = (buffer << 8) | (b & 0xFF)
+      bitsLeft += 8
+      while (bitsLeft >= 5) {
+        bitsLeft -= 5
+        sb.append(base32Alphabet((buffer >> bitsLeft) & 0x1F))
+      }
+    }
+    if (bitsLeft > 0) {
+      sb.append(base32Alphabet((buffer << (5 - bitsLeft)) & 0x1F))
+    }
+    sb.toString()
+  }
+
+  /**
+   * Compute a short hash suffix for the given name: SHA-256 → base32 → first 
`length` chars.
+   */
+  private def computeHashSuffix(name: String, length: Int): String = {
+    val digest = MessageDigest.getInstance("SHA-256")
+    val hashBytes = digest.digest(name.getBytes(StandardCharsets.UTF_8))
+    base32Encode(hashBytes).take(length)
+  }
+
   /**
-   * Limit the length of a name to 63 characters.
+   * Limit the length of a name to the given number of characters.
    * Some subsystem of Kubernetes cannot manage longer names.
    */
-  private def truncateTo63Characters(name: String): String = name.take(63)
+  private def truncateToLength(name: String, maxLength: Int): String = 
name.take(maxLength)
 
   /**
    * Removes from the leading and trailing positions the specified characters.
@@ -52,12 +89,31 @@ object AbstractKubernetesLease {
   /**
    * Make a name compatible with DNS 1039 standard: like a single domain name 
segment.
    * Regex to follow: [a-z]([-a-z0-9]*[a-z0-9])
-   * Limit the resulting name to 63 characters
+   * Limit the resulting name to maxLength characters (default 63).
+   * When truncation is necessary and hashLength > 0, the last (hashLength + 
1) characters of the
+   * truncated name are replaced by a hyphen followed by a 
hashLength-character hash suffix derived
+   * from a SHA-256 digest of the original name (base32-encoded, first 
hashLength chars taken).
+   * If hashLength >= maxLength the result consists entirely of the first 
maxLength hash characters.
    */
-  private def makeDNS1039Compatible(name: String): String = {
+  private[kubernetes] def makeDNS1039Compatible(name: String, maxLength: Int = 
63, hashLength: Int = 0): String = {
     val normalized =
       Normalizer.normalize(name, 
Normalizer.Form.NFKD).toLowerCase.replaceAll("[_.]", 
"-").replaceAll("[^-a-z0-9]", "")
-    trim(truncateTo63Characters(normalized), List('-'))
+    if (normalized.length <= maxLength || hashLength <= 0) {
+      trim(truncateToLength(normalized, maxLength), List('-'))
+    } else {
+      val maxSuffixLength = math.min(hashLength, maxLength)
+      val hashSuffix = computeHashSuffix(name, maxSuffixLength)
+      if (hashSuffix.length >= maxLength - 1) {
+        // Hash suffix alone fills or exceeds the max length, so return only 
hash chars (capped at maxLength)
+        // also account for the '-' that would be added if we had room for a 
prefix
+        hashSuffix.take(maxLength)
+      } else {
+        // Truncate prefix to fit the hash suffix and hyphen within maxLength
+        val prefixLength = maxLength - hashSuffix.length - 1
+        val prefix = trim(truncateToLength(normalized, prefixLength), 
List('-'))
+        s"$prefix-$hashSuffix"
+      }
+    }
   }
 }
 
@@ -74,7 +130,11 @@ abstract class AbstractKubernetesLease(system: 
ExtendedActorSystem, leaseTaken:
 
   private implicit val timeout: Timeout = 
Timeout(settings.timeoutSettings.operationTimeout)
 
-  private val leaseName = makeDNS1039Compatible(settings.leaseName)
+  private val leaseName =
+    AbstractKubernetesLease.makeDNS1039Compatible(
+      settings.leaseName,
+      k8sSettings.leaseLabelMaxLength,
+      k8sSettings.onTruncateAddHashLength)
   private val leaseActor = system.systemActorOf(
     LeaseActor.props(k8sApi, settings, leaseName, leaseTaken),
     s"kubernetesLease${AbstractKubernetesLease.leaseCounter.incrementAndGet}")
diff --git 
a/lease-kubernetes/src/main/scala/org/apache/pekko/coordination/lease/kubernetes/KubernetesSettings.scala
 
b/lease-kubernetes/src/main/scala/org/apache/pekko/coordination/lease/kubernetes/KubernetesSettings.scala
index d8ba328e..589bdae8 100644
--- 
a/lease-kubernetes/src/main/scala/org/apache/pekko/coordination/lease/kubernetes/KubernetesSettings.scala
+++ 
b/lease-kubernetes/src/main/scala/org/apache/pekko/coordination/lease/kubernetes/KubernetesSettings.scala
@@ -73,7 +73,9 @@ private[pekko] object KubernetesSettings {
       secure = config.getBoolean("secure-api-server"),
       tlsVersion = config.getString("tls-version"),
       bodyReadTimeout = apiServerRequestTimeout / 2,
-      tokenRetrySettings = tokenRetrySettings)
+      tokenRetrySettings = tokenRetrySettings,
+      leaseLabelMaxLength = config.getInt("lease-name-max-length"),
+      onTruncateAddHashLength = config.getInt("on-truncate-add-hash-length"))
   }
 }
 
@@ -107,4 +109,6 @@ private[pekko] class KubernetesSettings(
       10.millis,
       1.minute,
       0.3
-    ))
+    ),
+    val leaseLabelMaxLength: Int = 63,
+    val onTruncateAddHashLength: Int = 8)
diff --git 
a/lease-kubernetes/src/test/scala/org/apache/pekko/coordination/lease/kubernetes/Base32EncodeSpec.scala
 
b/lease-kubernetes/src/test/scala/org/apache/pekko/coordination/lease/kubernetes/Base32EncodeSpec.scala
new file mode 100644
index 00000000..a4cdd83d
--- /dev/null
+++ 
b/lease-kubernetes/src/test/scala/org/apache/pekko/coordination/lease/kubernetes/Base32EncodeSpec.scala
@@ -0,0 +1,144 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.pekko.coordination.lease.kubernetes
+
+import java.nio.charset.StandardCharsets.UTF_8
+
+import org.scalatest.matchers.should.Matchers
+import org.scalatest.wordspec.AnyWordSpec
+
+/**
+ * Tests for [[AbstractKubernetesLease.base32Encode]].
+ *
+ * Expected values are derived from RFC 4648 §10 test vectors, with the 
standard uppercase
+ * alphabet replaced by the lowercase alphabet used here ([a-z2-7] instead of 
[A-Z2-7]) and
+ * without the '=' padding characters.
+ */
+class Base32EncodeSpec extends AnyWordSpec with Matchers {
+
+  private def encode(bytes: Array[Byte]): String =
+    AbstractKubernetesLease.base32Encode(bytes)
+
+  private def encode(bytes: Int*): String =
+    encode(bytes.map(_.toByte).toArray)
+
+  "base32Encode" should {
+
+    // ---- edge cases -------------------------------------------------------
+
+    "return empty string for an empty array" in {
+      encode(Array.empty[Byte]) shouldEqual ""
+    }
+
+    "encode a single zero byte" in {
+      // 0x00 = 00000000 → 5 bits: 00000='a', remaining 3 bits: 
000<<2=00000='a'
+      encode(0x00) shouldEqual "aa"
+    }
+
+    "encode a single all-ones byte" in {
+      // 0xFF = 11111111 → 5 bits: 11111='7', remaining 3 bits: 
111<<2=11100=28='4'
+      encode(0xFF) shouldEqual "74"
+    }
+
+    "encode two zero bytes" in {
+      // 16 bits of zeros: 3 full groups of 5 + 1 remaining bit
+      // 00000 00000 00000 0[0000] → 'a','a','a','a'
+      encode(0x00, 0x00) shouldEqual "aaaa"
+    }
+
+    "encode two all-ones bytes" in {
+      // 0xFF 0xFF = 11111111 11111111 → 3×5 full + 1 remaining
+      // 11111=31='7', 11111=31='7', 11111=31='7', 1[0000]=16='q'
+      encode(0xFF, 0xFF) shouldEqual "777q"
+    }
+
+    // ---- RFC 4648 §10 test vectors (lowercase, no padding) ----------------
+
+    "encode 'f' (single byte 0x66) matching RFC 4648 vector" in {
+      // 0x66 = 01100110 → 01100=12='m', 110<<2=11000=24='y'
+      encode("f".getBytes(UTF_8)) shouldEqual "my"
+    }
+
+    "encode 'fo' (two bytes) matching RFC 4648 vector" in {
+      encode("fo".getBytes(UTF_8)) shouldEqual "mzxq"
+    }
+
+    "encode 'foo' (three bytes) matching RFC 4648 vector" in {
+      encode("foo".getBytes(UTF_8)) shouldEqual "mzxw6"
+    }
+
+    "encode 'foob' (four bytes) matching RFC 4648 vector" in {
+      encode("foob".getBytes(UTF_8)) shouldEqual "mzxw6yq"
+    }
+
+    "encode 'fooba' (five bytes — full 8-char group) matching RFC 4648 vector" 
in {
+      encode("fooba".getBytes(UTF_8)) shouldEqual "mzxw6ytb"
+    }
+
+    "encode 'foobar' (six bytes) matching RFC 4648 vector" in {
+      encode("foobar".getBytes(UTF_8)) shouldEqual "mzxw6ytboi"
+    }
+
+    // ---- output properties ------------------------------------------------
+
+    "produce output containing only [a-z2-7] characters for arbitrary inputs" 
in {
+      val inputs: Seq[Array[Byte]] = Seq(
+        Array.empty,
+        Array(0x00.toByte),
+        Array(0xFF.toByte),
+        Array.fill(1)(0xAB.toByte),
+        Array.fill(3)(0xCD.toByte),
+        (0 to 255).map(_.toByte).toArray)
+      for (input <- inputs) {
+        withClue(s"input length ${input.length}: ") {
+          (encode(input) should fullyMatch).regex("[a-z2-7]*")
+        }
+      }
+    }
+
+    "never emit '=' padding characters" in {
+      // Lengths 0–6 bytes cover all residue classes mod 5
+      for (len <- 0 to 6) {
+        val input = Array.fill(len)(0x42.toByte)
+        withClue(s"length $len: ") {
+          (encode(input) should not).include("=")
+        }
+      }
+    }
+
+    "produce correct output length (ceiling of 8n/5 characters for n input 
bytes)" in {
+      // RFC 4648 unpadded length = ceil(n * 8 / 5)
+      for (n <- 0 to 10) {
+        val expectedLen = if (n == 0) 0 else math.ceil(n * 8.0 / 5).toInt
+        val input = Array.fill(n)(0x00.toByte)
+        withClue(s"n=$n: ") {
+          encode(input).length shouldEqual expectedLen
+        }
+      }
+    }
+
+    "produce the same output for the same input (determinism)" in {
+      val input = "hello world".getBytes(UTF_8)
+      encode(input) shouldEqual encode(input)
+    }
+
+    "produce different output for different inputs" in {
+      (encode("abc".getBytes(UTF_8)) should 
not).equal(encode("abd".getBytes(UTF_8)))
+    }
+  }
+}
diff --git 
a/lease-kubernetes/src/test/scala/org/apache/pekko/coordination/lease/kubernetes/KubernetesSettingsSpec.scala
 
b/lease-kubernetes/src/test/scala/org/apache/pekko/coordination/lease/kubernetes/KubernetesSettingsSpec.scala
index 9a8d853c..8de798a9 100644
--- 
a/lease-kubernetes/src/test/scala/org/apache/pekko/coordination/lease/kubernetes/KubernetesSettingsSpec.scala
+++ 
b/lease-kubernetes/src/test/scala/org/apache/pekko/coordination/lease/kubernetes/KubernetesSettingsSpec.scala
@@ -47,6 +47,18 @@ class KubernetesSettingsSpec extends AnyWordSpec with 
Matchers {
     "support tls-version override" in {
       conf("tls-version=TLSv1.3").tlsVersion shouldEqual "TLSv1.3"
     }
+    "default lease-name-max-length to 63" in {
+      conf("").leaseLabelMaxLength shouldEqual 63
+    }
+    "support lease-name-max-length override" in {
+      conf("lease-name-max-length=40").leaseLabelMaxLength shouldEqual 40
+    }
+    "default on-truncate-add-hash-length to 8" in {
+      conf("").onTruncateAddHashLength shouldEqual 8
+    }
+    "support on-truncate-add-hash-length override" in {
+      conf("on-truncate-add-hash-length=12").onTruncateAddHashLength 
shouldEqual 12
+    }
     "not allow server request timeout greater than operation timeout" in {
       intercept[IllegalArgumentException] {
         conf("""
diff --git 
a/lease-kubernetes/src/test/scala/org/apache/pekko/coordination/lease/kubernetes/MakeDNS1039CompatibleSpec.scala
 
b/lease-kubernetes/src/test/scala/org/apache/pekko/coordination/lease/kubernetes/MakeDNS1039CompatibleSpec.scala
new file mode 100644
index 00000000..d650b5d4
--- /dev/null
+++ 
b/lease-kubernetes/src/test/scala/org/apache/pekko/coordination/lease/kubernetes/MakeDNS1039CompatibleSpec.scala
@@ -0,0 +1,205 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.pekko.coordination.lease.kubernetes
+
+import org.scalatest.matchers.should.Matchers
+import org.scalatest.wordspec.AnyWordSpec
+
+class MakeDNS1039CompatibleSpec extends AnyWordSpec with Matchers {
+
+  "makeDNS1039Compatible" should {
+
+    "leave a simple lowercase name unchanged" in {
+      AbstractKubernetesLease.makeDNS1039Compatible("my-lease") shouldEqual 
"my-lease"
+    }
+
+    "convert underscores and dots to hyphens" in {
+      AbstractKubernetesLease.makeDNS1039Compatible("my.lease_name") 
shouldEqual "my-lease-name"
+    }
+
+    "strip leading and trailing hyphens after normalization" in {
+      AbstractKubernetesLease.makeDNS1039Compatible("-my-lease-") shouldEqual 
"my-lease"
+    }
+
+    "remove characters that are not allowed in DNS 1039 labels" in {
+      AbstractKubernetesLease.makeDNS1039Compatible("my@lease!name") 
shouldEqual "myleasename"
+    }
+
+    "convert uppercase to lowercase" in {
+      AbstractKubernetesLease.makeDNS1039Compatible("MyLease") shouldEqual 
"mylease"
+    }
+
+    "truncate to 63 characters by default (no hash when hashLength is 0)" in {
+      val longName = "a" * 100
+      val result = AbstractKubernetesLease.makeDNS1039Compatible(longName, 63, 
0)
+      (result should have).length(63)
+      result shouldEqual "a" * 63
+    }
+
+    "truncate to a custom maxLength (no hash when hashLength is 0)" in {
+      val longName = "a" * 100
+      val result = AbstractKubernetesLease.makeDNS1039Compatible(longName, 40, 
0)
+      (result should have).length(40)
+      result shouldEqual "a" * 40
+    }
+
+    "trim trailing hyphens after truncation (no hash)" in {
+      val name = "a" * 30 + "-" + "b" * 30
+      val result = AbstractKubernetesLease.makeDNS1039Compatible(name, 31, 0)
+      (result should not).endWith("-")
+    }
+
+    "not truncate when name fits within maxLength" in {
+      val name63 = "a" * 63
+      AbstractKubernetesLease.makeDNS1039Compatible(name63, 63, 8) shouldEqual 
name63
+      (AbstractKubernetesLease.makeDNS1039Compatible(name63 + "extra", 63, 8) 
should not).equal(name63 + "extra")
+    }
+
+    "append hash suffix when truncation is needed and hashLength > 0" in {
+      val longName = "a" * 100
+      val result = AbstractKubernetesLease.makeDNS1039Compatible(longName, 63, 
8)
+      (result should have).length(63)
+      (result.takeRight(8) should fullyMatch).regex("[a-z2-7]{8}")
+      result should include("-")
+    }
+
+    "produce a deterministic hash suffix for the same input" in {
+      val name = 
"my-very-long-lease-name-that-exceeds-the-maximum-allowed-kubernetes-length"
+      val r1 = AbstractKubernetesLease.makeDNS1039Compatible(name, 63, 8)
+      val r2 = AbstractKubernetesLease.makeDNS1039Compatible(name, 63, 8)
+      r1 shouldEqual r2
+    }
+
+    "produce different hash suffixes for different original names that 
truncate to the same prefix" in {
+      // Both names normalize to 'a' * N, but originate from different strings
+      val name1 = "a" * 100
+      val name2 = "A" * 100 // normalizes to same 'a'*100 but is a different 
original
+      val r1 = AbstractKubernetesLease.makeDNS1039Compatible(name1, 63, 8)
+      val r2 = AbstractKubernetesLease.makeDNS1039Compatible(name2, 63, 8)
+      // The prefix is identical but hash suffixes differ because originals 
differ
+      (r1 should not).equal(r2)
+    }
+
+    "produce different results for long names that differ only in the last 
character" in {
+      // Both names are 70 chars and share the first 69 chars; they truncate 
to the same prefix
+      // without a hash but must produce different DNS1039 results with one
+      val base = "a" * 69
+      val name1 = base + "b"
+      val name2 = base + "c"
+      val r1 = AbstractKubernetesLease.makeDNS1039Compatible(name1, 63, 8)
+      val r2 = AbstractKubernetesLease.makeDNS1039Compatible(name2, 63, 8)
+      (r1 should not).equal(r2)
+      // Both must be valid length
+      (r1 should have).length(63)
+      (r2 should have).length(63)
+    }
+
+    "produce different results for long names that differ only in the last two 
characters" in {
+      val base = "a" * 68
+      val name1 = base + "bc"
+      val name2 = base + "de"
+      val r1 = AbstractKubernetesLease.makeDNS1039Compatible(name1, 63, 8)
+      val r2 = AbstractKubernetesLease.makeDNS1039Compatible(name2, 63, 8)
+      (r1 should not).equal(r2)
+      (r1 should have).length(63)
+      (r2 should have).length(63)
+    }
+
+    "produce only valid DNS 1039 characters when hash suffix is added" in {
+      val longName = 
"My-Very-Long-Lease.Name_With_Special-Characters-That-Exceeds-63-Chars-Limit"
+      val result = AbstractKubernetesLease.makeDNS1039Compatible(longName, 63, 
8)
+      (result should fullyMatch).regex("[a-z][a-z0-9-]*[a-z0-9]")
+      (result should have).length(63)
+    }
+
+    "respect a custom hashLength" in {
+      val longName = "a" * 100
+      val result = AbstractKubernetesLease.makeDNS1039Compatible(longName, 63, 
12)
+      (result should have).length(63)
+      // last 12 chars are the hash suffix
+      (result.takeRight(12) should fullyMatch).regex("[a-z2-7]{12}")
+    }
+
+    "not add hash when hashLength is 0 even if truncation occurs" in {
+      val longName = "a" * 100
+      val result = AbstractKubernetesLease.makeDNS1039Compatible(longName, 63, 
0)
+      result shouldEqual "a" * 63
+    }
+
+    "return full hash when hashLength equals maxLength" in {
+      val longName = "a" * 100
+      // SHA-256 → 32 bytes → 52 base32 chars; take(maxLength=63) returns the 
full 52-char hash
+      // but that leaves room for a short prefix and hyphen
+      val result = AbstractKubernetesLease.makeDNS1039Compatible(longName, 63, 
63)
+      (result should have).length(63)
+      assert(result.startsWith("aaaaaaaaaa-"))
+    }
+
+    "return full (capped at maxLength) when hashLength exceeds maxLength" in {
+      val longName = "a" * 100
+      val result = AbstractKubernetesLease.makeDNS1039Compatible(longName, 63, 
100)
+      (result should have).length(63)
+      assert(result.startsWith("aaaaaaaaaa-"))
+    }
+
+    "return different names when hashLength >= maxLength and original names 
differ" in {
+      val name1 = "a" * 100
+      val name2 = ("a" * 99) + "b" // normalizes to same 'a'*100 but is a 
different original
+      val r1 = AbstractKubernetesLease.makeDNS1039Compatible(name1, 63, 100)
+      val r2 = AbstractKubernetesLease.makeDNS1039Compatible(name2, 63, 100)
+      (r1 should not).equal(r2)
+      (r1 should have).length(63)
+      (r2 should have).length(63)
+    }
+
+    "return a valid DNS 1039 name when hashLength equals maxLength for a small 
maxLength" in {
+      // maxLength=10, hashLength=10: take(10) from 52 base32 chars → exactly 
10 chars
+      val longName = "My-Very-Long-Lease.Name_With_Special-Characters"
+      val result = AbstractKubernetesLease.makeDNS1039Compatible(longName, 10, 
10)
+      (result should have).length(10)
+      (result should fullyMatch).regex("[a-z2-7]{10}")
+    }
+
+    "return a valid DNS 1039 name when hashLength 1 less than maxLength for a 
small maxLength" in {
+      // maxLength=10, hashLength=9: take(9) from 52 base32 chars → exactly 9 
chars
+      val longName = "My-Very-Long-Lease.Name_With_Special-Characters"
+      val result = AbstractKubernetesLease.makeDNS1039Compatible(longName, 10, 
9)
+      (result should have).length(9)
+      (result should fullyMatch).regex("[a-z2-7]{9}")
+    }
+
+    "hash suffix contains only lowercase letters and digits (no uppercase, no 
'=' padding)" in {
+      // Use many different inputs to exercise the full base32 output, 
including partial-group chars
+      val inputs = Seq(
+        "a" * 100,
+        "my-very-long-lease-name-that-needs-to-be-truncated-for-kubernetes",
+        
"UPPER_CASE.DOTTED_NAME-that-is-indeed-too-long-for-kubernetes-label-limit",
+        "x" * 70)
+      for (name <- inputs) {
+        val result = AbstractKubernetesLease.makeDNS1039Compatible(name, 63, 8)
+        val suffix = result.takeRight(8)
+        withClue(s"suffix '$suffix' of '$result' (from '$name') must match 
[a-z2-7]{8}: ") {
+          (suffix should fullyMatch).regex("[a-z2-7]{8}")
+        }
+        withClue(s"result '$result' must not contain '=': ") {
+          (result should not).include("=")
+        }
+      }
+    }
+  }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to