// Package namer allows generating consistent resource names with length limits.
package namer
import (
"fmt"
"log/slog"
"math"
"regexp"
"strings"
)
// Namer provides consistent resource naming with length constraints
type Namer struct {
baseName string
// If true, any invalid characters will be replaced with dashes
replace bool
}
// Option is a function that can be used to configure the Namer
type Option func(*Namer)
// New creates a new Namer instance with the given base name
func New(baseName string, opts ...Option) Namer {
n := Namer{baseName: baseName}
for _, opt := range opts {
opt(&n)
}
return n
}
// WithReplace replaces periods with dashes
func WithReplace() Option {
return func(n *Namer) {
n.replace = true
}
}
// NewResourceName generates a consistent resource name with length limits.
func (e Namer) NewResourceName(resourceName, resourceType string, maxLength int) string {
// replace common characters on resource name and type parts
if e.replace {
resourceName, resourceType = applyReplacements(resourceName, resourceType)
}
var name string
if resourceType == "" {
name = fmt.Sprintf("%s-%s", e.baseName, resourceName)
} else {
name = fmt.Sprintf("%s-%s-%s", e.baseName, resourceName, resourceType)
}
if len(name) > maxLength {
surplus := len(name) - maxLength
name = e.truncateResourceName(resourceName, resourceType, surplus, maxLength)
}
if ok, err := isValidName(name); !ok {
slog.Error("Not a valid resource name", "name", name, "error", err)
panic("Resource name must start with a letter and end with a letter or digit")
}
return name
}
// applyReplacements replaces common characters and converts to lowercase
func applyReplacements(resourceName string, resourceType string) (string, string) {
resourceName = strings.ReplaceAll(resourceName, ".", "-")
resourceName = strings.ReplaceAll(resourceName, "_", "-")
resourceType = strings.ReplaceAll(resourceType, ".", "-")
resourceType = strings.ReplaceAll(resourceType, "_", "-")
resourceName = strings.ReplaceAll(resourceName, "/", "-")
resourceType = strings.ReplaceAll(resourceType, "/", "-")
resourceName = strings.ReplaceAll(resourceName, "@", "-")
resourceType = strings.ReplaceAll(resourceType, "@", "-")
resourceName = strings.ReplaceAll(resourceName, ":", "-")
resourceType = strings.ReplaceAll(resourceType, ":", "-")
// convert to lowercase
resourceName = strings.ToLower(resourceName)
resourceType = strings.ToLower(resourceType)
return resourceName, resourceType
}
// isValidName validates the final name in accord with RFC 1035.
// See: https://cloud.google.com/compute/docs/naming-resources
func isValidName(name string) (ok bool, err error) {
// validate final name in accord with RFC 1035:
// - Must start with a letter
// - Can contain letters, digits, and hyphens as interior characters
// - Must end with a letter or digit (cannot end with a hyphen)
// - Maximum length of 63 characters
matched, _ := regexp.MatchString("^[a-z]([-a-z0-9]*[a-z0-9])?$", name)
if !matched {
return false, fmt.Errorf("name must start with a letter and end with a letter or digit")
}
if len(name) > 63 {
return false, fmt.Errorf("name must be less than 63 characters")
}
return true, nil
}
// truncateResourceName truncates and handles max length constraints.
func (e Namer) truncateResourceName(resourceName, resourceType string, surplus, maxLength int) string {
mainComponentLength := len(e.baseName)
if mainComponentLength > surplus {
return e.truncateMainComponent(resourceName, resourceType, surplus)
}
return e.proportionalTruncate(resourceName, resourceType, maxLength)
}
// truncateMainComponent truncates the main component name when it's long enough.
func (e Namer) truncateMainComponent(resourceName, resourceType string, surplus int) string {
truncatedMainComponent := e.baseName[:len(e.baseName)-surplus]
truncatedMainComponent = trimTrailingHyphen(truncatedMainComponent)
if resourceType == "" {
return fmt.Sprintf("%s-%s", truncatedMainComponent, resourceName)
}
return fmt.Sprintf("%s-%s-%s", truncatedMainComponent, resourceName, resourceType)
}
// proportionalTruncate applies proportional truncation when main component is too short.
func (e Namer) proportionalTruncate(resourceName, resourceType string, maxLength int) string {
originalLength := len(join(e.baseName, resourceName, resourceType))
truncateFactorFloat := float64(maxLength) / float64(originalLength)
truncateFactor := math.Floor(truncateFactorFloat*100) / 100
mainComponentLength := int(math.Floor(float64(len(e.baseName)) * truncateFactor))
resourceNameLength := int(math.Floor(float64(len(resourceName)) * truncateFactor))
resourceTypeLength := int(math.Floor(float64(len(resourceType)) * truncateFactor))
// Truncate each component and remove trailing hyphens
truncatedBaseName := trimTrailingHyphen(e.baseName[:mainComponentLength])
truncatedResourceName := trimTrailingHyphen(resourceName[:resourceNameLength])
truncatedResourceType := trimTrailingHyphen(resourceType[:resourceTypeLength])
return join(truncatedBaseName, truncatedResourceName, truncatedResourceType)
}
// join composes the base name, resource name and resource type in the final format.
func join(baseName string, resourceName string, resourceType string) string {
if resourceType == "" {
return fmt.Sprintf("%s-%s", baseName, resourceName)
}
return fmt.Sprintf("%s-%s-%s", baseName, resourceName, resourceType)
}
// trimTrailingHyphen removes trailing hyphens from a string component
func trimTrailingHyphen(component string) string {
for len(component) > 0 && component[len(component)-1] == '-' {
component = component[:len(component)-1]
}
return component
}