Skip to content

Commit 2a24c4f

Browse files
committed
Add kustomizeconfig-generator; regenerate kustomizeconfig.yaml from API markers
examples/components/kustomizeconfig/kustomizeconfig.yaml was previously maintained by hand, per the (now removed) instruction in api-design.md. It had drifted significantly out of date: cross-references for Domain, Service, Group, Role, RoleAssignment, ApplicationCredential, User (including PasswordRef), RouterInterface, ShareNetwork, VolumeType, ServerGroup, and FloatingIP.FloatingSubnetRef were all missing, along with several spec.import.filter variants of existing entries. cmd/kustomizeconfig-generator instead derives the file automatically: - it scans api/v1alpha1 for '+orc:kustomize:ref=<Kind>' marker comments (added in the previous commit) - for every registered ORC Kind, it walks the real struct layout of that Kind's Spec via reflection to compute the JSON path of each marked field (handling embedded/inlined structs, list traversal, and RouterInterface's bespoke non-wrapped Spec) - it groups the results by referenced Kind and emits a kustomize Component nameReference configuration, with a generated-file header Wired into 'make generate' as a new 'generate-kustomizeconfig' target, covered by 'make verify-generated'. Verified: - the regenerated file is a strict superset of the old one (zero entries lost, confirmed via a structural diff ignoring the old file's inconsistent use of '[]' in one path) - a real 'kustomize build' exercise (with namePrefix + this component) correctly rewrites one of the newly-discovered references (Server.bootVolume.volumeRef -> Volume), and does not rewrite a deliberately-wrong path, confirming the generated paths are load-bearing and correct rather than accidentally permissive - 'make manifests' output (config/crd/bases) is unaffected Also updates website/docs/development/api-design.md: the file is now generated, not manually maintained; documents the new marker convention.
1 parent 6d67030 commit 2a24c4f

4 files changed

Lines changed: 595 additions & 105 deletions

File tree

Makefile

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ modules:
5858
go mod tidy
5959

6060
.PHONY: generate
61-
generate: generate-resources generate-controller-gen generate-codegen generate-go generate-docs modules manifests
61+
generate: generate-resources generate-controller-gen generate-kustomizeconfig generate-codegen generate-go generate-docs modules manifests
6262

6363
.PHONY: generate-resources
6464
generate-resources:
@@ -68,6 +68,10 @@ generate-resources:
6868
generate-controller-gen: controller-gen ## Generate code containing DeepCopy, DeepCopyInto, and DeepCopyObject method implementations.
6969
$(CONTROLLER_GEN) object:headerFile="hack/boilerplate.go.txt" paths="./..."
7070

71+
.PHONY: generate-kustomizeconfig
72+
generate-kustomizeconfig: generate-resources generate-controller-gen ## Generate examples/components/kustomizeconfig/kustomizeconfig.yaml from +orc:kustomize:ref markers on the API types.
73+
go run ./cmd/kustomizeconfig-generator
74+
7175
.PHONY: generate-codegen
7276
generate-codegen: generate-controller-gen ## codegen requires DeepCopy etc
7377
./hack/update-codegen.sh
Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
/*
2+
Copyright The ORC Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
// Command kustomizeconfig-generator generates
18+
// examples/components/kustomizeconfig/kustomizeconfig.yaml from the ORC API
19+
// types.
20+
//
21+
// It discovers cross-references between ORC objects (and core Secrets) by
22+
// scanning api/v1alpha1 for fields annotated with an
23+
// "+orc:kustomize:ref=<Kind>" marker, then walks the actual (reflected)
24+
// struct layout of every registered ORC Kind's Spec to compute the JSON path
25+
// of each annotated field. The result is emitted as a kustomize Component
26+
// "nameReference" configuration, grouped by referenced Kind.
27+
//
28+
// The "kustomizeref" golangci-lint rule (tools/orc-api-linter) separately
29+
// verifies that every KubernetesNameRef field carries the marker in the
30+
// first place; a missing marker produces no diff here, so `make lint` is
31+
// what catches that case, not this generator.
32+
package main
33+
34+
import (
35+
"fmt"
36+
"go/ast"
37+
"go/parser"
38+
"go/token"
39+
"os"
40+
"path/filepath"
41+
"reflect"
42+
"regexp"
43+
"sort"
44+
"strings"
45+
46+
"k8s.io/apimachinery/pkg/runtime"
47+
"sigs.k8s.io/yaml"
48+
49+
orcv1alpha1 "github.com/k-orc/openstack-resource-controller/v2/api/v1alpha1"
50+
)
51+
52+
const (
53+
apiDir = "api/v1alpha1"
54+
outputFile = "examples/components/kustomizeconfig/kustomizeconfig.yaml"
55+
)
56+
57+
var markerRE = regexp.MustCompile(`^\+orc:kustomize:ref=(\S+)$`)
58+
59+
var orcPkgPath = reflect.TypeOf(orcv1alpha1.KubernetesNameRef("")).PkgPath()
60+
61+
// fieldSpec mirrors kustomize's kustomizeconfig fieldSpec entry.
62+
type fieldSpec struct {
63+
Path string `json:"path"`
64+
Kind string `json:"kind"`
65+
}
66+
67+
// nameReferenceEntry mirrors kustomize's kustomizeconfig nameReference entry.
68+
type nameReferenceEntry struct {
69+
Kind string `json:"kind"`
70+
FieldSpecs []fieldSpec `json:"fieldSpecs"`
71+
}
72+
73+
type kustomizeConfig struct {
74+
NameReference []nameReferenceEntry `json:"nameReference"`
75+
}
76+
77+
// markerTable maps struct type name -> field name -> referenced Kind, as
78+
// declared by "+orc:kustomize:ref=<Kind>" marker comments in the API source.
79+
type markerTable map[string]map[string]string
80+
81+
func main() {
82+
if err := run(); err != nil {
83+
fmt.Fprintln(os.Stderr, "kustomizeconfig-generator:", err)
84+
os.Exit(1)
85+
}
86+
}
87+
88+
func run() error {
89+
markers, err := loadMarkers(apiDir)
90+
if err != nil {
91+
return fmt.Errorf("loading markers from %s: %w", apiDir, err)
92+
}
93+
94+
scheme := runtime.NewScheme()
95+
if err := orcv1alpha1.AddToScheme(scheme); err != nil {
96+
return fmt.Errorf("building scheme: %w", err)
97+
}
98+
99+
type hit struct {
100+
sourceKind string
101+
path string
102+
targetKind string
103+
}
104+
var hits []hit
105+
106+
for gvk, t := range scheme.AllKnownTypes() {
107+
if gvk.Group != orcv1alpha1.GroupName {
108+
continue
109+
}
110+
if strings.HasSuffix(gvk.Kind, "List") {
111+
continue
112+
}
113+
114+
specField, ok := t.FieldByName("Spec")
115+
if !ok {
116+
continue
117+
}
118+
119+
sourceKind := gvk.Kind
120+
w := walker{markers: markers}
121+
w.walk(derefStruct(specField.Type), "spec", 0, func(path, targetKind string) {
122+
hits = append(hits, hit{sourceKind: sourceKind, path: path, targetKind: targetKind})
123+
})
124+
}
125+
126+
if len(hits) == 0 {
127+
return fmt.Errorf("no +orc:kustomize:ref markers found; refusing to write an empty kustomizeconfig.yaml")
128+
}
129+
130+
grouped := map[string][]fieldSpec{}
131+
for _, h := range hits {
132+
grouped[h.targetKind] = append(grouped[h.targetKind], fieldSpec{Path: h.path, Kind: h.sourceKind})
133+
}
134+
135+
var targetKinds []string
136+
for k := range grouped {
137+
targetKinds = append(targetKinds, k)
138+
}
139+
sort.Strings(targetKinds)
140+
141+
cfg := kustomizeConfig{}
142+
for _, k := range targetKinds {
143+
specs := grouped[k]
144+
sort.Slice(specs, func(i, j int) bool {
145+
if specs[i].Kind != specs[j].Kind {
146+
return specs[i].Kind < specs[j].Kind
147+
}
148+
return specs[i].Path < specs[j].Path
149+
})
150+
// Deduplicate identical (kind, path) pairs which can legitimately
151+
// arise, e.g. the same field appearing in both a create and an
152+
// update variant of a resource spec.
153+
deduped := specs[:0]
154+
for i, s := range specs {
155+
if i > 0 && s == specs[i-1] {
156+
continue
157+
}
158+
deduped = append(deduped, s)
159+
}
160+
cfg.NameReference = append(cfg.NameReference, nameReferenceEntry{Kind: k, FieldSpecs: deduped})
161+
}
162+
163+
out, err := yaml.Marshal(cfg)
164+
if err != nil {
165+
return fmt.Errorf("marshalling: %w", err)
166+
}
167+
168+
header := `# Code generated by kustomizeconfig-generator. DO NOT EDIT.
169+
#
170+
# This file teaches kustomize how to substitute name references between ORC
171+
# objects (and core Secrets) when transforming example manifests, e.g. via a
172+
# nameSuffix or nameReference transformer. It is derived from
173+
# "+orc:kustomize:ref=<Kind>" markers on the ORC API types.
174+
#
175+
# To add a new reference, annotate the field in api/v1alpha1 with
176+
# "+orc:kustomize:ref=<Kind>" and run ` + "`make generate`" + `.
177+
# See https://k-orc.cloud/development/api-design/.
178+
`
179+
180+
return os.WriteFile(outputFile, append([]byte(header), out...), 0o644)
181+
}
182+
183+
// derefStruct dereferences pointer types until it reaches the underlying type.
184+
func derefStruct(t reflect.Type) reflect.Type {
185+
for t.Kind() == reflect.Ptr {
186+
t = t.Elem()
187+
}
188+
return t
189+
}
190+
191+
type walker struct {
192+
markers markerTable
193+
}
194+
195+
// walk recursively descends into struct t, calling record(path, kind) for
196+
// every field annotated with an "+orc:kustomize:ref=<kind>" marker. path is
197+
// the JSON path accumulated so far, using "[]" to denote list elements,
198+
// matching kustomize's fieldSpec path syntax.
199+
func (w walker) walk(t reflect.Type, path string, depth int, record func(path, kind string)) {
200+
if depth > 25 {
201+
// Defensive guard; the API has no legitimate structures this deep.
202+
return
203+
}
204+
if t.Kind() != reflect.Struct || t.PkgPath() != orcPkgPath {
205+
return
206+
}
207+
208+
structName := t.Name()
209+
for i := 0; i < t.NumField(); i++ {
210+
f := t.Field(i)
211+
if f.PkgPath != "" {
212+
// unexported field
213+
continue
214+
}
215+
216+
if f.Anonymous {
217+
// Embedded/inlined field: recurse without adding a path segment.
218+
w.walk(derefStruct(f.Type), path, depth+1, record)
219+
continue
220+
}
221+
222+
jsonTag := f.Tag.Get("json")
223+
if jsonTag == "" || jsonTag == "-" {
224+
continue
225+
}
226+
name := strings.Split(jsonTag, ",")[0]
227+
if name == "" {
228+
continue
229+
}
230+
fieldPath := path + "/" + name
231+
232+
ft := derefStruct(f.Type)
233+
isSlice := ft.Kind() == reflect.Slice
234+
235+
if kind, ok := w.markers[structName][f.Name]; ok {
236+
if isSlice {
237+
fieldPath += "[]"
238+
}
239+
record(fieldPath, kind)
240+
continue
241+
}
242+
243+
if isSlice {
244+
elem := derefStruct(ft.Elem())
245+
if elem.Kind() == reflect.Struct && elem.PkgPath() == orcPkgPath {
246+
w.walk(elem, fieldPath+"[]", depth+1, record)
247+
}
248+
continue
249+
}
250+
251+
if ft.Kind() == reflect.Struct && ft.PkgPath() == orcPkgPath {
252+
w.walk(ft, fieldPath, depth+1, record)
253+
}
254+
}
255+
}
256+
257+
// loadMarkers scans all Go source files directly under dir for struct field
258+
// doc comments containing "+orc:kustomize:ref=<Kind>", returning a table
259+
// keyed by struct type name and Go field name.
260+
func loadMarkers(dir string) (markerTable, error) {
261+
table := markerTable{}
262+
263+
entries, err := os.ReadDir(dir)
264+
if err != nil {
265+
return nil, err
266+
}
267+
268+
fset := token.NewFileSet()
269+
for _, entry := range entries {
270+
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") {
271+
continue
272+
}
273+
274+
filename := filepath.Join(dir, entry.Name())
275+
file, err := parser.ParseFile(fset, filename, nil, parser.ParseComments)
276+
if err != nil {
277+
return nil, fmt.Errorf("parsing %s: %w", filename, err)
278+
}
279+
280+
for _, decl := range file.Decls {
281+
genDecl, ok := decl.(*ast.GenDecl)
282+
if !ok || genDecl.Tok != token.TYPE {
283+
continue
284+
}
285+
for _, spec := range genDecl.Specs {
286+
typeSpec, ok := spec.(*ast.TypeSpec)
287+
if !ok {
288+
continue
289+
}
290+
structType, ok := typeSpec.Type.(*ast.StructType)
291+
if !ok {
292+
continue
293+
}
294+
structName := typeSpec.Name.Name
295+
296+
for _, field := range structType.Fields.List {
297+
if field.Doc == nil {
298+
continue
299+
}
300+
kind := extractRefMarker(field.Doc)
301+
if kind == "" {
302+
continue
303+
}
304+
for _, fieldName := range field.Names {
305+
if table[structName] == nil {
306+
table[structName] = map[string]string{}
307+
}
308+
table[structName][fieldName.Name] = kind
309+
}
310+
}
311+
}
312+
}
313+
}
314+
315+
return table, nil
316+
}
317+
318+
func extractRefMarker(doc *ast.CommentGroup) string {
319+
for _, c := range doc.List {
320+
text := strings.TrimSpace(strings.TrimPrefix(c.Text, "//"))
321+
if m := markerRE.FindStringSubmatch(text); m != nil {
322+
return m[1]
323+
}
324+
}
325+
return ""
326+
}

0 commit comments

Comments
 (0)