Skip to content

Commit

Permalink
Refactor code to take into account optional fields
Browse files Browse the repository at this point in the history
Refactor code so we are not passing in values and only using the defaults from the module config

Show parent field if there are no child fields to show

We want to show the labels field (among others) in the markdown table if
so that it is part of the documentation rather than showing all the
preset labels as individual fields in the docs.

Update schema with +nodoc tags and copy to blueprint starter module

Hide certain fields

This change brings the output more inline with the definition

Update output to remove default column

Commit updated test data

Update nodoc behaviour

Update structure to accomodate nodoc

Signed-off-by: Luke Mallon (Nalum) <[email protected]>
  • Loading branch information
Nalum committed Oct 11, 2024
1 parent cf22fe3 commit f44efe5
Show file tree
Hide file tree
Showing 15 changed files with 285 additions and 108 deletions.
49 changes: 33 additions & 16 deletions blueprints/starter/README.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import (

// Reference is the image address computed from repository, tag and digest
// in the format [REPOSITORY]:[TAG]@[DIGEST].
// +nodoc
reference: string

if digest != "" && tag != "" {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,17 @@ import "strings"

// Standard Kubernetes labels: app name, version and managed-by.
labels: {
(#StdLabelName): name
(#StdLabelVersion): #Version
// +nodoc
(#StdLabelName): name
// +nodoc
(#StdLabelVersion): #Version
// +nodoc
(#StdLabelManagedBy): "timoni"
}

// LabelSelector selects Pods based on the app.kubernetes.io/name label.
#LabelSelector: #Labels & {
// +nodoc
(#StdLabelName): name
}

Expand All @@ -74,6 +78,7 @@ import "strings"
namespace: #Meta.namespace

labels: #Meta.labels
// +nodoc
labels: (#StdLabelComponent): #Component

annotations?: #Annotations
Expand All @@ -84,8 +89,10 @@ import "strings"
// LabelSelector selects Pods based on the app.kubernetes.io/name
// and app.kubernetes.io/component labels.
#LabelSelector: #Labels & {
// +nodoc
(#StdLabelComponent): #Component
(#StdLabelName): #Meta.name
// +nodoc
(#StdLabelName): #Meta.name
}
}

Expand All @@ -104,6 +111,7 @@ import "strings"
name: #Meta.name + "-" + #Component

labels: #Meta.labels
// +nodoc
labels: (#StdLabelComponent): #Component

annotations?: #Annotations
Expand All @@ -114,7 +122,9 @@ import "strings"
// LabelSelector selects Pods based on the app.kubernetes.io/name
// and app.kubernetes.io/component labels.
#LabelSelector: #Labels & {
// +nodoc
(#StdLabelComponent): #Component
(#StdLabelName): #Meta.name
// +nodoc
(#StdLabelName): #Meta.name
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,8 @@ package v1alpha1
labels: #Labels

// Standard Kubernetes label: app name.
labels: (#StdLabelName): #Name
labels: {
// +nodoc
(#StdLabelName): #Name
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,11 @@ import (
let minMajor = strconv.Atoi(strings.Split(#Minimum, ".")[0])
let minMinor = strconv.Atoi(strings.Split(#Minimum, ".")[1])

// +nodoc
major: int & >=minMajor
major: strconv.Atoi(strings.Split(#Version, ".")[0])

// +nodoc
minor: int & >=minMinor
minor: strconv.Atoi(strings.Split(#Version, ".")[1])
}
3 changes: 3 additions & 0 deletions blueprints/starter/templates/config.cue
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,16 @@ import (
#Config: {
// The kubeVersion is a required field, set at apply-time
// via timoni.cue by querying the user's Kubernetes API.
// +nodoc
kubeVersion!: string
// Using the kubeVersion you can enforce a minimum Kubernetes minor version.
// By default, the minimum Kubernetes version is set to 1.20.
// +nodoc
clusterVersion: timoniv1.#SemVer & {#Version: kubeVersion, #Minimum: "1.20.0"}

// The moduleVersion is set from the user-supplied module version.
// This field is used for the `app.kubernetes.io/version` label.
// +nodoc
moduleVersion!: string

// The Kubernetes metadata common to all resources.
Expand Down
8 changes: 2 additions & 6 deletions cmd/timoni/mod_show_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,17 +124,13 @@ func runConfigShowModCmd(cmd *cobra.Command, args []string) error {
return fmt.Errorf("build failed: %w", err)
}

buildResult, err := builder.Build()
if err != nil {
return describeErr(f.GetModuleRoot(), "validation failed", err)
}
rows, err := builder.GetConfigDoc()

rows, err := builder.GetConfigDoc(buildResult)
if err != nil {
return describeErr(f.GetModuleRoot(), "failed to get config structure", err)
}

header := []string{"Key", "Type", "Default", "Description"}
header := []string{"Key", "Type", "Description"}

if configShowModArgs.output == "" {
printMarkDownTable(rootCmd.OutOrStdout(), header, rows)
Expand Down
21 changes: 10 additions & 11 deletions cmd/timoni/testdata/module/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,14 @@ timoni -n module delete module

## Configuration

| KEY | TYPE | DEFAULT | DESCRIPTION |
|------------------------------|----------|------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `metadata: labels:` | `struct` | `{"app.kubernetes.io/name": "module-name","app.kubernetes.io/kube": "1.27.5","app.kubernetes.io/version": "0.0.0-devel","app.kubernetes.io/team": "test"}` | Map of string keys and values that can be used to organize and categorize (scope and select) objects. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels Standard Kubernetes labels: app name and version. |
| `client: enabled:` | `bool` | `true` | |
| `client: image: repository:` | `string` | `"cgr.dev/chainguard/timoni"` | Repository is the address of a container registry repository. An image repository is made up of slash-separated name components, optionally prefixed by a registry hostname and port in the format [HOST[:PORT_NUMBER]/]PATH. |
| `client: image: tag:` | `string` | `"latest-dev"` | Tag identifies an image in the repository. A tag name may contain lowercase and uppercase characters, digits, underscores, periods and dashes. A tag name may not start with a period or a dash and may contain a maximum of 128 characters. |
| `client: image: digest:` | `string` | `"sha256:b49fbaac0eedc22c1cfcd26684707179cccbed0df205171bae3e1bae61326a10"` | Digest uniquely and immutably identifies an image in the repository. Spec: https://github.com/opencontainers/image-spec/blob/main/descriptor.md#digests. |
| `server: enabled:` | `bool` | `true` | |
| `domain:` | `string` | `"example.internal"` | |
| `ns: enabled:` | `bool` | `false` | |
| `team:` | `string` | `"test"` | |
| KEY | TYPE | DESCRIPTION |
|------------------------------|----------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `client: enabled:` | `*true \| bool` | |
| `client: image: repository:` | `*"cgr.dev/chainguard/timoni" \| string` | Repository is the address of a container registry repository. An image repository is made up of slash-separated name components, optionally prefixed by a registry hostname and port in the format [HOST[:PORT_NUMBER]/]PATH. |
| `client: image: tag:` | `*"latest-dev" \| strings.MaxRunes(128)` | Tag identifies an image in the repository. A tag name may contain lowercase and uppercase characters, digits, underscores, periods and dashes. A tag name may not start with a period or a dash and may contain a maximum of 128 characters. |
| `client: image: digest:` | `*"sha256:b49fbaac0eedc22c1cfcd26684707179cccbed0df205171bae3e1bae61326a10" \| string` | Digest uniquely and immutably identifies an image in the repository. Spec: https://github.com/opencontainers/image-spec/blob/main/descriptor.md#digests. |
| `server: enabled:` | `*true \| bool` | |
| `domain:` | `*"example.internal" \| string` | |
| `ns: enabled:` | `*false \| bool` | |
| `team:` | `"test"` | |

4 changes: 0 additions & 4 deletions cmd/timoni/testdata/module/templates/config.cue
Original file line number Diff line number Diff line change
Expand Up @@ -17,25 +17,21 @@ import (
"app.kubernetes.io/team": team
}

// +nodoc
client: {
enabled: *true | bool

// +nodoc
image: timoniv1.#Image & {
repository: *"cgr.dev/chainguard/timoni" | string
tag: *"latest-dev" | string
digest: *"sha256:b49fbaac0eedc22c1cfcd26684707179cccbed0df205171bae3e1bae61326a10" | string
}
}

// +nodoc
server: {
enabled: *true | bool
}
domain: *"example.internal" | string

// +nodoc
ns: {
enabled: *false | bool
}
Expand Down
189 changes: 189 additions & 0 deletions internal/engine/get_config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
/*
Copyright 2023 Stefan Prodan
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 engine

import (
"errors"
"fmt"
"regexp"
"strings"

"cuelang.org/go/cue"
"cuelang.org/go/cue/ast"
"cuelang.org/go/cue/load"

apiv1 "github.com/stefanprodan/timoni/api/v1alpha1"
)

// GetConfigDoc extracts the config structure from the module.
func (b *ModuleBuilder) GetConfigDoc() ([][]string, error) {
var value cue.Value

cfg := &load.Config{
ModuleRoot: b.moduleRoot,
Package: b.pkgName,
Dir: b.pkgPath,
DataFiles: true,
Tags: []string{
"name=" + b.name,
"namespace=" + b.namespace,
},
TagVars: map[string]load.TagVar{
"moduleVersion": {
Func: func() (ast.Expr, error) {
return ast.NewString(b.moduleVersion), nil
},
},
"kubeVersion": {
Func: func() (ast.Expr, error) {
return ast.NewString(b.kubeVersion), nil
},
},
},
}

modInstances := load.Instances([]string{}, cfg)
if len(modInstances) == 0 {
return nil, errors.New("no instances found")
}

modInstance := modInstances[0]
if modInstance.Err != nil {
return nil, fmt.Errorf("instance error: %w", modInstance.Err)
}

value = b.ctx.BuildInstance(modInstance)
if value.Err() != nil {
return nil, value.Err()
}

cfgValues := value.LookupPath(cue.ParsePath(apiv1.ConfigValuesSelector.String()))
if cfgValues.Err() != nil {
return nil, fmt.Errorf("lookup %s failed: %w", apiv1.ConfigValuesSelector, cfgValues.Err())
}

rows, err := iterateFields(cfgValues)
if err != nil {
return nil, err
}

return rows, nil
}

func iterateFields(v cue.Value) ([][]string, error) {
var rows [][]string

fields, err := v.Fields(
cue.Optional(true),
cue.Concrete(true),
cue.Docs(true),
)
if err != nil {
return nil, fmt.Errorf("Cue Fields Error: %w", err)
}

for fields.Next() {
v := fields.Value()
_, noDoc := hasNoDoc(v)

if noDoc {
continue
}

// We are chekcing if the field is a struct and not optional and is concrete before we iterate through it
// this allows for definition of default values as full structs without generating output for each
// field in the struct where it doesn't make sense e.g.
//
// - annotations?: {[string]: string}
// - affinity: corev1.Affinity | *{nodeAffinity: requiredDuringSchedulingIgnoredDuringExecution: nodeSelectorTerms: [...]}
if v.IncompleteKind() == cue.StructKind && !fields.IsOptional() && v.IsConcrete() {
//if _, ok := v.Default(); v.IncompleteKind() == cue.StructKind && !fields.IsOptional() && ok {
// Assume we want to use the field
useField := true
iRows, err := iterateFields(v)

if err != nil {
return nil, err
}

for _, row := range iRows {
if len(row) > 0 {
// If we have a row with more than 0 elements, we don't want to use the field and should use the child rows instead
useField = false
rows = append(rows, row)
}
}

if useField {
rows = append(rows, getField(v))
}
} else {
rows = append(rows, getField(v))
}
}

return rows, nil
}

func hasNoDoc(v cue.Value) (string, bool) {
var noDoc bool
var doc string

for _, d := range v.Doc() {
if line := len(d.List) - 1; line >= 0 {
switch d.List[line].Text {
case "// +nodoc":
noDoc = true
break
}
}

doc += d.Text()
doc = strings.ReplaceAll(doc, "\n", " ")
doc = strings.ReplaceAll(doc, "+required", "")
doc = strings.ReplaceAll(doc, "+optional", "")
}

return doc, noDoc
}

func getField(v cue.Value) []string {
var row []string
labelDomain := regexp.MustCompile(`^([a-zA-Z0-9-_.]+)?(".+")?$`)
doc, noDoc := hasNoDoc(v)

if !noDoc {
fieldType := strings.ReplaceAll(fmt.Sprintf("%v", v), "\n", "")
fieldType = strings.ReplaceAll(fieldType, "|", "\\|")
fieldType = strings.ReplaceAll(fieldType, "\":", "\": ")
fieldType = strings.ReplaceAll(fieldType, "\":[", "\": [")
fieldType = strings.ReplaceAll(fieldType, "},", "}, ")

if len(fieldType) == 0 {
fieldType = " "
}

field := strings.Replace(v.Path().String(), "timoni.instance.config.", "", 1)
match := labelDomain.FindStringSubmatch(field)

row = append(row, fmt.Sprintf("`%s:`", strings.ReplaceAll(match[1], ".", ": ")+match[2]))
row = append(row, fmt.Sprintf("`%s`", fieldType))
row = append(row, fmt.Sprintf("%s", doc))
}

return row
}
Loading

0 comments on commit f44efe5

Please sign in to comment.