Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
# It should be run by config/default
resources:
{{- range . }}
- bases/openstack.k-orc.cloud_{{ .NameLower }}s.yaml
- bases/openstack.k-orc.cloud_{{ .NameLower | plural }}.yaml
{{- end}}
# +kubebuilder:scaffold:crdkustomizeresource

Expand Down
36 changes: 35 additions & 1 deletion cmd/resource-generator/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,9 @@ func main() {
controllerTemplate := template.Must(template.New("controller").Parse(controller_template))
projectTemplate := template.Must(template.New("project").Parse(project_template))
kuttlTestTemplate := template.Must(template.New("kuttl-test").Parse(kuttl_test_template))
crdKustomizationTemplate := template.Must(template.New("crd-kustomization").Parse(crd_kustomization_template))
funcMap := template.FuncMap{"plural": pluralize}
crdKustomizationTemplate := template.Must(
template.New("crd-kustomization").Funcs(funcMap).Parse(crd_kustomization_template))
samplesKustomizationTemplate := template.Must(
template.New("samples-kustomization").Parse(samples_kustomization_template))
mockDocTemplate := template.Must(template.New("mock-doc").Parse(mock_doc_template))
Expand Down Expand Up @@ -356,6 +358,38 @@ func writeTemplate[T ResourceType](path string, tmpl *template.Template, resourc
return tmpl.Execute(file, resource)
}

// pluralize returns the English plural of the given word.
// It handles common suffixes: consonant+y → ies, sibilants → es,
// and falls back to appending s.
func pluralize(s string) string {
if s == "" {
return s
}

lower := strings.ToLower(s)

// Words ending in a consonant followed by "y": replace "y" with "ies"
if strings.HasSuffix(lower, "y") {
// Check the character before 'y' is a consonant (not a vowel)
if len(lower) >= 2 {
beforeY := lower[len(lower)-2]
if !strings.ContainsRune("aeiou", rune(beforeY)) {
return s[:len(s)-1] + "ies"
}
}
}

// Words ending in s, sh, ch, x, z: append "es"
for _, suffix := range []string{"s", "sh", "ch", "x", "z"} {
if strings.HasSuffix(lower, suffix) {
return s + "es"
}
}

// Default: append "s"
return s + "s"
}

func writeAutogeneratedHeader(f *os.File) error {
var commentPrefix string

Expand Down
63 changes: 63 additions & 0 deletions cmd/resource-generator/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package main

import "testing"

func TestPluralize(t *testing.T) {
tests := []struct {
input string
want string
}{
// Empty string
{"", ""},

// All existing ORC resources (lowercase, as used by NameLower)
{"addressscope", "addressscopes"},
{"applicationcredential", "applicationcredentials"},
{"domain", "domains"},
{"endpoint", "endpoints"},
{"flavor", "flavors"},
{"floatingip", "floatingips"},
{"group", "groups"},
{"image", "images"},
{"keypair", "keypairs"},
{"limit", "limits"},
{"network", "networks"},
{"port", "ports"},
{"project", "projects"},
{"region", "regions"},
{"registeredlimit", "registeredlimits"},
{"role", "roles"},
{"roleassignment", "roleassignments"},
{"router", "routers"},
{"routerinterface", "routerinterfaces"},
{"securitygroup", "securitygroups"},
{"server", "servers"},
{"servergroup", "servergroups"},
{"service", "services"},
{"sharenetwork", "sharenetworks"},
{"subnet", "subnets"},
{"trunk", "trunks"},
{"user", "users"},
{"volume", "volumes"},
{"volumetype", "volumetypes"},

// Consonant + y → ies
{"policy", "policies"},
{"qospolicy", "qospolicies"},

// Vowel + y → just s
{"key", "keys"},

// Sibilant endings → es
{"address", "addresses"},
}

for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := pluralize(tt.input)
if got != tt.want {
t.Errorf("pluralize(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
8 changes: 4 additions & 4 deletions cmd/scaffold-controller/data/client/client.go.template
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import (
)

type {{ .Kind }}Client interface {
List{{ .Kind }}s(ctx context.Context, listOpts {{ .GophercloudPackage }}.ListOptsBuilder) iter.Seq2[*{{ .GophercloudPackage }}.{{ .GophercloudType }}, error]
List{{ .Kind | plural }}(ctx context.Context, listOpts {{ .GophercloudPackage }}.ListOptsBuilder) iter.Seq2[*{{ .GophercloudPackage }}.{{ .GophercloudType }}, error]
Create{{ .Kind }}(ctx context.Context, opts {{ .GophercloudPackage }}.CreateOptsBuilder) (*{{ .GophercloudPackage }}.{{ .GophercloudType }}, error)
Delete{{ .Kind }}(ctx context.Context, resourceID string) error
Get{{ .Kind }}(ctx context.Context, resourceID string) (*{{ .GophercloudPackage }}.{{ .GophercloudType }}, error)
Expand All @@ -51,10 +51,10 @@ func New{{ .Kind }}Client(providerClient *gophercloud.ProviderClient, providerCl
return &{{ .PackageName }}Client{client}, nil
}

func (c {{ .PackageName }}Client) List{{ .Kind }}s(ctx context.Context, listOpts {{ .GophercloudPackage }}.ListOptsBuilder) iter.Seq2[*{{ .GophercloudPackage }}.{{ .GophercloudType }}, error] {
func (c {{ .PackageName }}Client) List{{ .Kind | plural }}(ctx context.Context, listOpts {{ .GophercloudPackage }}.ListOptsBuilder) iter.Seq2[*{{ .GophercloudPackage }}.{{ .GophercloudType }}, error] {
pager := {{ .GophercloudPackage }}.List(c.client, listOpts)
return func(yield func(*{{ .GophercloudPackage }}.{{ .GophercloudType }}, error) bool) {
_ = pager.EachPage(ctx, yieldPage({{ .GophercloudPackage }}.Extract{{ .GophercloudType }}s, yield))
_ = pager.EachPage(ctx, yieldPage({{ .GophercloudPackage }}.Extract{{ .GophercloudType | plural }}, yield))
}
}

Expand All @@ -81,7 +81,7 @@ func New{{ .Kind }}ErrorClient(e error) {{ .Kind }}Client {
return {{ .PackageName }}ErrorClient{e}
}

func (e {{ .PackageName }}ErrorClient) List{{ .Kind }}s(_ context.Context, _ {{ .GophercloudPackage }}.ListOptsBuilder) iter.Seq2[*{{ .GophercloudPackage }}.{{ .GophercloudType }}, error] {
func (e {{ .PackageName }}ErrorClient) List{{ .Kind | plural }}(_ context.Context, _ {{ .GophercloudPackage }}.ListOptsBuilder) iter.Seq2[*{{ .GophercloudPackage }}.{{ .GophercloudType }}, error] {
return func(yield func(*{{ .GophercloudPackage }}.{{ .GophercloudType }}, error) bool) {
yield(nil, e.error)
}
Expand Down
4 changes: 2 additions & 2 deletions cmd/scaffold-controller/data/controller/actuator.go.template
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ func (actuator {{ .PackageName }}Actuator) ListOSResourcesForAdoption(ctx contex
// TODO(scaffolding): Add more adoption filters
}

return actuator.osClient.List{{ .Kind }}s(ctx, listOpts), true
return actuator.osClient.List{{ .Kind | plural }}(ctx, listOpts), true
}

func (actuator {{ .PackageName }}Actuator) ListOSResourcesForImport(ctx context.Context, obj orcObjectPT, filter filterT) (iter.Seq2[*osResourceT, error], progress.ReconcileStatus) {
Expand Down Expand Up @@ -163,7 +163,7 @@ func (actuator {{ .PackageName }}Actuator) ListOSResourcesForImport(ctx context.
// TODO(scaffolding): Add more import filters
}

return actuator.osClient.List{{ .Kind }}s(ctx, listOpts), {{ if len .ImportDependencies }}reconcileStatus{{ else }}nil{{ end }}
return actuator.osClient.List{{ .Kind | plural }}(ctx, listOpts), {{ if len .ImportDependencies }}reconcileStatus{{ else }}nil{{ end }}
}

func (actuator {{ .PackageName }}Actuator) CreateResource(ctx context.Context, obj orcObjectPT) (*osResourceT, progress.ReconcileStatus) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ import (

const controllerName = "{{ .PackageName }}"

// +kubebuilder:rbac:groups=openstack.k-orc.cloud,resources={{ .PackageName }}s,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=openstack.k-orc.cloud,resources={{ .PackageName }}s/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=openstack.k-orc.cloud,resources={{ .PackageName | plural }},verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=openstack.k-orc.cloud,resources={{ .PackageName | plural }}/status,verbs=get;update;patch

type {{ .PackageName }}ReconcilerConstructor struct {
scopeFactory scope.Factory
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,19 @@

## Step 00

Create {{ .Kind }}s referencing non-existing resources. Each {{ .Kind }} is dependent on other non-existing resource. Verify that the {{ .Kind }}s are waiting for the needed resources to be created externally.
Create {{ .Kind | plural }} referencing non-existing resources. Each {{ .Kind }} is dependent on other non-existing resource. Verify that the {{ .Kind | plural }} are waiting for the needed resources to be created externally.

## Step 01

Create the missing dependencies and verify all the {{ .Kind }}s are available.
Create the missing dependencies and verify all the {{ .Kind | plural }} are available.

## Step 02

Delete all the dependencies and check that ORC prevents deletion since there is still a resource that depends on them.

## Step 03

Delete the {{ .Kind }}s and validate that all resources are gone.
Delete the {{ .Kind | plural }} and validate that all resources are gone.

## Reference

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Step 00

Create two {{ .Kind }}s with identical specs.
Create two {{ .Kind | plural }} with identical specs.

## Step 01

Expand Down
33 changes: 33 additions & 0 deletions cmd/scaffold-controller/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@ func render(srcDir, distDir string, resource *templateFields) {
var funcMap = template.FuncMap{
"lower": strings.ToLower,
"camelCase": toCamelCase,
"plural": pluralize,
}
tpl := template.Must(template.New(tplName).Funcs(funcMap).Parse(string(templateContent)))

Expand Down Expand Up @@ -298,6 +299,38 @@ func camelToSnake(s string) string {
return strings.ToLower(s)
}

// pluralize returns the English plural of the given word.
// It handles common suffixes: consonant+y → ies, sibilants → es,
// and falls back to appending s.
func pluralize(s string) string {
if s == "" {
return s
}

lower := strings.ToLower(s)

// Words ending in a consonant followed by "y": replace "y" with "ies"
if strings.HasSuffix(lower, "y") {
// Check the character before 'y' is a consonant (not a vowel)
if len(lower) >= 2 {
beforeY := lower[len(lower)-2]
if !strings.ContainsRune("aeiou", rune(beforeY)) {
return s[:len(s)-1] + "ies"
}
}
}

// Words ending in s, sh, ch, x, z: append "es"
for _, suffix := range []string{"s", "sh", "ch", "x", "z"} {
if strings.HasSuffix(lower, suffix) {
return s + "es"
}
}

// Default: append "s"
return s + "s"
}

// toCamelCase converts a string to camelCase.
// From https://stackoverflow.com/questions/70083837/how-to-convert-a-string-to-camelcase-in-go
func toCamelCase(s string) string {
Expand Down
78 changes: 78 additions & 0 deletions cmd/scaffold-controller/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package main

import "testing"

func TestPluralize(t *testing.T) {
tests := []struct {
input string
want string
}{
// Empty string
{"", ""},

// Default: append "s"
{"Flavor", "Flavors"},
{"Network", "Networks"},
{"Server", "Servers"},
{"Image", "Images"},
{"Port", "Ports"},
{"Router", "Routers"},
{"Subnet", "Subnets"},
{"Trunk", "Trunks"},
{"Volume", "Volumes"},
{"User", "Users"},
{"Role", "Roles"},
{"Domain", "Domains"},
{"Endpoint", "Endpoints"},
{"Limit", "Limits"},
{"Region", "Regions"},
{"Group", "Groups"},
{"Service", "Services"},
{"FloatingIP", "FloatingIPs"},
{"KeyPair", "KeyPairs"},

// Compound names (still just +s)
{"SecurityGroup", "SecurityGroups"},
{"ServerGroup", "ServerGroups"},
{"VolumeType", "VolumeTypes"},
{"ShareNetwork", "ShareNetworks"},
{"AddressScope", "AddressScopes"},
{"ApplicationCredential", "ApplicationCredentials"},
{"RoleAssignment", "RoleAssignments"},
{"RegisteredLimit", "RegisteredLimits"},
{"RouterInterface", "RouterInterfaces"},

// Consonant + y → ies
{"Policy", "Policies"},
{"policy", "policies"},
{"QoSPolicy", "QoSPolicies"},
{"qospolicy", "qospolicies"},

// Vowel + y → just s (not ies)
{"Key", "Keys"},
{"key", "keys"},

// Sibilant endings → es
{"address", "addresses"},
{"class", "classes"},
{"bus", "buses"},
{"match", "matches"},
{"box", "boxes"},
{"buzz", "buzzes"},
{"mesh", "meshes"},

// Lowercase defaults
{"flavor", "flavors"},
{"network", "networks"},
{"securitygroup", "securitygroups"},
}

for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := pluralize(tt.input)
if got != tt.want {
t.Errorf("pluralize(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
Loading