diff --git a/Makefile b/Makefile index 338396a59e..abf848e88d 100644 --- a/Makefile +++ b/Makefile @@ -94,7 +94,7 @@ GO_BUILD_FLAGS :=-tags 'include_gcs include_oss containers_image_openpgp gssapi GO_TEST_FLAGS=$(GO_BUILD_FLAGS) GO_TEST_PACKAGES=./cmd/... ./pkg/... -all: microshift etcd +all: generate-config microshift etcd # target "build:" defined in vendor/github.com/openshift/build-machinery-go/make/targets/golang/build.mk # Disable CGO when building microshift binary @@ -121,7 +121,7 @@ verify: verify-fast # Fast verification checks that developers can/should run locally .PHONY: verify-fast -verify-fast: verify-go verify-assets verify-sh verify-py +verify-fast: verify-go verify-assets verify-sh verify-py verify-config # Full verification checks that should run in CI .PHONY: verify-ci @@ -312,3 +312,12 @@ vendor-etcd: verify: verify-vendor-etcd verify-vendor-etcd: vendor-etcd ./hack/verify-vendor-etcd.sh + +# Use helper `go generate script` to dynamically config information into packaging info as well as documentation. +.PHONY: generate-config verify-config +generate-config: + ./scripts/fetch_tools.sh controller-gen && \ + go generate -mod vendor ./pkg/config + +verify-config: generate-config + ./hack/verify-config.sh \ No newline at end of file diff --git a/cockpit-plugin/packaging/config-openapi-spec.json b/cockpit-plugin/packaging/config-openapi-spec.json new file mode 100755 index 0000000000..e0d2f0b074 --- /dev/null +++ b/cockpit-plugin/packaging/config-openapi-spec.json @@ -0,0 +1,132 @@ +{ + "type": "object", + "required": [ + "apiServer", + "debugging", + "dns", + "etcd", + "network", + "node" + ], + "properties": { + "apiServer": { + "type": "object", + "required": [ + "subjectAltNames" + ], + "properties": { + "advertiseAddress": { + "description": "Kube apiserver advertise address to work around the certificates issue when requiring external access using the node IP. This will turn into the IP configured in the endpoint slice for kubernetes service. Must be a reachable IP from pods. Defaults to service network CIDR first address.", + "type": "string" + }, + "subjectAltNames": { + "description": "SubjectAltNames added to API server certs", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "debugging": { + "type": "object", + "required": [ + "logLevel" + ], + "properties": { + "logLevel": { + "description": "Valid values are: \"Normal\", \"Debug\", \"Trace\", \"TraceAll\". Defaults to \"Normal\".", + "type": "string", + "default": "Normal" + } + } + }, + "dns": { + "type": "object", + "required": [ + "baseDomain" + ], + "properties": { + "baseDomain": { + "description": "baseDomain is the base domain of the cluster. All managed DNS records will be sub-domains of this base. \n For example, given the base domain `example.com`, router exposed domains will be formed as `*.apps.example.com` by default, and API service will have a DNS entry for `api.example.com`, as well as \"api-int.example.com\" for internal k8s API access. \n Once set, this field cannot be changed.", + "type": "string", + "default": "example.com", + "example": "microshift.example.com" + } + } + }, + "etcd": { + "type": "object", + "required": [ + "memoryLimitMB" + ], + "properties": { + "memoryLimitMB": { + "description": "Set a memory limit on the etcd process; etcd will begin paging memory when it gets to this value. 0 means no limit.", + "type": "integer", + "format": "int64" + } + } + }, + "network": { + "type": "object", + "required": [ + "clusterNetwork", + "serviceNetwork", + "serviceNodePortRange" + ], + "properties": { + "clusterNetwork": { + "description": "IP address pool to use for pod IPs. This field is immutable after installation.", + "type": "array", + "items": { + "type": "object", + "required": [ + "cidr" + ], + "properties": { + "cidr": { + "description": "The complete block for pod IPs.", + "type": "string", + "default": "10.42.0.0/16" + } + } + } + }, + "serviceNetwork": { + "description": "IP address pool for services. Currently, we only support a single entry here. This field is immutable after installation.", + "type": "array", + "default": [ + "10.43.0.0/16" + ], + "items": { + "type": "string" + } + }, + "serviceNodePortRange": { + "description": "The port range allowed for Services of type NodePort. If not specified, the default of 30000-32767 will be used. Such Services without a NodePort specified will have one automatically allocated from this range. This parameter can be updated after the cluster is installed.", + "type": "string", + "default": "30000-32767", + "pattern": "^([0-9]{1,4}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])-([0-9]{1,4}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])$" + } + } + }, + "node": { + "type": "object", + "required": [ + "hostnameOverride", + "nodeIP" + ], + "properties": { + "hostnameOverride": { + "description": "If non-empty, will use this string to identify the node instead of the hostname", + "type": "string" + }, + "nodeIP": { + "description": "IP address of the node, passed to the kubelet. If not specified, kubelet will use the node's default IP address.", + "type": "string" + } + } + } + } +} \ No newline at end of file diff --git a/docs/howto_config.md b/docs/howto_config.md index ca71b6c918..25bd764064 100644 --- a/docs/howto_config.md +++ b/docs/howto_config.md @@ -2,51 +2,68 @@ The MicroShift configuration file must be located at `/etc/microshift/config.yaml`. A sample `/etc/microshift/config.yaml.default` configuration file is installed by the MicroShift RPM and it can be used as a template when customizing MicroShift. The format of the `config.yaml` configuration file is as follows. - + ```yaml -dns: - baseDomain: "" -network: - clusterNetwork: - - cidr: "" - serviceNetwork: - - "" - serviceNodePortRange: "" -node: - hostnameOverride: "" - nodeIP: "" apiServer: - subjectAltNames: - - "" + advertiseAddress: "" + subjectAltNames: + - "" debugging: - logLevel: "" + logLevel: "" +dns: + baseDomain: "" etcd: - memoryLimitMB: 0 + memoryLimitMB: 0 +network: + clusterNetwork: + - cidr: "" + serviceNetwork: + - "" + serviceNodePortRange: "" +node: + hostnameOverride: "" + nodeIP: "" + ``` + ## Default Settings In case `config.yaml` is not provided, the following default settings will be used. - + ```yaml -dns: - baseDomain: microshift.example.com -network: - clusterNetwork: - - cidr: 10.42.0.0/16 - serviceNetwork: - - 10.43.0.0/16 - serviceNodePortRange: 30000-32767 -node: - hostnameOverride: "" - nodeIP: '' apiServer: - subjectAltNames: [] + advertiseAddress: "" + subjectAltNames: + - "" debugging: - logLevel: "Normal" + logLevel: Normal +dns: + baseDomain: example.com etcd: - memoryLimitMB: 0 + memoryLimitMB: 0 +network: + clusterNetwork: + - cidr: 10.42.0.0/16 + serviceNetwork: + - 10.43.0.0/16 + serviceNodePortRange: 30000-32767 +node: + hostnameOverride: "" + nodeIP: "" + ``` + ## Service NodePort range diff --git a/etcd/vendor/github.com/openshift/microshift/pkg/config/config.go b/etcd/vendor/github.com/openshift/microshift/pkg/config/config.go index e7c7e17576..da4adbc2e7 100644 --- a/etcd/vendor/github.com/openshift/microshift/pkg/config/config.go +++ b/etcd/vendor/github.com/openshift/microshift/pkg/config/config.go @@ -1,5 +1,7 @@ package config +//go:generate ../../hack/generate-config.sh + import ( "bytes" "fmt" diff --git a/etcd/vendor/github.com/openshift/microshift/pkg/config/debugging.go b/etcd/vendor/github.com/openshift/microshift/pkg/config/debugging.go index a1c40b8ed4..ca1017525d 100644 --- a/etcd/vendor/github.com/openshift/microshift/pkg/config/debugging.go +++ b/etcd/vendor/github.com/openshift/microshift/pkg/config/debugging.go @@ -5,6 +5,7 @@ import "strings" type Debugging struct { // Valid values are: "Normal", "Debug", "Trace", "TraceAll". // Defaults to "Normal". + // +kubebuilder:default="Normal" LogLevel string `json:"logLevel"` } diff --git a/etcd/vendor/github.com/openshift/microshift/pkg/config/dns.go b/etcd/vendor/github.com/openshift/microshift/pkg/config/dns.go index ca6f11e88e..a0b9cd24d6 100644 --- a/etcd/vendor/github.com/openshift/microshift/pkg/config/dns.go +++ b/etcd/vendor/github.com/openshift/microshift/pkg/config/dns.go @@ -10,5 +10,7 @@ type DNS struct { // as well as "api-int.example.com" for internal k8s API access. // // Once set, this field cannot be changed. + // +kubebuilder:default=example.com + // +kubebuilder:example=microshift.example.com BaseDomain string `json:"baseDomain"` } diff --git a/etcd/vendor/github.com/openshift/microshift/pkg/config/network.go b/etcd/vendor/github.com/openshift/microshift/pkg/config/network.go index 3a3f8cc211..62408402a5 100644 --- a/etcd/vendor/github.com/openshift/microshift/pkg/config/network.go +++ b/etcd/vendor/github.com/openshift/microshift/pkg/config/network.go @@ -15,6 +15,7 @@ type Network struct { // IP address pool for services. // Currently, we only support a single entry here. // This field is immutable after installation. + // +kubebuilder:default={"10.43.0.0/16"} ServiceNetwork []string `json:"serviceNetwork"` // The port range allowed for Services of type NodePort. @@ -24,6 +25,7 @@ type Network struct { // This parameter can be updated after the cluster is // installed. // +kubebuilder:validation:Pattern=`^([0-9]{1,4}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])-([0-9]{1,4}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])$` + // +kubebuilder:default="30000-32767" ServiceNodePortRange string `json:"serviceNodePortRange"` // The DNS server to use @@ -32,6 +34,7 @@ type Network struct { type ClusterNetworkEntry struct { // The complete block for pod IPs. + // +kubebuilder:default="10.42.0.0/16" CIDR string `json:"cidr"` } diff --git a/hack/config-gen/README.md b/hack/config-gen/README.md new file mode 100644 index 0000000000..d4073eb78a --- /dev/null +++ b/hack/config-gen/README.md @@ -0,0 +1,78 @@ +# Config-Gen + +This is a simple generator tool that will read files for a specific struct and generate a yaml representation of it with comments to help keep things in sync. This is meant to be used as part of the `//go:generate` command but can also be installed and used as a stand alone binary. + +### Install + +```sh +go install . +``` + +### Usage + +CLI flags. + +```sh +Usage: + config-gen [flags] + +Flags: + -a, --api-output string output path for openapi spec if desired + -f, --file string default is stdin + -h, --help help for config-gen + -o, --output string output path, default is stdout + -t, --template string template file to use + -v, --v Level number for the log level verbosity +``` + +Use as a go generate command example +```go +//go:generate sh -c "controller-gen crd paths=../../hack/config-gen/configcrd output:stdout | go run -mod vendor ../../hack/config-gen -a ../../cockpit-plugin/packaging/config-openapi-spec.json -o ../../packaging/microshift/config.yaml" +//go:generate sh -c "controller-gen crd paths=../../hack/config-gen/configcrd output:stdout | go run -mod vendor ../../hack/config-gen -o ../../docs/howto_config.md -t ../../docs/howto_config.md" +``` + +Use the example test to see it in action, run the command from the `hack/config-gen` directory. + +```sh +controller-gen crd paths=../../hack/config-gen/configcrd output:stdout | go run -mod vendor ../../hack/config-gen +``` + +The sample output should be. +```yaml +#!! Do Not Edit +#!! This is a generated file +apiServer: + # Kube apiserver advertise address to work around the certificates issue when requiring external access using the node IP. This will turn into the IP configured in the endpoint slice for kubernetes service. Must be a reachable IP from pods. Defaults to service network CIDR first address. + advertiseAddress: "" + # SubjectAltNames added to API server certs + subjectAltNames: + - "" +debugging: + # Valid values are: "Normal", "Debug", "Trace", "TraceAll". Defaults to "Normal". + logLevel: Normal +dns: + # baseDomain is the base domain of the cluster. All managed DNS records will be sub-domains of this base. + # For example, given the base domain `example.com`, router exposed domains will be formed as `*.apps.example.com` by default, and API service will have a DNS entry for `api.example.com`, as well as "api-int.example.com" for internal k8s API access. + # Once set, this field cannot be changed. + # example: + # microshift.example.com + baseDomain: example.com +etcd: + # Set a memory limit on the etcd process; etcd will begin paging memory when it gets to this value. 0 means no limit. + memoryLimitMB: 0 +network: + # IP address pool to use for pod IPs. This field is immutable after installation. + clusterNetwork: + - # The complete block for pod IPs. + cidr: 10.42.0.0/16 + # IP address pool for services. Currently, we only support a single entry here. This field is immutable after installation. + serviceNetwork: + - "" + # The port range allowed for Services of type NodePort. If not specified, the default of 30000-32767 will be used. Such Services without a NodePort specified will have one automatically allocated from this range. This parameter can be updated after the cluster is installed. + serviceNodePortRange: 30000-32767 +node: + # If non-empty, will use this string to identify the node instead of the hostname + hostnameOverride: "" + # IP address of the node, passed to the kubelet. If not specified, kubelet will use the node's default IP address. + nodeIP: "" +``` diff --git a/hack/config-gen/cmd.go b/hack/config-gen/cmd.go new file mode 100644 index 0000000000..322b07b1d5 --- /dev/null +++ b/hack/config-gen/cmd.go @@ -0,0 +1,32 @@ +package main + +import ( + "os" + + "github.com/spf13/cobra" + "k8s.io/component-base/cli" +) + +func main() { + command := newCommand() + code := cli.Run(command) + os.Exit(code) +} + +func newCommand() *cobra.Command { + opt := configGenOpts{} + + cmd := &cobra.Command{ + Use: "config-gen", + Short: "use openapiv3 schemas in CRDs format to generate yaml or embed in files", + RunE: func(cmd *cobra.Command, args []string) error { + if err := opt.Options(); err != nil { + return err + } + return opt.Run() + }, + } + opt.BindFlags(cmd.Flags()) + + return cmd +} diff --git a/hack/config-gen/configcrd/config_crd.go b/hack/config-gen/configcrd/config_crd.go new file mode 100644 index 0000000000..c17dd4343d --- /dev/null +++ b/hack/config-gen/configcrd/config_crd.go @@ -0,0 +1,20 @@ +// +kubebuilder:object:generate=true +// +groupName=config.microshift.openshift.io +// +versionName=v1 + +// This file only exists to help translate our config struct +// to an OpenAPIV3 spec via controller-gen, it should not be exported +package configcrd + +import ( + "github.com/openshift/microshift/pkg/config" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +//nolint:unused +type configSpec struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Config config.Config `json:"config"` +} diff --git a/hack/config-gen/opts.go b/hack/config-gen/opts.go new file mode 100644 index 0000000000..c9f00c6bd1 --- /dev/null +++ b/hack/config-gen/opts.go @@ -0,0 +1,106 @@ +package main + +import ( + "io" + "os" + "text/template" + + "github.com/spf13/pflag" + + _ "embed" +) + +var ( + //go:embed templates/config-file.template.yaml + defaultTemplateString string + //go:embed templates/custom-templates.tpl + customTemplateBlocks string +) + +type configGenOpts struct { + fileOutput string + openApiFileOutput string + fileInput string + templateFile string + templateText string +} + +func (opt *configGenOpts) BindFlags(f *pflag.FlagSet) { + f.StringVarP(&opt.fileOutput, "output", "o", "", "output path, default is stdout") + f.StringVarP(&opt.openApiFileOutput, "api-output", "a", "", "output path for openapi spec if desired") + f.StringVarP(&opt.fileInput, "file", "f", "", "default is stdin") + f.StringVarP(&opt.templateFile, "template", "t", "", "template file to use") +} + +func (opt *configGenOpts) Options() error { + opt.templateText = defaultTemplateString + if opt.templateFile != "" { + data, err := os.ReadFile(opt.templateFile) + if err != nil { + return err + } + opt.templateText = string(data) + } + return nil +} + +func (opt configGenOpts) Run() error { + yamlTemplate, err := template.New("yamlTemplate").Funcs(defaultTemplateFuncs).Parse(customTemplateBlocks) + if err != nil { + return err + } + + yamlTemplate, err = yamlTemplate.Parse(opt.templateText) + if err != nil { + return err + } + + var dataReader io.ReadCloser + switch { + case opt.fileInput != "": + f, err := os.Open(opt.fileInput) + if err != nil { + return err + } + dataReader = f + defer dataReader.Close() + default: + dataReader = os.Stdin + } + + crdRawData, err := io.ReadAll(dataReader) + if err != nil { + return err + } + + if opt.openApiFileOutput != "" { + parser := crdParser{} + parsedOpenApiSchema, err := parser.parseToJsonOpenAPI(crdRawData) + if err != nil { + return err + } + + file, err := os.OpenFile(opt.openApiFileOutput, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.ModePerm) + if err != nil { + return err + } + _, err = file.Write(parsedOpenApiSchema) + if err != nil { + return err + } + file.Close() + } + + var dataWriter io.WriteCloser + if opt.fileOutput != "" { + file, err := os.OpenFile(opt.fileOutput, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.ModePerm) + if err != nil { + return err + } + dataWriter = file + defer dataWriter.Close() + } else { + dataWriter = os.Stdout + } + return yamlTemplate.Execute(dataWriter, crdRawData) +} diff --git a/hack/config-gen/parser.go b/hack/config-gen/parser.go new file mode 100644 index 0000000000..76323b2703 --- /dev/null +++ b/hack/config-gen/parser.go @@ -0,0 +1,181 @@ +package main + +import ( + "encoding/json" + "fmt" + + "gopkg.in/yaml.v3" + v1ext "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + kubeYaml "k8s.io/apimachinery/pkg/util/yaml" +) + +const ( + jsonTypeString = "string" + jsonTypeNumber = "number" + jsonTypeInteger = "integer" + jsonTypeObject = "object" + jsonTypeArray = "array" +) + +type crdParser struct { + NoComments bool + NoDefaults bool +} + +func (p crdParser) parseToJsonSchema(data []byte) (v1ext.JSONSchemaProps, error) { + crd := v1ext.CustomResourceDefinition{} + err := kubeYaml.Unmarshal(data, &crd) + if err != nil { + return v1ext.JSONSchemaProps{}, fmt.Errorf("failed to unmarshal custom resource config: %w", err) + } + + if len(crd.Spec.Versions) != 1 { + return v1ext.JSONSchemaProps{}, fmt.Errorf("expected length of crd.spec.versions to be 1 but got %d", len(crd.Spec.Versions)) + } + + configData := crd.Spec.Versions[0].Schema.OpenAPIV3Schema.Properties["config"] + return configData, nil +} + +func (p crdParser) parseToJsonOpenAPI(data []byte) ([]byte, error) { + configData, err := p.parseToJsonSchema(data) + if err != nil { + return nil, err + } + + openAPIJsonData, err := json.MarshalIndent(configData, "", " ") + if err != nil { + return nil, fmt.Errorf("failed to marshal openapi into json: %w", err) + } + return openAPIJsonData, nil +} + +func (p crdParser) parseToYamlNode(data []byte) (*yaml.Node, error) { + configData, err := p.parseToJsonSchema(data) + if err != nil { + return nil, err + } + + node := p.toYamlNodeObject(configData.Properties) + return node, nil +} + +func (p crdParser) toYamlNodeObject(val map[string]v1ext.JSONSchemaProps) *yaml.Node { + node := &yaml.Node{ + Kind: yaml.MappingNode, + } + + orderedKeyNameArray := schemaKeyToOrderedArray(val) + + for _, schemaKeyName := range orderedKeyNameArray { + field, ok := val[schemaKeyName] + if !ok { + // This should never happen since the ordered key array is created from the keys in val. + // This would definitely mean it's time to panic. + panic(fmt.Errorf("failed to find %s in the map of JSONSchemaProps: \nData ===\n%+v\n==", schemaKeyName, val)) + } + + keyNode := &yaml.Node{ + Value: schemaKeyName, + Kind: yaml.ScalarNode, + } + + if !p.NoComments { + keyNode.HeadComment = field.Description + } + + var valueNode *yaml.Node + switch field.Type { + case jsonTypeArray: + + valueNode = p.toYamlNodeArray(field.Items) + if nodes := parseArrayJSONValue(field.Default); nodes != nil && !p.NoDefaults { + valueNode.Content = nodes + } + + if exampleValue := parseArrayJSONExample(field.Example); exampleValue != "" && !p.NoComments { + keyNode.HeadComment = fmt.Sprintf("%s\nexample:\n %s", keyNode.HeadComment, exampleValue) + } + + case jsonTypeObject: + + valueNode = p.toYamlNodeObject(field.Properties) + + if exampleValue := parseMapJSONExample(field.Example); exampleValue != "" && !p.NoComments { + keyNode.HeadComment = fmt.Sprintf("%s\nexample:\n %s", keyNode.HeadComment, exampleValue) + } + + default: + + valueNode = p.toYamlNodeValue(field) + + if exampleValue := parseScalarJSONExample(field.Example); exampleValue != "" && !p.NoComments { + keyNode.HeadComment = fmt.Sprintf("%s\nexample:\n %s", keyNode.HeadComment, exampleValue) + } + } + node.Content = append(node.Content, keyNode, valueNode) + } + return node +} + +func (p crdParser) toYamlNodeArray(val *v1ext.JSONSchemaPropsOrArray) *yaml.Node { + node := &yaml.Node{ + Kind: yaml.SequenceNode, + } + if val == nil { + return node + } + + if val.Schema != nil { + var valueNode *yaml.Node + switch val.Schema.Type { + case jsonTypeObject: + valueNode = p.toYamlNodeObject(val.Schema.Properties) + case jsonTypeArray: + valueNode = p.toYamlNodeArray(val.Schema.Items) + default: + valueNode = p.toYamlNodeValue(*val.Schema) + } + node.Content = append(node.Content, valueNode) + + return node + } + + for _, field := range val.JSONSchemas { + var valueNode *yaml.Node + switch field.Type { + case jsonTypeObject: + valueNode = p.toYamlNodeObject(field.Properties) + case jsonTypeArray: + valueNode = p.toYamlNodeArray(field.Items) + default: + valueNode = p.toYamlNodeValue(field) + } + node.Content = append(node.Content, valueNode) + } + + return node +} + +func (p crdParser) toYamlNodeValue(val v1ext.JSONSchemaProps) *yaml.Node { + node := &yaml.Node{ + Kind: yaml.ScalarNode, + } + + if !p.NoDefaults { + node.Value = parseScalarJSONValue(val.Default) + } + + if node.Value == "" { + switch val.Type { + case jsonTypeString: + node.SetString("") + case jsonTypeInteger: + node.Value = "0" + case jsonTypeNumber: + node.Value = "0.0" + } + } + + return node +} diff --git a/hack/config-gen/templates/config-file.template.yaml b/hack/config-gen/templates/config-file.template.yaml new file mode 100644 index 0000000000..7389508cd6 --- /dev/null +++ b/hack/config-gen/templates/config-file.template.yaml @@ -0,0 +1 @@ +{{ parseToConfigYaml . }} diff --git a/hack/config-gen/templates/custom-templates.tpl b/hack/config-gen/templates/custom-templates.tpl new file mode 100644 index 0000000000..01bc165a00 --- /dev/null +++ b/hack/config-gen/templates/custom-templates.tpl @@ -0,0 +1,29 @@ +{{/**}} +Print out basic config information inside yaml tags for use in .md files with a replace clause. +This means you should be able to run the same generate on a file with these tags. + +It is expected that once you setup the initial template comments in an .md file subsequent calls to the same file +are idempodent. +{{**/}} +{{- define "docsReplaceBasic" }} +{{`{{- template "docsReplaceBasic" . }}`}} +{{`{{- with deleteCurrent -}}`}} +---> +```yaml +{{ parseToConfigYamlOpts . true true }} +``` + +```yaml +{{ parseToConfigYamlOpts . true false }} +``` +