diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 58bf8f0b..9ba17b49 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -35,6 +35,6 @@ jobs: - name: Check if working tree is dirty run: | if [[ $(git diff --stat) != '' ]]; then - echo 'run "make test lint-samples" and commit changes' + echo 'run "make test cue-vet" and commit changes' exit 1 fi diff --git a/cmd/timoni/mod_show.go b/cmd/timoni/mod_show.go new file mode 100644 index 00000000..84afd107 --- /dev/null +++ b/cmd/timoni/mod_show.go @@ -0,0 +1,30 @@ +/* +Copyright 2024 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 main + +import ( + "github.com/spf13/cobra" +) + +var showModCmd = &cobra.Command{ + Use: "show", + Short: "Commands for showing module information", +} + +func init() { + modCmd.AddCommand(showModCmd) +} diff --git a/cmd/timoni/mod_show_config.go b/cmd/timoni/mod_show_config.go new file mode 100644 index 00000000..d8e643fa --- /dev/null +++ b/cmd/timoni/mod_show_config.go @@ -0,0 +1,250 @@ +/* +Copyright 2024 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 main + +import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "regexp" + "strings" + + "cuelang.org/go/cue/cuecontext" + "github.com/olekukonko/tablewriter" + "github.com/spf13/cobra" + apiv1 "github.com/stefanprodan/timoni/api/v1alpha1" + "github.com/stefanprodan/timoni/internal/engine" + "github.com/stefanprodan/timoni/internal/flags" +) + +var configShowModCmd = &cobra.Command{ + Use: "config [MODULE PATH]", + Short: "Output the #Config structure of a local module", + Long: `The config command parses the local module configuration structure and outputs the information to stdout.`, + Example: ` # print the config of a module in the current directory + timoni mod show config + + # output the config to a file, if the file is markdown, the table will overwrite a table in a Configuration section or + # be appended to the end of the file + timoni mod show config --output ./README.md +`, + RunE: runConfigShowModCmd, +} + +type configModFlags struct { + path string + pkg flags.Package + name string + output string +} + +var configShowModArgs = configModFlags{ + name: "module-name", +} + +func init() { + configShowModCmd.Flags().StringVarP(&configShowModArgs.output, "output", "o", "", "The file to output the config Markdown to, defaults to stdout") + showModCmd.AddCommand(configShowModCmd) +} + +func runConfigShowModCmd(cmd *cobra.Command, args []string) error { + if len(args) < 1 { + configShowModArgs.path = "." + } else { + configShowModArgs.path = args[0] + } + + if fs, err := os.Stat(configShowModArgs.path); err != nil || !fs.IsDir() { + return fmt.Errorf("module not found at path %s", configShowModArgs.path) + } + + cuectx := cuecontext.New() + + tmpDir, err := os.MkdirTemp("", apiv1.FieldManager) + if err != nil { + return err + } + defer os.RemoveAll(tmpDir) + + ctxPull, cancel := context.WithTimeout(context.Background(), rootArgs.timeout) + defer cancel() + + fetcher := engine.NewFetcher( + ctxPull, + configShowModArgs.path, + apiv1.LatestVersion, + tmpDir, + rootArgs.cacheDir, + "", + rootArgs.registryInsecure, + ) + mod, err := fetcher.Fetch() + if err != nil { + return err + } + + builder := engine.NewModuleBuilder( + cuectx, + configShowModArgs.name, + *kubeconfigArgs.Namespace, + fetcher.GetModuleRoot(), + configShowModArgs.pkg.String(), + ) + + if err := builder.WriteSchemaFile(); err != nil { + return err + } + + mod.Name, err = builder.GetModuleName() + if err != nil { + return fmt.Errorf("build failed: %w", err) + } + + buildResult, err := builder.Build() + if err != nil { + return describeErr(fetcher.GetModuleRoot(), "validation failed", err) + } + + rows, err := builder.GetConfigDoc(buildResult) + if err != nil { + return describeErr(fetcher.GetModuleRoot(), "failed to get config structure", err) + } + + header := []string{"Key", "Type", "Default", "Description"} + + if configShowModArgs.output == "" { + printMarkDownTable(rootCmd.OutOrStdout(), header, rows) + } else { + tmpFile, err := writeFile(configShowModArgs.output, header, rows, fetcher) + if err != nil { + return err + } + + err = os.Rename(tmpFile, configShowModArgs.output) + if err != nil { + return describeErr(fetcher.GetModuleRoot(), "Unable to rename file", err) + } + } + + return nil +} + +func writeFile(readFile string, header []string, rows [][]string, fetcher *engine.Fetcher) (string, error) { + // Generate the markdown table + var tableBuffer bytes.Buffer + tableWriter := bufio.NewWriter(&tableBuffer) + printMarkDownTable(tableWriter, header, rows) + tableWriter.Flush() + // get a temporary file name + tmpFileName := readFile + ".tmp" + // open the input file + inputFile, err := os.Open(readFile) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + inputFile, err = os.Create(readFile) + + if err != nil { + return "", describeErr(fetcher.GetModuleRoot(), "Unable to create the temporary output file", err) + } + } else { + return "", describeErr(fetcher.GetModuleRoot(), "Unable to create the temporary output file", err) + } + } + defer inputFile.Close() + + // open the output file + outputFile, err := os.Create(tmpFileName) + if err != nil { + return "", describeErr(fetcher.GetModuleRoot(), "Unable to create the temporary output file", err) + } + defer outputFile.Close() + + // Create the scanner and writer + inputScanner := bufio.NewScanner(inputFile) + outputWriter := bufio.NewWriter(outputFile) + var configSection bool + var foundTable bool + + // Scan the input file line by line to find the table and replace it or append it to the end + for inputScanner.Scan() { + line := inputScanner.Text() + + if isMarkdownFile(readFile) { + if !configSection && line == "## Configuration" { + configSection = true + } + + matched, err := regexp.MatchString(`^\|.*\|$`, line) + if err != nil { + return "", describeErr(fetcher.GetModuleRoot(), "Regex Match for table content failed", err) + } + + if configSection && !foundTable && matched { + foundTable = true + outputWriter.WriteString(tableBuffer.String() + "\n") + } else if configSection && foundTable && matched { + } else if configSection && foundTable && !matched { + configSection = false + } else { + outputWriter.WriteString(line + "\n") + } + } else { + outputWriter.WriteString(line + "\n") + } + } + + // If no table was found, append it to the end of the file + if !foundTable { + outputWriter.WriteString("\n" + tableBuffer.String()) + } + + err = outputWriter.Flush() + if err != nil { + return "", describeErr(fetcher.GetModuleRoot(), "Failed to Flush Writer", err) + } + + return tmpFileName, nil +} + +func printMarkDownTable(writer io.Writer, header []string, rows [][]string) { + table := tablewriter.NewWriter(writer) + table.SetHeader(header) + table.SetAutoWrapText(false) + table.SetAutoFormatHeaders(true) + table.SetHeaderAlignment(tablewriter.ALIGN_LEFT) + table.SetAlignment(tablewriter.ALIGN_LEFT) + table.SetCenterSeparator("|") + table.SetColumnSeparator("|") + table.SetRowSeparator("-") + table.SetHeaderLine(true) + table.SetBorders(tablewriter.Border{Left: true, Top: false, Right: true, Bottom: false}) + table.SetTablePadding("\t") + table.SetNoWhiteSpace(false) + table.AppendBulk(rows) + table.Render() +} + +func isMarkdownFile(filename string) bool { + extension := strings.ToLower(filepath.Ext(filename)) + return extension == ".md" || extension == ".markdown" +} diff --git a/cmd/timoni/mod_show_config_test.go b/cmd/timoni/mod_show_config_test.go new file mode 100644 index 00000000..3328afe9 --- /dev/null +++ b/cmd/timoni/mod_show_config_test.go @@ -0,0 +1,96 @@ +/* +Copyright 2024 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 main + +import ( + "fmt" + "os" + "testing" + + . "github.com/onsi/gomega" +) + +func Test_ShowConfig(t *testing.T) { + modPath := "testdata/module" + + g := NewWithT(t) + + // Push the module to registry + output, err := executeCommand(fmt.Sprintf( + "mod show config %s", + modPath, + )) + g.Expect(err).ToNot(HaveOccurred()) + + g.Expect(output).To(ContainSubstring("`client: enabled:`")) + g.Expect(output).To(ContainSubstring("`client: image: repository:`")) + g.Expect(output).To(ContainSubstring("`server: enabled:`")) +} + +func Test_ShowConfigOutput(t *testing.T) { + modPath := "testdata/module" + filePath := fmt.Sprintf("%s/README.md", modPath) + + g := NewWithT(t) + + // Push the module to registry + _, err := executeCommand(fmt.Sprintf( + "mod show config %s --output %s", + modPath, + filePath, + )) + g.Expect(err).ToNot(HaveOccurred()) + + rmFile, err := os.ReadFile(filePath) + g.Expect(err).ToNot(HaveOccurred()) + + strContent := string(rmFile) + + g.Expect(strContent).To(ContainSubstring("# module")) + g.Expect(strContent).To(ContainSubstring("## Install")) + g.Expect(strContent).To(ContainSubstring("## Uninstall")) + g.Expect(strContent).To(ContainSubstring("## Configuration")) + g.Expect(strContent).To(ContainSubstring("`client: enabled:`")) + g.Expect(strContent).To(ContainSubstring("`client: image: repository:`")) + g.Expect(strContent).To(ContainSubstring("`server: enabled:`")) + + g.Expect(err).ToNot(HaveOccurred()) +} + +func Test_ShowConfigOutputNewFile(t *testing.T) { + modPath := "testdata/module" + filePath := fmt.Sprintf("%s/testing.md", t.TempDir()) + + g := NewWithT(t) + + // Push the module to registry + _, err := executeCommand(fmt.Sprintf( + "mod show config %s --output %s", + modPath, + filePath, + )) + g.Expect(err).ToNot(HaveOccurred()) + + rmFile, err := os.ReadFile(filePath) + g.Expect(err).ToNot(HaveOccurred()) + + strContent := string(rmFile) + + g.Expect(strContent).To(ContainSubstring("`client: enabled:`")) + g.Expect(strContent).To(ContainSubstring("`client: image: repository:`")) + g.Expect(strContent).To(ContainSubstring("`server: enabled:`")) +} diff --git a/cmd/timoni/testdata/module/README.md b/cmd/timoni/testdata/module/README.md new file mode 100644 index 00000000..8672510b --- /dev/null +++ b/cmd/timoni/testdata/module/README.md @@ -0,0 +1,55 @@ +# module + +A [timoni.sh](http://timoni.sh) module for deploying blueprint to Kubernetes clusters. + +## Install + +To create an instance using the default values: + +```shell +timoni -n module apply module oci:// +``` + +To change the [default configuration](#configuration), +create one or more `values.cue` files and apply them to the instance. + +For example, create a file `my-values.cue` with the following content: + +```cue +values: { + team: "timoni" + metadata: labels: testing: "true" + domain: "example.com" + ns: enabled: true +} +``` + +And apply the values with: + +```shell +timoni -n module apply module oci:// \ +--values ./my-values.cue +``` + +## Uninstall + +To uninstall an instance and delete all its Kubernetes resources: + +```shell +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"` | | + diff --git a/cmd/timoni/testdata/module/cue.mod/pkg/timoni.sh/core/v1alpha1/image.cue b/cmd/timoni/testdata/module/cue.mod/pkg/timoni.sh/core/v1alpha1/image.cue index 3c6b93c5..dd1085aa 100644 --- a/cmd/timoni/testdata/module/cue.mod/pkg/timoni.sh/core/v1alpha1/image.cue +++ b/cmd/timoni/testdata/module/cue.mod/pkg/timoni.sh/core/v1alpha1/image.cue @@ -24,6 +24,7 @@ import "strings" // Reference is the image address computed from repository, tag and digest // in the format [REPOSITORY]:[TAG]@[DIGEST]. + // +nodoc reference: string if digest != "" && tag != "" { diff --git a/cmd/timoni/testdata/module/cue.mod/pkg/timoni.sh/core/v1alpha1/metadata.cue b/cmd/timoni/testdata/module/cue.mod/pkg/timoni.sh/core/v1alpha1/metadata.cue index 2b6cfe88..56f549c2 100644 --- a/cmd/timoni/testdata/module/cue.mod/pkg/timoni.sh/core/v1alpha1/metadata.cue +++ b/cmd/timoni/testdata/module/cue.mod/pkg/timoni.sh/core/v1alpha1/metadata.cue @@ -30,7 +30,9 @@ import "strings" // Standard Kubernetes labels: app name and version. labels: { - "app.kubernetes.io/name": name + // +nodoc + "app.kubernetes.io/name": name + // +nodoc "app.kubernetes.io/version": #Version } } diff --git a/cmd/timoni/testdata/module/templates/config.cue b/cmd/timoni/testdata/module/templates/config.cue index 765d58b6..dd5722b8 100644 --- a/cmd/timoni/testdata/module/templates/config.cue +++ b/cmd/timoni/testdata/module/templates/config.cue @@ -8,24 +8,37 @@ import ( moduleVersion!: string kubeVersion!: string + // Common metadata for all objects metadata: timoniv1.#Metadata & {#Version: moduleVersion} metadata: labels: { + // +nodoc "app.kubernetes.io/kube": kubeVersion + // +nodoc "app.kubernetes.io/team": team } - client: enabled: *true | bool + // +nodoc + client: { + enabled: *true | bool - client: image: timoniv1.#Image & { - repository: *"cgr.dev/chainguard/timoni" | string - tag: *"latest-dev" | string - digest: *"sha256:b49fbaac0eedc22c1cfcd26684707179cccbed0df205171bae3e1bae61326a10" | string + // +nodoc + image: timoniv1.#Image & { + repository: *"cgr.dev/chainguard/timoni" | string + tag: *"latest-dev" | string + digest: *"sha256:b49fbaac0eedc22c1cfcd26684707179cccbed0df205171bae3e1bae61326a10" | string + } } - server: enabled: *true | bool + // +nodoc + server: { + enabled: *true | bool + } domain: *"example.internal" | string - ns: enabled: *false | bool + // +nodoc + ns: { + enabled: *false | bool + } team!: string } diff --git a/cmd/timoni/testdata/module/timoni.cue b/cmd/timoni/testdata/module/timoni.cue index 135501c2..f4f32ca9 100644 --- a/cmd/timoni/testdata/module/timoni.cue +++ b/cmd/timoni/testdata/module/timoni.cue @@ -27,15 +27,21 @@ timoni: { instance: templates.#Instance & { // The user-supplied values are merged with the // default values at runtime by Timoni. + // +nodoc config: values // These values are injected at runtime by Timoni. config: { + // +nodoc metadata: { - name: string @tag(name) + // +nodoc + name: string @tag(name) + // +nodoc namespace: string @tag(namespace) } + // +nodoc moduleVersion: string @tag(mv, var=moduleVersion) - kubeVersion: string @tag(kv, var=kubeVersion) + // +nodoc + kubeVersion: string @tag(kv, var=kubeVersion) } } @@ -45,5 +51,5 @@ timoni: { // Pass Kubernetes resources outputted by the instance // to Timoni's multi-step apply. - apply: all: [ for obj in instance.objects {obj}] + apply: all: [for obj in instance.objects {obj}] } diff --git a/internal/engine/module_builder.go b/internal/engine/module_builder.go index 26ef562c..cf0ce1aa 100644 --- a/internal/engine/module_builder.go +++ b/internal/engine/module_builder.go @@ -22,7 +22,9 @@ import ( "os" "path/filepath" "reflect" + "regexp" "slices" + "strings" "cuelang.org/go/cue" "cuelang.org/go/cue/ast" @@ -305,3 +307,60 @@ func (b *ModuleBuilder) GetContainerImages(value cue.Value) ([]string, error) { return images, nil } + +// GetConfigDoc extracts the config structure from the module. +func (b *ModuleBuilder) GetConfigDoc(value cue.Value) ([][]string, error) { + cfgValues := value.LookupPath(cue.ParsePath(apiv1.ConfigValuesSelector.String())) + if cfgValues.Err() != nil { + return nil, fmt.Errorf("lookup %s failed: %w", apiv1.ConfigValuesSelector, cfgValues.Err()) + } + + labelDomain := regexp.MustCompile(`^([a-zA-Z0-9-_.]+)?(".+")?$`) + + var rows [][]string + configDataInfo := func(v cue.Value) bool { + var row []string + 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", "") + } + + if !noDoc { + defaultVal, _ := v.Default() + valueBytes, _ := defaultVal.MarshalJSON() + valueType := strings.ReplaceAll(v.IncompleteKind().String(), "|", "\\|") + + value := strings.ReplaceAll(string(valueBytes), "\":", "\": ") + value = strings.ReplaceAll(value, "\":[", "\": [") + value = strings.ReplaceAll(value, "},", "}, ") + value = strings.ReplaceAll(value, "|", "\\|") + + 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`", valueType)) + row = append(row, fmt.Sprintf("`%s`", value)) + row = append(row, fmt.Sprintf("%s", doc)) + rows = append(rows, row) + } + + return true + } + + cfgValues.Walk(configDataInfo, nil) + return rows, nil +}