Repository: awslabs/cdk8s
Pull request #326 (2020-09-23 03:52:55)

fix(lib): `uniqueId` is not compatible with the k8s labels
`Node.of(construct).uniqueId` is bound to CloudFormation's resource logical ID rule, which is not strictly compatible with the K8s label rule. It might lead to invalid K8s manifests.
I've changed all `Node.of(construct).uniqueId` with `Node.of(construct).path`, or `Names.toDnsLabel(Node.of(construct).path)` if needed.
Since K8s DNS label rule are subset of label key name segment/value rule, I've added `Names.toLabelValue` and used it for selector labels.

fixes: #323

BREAKING CHANGE: cdk8s-plus's value of a label `cdk8s.deployment` of Pods are changed

*By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*


foriequal0: Rebase to resolve merge conflict.
mergify[bot]: Your pull request will be updated and merged automatically (do not update manually).
mergify[bot]: Your pull request will be updated and merged automatically (do not update manually).

Patch:
diff --git a/docs/getting-started/typescript.md b/docs/getting-started/typescript.md
index e3a9c8ba0..22f48a0ac 100644
--- a/docs/getting-started/typescript.md
+++ b/docs/getting-started/typescript.md
@@ -246,6 +246,7 @@ to use `lib` for reusable components):
 
 ```ts
 import { Construct, Node } from 'constructs';
+import { Names } from 'cdk8s';
 import { Deployment, Service, IntOrString } from '../imports/k8s';
 
 export interface WebServiceOptions {
@@ -282,7 +283,7 @@ export class WebService extends Construct {
 
     const port = options.port || 80;
     const containerPort = options.containerPort || 8080;
-    const label = { app: Node.of(this).uniqueId };
+    const label = { app: Names.toDnsLabel(Node.of(this).path) };
     const replicas = options.replicas ?? 1;
 
     new Service(this, 'service', {
diff --git a/examples/typescript/podinfo/lib/deployment.ts b/examples/typescript/podinfo/lib/deployment.ts
index 240e33cdd..73a11e0db 100644
--- a/examples/typescript/podinfo/lib/deployment.ts
+++ b/examples/typescript/podinfo/lib/deployment.ts
@@ -1,5 +1,5 @@
 import { Construct, Node } from 'constructs';
-import { ApiObject } from 'cdk8s';
+import { ApiObject, Names } from 'cdk8s';
 import { Deployment as DeploymentObject, Affinity, Container, IntOrString } from '../imports/k8s';
 import { Autoscaler, AutoscalingOptions } from './autoscaler';
 import { ISelector } from './service';
@@ -90,7 +90,7 @@ export class Deployment extends Construct implements ISelector {
     // labels
 
     this.selector = { 
-      deploymentId: Node.of(this).uniqueId,
+      deploymentId: Names.toLabelValue(Node.of(this).path),
       ...options.labels
     };
 
diff --git a/examples/typescript/web-service/web-service.ts b/examples/typescript/web-service/web-service.ts
index 299ecb57f..9288e3eeb 100644
--- a/examples/typescript/web-service/web-service.ts
+++ b/examples/typescript/web-service/web-service.ts
@@ -1,4 +1,5 @@
 import { Construct, Node } from 'constructs';
+import { Names } from 'cdk8s';
 import { Deployment, Service, IntOrString } from './imports/k8s';
 
 export interface WebServiceOptions {
@@ -30,7 +31,7 @@ export class WebService extends Construct {
 
     const port = options.port || 80;
     const containerPort = options.containerPort || 8080;
-    const label = { app: Node.of(this).uniqueId };
+    const label = { app: Names.toLabelValue(Node.of(this).path) };
     const replicas = options.replicas ?? 1;
 
     new Service(this, 'service', {
diff --git a/packages/cdk8s-plus/src/deployment.ts b/packages/cdk8s-plus/src/deployment.ts
index 0c200d0e6..b95669ffc 100644
--- a/packages/cdk8s-plus/src/deployment.ts
+++ b/packages/cdk8s-plus/src/deployment.ts
@@ -4,7 +4,7 @@ import { Service, ServiceType } from './service';
 import { Resource, ResourceProps } from './base';
 import * as cdk8s from 'cdk8s';
 import { PodSpecDefinition, PodSpec } from './pod';
-import { ApiObjectMetadata, ApiObjectMetadataDefinition } from 'cdk8s';
+import { ApiObjectMetadata, ApiObjectMetadataDefinition, Names } from 'cdk8s';
 
 /**
  * Properties for initialization of `Deployment`.
@@ -103,7 +103,7 @@ export class Deployment extends Resource {
 
     // create a label and attach it to the deployment pods
     const selector = 'cdk8s.deployment';
-    const matcher = Node.of(this).uniqueId;
+    const matcher = Names.toLabelValue(Node.of(this).path);
 
     const service = new Service(this, 'Service', {
       spec: {
@@ -204,7 +204,7 @@ export class DeploymentSpecDefinition {
     // automatically select pods in this deployment
 
     const selector = 'cdk8s.deployment';
-    const matcher = Node.of(deployment).uniqueId;
+    const matcher = Names.toLabelValue(Node.of(deployment).path);
 
     this.podMetadataTemplate.addLabel(selector, matcher);
 
diff --git a/packages/cdk8s/API.md b/packages/cdk8s/API.md
index d7220fd4e..d570edefe 100644
--- a/packages/cdk8s/API.md
+++ b/packages/cdk8s/API.md
@@ -12,6 +12,7 @@ Name|Description
 [DependencyVertex](#cdk8s-dependencyvertex)|Represents a vertex in the graph.
 [Include](#cdk8s-include)|Reads a YAML manifest from a file or a URL and defines all resources as API objects within the defined scope.
 [Lazy](#cdk8s-lazy)|*No description*
+[Names](#cdk8s-names)|Utilities for generating unique and stable names.
 [Testing](#cdk8s-testing)|Testing utilities for cdk8s applications.
 [Yaml](#cdk8s-yaml)|YAML utilities.
 
@@ -529,6 +530,77 @@ __Returns__:
 
 
 
+## class Names 🔹 <a id="cdk8s-names"></a>
+
+Utilities for generating unique and stable names.
+
+
+### Methods
+
+
+#### *static* toDnsLabel(path, maxLen?)🔹 <a id="cdk8s-names-todnslabel"></a>
+
+Generates a unique and stable name compatible DNS_LABEL from RFC-1123 from a path.
+
+The generated name will:
+  - contain at most 63 characters
+  - contain only lowercase alphanumeric characters or ‘-’
+  - start with an alphanumeric character
+  - end with an alphanumeric character
+
+The generated name will have the form:
+  <comp0>-<comp1>-..-<compN>-<short-hash>
+
+Where <comp> are the path components (assuming they are is separated by
+"/").
+
+Note that if the total length is longer than 63 characters, we will trim
+the first components since the last components usually encode more meaning.
+
+```ts
+static toDnsLabel(path: string, maxLen?: number): string
+```
+
+* **path** (<code>string</code>)  a path to a node (components separated by "/").
+* **maxLen** (<code>number</code>)  maximum allowed length for name.
+
+__Returns__:
+* <code>string</code>
+
+#### *static* toLabelValue(path, delim?, maxLen?)🔹 <a id="cdk8s-names-tolabelvalue"></a>
+
+Generates a unique and stable name compatible label key name segment and label value from a path.
+
+The name segment is required and must be 63 characters or less, beginning
+and ending with an alphanumeric character ([a-z0-9A-Z]) with dashes (-),
+underscores (_), dots (.), and alphanumerics between.
+
+Valid label values must be 63 characters or less and must be empty or
+begin and end with an alphanumeric character ([a-z0-9A-Z]) with dashes
+(-), underscores (_), dots (.), and alphanumerics between.
+
+The generated name will have the form:
+  <comp0><delim><comp1><delim>..<delim><compN><delim><short-hash>
+
+Where <comp> are the path components (assuming they are is separated by
+"/").
+
+Note that if the total length is longer than 63 characters, we will trim
+the first components since the last components usually encode more meaning.
+
+```ts
+static toLabelValue(path: string, delim?: string, maxLen?: number): string
+```
+
+* **path** (<code>string</code>)  a path to a node (components separated by "/").
+* **delim** (<code>string</code>)  a delimiter to separates components.
+* **maxLen** (<code>number</code>)  maximum allowed length for name.
+
+__Returns__:
+* <code>string</code>
+
+
+
 ## class Testing 🔹 <a id="cdk8s-testing"></a>
 
 Testing utilities for cdk8s applications.
diff --git a/packages/cdk8s/src/app.ts b/packages/cdk8s/src/app.ts
index 25f85f8bb..0062be194 100644
--- a/packages/cdk8s/src/app.ts
+++ b/packages/cdk8s/src/app.ts
@@ -5,6 +5,7 @@ import * as path from 'path';
 import { Yaml } from './yaml';
 import { DependencyGraph } from './dependency';
 import { ApiObject } from './api-object';
+import { Names } from './names';
 
 export interface AppOptions {
   /**
@@ -48,7 +49,7 @@ export class App extends Construct {
     // the necessary operations. We do however want to preserve the distributed validation.
     validate(this);
 
-    const simpleManifestNamer = (chart: Chart) => `${Node.of(chart).uniqueId}.k8s.yaml`;
+    const simpleManifestNamer = (chart: Chart) => `${Names.toDnsLabel(Node.of(chart).path)}.k8s.yaml`;
     const manifestNamer = hasDependantCharts ? (chart: Chart) => `${index.toString().padStart(4, '0')}-${simpleManifestNamer(chart)}` : simpleManifestNamer;
 
     const charts: IConstruct[] = new DependencyGraph(Node.of(this)).topology().filter(x => x instanceof Chart);
diff --git a/packages/cdk8s/src/dependency.ts b/packages/cdk8s/src/dependency.ts
index 2afdba14e..75cdf9330 100644
--- a/packages/cdk8s/src/dependency.ts
+++ b/packages/cdk8s/src/dependency.ts
@@ -26,11 +26,11 @@ export class DependencyGraph {
     const nodes: Record<string, DependencyVertex> = {};
 
     function putVertex(construct: IConstruct) {
-      nodes[Node.of(construct).uniqueId] = new DependencyVertex(construct);
+      nodes[Node.of(construct).path] = new DependencyVertex(construct);
     }
 
     function getVertex(construct: IConstruct): DependencyVertex {
-      return nodes[Node.of(construct).uniqueId];
+      return nodes[Node.of(construct).path];
     }
 
     // create all vertices of the graph.
@@ -159,7 +159,7 @@ export class DependencyVertex {
     const cycle: DependencyVertex[] = dep.findRoute(this);
     if (cycle.length !== 0) {
       cycle.push(dep);
-      throw new Error(`Dependency cycle detected: ${cycle.filter(d => d.value).map(d => Node.of(d.value!).uniqueId).join(' => ')}`);
+      throw new Error(`Dependency cycle detected: ${cycle.filter(d => d.value).map(d => Node.of(d.value!).path).join(' => ')}`);
     }
 
     this._children.add(dep);
diff --git a/packages/cdk8s/src/index.ts b/packages/cdk8s/src/index.ts
index 26d7e0f9e..f3cc049a6 100644
--- a/packages/cdk8s/src/index.ts
+++ b/packages/cdk8s/src/index.ts
@@ -7,3 +7,4 @@ export * from './include';
 export * from './yaml';
 export * from './metadata';
 export * from './lazy';
+export * from './names';
diff --git a/packages/cdk8s/src/names.ts b/packages/cdk8s/src/names.ts
index 504486a53..c5330d0bb 100644
--- a/packages/cdk8s/src/names.ts
+++ b/packages/cdk8s/src/names.ts
@@ -2,6 +2,8 @@ import * as crypto from 'crypto';
 
 const MAX_DNS_NAME_LEN = 63;
 const VALIDATE = /^[0-9a-z-]+$/;
+const MAX_LABEL_VALUE_LEN = 63;
+const VALIDATE_LABEL_VALUE = /^(([0-9a-zA-Z][0-9a-zA-Z-_.]*)?[0-9a-zA-Z])?$/
 const HASH_LEN = 8;
 
 /**
@@ -62,6 +64,70 @@ export class Names {
       .join('-');
   }
 
+  /**
+   * Generates a unique and stable name compatible label key name segment and
+   * label value from a path.
+   *
+   * The name segment is required and must be 63 characters or less, beginning
+   * and ending with an alphanumeric character ([a-z0-9A-Z]) with dashes (-),
+   * underscores (_), dots (.), and alphanumerics between.
+   *
+   * Valid label values must be 63 characters or less and must be empty or
+   * begin and end with an alphanumeric character ([a-z0-9A-Z]) with dashes
+   * (-), underscores (_), dots (.), and alphanumerics between.
+   *
+   * The generated name will have the form:
+   *  <comp0><delim><comp1><delim>..<delim><compN><delim><short-hash>
+   *
+   * Where <comp> are the path components (assuming they are is separated by
+   * "/").
+   *
+   * Note that if the total length is longer than 63 characters, we will trim
+   * the first components since the last components usually encode more meaning.
+   *
+   * @link https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#syntax-and-character-set
+   *
+   * @param path a path to a node (components separated by "/")
+   * @param delim a delimiter to separates components
+   * @param maxLen maximum allowed length for name
+   * @throws if any of the components do not adhere to naming constraints or
+   * length.
+   */
+  public static toLabelValue(path: string, delim: string = '-', maxLen: number = MAX_LABEL_VALUE_LEN) {
+    if (maxLen < HASH_LEN) {
+      throw new Error(`minimum max length for label is ${HASH_LEN} (required for hash)`);
+    }
+
+    if (/[^0-9a-zA-Z-_.]/.test(delim)) {
+      throw new Error('delim should not contain "[^0-9a-zA-Z-_.]"');
+    }
+
+    let components = path.split('/');
+
+    // special case: if we only have one component in our path and it adheres to DNS_NAME, we don't decorate it
+    if (components.length === 1 && VALIDATE_LABEL_VALUE.test(components[0]) && components[0].length <= maxLen) {
+      return components[0];
+    }
+
+    // okay, now we need to normalize all components to adhere to label and append the hash of the full path.
+    components = components.map(c => normalizeToLabelValue(c, maxLen));
+
+    components.push(calcHash(path, HASH_LEN));
+
+    const result = components
+      .reverse()
+      .filter(omitDuplicates)
+      .join('/')
+      .slice(0, maxLen)
+      .split('/')
+      .reverse()
+      .filter(x => x)
+      .join(delim);
+
+    // slicing might let '-', '_', '.' be in the start of the result.
+    return result.replace(/^[^0-9a-zA-Z]+/, '');
+  }
+
   /* istanbul ignore next */
   private constructor() {
     return;
@@ -75,7 +141,13 @@ function omitDuplicates(value: string, index: number, components: string[]) {
 function normalizeToDnsName(c: string, maxLen: number) {
   return c
     .toLocaleLowerCase()        // lower case
-    .replace(/[^0-9a-z-]/g, '') // remove non-allowed characters
+    .replace(/[^0-9a-zA-Z-_.]/g, '') // remove non-allowed characters
+    .substr(0, maxLen)          // trim to maxLength
+}
+
+function normalizeToLabelValue(c: string, maxLen: number) {
+  return c
+    .replace(/[^0-9a-zA-Z-_.]/g, '') // remove non-allowed characters
     .substr(0, maxLen)          // trim to maxLength
 }
 


Test patch:
diff --git a/examples/typescript/cdk8s-plus-elasticsearch-query/__snapshots__/main.test.ts.snap b/examples/typescript/cdk8s-plus-elasticsearch-query/__snapshots__/main.test.ts.snap
index e72a6c5fa..7083921d9 100644
--- a/examples/typescript/cdk8s-plus-elasticsearch-query/__snapshots__/main.test.ts.snap
+++ b/examples/typescript/cdk8s-plus-elasticsearch-query/__snapshots__/main.test.ts.snap
@@ -128,13 +128,13 @@ function doSearch(uri, callback) {
       "replicas": 1,
       "selector": Object {
         "matchLabels": Object {
-          "cdk8s.deployment": "testchartDeploymentAAAF687B",
+          "cdk8s.deployment": "test-chart-Deployment-98e15317",
         },
       },
       "template": Object {
         "metadata": Object {
           "labels": Object {
-            "cdk8s.deployment": "testchartDeploymentAAAF687B",
+            "cdk8s.deployment": "test-chart-Deployment-98e15317",
           },
         },
         "spec": Object {
@@ -208,7 +208,7 @@ function doSearch(uri, callback) {
         },
       ],
       "selector": Object {
-        "cdk8s.deployment": "testchartDeploymentAAAF687B",
+        "cdk8s.deployment": "test-chart-Deployment-98e15317",
       },
       "type": "ClusterIP",
     },
diff --git a/packages/cdk8s-plus/test/deployment.test.ts b/packages/cdk8s-plus/test/deployment.test.ts
index 23753189a..477bc59e8 100644
--- a/packages/cdk8s-plus/test/deployment.test.ts
+++ b/packages/cdk8s-plus/test/deployment.test.ts
@@ -1,6 +1,6 @@
 import * as kplus from '../src';
 import * as k8s from '../src/imports/k8s';
-import { Testing } from 'cdk8s';
+import { Names, Testing } from 'cdk8s';
 import { Node } from 'constructs';
 
 describe('DeploymentSpecDefinition', () => {
@@ -44,7 +44,7 @@ describe('DeploymentSpecDefinition', () => {
     const expected: k8s.LabelSelector = {
       matchLabels: {
         'key': 'value',
-        'cdk8s.deployment': Node.of(deployment).uniqueId,
+        'cdk8s.deployment': Names.toLabelValue(Node.of(deployment).path),
       },
     };
 
@@ -73,7 +73,7 @@ describe('Deployment', () => {
 
     expect(actual.type).toEqual('LoadBalancer');
     expect(actual.selector).toEqual({
-      'cdk8s.deployment': Node.of(deployment).uniqueId,
+      'cdk8s.deployment': Names.toLabelValue(Node.of(deployment).path),
     });
     expect(actual.ports![0].port).toEqual(9200);
     expect(actual.ports![0].targetPort).toEqual(9300);
@@ -100,7 +100,7 @@ describe('Deployment', () => {
 
     expect(actual.type).toEqual('ClusterIP');
     expect(actual.selector).toEqual({
-      'cdk8s.deployment': Node.of(deployment).uniqueId,
+      'cdk8s.deployment': Names.toLabelValue(Node.of(deployment).path),
     });
     expect(actual.ports![0].port).toEqual(9200);
     expect(actual.ports![0].targetPort).toEqual(9300);
diff --git a/packages/cdk8s/test/dependency.test.ts b/packages/cdk8s/test/dependency.test.ts
index 162f32837..f82b7a9ac 100644
--- a/packages/cdk8s/test/dependency.test.ts
+++ b/packages/cdk8s/test/dependency.test.ts
@@ -33,7 +33,7 @@ test('cycle detection', () => {
 
   expect(() => {
     new DependencyGraph(Node.of(group))
-  }).toThrowError(`Dependency cycle detected: ${Node.of(obj1).uniqueId} => ${Node.of(obj2).uniqueId} => ${Node.of(obj3).uniqueId} => ${Node.of(obj1).uniqueId}`);
+  }).toThrowError(`Dependency cycle detected: ${Node.of(obj1).path} => ${Node.of(obj2).path} => ${Node.of(obj3).path} => ${Node.of(obj1).path}`);
 
 });
 
diff --git a/packages/cdk8s/test/names.test.ts b/packages/cdk8s/test/names.test.ts
index a12d2a794..6c4f79add 100644
--- a/packages/cdk8s/test/names.test.ts
+++ b/packages/cdk8s/test/names.test.ts
@@ -1,60 +1,121 @@
 import { Names } from '../src/names';
 
-const toDnsName = Names.toDnsLabel;
-
-test('normalize to dns_name', () => {
-  expect(toDnsName(' ')).toEqual('36a9e7f1');
-  expect(toDnsName('')).toEqual('e3b0c442');
-  expect(toDnsName('Hello')).toEqual('hello-185f8db3');
-  expect(toDnsName('hey*')).toEqual('hey-96c05e6c');
-  expect(toDnsName('not allowed')).toEqual('notallowed-a26075ed');
-});
+describe('toDnsLabel', () => {
+  const toDnsName = Names.toDnsLabel;
 
-test('maximum length for a single term', () => {
-  expect(toDnsName('1234567890abcdef', 15)).toEqual('123456-8e9916c5');
-  expect(toDnsName('x' + 'a'.repeat(64))).toEqual('xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-f69f4ba1');
-});
+  test('normalize to dns_name', () => {
+    expect(toDnsName(' ')).toEqual('36a9e7f1');
+    expect(toDnsName('')).toEqual('e3b0c442');
+    expect(toDnsName('Hello')).toEqual('hello-185f8db3');
+    expect(toDnsName('hey*')).toEqual('hey-96c05e6c');
+    expect(toDnsName('not allowed')).toEqual('notallowed-a26075ed');
+  });
 
-test('single term is not decorated with a hash', () => {
-  expect(toDnsName('foo')).toEqual('foo');
-  expect(toDnsName('foo-bar-123-455')).toEqual('foo-bar-123-455');
-  expect(toDnsName('z'.repeat(63))).toEqual('z'.repeat(63));
-});
+  test('maximum length for a single term', () => {
+    expect(toDnsName('1234567890abcdef', 15)).toEqual('123456-8e9916c5');
+    expect(toDnsName('x' + 'a'.repeat(64))).toEqual('xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-f69f4ba1');
+  });
 
-test('multiple terms are separated by "." and a hash is appended', () => {
-  expect(toDnsName('hello-foo-world')).toEqual('hello-foo-world'); // this is actually a single term
-  expect(toDnsName('hello-hello-foo-world')).toEqual('hello-hello-foo-world'); // intentionally duplicated
-  expect(toDnsName('hello-foo/world')).toEqual('hello-foo-world-54700203'); // two terms
-  expect(toDnsName('hello-foo/foo')).toEqual('hello-foo-foo-e078a973'); // two terms, intentionally duplicated
-  expect(toDnsName('hello/foo/world')).toEqual('hello-foo-world-4f6e4fd8'); // three terms
-});
+  test('single term is not decorated with a hash', () => {
+    expect(toDnsName('foo')).toEqual('foo');
+    expect(toDnsName('foo-bar-123-455')).toEqual('foo-bar-123-455');
+    expect(toDnsName('z'.repeat(63))).toEqual('z'.repeat(63));
+  });
 
-test('invalid max length (minimum is 8 - for hash)', () => {
-  const expected = /minimum max length for object names is 8/;
-  expect(() => toDnsName('foo', 4)).toThrow(expected);
-  expect(() => toDnsName('foo', 7)).toThrow(expected);
+  test('multiple terms are separated by "." and a hash is appended', () => {
+    expect(toDnsName('hello-foo-world')).toEqual('hello-foo-world'); // this is actually a single term
+    expect(toDnsName('hello-hello-foo-world')).toEqual('hello-hello-foo-world'); // intentionally duplicated
+    expect(toDnsName('hello-foo/world')).toEqual('hello-foo-world-54700203'); // two terms
+    expect(toDnsName('hello-foo/foo')).toEqual('hello-foo-foo-e078a973'); // two terms, intentionally duplicated
+    expect(toDnsName('hello/foo/world')).toEqual('hello-foo-world-4f6e4fd8'); // three terms
+  });
 
-  // these are ok
-  expect(toDnsName('foo', 8)).toEqual('foo');
-  expect(toDnsName('foo', 9)).toEqual('foo');
-});
+  test('invalid max length (minimum is 8 - for hash)', () => {
+    const expected = /minimum max length for object names is 8/;
+    expect(() => toDnsName('foo', 4)).toThrow(expected);
+    expect(() => toDnsName('foo', 7)).toThrow(expected);
+
+    // these are ok
+    expect(toDnsName('foo', 8)).toEqual('foo');
+    expect(toDnsName('foo', 9)).toEqual('foo');
+  });
 
-test('omit duplicate components in names', () => {
-  expect(toDnsName('hello/hello/foo/world')).toEqual('hello-foo-world-1d4999d0');
-  expect(toDnsName('hello/hello/hello/foo/world')).toEqual('hello-foo-world-d3ebcda3');
-  expect(toDnsName('hello/hello/hello/hello/hello')).toEqual('hello-456bb9d7');
-  expect(toDnsName('hello/cool/cool/cool/cool')).toEqual('hello-cool-83150e81');
-  expect(toDnsName('hello/world/world/world/cool')).toEqual('hello-world-cool-0148a798');
-})
-
-test('trimming (prioritize last component)', () => {
-  expect(toDnsName('hello/world', 8)).toEqual('761e91eb');
-  expect(toDnsName('hello/world/this/is/cool', 8)).toEqual('a7c39f00');
-  expect(toDnsName('hello/world/this/is/cool', 12)).toEqual('coo-a7c39f00');
-  expect(toDnsName('hello/hello/this/is/cool', 12)).toEqual('coo-8751188b');
-  expect(toDnsName('hello/cool/cool/cool/cool', 15)).toEqual('h-cool-83150e81');
-  expect(toDnsName('hello/world/this/is/cool', 14)).toEqual('cool-a7c39f00');
-  expect(toDnsName('hello/world/this/is/cool', 15)).toEqual('i-cool-a7c39f00');
-  expect(toDnsName('hello/world/this/is/cool', 25)).toEqual('wor-this-is-cool-a7c39f00');
+  test('omit duplicate components in names', () => {
+    expect(toDnsName('hello/hello/foo/world')).toEqual('hello-foo-world-1d4999d0');
+    expect(toDnsName('hello/hello/hello/foo/world')).toEqual('hello-foo-world-d3ebcda3');
+    expect(toDnsName('hello/hello/hello/hello/hello')).toEqual('hello-456bb9d7');
+    expect(toDnsName('hello/cool/cool/cool/cool')).toEqual('hello-cool-83150e81');
+    expect(toDnsName('hello/world/world/world/cool')).toEqual('hello-world-cool-0148a798');
+  })
+
+  test('trimming (prioritize last component)', () => {
+    expect(toDnsName('hello/world', 8)).toEqual('761e91eb');
+    expect(toDnsName('hello/world/this/is/cool', 8)).toEqual('a7c39f00');
+    expect(toDnsName('hello/world/this/is/cool', 12)).toEqual('coo-a7c39f00');
+    expect(toDnsName('hello/hello/this/is/cool', 12)).toEqual('coo-8751188b');
+    expect(toDnsName('hello/cool/cool/cool/cool', 15)).toEqual('h-cool-83150e81');
+    expect(toDnsName('hello/world/this/is/cool', 14)).toEqual('cool-a7c39f00');
+    expect(toDnsName('hello/world/this/is/cool', 15)).toEqual('i-cool-a7c39f00');
+    expect(toDnsName('hello/world/this/is/cool', 25)).toEqual('wor-this-is-cool-a7c39f00');
+  });
 });
 
+describe('toLabel', () => {
+  const toLabelValue = Names.toLabelValue;
+
+  test('normalize to dns_name', () => {
+    expect(toLabelValue(' ')).toEqual('36a9e7f1');
+    expect(toLabelValue('')).toEqual(''); // Empty label is allowed for a label value
+    expect(toLabelValue('Hello')).toEqual('Hello'); // Upper case is allowed for a label
+    expect(toLabelValue('hey*')).toEqual('hey-96c05e6c');
+    expect(toLabelValue('not allowed')).toEqual('notallowed-a26075ed');
+  });
+
+  test('maximum length for a single term', () => {
+    expect(toLabelValue('1234567890abcdef', '-', 15)).toEqual('123456-8e9916c5');
+    expect(toLabelValue('x' + 'a'.repeat(64))).toEqual('xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-f69f4ba1');
+  });
+
+  test('single term is not decorated with a hash', () => {
+    expect(toLabelValue('foo')).toEqual('foo');
+    expect(toLabelValue('foo-bar-123-455')).toEqual('foo-bar-123-455');
+    expect(toLabelValue('z'.repeat(63))).toEqual('z'.repeat(63));
+  });
+
+  test('multiple terms are separated by "." and a hash is appended', () => {
+    expect(toLabelValue('hello-foo-world')).toEqual('hello-foo-world'); // this is actually a single term
+    expect(toLabelValue('hello-hello-foo-world')).toEqual('hello-hello-foo-world'); // intentionally duplicated
+    expect(toLabelValue('hello-foo/world')).toEqual('hello-foo-world-54700203'); // two terms
+    expect(toLabelValue('hello-foo/foo')).toEqual('hello-foo-foo-e078a973'); // two terms, intentionally duplicated
+    expect(toLabelValue('hello/foo/world')).toEqual('hello-foo-world-4f6e4fd8'); // three terms
+  });
+
+  test('invalid max length (minimum is 8 - for hash)', () => {
+    const expected = /minimum max length for label is 8/;
+    expect(() => toLabelValue('foo', '-', 4)).toThrow(expected);
+    expect(() => toLabelValue('foo', '-', 7)).toThrow(expected);
+
+    // these are ok
+    expect(toLabelValue('foo', '-', 8)).toEqual('foo');
+    expect(toLabelValue('foo', '-', 9)).toEqual('foo');
+  });
+
+  test('omit duplicate components in names', () => {
+    expect(toLabelValue('hello/hello/foo/world')).toEqual('hello-foo-world-1d4999d0');
+    expect(toLabelValue('hello/hello/hello/foo/world')).toEqual('hello-foo-world-d3ebcda3');
+    expect(toLabelValue('hello/hello/hello/hello/hello')).toEqual('hello-456bb9d7');
+    expect(toLabelValue('hello/cool/cool/cool/cool')).toEqual('hello-cool-83150e81');
+    expect(toLabelValue('hello/world/world/world/cool')).toEqual('hello-world-cool-0148a798');
+  })
+
+  test('trimming (prioritize last component)', () => {
+    expect(toLabelValue('hello/world', '-', 8)).toEqual('761e91eb');
+    expect(toLabelValue('hello/world/this/is/cool', '-', 8)).toEqual('a7c39f00');
+    expect(toLabelValue('hello/world/this/is/cool', '-', 12)).toEqual('coo-a7c39f00');
+    expect(toLabelValue('hello/hello/this/is/cool', '-', 12)).toEqual('coo-8751188b');
+    expect(toLabelValue('hello/cool/cool/cool/cool', '-', 15)).toEqual('h-cool-83150e81');
+    expect(toLabelValue('hello/world/this/is/cool', '-', 14)).toEqual('cool-a7c39f00');
+    expect(toLabelValue('hello/world/this/is/cool', '-', 15)).toEqual('i-cool-a7c39f00');
+    expect(toLabelValue('hello/world/this/is/cool', '-', 25)).toEqual('wor-this-is-cool-a7c39f00');
+  });
+});
\ No newline at end of file