Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Module Markdown Optional Fields #334

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
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
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