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
3 changes: 3 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ linters:
- dupl
- lll
path: internal/*
- linters:
- dupl
path: test/*
- linters:
- dupl
- goimports
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/*
Copyright The ORC Authors.

Licensed 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 apivalidations

import (
{{- if .AllCreateDependencies }}
"context"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
{{- else }}
. "github.com/onsi/ginkgo/v2"
{{- end }}
corev1 "k8s.io/api/core/v1"
"sigs.k8s.io/controller-runtime/pkg/client"

orcv1alpha1 "github.com/k-orc/openstack-resource-controller/v2/api/v1alpha1"
applyconfigv1alpha1 "github.com/k-orc/openstack-resource-controller/v2/pkg/clients/applyconfiguration/api/v1alpha1"
)

const (
{{ .PackageName }}Name = "{{ .PackageName }}"
{{ .PackageName }}ID = "265c9e4f-0f5a-46e4-9f3f-fb8de25ae120"
)

func {{ .PackageName }}Stub(namespace *corev1.Namespace) *orcv1alpha1.{{ .Kind }} {
obj := &orcv1alpha1.{{ .Kind }}{}
obj.Name = {{ .PackageName }}Name
obj.Namespace = namespace.Name
return obj
}

func test{{ .Kind }}Resource() *applyconfigv1alpha1.{{ .Kind }}ResourceSpecApplyConfiguration {
return applyconfigv1alpha1.{{ .Kind }}ResourceSpec(){{ range .RequiredCreateDependencies }}.
With{{ . }}Ref("{{ . | lower }}"){{ end }}
}

func base{{ .Kind }}Patch(obj client.Object) *applyconfigv1alpha1.{{ .Kind }}ApplyConfiguration {
return applyconfigv1alpha1.{{ .Kind }}(obj.GetName(), obj.GetNamespace()).
WithSpec(applyconfigv1alpha1.{{ .Kind }}Spec().
WithCloudCredentialsRef(testCredentials()))
}

func test{{ .Kind }}Import() *applyconfigv1alpha1.{{ .Kind }}ImportApplyConfiguration {
return applyconfigv1alpha1.{{ .Kind }}Import().WithID({{ .PackageName }}ID)
}

var _ = Describe("ORC {{ .Kind }} API validations", func() {
var namespace *corev1.Namespace
BeforeEach(func() {
namespace = createNamespace()
})

runManagementPolicyTests(func() *corev1.Namespace { return namespace }, managementPolicyTestArgs[*applyconfigv1alpha1.{{ .Kind }}ApplyConfiguration]{
createObject: func(ns *corev1.Namespace) client.Object { return {{ .PackageName }}Stub(ns) },
basePatch: func(obj client.Object) *applyconfigv1alpha1.{{ .Kind }}ApplyConfiguration {
return base{{ .Kind }}Patch(obj)
},
applyResource: func(p *applyconfigv1alpha1.{{ .Kind }}ApplyConfiguration) {
p.Spec.WithResource(test{{ .Kind }}Resource())
},
applyImport: func(p *applyconfigv1alpha1.{{ .Kind }}ApplyConfiguration) {
p.Spec.WithImport(test{{ .Kind }}Import())
},
applyEmptyImport: func(p *applyconfigv1alpha1.{{ .Kind }}ApplyConfiguration) {
p.Spec.WithImport(applyconfigv1alpha1.{{ .Kind }}Import())
},
applyEmptyFilter: func(p *applyconfigv1alpha1.{{ .Kind }}ApplyConfiguration) {
p.Spec.WithImport(applyconfigv1alpha1.{{ .Kind }}Import().WithFilter(applyconfigv1alpha1.{{ .Kind }}Filter()))
},
applyValidFilter: func(p *applyconfigv1alpha1.{{ .Kind }}ApplyConfiguration) {
p.Spec.WithImport(applyconfigv1alpha1.{{ .Kind }}Import().WithFilter(applyconfigv1alpha1.{{ .Kind }}Filter().WithName("foo")))
},
applyManaged: func(p *applyconfigv1alpha1.{{ .Kind }}ApplyConfiguration) {
p.Spec.WithManagementPolicy(orcv1alpha1.ManagementPolicyManaged)
},
applyUnmanaged: func(p *applyconfigv1alpha1.{{ .Kind }}ApplyConfiguration) {
p.Spec.WithManagementPolicy(orcv1alpha1.ManagementPolicyUnmanaged)
},
applyManagedOptions: func(p *applyconfigv1alpha1.{{ .Kind }}ApplyConfiguration) {
p.Spec.WithManagedOptions(applyconfigv1alpha1.ManagedOptions().WithOnDelete(orcv1alpha1.OnDeleteDetach))
},
getManagementPolicy: func(obj client.Object) orcv1alpha1.ManagementPolicy {
return obj.(*orcv1alpha1.{{ .Kind }}).Spec.ManagementPolicy
},
getOnDelete: func(obj client.Object) orcv1alpha1.OnDelete {
return obj.(*orcv1alpha1.{{ .Kind }}).Spec.ManagedOptions.OnDelete
},
})
{{- if .RequiredCreateDependencies }}

It("should reject a {{ .PackageName }} without required fields", func(ctx context.Context) {
obj := {{ .PackageName }}Stub(namespace)
patch := base{{ .Kind }}Patch(obj)
patch.Spec.WithResource(applyconfigv1alpha1.{{ .Kind }}ResourceSpec())
Expect(applyObj(ctx, obj, patch)).NotTo(Succeed())
})
{{- end }}
{{- range .AllCreateDependencies }}

It("should have immutable {{ . | camelCase }}Ref", func(ctx context.Context) {
obj := {{ $.PackageName }}Stub(namespace)
patch := base{{ $.Kind }}Patch(obj)
patch.Spec.WithResource(test{{ $.Kind }}Resource().
With{{ . }}Ref("{{ . | lower }}-a"))
Expect(applyObj(ctx, obj, patch)).To(Succeed())

patch.Spec.WithResource(test{{ $.Kind }}Resource().
With{{ . }}Ref("{{ . | lower }}-b"))
Expect(applyObj(ctx, obj, patch)).To(MatchError(ContainSubstring("{{ . | camelCase }}Ref is immutable")))
})
{{- end }}

// TODO(scaffolding): Add more resource-specific validation tests.
// Some common things to test:
// - Immutability of fields with `self == oldSelf` validation
// - Enum validation (valid and invalid values)
// - Numeric range validation (min/max bounds)
// - Tag uniqueness (if the resource has tags with listType=set)
// - Format validation (CIDR, UUID, etc.)
// - Cross-field validation rules
})
3 changes: 3 additions & 0 deletions cmd/scaffold-controller/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ func main() {
render("data/controller", filepath.Join("internal", "controllers", fields.PackageName), &fields)
render("data/tests", filepath.Join("internal", "controllers", fields.PackageName, "tests"), &fields)
render("data/samples", filepath.Join("config", "samples"), &fields)
render("data/apivalidation", filepath.Join("test", "apivalidations"), &fields)
}

func render(srcDir, distDir string, resource *templateFields) {
Expand Down Expand Up @@ -231,6 +232,8 @@ func render(srcDir, distDir string, resource *templateFields) {
tplName = resource.PackageName + ".go"
case "sample.yaml":
tplName = "openstack_v1alpha1_" + resource.PackageName + ".yaml"
case "apivalidation_test.go":
tplName = resource.PackageName + "_test.go"
}

var funcMap = template.FuncMap{
Expand Down
131 changes: 131 additions & 0 deletions test/apivalidations/common_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ limitations under the License.
package apivalidations

import (
"context"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
corev1 "k8s.io/api/core/v1"
"sigs.k8s.io/controller-runtime/pkg/client"

orcv1alpha1 "github.com/k-orc/openstack-resource-controller/v2/api/v1alpha1"
applyconfigv1alpha1 "github.com/k-orc/openstack-resource-controller/v2/pkg/clients/applyconfiguration/api/v1alpha1"
)

Expand All @@ -25,3 +33,126 @@ func testCredentials() *applyconfigv1alpha1.CloudCredentialsReferenceApplyConfig
WithSecretName("openstack-credentials").
WithCloudName("openstack")
}

// managementPolicyTestArgs provides resource-specific callbacks for the shared
// management policy validation tests. PatchT is the concrete apply
// configuration type for the resource (e.g. *applyconfigv1alpha1.FlavorApplyConfiguration).
type managementPolicyTestArgs[PatchT any] struct {
// createObject returns a new stub object in the given namespace.
createObject func(*corev1.Namespace) client.Object
// basePatch returns a patch with only cloudCredentialsRef set.
basePatch func(client.Object) PatchT
// applyResource adds a valid resource spec to the patch.
applyResource func(PatchT)
// applyImport adds a valid import (by ID) to the patch.
applyImport func(PatchT)
// applyEmptyImport adds an empty import to the patch.
applyEmptyImport func(PatchT)
// applyEmptyFilter adds an import with an empty filter to the patch.
applyEmptyFilter func(PatchT)
// applyValidFilter adds an import with a valid filter to the patch.
applyValidFilter func(PatchT)
// applyManaged sets the management policy to managed.
applyManaged func(PatchT)
// applyUnmanaged sets the management policy to unmanaged.
applyUnmanaged func(PatchT)
// applyManagedOptions adds managedOptions to the patch.
applyManagedOptions func(PatchT)
// getManagementPolicy reads the management policy from the object.
getManagementPolicy func(client.Object) orcv1alpha1.ManagementPolicy
// getOnDelete reads the onDelete value from the object's managedOptions.
getOnDelete func(client.Object) orcv1alpha1.OnDelete
}

// runManagementPolicyTests registers shared Ginkgo test cases for the standard
// management policy validations that apply to all ORC resources with a
// managementPolicy field.
func runManagementPolicyTests[PatchT any](getNamespace func() *corev1.Namespace, args managementPolicyTestArgs[PatchT]) {
It("should allow to create a minimal resource and managementPolicy should default to managed", func(ctx context.Context) {
obj := args.createObject(getNamespace())
patch := args.basePatch(obj)
args.applyResource(patch)
Expect(applyObj(ctx, obj, patch)).To(Succeed())
Expect(args.getManagementPolicy(obj)).To(Equal(orcv1alpha1.ManagementPolicyManaged))
})

It("should require import for unmanaged", func(ctx context.Context) {
obj := args.createObject(getNamespace())
patch := args.basePatch(obj)
args.applyUnmanaged(patch)
Expect(applyObj(ctx, obj, patch)).To(MatchError(ContainSubstring("import must be specified when policy is unmanaged")))

args.applyImport(patch)
Expect(applyObj(ctx, obj, patch)).To(Succeed())
})

It("should not permit unmanaged with resource", func(ctx context.Context) {
obj := args.createObject(getNamespace())
patch := args.basePatch(obj)
args.applyUnmanaged(patch)
args.applyImport(patch)
args.applyResource(patch)
Expect(applyObj(ctx, obj, patch)).To(MatchError(ContainSubstring("resource may not be specified when policy is unmanaged")))
})

It("should not permit empty import", func(ctx context.Context) {
obj := args.createObject(getNamespace())
patch := args.basePatch(obj)
args.applyUnmanaged(patch)
args.applyEmptyImport(patch)
Expect(applyObj(ctx, obj, patch)).To(MatchError(ContainSubstring("spec.import in body should have at least 1 properties")))
})

It("should not permit empty import filter", func(ctx context.Context) {
obj := args.createObject(getNamespace())
patch := args.basePatch(obj)
args.applyUnmanaged(patch)
args.applyEmptyFilter(patch)
Expect(applyObj(ctx, obj, patch)).To(MatchError(ContainSubstring("spec.import.filter in body should have at least 1 properties")))
})

It("should permit valid import filter", func(ctx context.Context) {
obj := args.createObject(getNamespace())
patch := args.basePatch(obj)
args.applyUnmanaged(patch)
args.applyValidFilter(patch)
Expect(applyObj(ctx, obj, patch)).To(Succeed())
})

It("should require resource for managed", func(ctx context.Context) {
obj := args.createObject(getNamespace())
patch := args.basePatch(obj)
args.applyManaged(patch)
Expect(applyObj(ctx, obj, patch)).To(MatchError(ContainSubstring("resource must be specified when policy is managed")))

args.applyResource(patch)
Expect(applyObj(ctx, obj, patch)).To(Succeed())
})

It("should not permit managed with import", func(ctx context.Context) {
obj := args.createObject(getNamespace())
patch := args.basePatch(obj)
args.applyImport(patch)
args.applyManaged(patch)
args.applyResource(patch)
Expect(applyObj(ctx, obj, patch)).To(MatchError(ContainSubstring("import may not be specified when policy is managed")))
})

It("should not permit managedOptions for unmanaged", func(ctx context.Context) {
obj := args.createObject(getNamespace())
patch := args.basePatch(obj)
args.applyImport(patch)
args.applyUnmanaged(patch)
args.applyManagedOptions(patch)
Expect(applyObj(ctx, obj, patch)).To(MatchError(ContainSubstring("managedOptions may only be provided when policy is managed")))
})

It("should permit managedOptions for managed", func(ctx context.Context) {
obj := args.createObject(getNamespace())
patch := args.basePatch(obj)
args.applyResource(patch)
args.applyManagedOptions(patch)
Expect(applyObj(ctx, obj, patch)).To(Succeed())
Expect(args.getOnDelete(obj)).To(Equal(orcv1alpha1.OnDelete("detach")))
})
}
90 changes: 90 additions & 0 deletions test/apivalidations/domain_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/*
Copyright The ORC Authors.

Licensed 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 apivalidations

import (
. "github.com/onsi/ginkgo/v2"
corev1 "k8s.io/api/core/v1"
"sigs.k8s.io/controller-runtime/pkg/client"

orcv1alpha1 "github.com/k-orc/openstack-resource-controller/v2/api/v1alpha1"
applyconfigv1alpha1 "github.com/k-orc/openstack-resource-controller/v2/pkg/clients/applyconfiguration/api/v1alpha1"
)

const (
domainName = "domain"
domainID = "265c9e4f-0f5a-46e4-9f3f-fb8de25ae120"
)

func domainStub(namespace *corev1.Namespace) *orcv1alpha1.Domain {
obj := &orcv1alpha1.Domain{}
obj.Name = domainName
obj.Namespace = namespace.Name
return obj
}

func testDomainResource() *applyconfigv1alpha1.DomainResourceSpecApplyConfiguration {
return applyconfigv1alpha1.DomainResourceSpec()
}

func baseDomainPatch(domain client.Object) *applyconfigv1alpha1.DomainApplyConfiguration {
return applyconfigv1alpha1.Domain(domain.GetName(), domain.GetNamespace()).
WithSpec(applyconfigv1alpha1.DomainSpec().
WithCloudCredentialsRef(testCredentials()))
}

func testDomainImport() *applyconfigv1alpha1.DomainImportApplyConfiguration {
return applyconfigv1alpha1.DomainImport().WithID(domainID)
}

var _ = Describe("ORC Domain API validations", func() {
var namespace *corev1.Namespace
BeforeEach(func() {
namespace = createNamespace()
})

runManagementPolicyTests(func() *corev1.Namespace { return namespace }, managementPolicyTestArgs[*applyconfigv1alpha1.DomainApplyConfiguration]{
createObject: func(ns *corev1.Namespace) client.Object { return domainStub(ns) },
basePatch: func(obj client.Object) *applyconfigv1alpha1.DomainApplyConfiguration { return baseDomainPatch(obj) },
applyResource: func(p *applyconfigv1alpha1.DomainApplyConfiguration) { p.Spec.WithResource(testDomainResource()) },
applyImport: func(p *applyconfigv1alpha1.DomainApplyConfiguration) { p.Spec.WithImport(testDomainImport()) },
applyEmptyImport: func(p *applyconfigv1alpha1.DomainApplyConfiguration) {
p.Spec.WithImport(applyconfigv1alpha1.DomainImport())
},
applyEmptyFilter: func(p *applyconfigv1alpha1.DomainApplyConfiguration) {
p.Spec.WithImport(applyconfigv1alpha1.DomainImport().WithFilter(applyconfigv1alpha1.DomainFilter()))
},
applyValidFilter: func(p *applyconfigv1alpha1.DomainApplyConfiguration) {
p.Spec.WithImport(applyconfigv1alpha1.DomainImport().WithFilter(applyconfigv1alpha1.DomainFilter().WithName("foo")))
},
applyManaged: func(p *applyconfigv1alpha1.DomainApplyConfiguration) {
p.Spec.WithManagementPolicy(orcv1alpha1.ManagementPolicyManaged)
},
applyUnmanaged: func(p *applyconfigv1alpha1.DomainApplyConfiguration) {
p.Spec.WithManagementPolicy(orcv1alpha1.ManagementPolicyUnmanaged)
},
applyManagedOptions: func(p *applyconfigv1alpha1.DomainApplyConfiguration) {
p.Spec.WithManagedOptions(applyconfigv1alpha1.ManagedOptions().WithOnDelete(orcv1alpha1.OnDeleteDetach))
},
getManagementPolicy: func(obj client.Object) orcv1alpha1.ManagementPolicy {
return obj.(*orcv1alpha1.Domain).Spec.ManagementPolicy
},
getOnDelete: func(obj client.Object) orcv1alpha1.OnDelete {
return obj.(*orcv1alpha1.Domain).Spec.ManagedOptions.OnDelete
},
})
})
Loading
Loading