From 4035ef605ae6c65311f2c1987698efbf6dac5af2 Mon Sep 17 00:00:00 2001 From: Lucian Petrut Date: Wed, 2 Oct 2024 11:39:44 +0000 Subject: [PATCH] Automatically generate the bootstrap config documentation The bootstrap config documentation may get out of sync as a result of apiv1.BootstrapConfig changes. To avoid this, we're going to automatically generate the documentation based on the apiv1.BootstrapConfig structure definition and docstrings. This change adds the necessary tooling as part of the ``docgen`` package, which may be reused for other structures or projects. --- docs/src/_parts/bootstrap_config.md | 291 ++++++++++ .../reference/bootstrap-config-reference.md | 512 +----------------- src/k8s/Makefile | 2 +- src/k8s/cmd/k8s/k8s_generate_docs.go | 24 +- src/k8s/go.mod | 1 + src/k8s/pkg/docgen/godoc.go | 117 ++++ src/k8s/pkg/docgen/gomod.go | 125 +++++ src/k8s/pkg/docgen/json_struct.go | 109 ++++ 8 files changed, 669 insertions(+), 512 deletions(-) create mode 100644 docs/src/_parts/bootstrap_config.md create mode 100755 src/k8s/pkg/docgen/godoc.go create mode 100755 src/k8s/pkg/docgen/gomod.go create mode 100755 src/k8s/pkg/docgen/json_struct.go diff --git a/docs/src/_parts/bootstrap_config.md b/docs/src/_parts/bootstrap_config.md new file mode 100644 index 000000000..fa7ef5c3e --- /dev/null +++ b/docs/src/_parts/bootstrap_config.md @@ -0,0 +1,291 @@ +## cluster-config.network.enabled +**Type:** `bool`
+ + +## cluster-config.dns.enabled +**Type:** `bool`
+ + +## cluster-config.dns.cluster-domain +**Type:** `string`
+ + +## cluster-config.dns.service-ip +**Type:** `string`
+ + +## cluster-config.dns.upstream-nameservers +**Type:** `[]string`
+ + +## cluster-config.ingress.enabled +**Type:** `bool`
+ + +## cluster-config.ingress.default-tls-secret +**Type:** `string`
+ + +## cluster-config.ingress.enable-proxy-protocol +**Type:** `bool`
+ + +## cluster-config.load-balancer.enabled +**Type:** `bool`
+ + +## cluster-config.load-balancer.cidrs +**Type:** `[]string`
+ + +## cluster-config.load-balancer.l2-mode +**Type:** `bool`
+ + +## cluster-config.load-balancer.l2-interfaces +**Type:** `[]string`
+ + +## cluster-config.load-balancer.bgp-mode +**Type:** `bool`
+ + +## cluster-config.load-balancer.bgp-local-asn +**Type:** `int`
+ + +## cluster-config.load-balancer.bgp-peer-address +**Type:** `string`
+ + +## cluster-config.load-balancer.bgp-peer-asn +**Type:** `int`
+ + +## cluster-config.load-balancer.bgp-peer-port +**Type:** `int`
+ + +## cluster-config.local-storage.enabled +**Type:** `bool`
+ + +## cluster-config.local-storage.local-path +**Type:** `string`
+ + +## cluster-config.local-storage.reclaim-policy +**Type:** `string`
+ + +## cluster-config.local-storage.default +**Type:** `bool`
+ + +## cluster-config.gateway.enabled +**Type:** `bool`
+ + +## cluster-config.metrics-server.enabled +**Type:** `bool`
+ + +## cluster-config.cloud-provider +**Type:** `string`
+ + +## cluster-config.annotations +**Type:** `map[string]string`
+ + +## control-plane-taints +**Type:** `[]string`
+ +Seed configuration for the control plane (flat on purpose). Empty values are ignored + +## pod-cidr +**Type:** `string`
+ + +## service-cidr +**Type:** `string`
+ + +## disable-rbac +**Type:** `bool`
+ + +## secure-port +**Type:** `int`
+ + +## k8s-dqlite-port +**Type:** `int`
+ + +## datastore-type +**Type:** `string`
+ + +## datastore-servers +**Type:** `[]string`
+ + +## datastore-ca-crt +**Type:** `string`
+ + +## datastore-client-crt +**Type:** `string`
+ + +## datastore-client-key +**Type:** `string`
+ + +## extra-sans +**Type:** `[]string`
+ +Seed configuration for certificates + +## ca-crt +**Type:** `string`
+ +Seed configuration for external certificates (cluster-wide) + +## ca-key +**Type:** `string`
+ + +## client-ca-crt +**Type:** `string`
+ + +## client-ca-key +**Type:** `string`
+ + +## front-proxy-ca-crt +**Type:** `string`
+ + +## front-proxy-ca-key +**Type:** `string`
+ + +## front-proxy-client-crt +**Type:** `string`
+ + +## front-proxy-client-key +**Type:** `string`
+ + +## apiserver-kubelet-client-crt +**Type:** `string`
+ + +## apiserver-kubelet-client-key +**Type:** `string`
+ + +## admin-client-crt +**Type:** `string`
+ + +## admin-client-key +**Type:** `string`
+ + +## kube-proxy-client-crt +**Type:** `string`
+ + +## kube-proxy-client-key +**Type:** `string`
+ + +## kube-scheduler-client-crt +**Type:** `string`
+ + +## kube-scheduler-client-key +**Type:** `string`
+ + +## kube-controller-manager-client-crt +**Type:** `string`
+ + +## kube-controller-manager-client-key +**Type:** `string`
+ + +## service-account-key +**Type:** `string`
+ + +## apiserver-crt +**Type:** `string`
+ +Seed configuration for external certificates (node-specific) + +## apiserver-key +**Type:** `string`
+ + +## kubelet-crt +**Type:** `string`
+ + +## kubelet-key +**Type:** `string`
+ + +## kubelet-client-crt +**Type:** `string`
+ + +## kubelet-client-key +**Type:** `string`
+ + +## extra-node-config-files +**Type:** `map[string]string`
+ +ExtraNodeConfigFiles will be written to /var/snap/k8s/common/args/conf.d + +## extra-node-kube-apiserver-args +**Type:** `map[string]string`
+ +Extra args to add to individual services (set any arg to null to delete) + +## extra-node-kube-controller-manager-args +**Type:** `map[string]string`
+ + +## extra-node-kube-scheduler-args +**Type:** `map[string]string`
+ + +## extra-node-kube-proxy-args +**Type:** `map[string]string`
+ + +## extra-node-kubelet-args +**Type:** `map[string]string`
+ + +## extra-node-containerd-args +**Type:** `map[string]string`
+ + +## extra-node-k8s-dqlite-args +**Type:** `map[string]string`
+ + +## extra-node-containerd-config +**Type:** `apiv1.MapStringAny`
+ +Extra configuration for the containerd config.toml + diff --git a/docs/src/snap/reference/bootstrap-config-reference.md b/docs/src/snap/reference/bootstrap-config-reference.md index 047622c5d..4cc53764f 100644 --- a/docs/src/snap/reference/bootstrap-config-reference.md +++ b/docs/src/snap/reference/bootstrap-config-reference.md @@ -4,517 +4,9 @@ A YAML file can be supplied to the `k8s bootstrap` command to configure and customise the cluster. This reference section provides the format of this file by listing all available options and their details. See below for an example. -## Format Specification - -### cluster-config.network - -**Type:** `object`
-**Required:** `No` - -Configuration options for the network feature - -#### cluster-config.network.enabled - -**Type:** `bool`
-**Required:** `No`
- -Determines if the feature should be enabled. -If omitted defaults to `true` - -### cluster-config.dns - -**Type:** `object`
-**Required:** `No` - -Configuration options for the dns feature - -#### cluster-config.dns.enabled - -**Type:** `bool`
-**Required:** `No`
- -Determines if the feature should be enabled. -If omitted defaults to `true` - -#### cluster-config.dns.cluster-domain - -**Type:** `string`
-**Required:** `No`
- -Sets the local domain of the cluster. -If omitted defaults to `cluster.local` - -#### cluster-config.dns.service-ip - -**Type:** `string`
-**Required:** `No`
- -Sets the IP address of the dns service. If omitted defaults to the IP address -of the Kubernetes service created by the feature. - -Can be used to point to an external dns server when feature is disabled. - - -#### cluster-config.dns.upstream-nameservers - -**Type:** `list[string]`
-**Required:** `No`
- -Sets the upstream nameservers used to forward queries for out-of-cluster -endpoints. -If omitted defaults to `/etc/resolv.conf` and uses the nameservers of the node. - - -### cluster-config.ingress - -**Type:** `object`
-**Required:** `No` - -Configuration options for the ingress feature - -#### cluster-config.ingress.enabled - -**Type:** `bool`
-**Required:** `No`
- -Determines if the feature should be enabled. -If omitted defaults to `false` - -#### cluster-config.ingress.default-tls-secret - -**Type:** `string`
-**Required:** `No`
- -Sets the name of the secret to be used for providing default encryption to -ingresses. - -Ingresses can specify another TLS secret in their resource definitions, -in which case the default secret won't be used. - -#### cluster-config.ingress.enable-proxy-protocol - -**Type:** `bool`
-**Required:** `No`
- -Determines if the proxy protocol should be enabled for ingresses. -If omitted defaults to `false` - - -### cluster-config.load-balancer - -**Type:** `object`
-**Required:** `No` - -Configuration options for the load-balancer feature - -#### cluster-config.load-balancer.enabled - -**Type:** `bool`
-**Required:** `No`
- -Determines if the feature should be enabled. -If omitted defaults to `false` - -#### cluster-config.load-balancer.cidrs - -**Type:** `list[string]`
-**Required:** `No`
- -Sets the CIDRs used for assigning IP addresses to Kubernetes services with type -`LoadBalancer`. - -#### cluster-config.load-balancer.l2-mode - -**Type:** `bool`
-**Required:** `No`
- -Determines if L2 mode should be enabled. -If omitted defaults to `false` - -#### cluster-config.load-balancer.l2-interfaces - -**Type:** `list[string]`
-**Required:** `No`
- -Sets the interfaces to be used for announcing IP addresses through ARP. -If omitted all interfaces will be used. - -#### cluster-config.load-balancer.bgp-mode - -**Type:** `bool`
-**Required:** `No`
- -Determines if BGP mode should be enabled. -If omitted defaults to `false` - -#### cluster-config.load-balancer.bgp-local-asn - -**Type:** `int`
-**Required:** `Yes if bgp-mode is true`
- -Sets the ASN to be used for the local virtual BGP router. - -#### cluster-config.load-balancer.bgp-peer-address - -**Type:** `string`
-**Required:** `Yes if bgp-mode is true`
- -Sets the IP address of the BGP peer. - -#### cluster-config.load-balancer.bgp-peer-asn - -**Type:** `int`
-**Required:** `Yes if bgp-mode is true`
- -Sets the ASN of the BGP peer. - -#### cluster-config.load-balancer.bgp-peer-port - -**Type:** `int`
-**Required:** `Yes if bgp-mode is true`
- -Sets the port of the BGP peer. - - -### cluster-config.local-storage - -**Type:** `object`
-**Required:** `No` - -Configuration options for the local-storage feature - -#### cluster-config.local-storage.enabled - -**Type:** `bool`
-**Required:** `No`
- -Determines if the feature should be enabled. -If omitted defaults to `false` - -#### cluster-config.local-storage.local-path - -**Type:** `string`
-**Required:** `No`
- -Sets the path to be used for storing volume data. -If omitted defaults to `/var/snap/k8s/common/rawfile-storage` - -#### cluster-config.local-storage.reclaim-policy - -**Type:** `string`
-**Required:** `No`
-**Possible Values:** `Retain | Recycle | Delete` - -Sets the reclaim policy of the storage class. -If omitted defaults to `Delete` - -#### cluster-config.local-storage.default - -**Type:** `bool`
-**Required:** `No`
- -Determines if the storage class should be set as default. -If omitted defaults to `true` - - -### cluster-config.gateway - -**Type:** `object`
-**Required:** `No` - -Configuration options for the gateway feature - -#### cluster-config.gateway.enabled - -**Type:** `bool`
-**Required:** `No`
- -Determines if the feature should be enabled. -If omitted defaults to `true` - -### cluster-config.cloud-provider - -**Type:** `string`
-**Required:** `No`
-**Possible Values:** `external` - -Sets the cloud provider to be used by the cluster. - -When this is set as `external`, node will wait for an external cloud provider to -do cloud specific setup and finish node initialization. - -### control-plane-taints - -**Type:** `list[string]`
-**Required:** `No` - -List of taints to be applied to control plane nodes. - -### pod-cidr - -**Type:** `string`
-**Required:** `No` - -The CIDR to be used for assigning pod addresses. -If omitted defaults to `10.1.0.0/16` - -### service-cidr - -**Type:** `string`
-**Required:** `No` - -The CIDR to be used for assigning service addresses. -If omitted defaults to `10.152.183.0/24` - -### disable-rbac - -**Type:** `bool`
-**Required:** `No` - -Determines if RBAC should be disabled. -If omitted defaults to `false` - -### secure-port - -**Type:** `int`
-**Required:** `No` - -The port number for kube-apiserver to use. -If omitted defaults to `6443` - -### k8s-dqlite-port - -**Type:** `int`
-**Required:** `No` - -The port number for k8s-dqlite to use. -If omitted defaults to `9000` - -### datastore-type - -**Type:** `string`
-**Required:** `No`
-**Possible Values:** `k8s-dqlite | external` - -The type of datastore to be used. -If omitted defaults to `k8s-dqlite` - -Can be used to point to an external datastore like etcd. - -### datastore-servers - -**Type:** `list[string]`
-**Required:** `No`
- -The server addresses to be used when `datastore-type` is set to `external`. - -### datastore-ca-crt - -**Type:** `string`
-**Required:** `No`
- -The CA certificate to be used when communicating with the external datastore. - -### datastore-client-crt - -**Type:** `string`
-**Required:** `No`
- -The client certificate to be used when communicating with the external -datastore. - -### datastore-client-key - -**Type:** `string`
-**Required:** `No`
- -The client key to be used when communicating with the external datastore. - -### extra-sans - -**Type:** `list[string]`
-**Required:** `No`
- -List of extra SANs to be added to certificates. - -### ca-crt - -**Type:** `string`
-**Required:** `No`
- -The CA certificate to be used for Kubernetes services. -If omitted defaults to an auto generated certificate. - -### ca-key - -**Type:** `string`
-**Required:** `No`
- -The CA key to be used for Kubernetes services. -If omitted defaults to an auto generated key. - -### front-proxy-ca-crt - -**Type:** `string`
-**Required:** `No`
- -The CA certificate to be used for the front proxy. -If omitted defaults to an auto generated certificate. - -### front-proxy-ca-key - -**Type:** `string`
-**Required:** `No`
- -The CA key to be used for the front proxy. -If omitted defaults to an auto generated key. - -### front-proxy-client-crt - -**Type:** `string`
-**Required:** `No`
- -The client certificate to be used for the front proxy. -If omitted defaults to an auto generated certificate. - -### front-proxy-client-key - -**Type:** `string`
-**Required:** `No`
- -The client key to be used for the front proxy. -If omitted defaults to an auto generated key. - - -### apiserver-kubelet-client-crt - -**Type:** `string`
-**Required:** `No`
- -The client certificate to be used by kubelet for communicating with the -kube-apiserver. -If omitted defaults to an auto generated certificate. - -### apiserver-kubelet-client-key - -**Type:** `string`
-**Required:** `No`
- -The client key to be used by kubelet for communicating with the kube-apiserver. -If omitted defaults to an auto generated key. - -### service-account-key - -**Type:** `string`
-**Required:** `No`
- -The key to be used by the default service account. -If omitted defaults to an auto generated key. - -### apiserver-crt - -**Type:** `string`
-**Required:** `No`
- -The certificate to be used for the kube-apiserver. -If omitted defaults to an auto generated certificate. - -### apiserver-key - -**Type:** `string`
-**Required:** `No`
- -The key to be used for the kube-apiserver. -If omitted defaults to an auto generated key. - -### kubelet-crt - -**Type:** `string`
-**Required:** `No`
- -The certificate to be used for the kubelet. -If omitted defaults to an auto generated certificate. - -### kubelet-key - -**Type:** `string`
-**Required:** `No`
- -The key to be used for the kubelet. -If omitted defaults to an auto generated key. - -### extra-node-config-files - -**Type:** `map[string]string`
-**Required:** `No`
- -Additional files that are uploaded `/var/snap/k8s/common/args/conf.d/` -to a node on bootstrap. These files can them be references by Kubernetes -service arguments. -The format is `map[]`. - -### extra-node-kube-apiserver-args - -**Type:** `map[string]string`
-**Required:** `No`
- -Additional arguments that are passed to the `kube-apiserver` only for that -specific node. Overwrites default configuration. A parameter that is explicitly -set to `null` is deleted. The format is `map[<--flag-name>]`. - -### extra-node-kube-controller-manager-args - -**Type:** `map[string]string`
-**Required:** `No`
- -Additional arguments that are passed to the `kube-controller-manager` only for -that specific node. Overwrites default configuration. A parameter that is -explicitly set to `null` is deleted. The format is `map[<--flag-name>]`. - -### extra-node-kube-scheduler-args - -**Type:** `map[string]string`
-**Required:** `No`
- -Additional arguments that are passed to the `kube-scheduler` only for that -specific node. Overwrites default configuration. A parameter that is explicitly -set to `null` is deleted. The format is `map[<--flag-name>]`. - -### extra-node-kube-proxy-args - -**Type:** `map[string]string`
-**Required:** `No`
- -Additional arguments that are passed to the `kube-proxy` only for that -specific node. Overwrites default configuration. A parameter that is explicitly -set to `null` is deleted. The format is `map[<--flag-name>]`. - -### extra-node-kubelet-args - -**Type:** `map[string]string`
-**Required:** `No`
- -Additional arguments that are passed to the `kubelet` only for that -specific node. Overwrites default configuration. A parameter that is explicitly -set to `null` is deleted. The format is `map[<--flag-name>]`. - -### extra-node-containerd-args - -**Type:** `map[string]string`
-**Required:** `No`
- -Additional arguments that are passed to `containerd` only for that -specific node. Overwrites default configuration. A parameter that is explicitly -set to `null` is deleted. The format is `map[<--flag-name>]`. - -### extra-node-k8s-dqlite-args - -**Type:** `map[string]string`
-**Required:** `No`
+```{include} ../../_parts/bootstrap_config.md +``` -Additional arguments that are passed to `k8s-dqlite` only for that -specific node. Overwrites default configuration. A parameter that is explicitly -set to `null` is deleted. The format is `map[<--flag-name>]`. ## Example diff --git a/src/k8s/Makefile b/src/k8s/Makefile index 4e7d33154..389a050d5 100644 --- a/src/k8s/Makefile +++ b/src/k8s/Makefile @@ -14,7 +14,7 @@ go.unit: $(DQLITE_BUILD_SCRIPTS_DIR)/static-go-test.sh -v ./pkg/... ./cmd/... -coverprofile=coverage.txt --cover go.doc: bin/static/k8s - bin/static/k8s generate-docs --output-dir ../../docs/src/_parts/commands/ + bin/static/k8s generate-docs --output-dir ../../docs/src/_parts/ ## Static Builds static: bin/static/k8s bin/static/k8sd bin/static/k8s-apiserver-proxy diff --git a/src/k8s/cmd/k8s/k8s_generate_docs.go b/src/k8s/cmd/k8s/k8s_generate_docs.go index 10a8ea715..53a95409b 100644 --- a/src/k8s/cmd/k8s/k8s_generate_docs.go +++ b/src/k8s/cmd/k8s/k8s_generate_docs.go @@ -1,7 +1,11 @@ package k8s import ( + "os" + + apiv1 "github.com/canonical/k8s-snap-api/api/v1" cmdutil "github.com/canonical/k8s/cmd/util" + "github.com/canonical/k8s/pkg/docgen" "github.com/spf13/cobra" "github.com/spf13/cobra/doc" ) @@ -15,11 +19,29 @@ func newGenerateDocsCmd(env cmdutil.ExecutionEnvironment) *cobra.Command { Hidden: true, Short: "Generate markdown documentation", Run: func(cmd *cobra.Command, args []string) { - if err := doc.GenMarkdownTree(cmd.Parent(), opts.outputDir); err != nil { + if err := doc.GenMarkdownTree(cmd.Parent(), opts.outputDir + "/commands"); err != nil { cmd.PrintErrf("Error: Failed to generate markdown documentation for k8s command.\n\nThe error was: %v\n", err) env.Exit(1) return } + + bootstrap_doc, err := docgen.MarkdownFromJsonStruct(apiv1.BootstrapConfig{}) + if err != nil { + cmd.PrintErrf("Error: Failed to generate markdown documentation for bootstrap configuration\n\n") + cmd.PrintErrf("Error: %v", err) + env.Exit(1) + return + } + + bootstrap_doc_path := opts.outputDir + "/bootstrap_config.md" + err = os.WriteFile(bootstrap_doc_path, []byte(bootstrap_doc), 0644) + if err != nil { + cmd.PrintErrf("Error: Failed to write markdown documentation for bootstrap configuration\n\n") + cmd.PrintErrf("Error: %v", ) + env.Exit(1) + return + } + cmd.Printf("Generated documentation in %s\n", opts.outputDir) }, } diff --git a/src/k8s/go.mod b/src/k8s/go.mod index ca4a888d6..45ee278b8 100644 --- a/src/k8s/go.mod +++ b/src/k8s/go.mod @@ -14,6 +14,7 @@ require ( github.com/onsi/gomega v1.32.0 github.com/pelletier/go-toml v1.9.5 github.com/spf13/cobra v1.8.1 + golang.org/x/mod v0.20.0 golang.org/x/net v0.28.0 golang.org/x/sync v0.8.0 golang.org/x/sys v0.24.0 diff --git a/src/k8s/pkg/docgen/godoc.go b/src/k8s/pkg/docgen/godoc.go new file mode 100755 index 000000000..a875c3251 --- /dev/null +++ b/src/k8s/pkg/docgen/godoc.go @@ -0,0 +1,117 @@ +package docgen + +import ( + "fmt" + "go/ast" + "go/doc" + "go/parser" + "go/token" + "reflect" +) + +var packageDocCache = make(map[string]*doc.Package) + + +func findTypeSpec(decl *ast.GenDecl, symbol string) *ast.TypeSpec { + for _, spec := range decl.Specs { + typeSpec := spec.(*ast.TypeSpec) + if symbol == typeSpec.Name.Name { + return typeSpec + } + } + return nil +} + +func getStructTypeFromDoc(packageDoc *doc.Package, structName string) *ast.StructType { + for _, docType := range packageDoc.Types { + if structName != docType.Name { + continue + } + typeSpec := findTypeSpec(docType.Decl, docType.Name) + structType, ok := typeSpec.Type.(*ast.StructType) + if !ok { + // Not a structure. + continue + } + return structType; + } + return nil +} + +func parsePackageDir(packageDir string) (*ast.Package, error) { + fset := token.NewFileSet() + packages, err := parser.ParseDir(fset, packageDir, nil, parser.ParseComments) + if err != nil { + return nil, fmt.Errorf("couldn't parse go package: %s", packageDir) + } + + if len(packages) == 0 { + return nil, fmt.Errorf("no go package found: %s", packageDir) + } + if len(packages) > 1 { + return nil, fmt.Errorf("multiple go package found: %s", packageDir) + } + + // We have a map containing a single entry and we need to return it. + for _, pkg := range packages { + return pkg, nil + } + + // shouldn't really get here. + return nil, fmt.Errorf("failed to parse go package") +} + + +func getAstStructField(structType *ast.StructType, fieldName string) *ast.Field { + for _, field := range structType.Fields.List { + for _, fieldIdent := range field.Names { + if fieldIdent.Name == fieldName { + return field + } + } + } + return nil +} + +func getPackageDoc(packagePath string) (*doc.Package, error) { + packageDoc, found := packageDocCache[packagePath] + if found { + return packageDoc, nil + } + + packageDir, err := getGoPackageDir(packagePath) + if err != nil { + return nil, fmt.Errorf("couldn't retrieve package dir, error: %v", err) + } + + pkg, err := parsePackageDir(packageDir) + if err != nil { + return nil, err + } + + packageDoc = doc.New(pkg, packageDir, doc.AllDecls | doc.PreserveAST) + packageDocCache[packagePath] = packageDoc + + return packageDoc, nil +} + +func getFieldDocstring(i any, field reflect.StructField) (string, error) { + inType := reflect.TypeOf(i) + + packageDoc, err := getPackageDoc(inType.PkgPath()) + if err != nil { + return "", err + } + + structType := getStructTypeFromDoc(packageDoc, inType.Name()) + if structType == nil { + return "", fmt.Errorf("could not find %s structure definition", inType.Name) + } + + astField := getAstStructField(structType, field.Name) + if astField == nil { + return "", fmt.Errorf("could not find %s.%s field definition", inType.Name, field.Name) + } + + return astField.Doc.Text(), nil +} diff --git a/src/k8s/pkg/docgen/gomod.go b/src/k8s/pkg/docgen/gomod.go new file mode 100755 index 000000000..8b621ec69 --- /dev/null +++ b/src/k8s/pkg/docgen/gomod.go @@ -0,0 +1,125 @@ +package docgen + +import ( + "fmt" + "os" + "path" + "path/filepath" + "strings" + "golang.org/x/mod/modfile" + "golang.org/x/mod/module" +) + +func getGoDepModulePath(name string, version string) (string, error) { + cachePath := os.Getenv("GOMODCACHE") + if cachePath == "" { + goPath := os.Getenv("GOPATH") + if goPath == "" { + goPath = path.Join(os.Getenv("HOME"), "/go") + } + cachePath = path.Join(goPath, "pkg", "mod") + } + + escapedPath, err := module.EscapePath(name) + if err != nil { + return "", fmt.Errorf( + "couldn't escape module path: %s %v", name, err) + } + + escapedVersion, err := module.EscapeVersion(version) + if err != nil { + return "", fmt.Errorf( + "couldn't escape module version: %s %v", version, err) + } + + path := path.Join(cachePath, escapedPath+"@"+escapedVersion) + + // Validate the path. + if _, err := os.Stat(path); err != nil { + return "", fmt.Errorf( + "Go module path not accessible: %s %s %s. Error: %v.", + name, version, path, err) + } + + return path, nil +} + +func getDependencyVersionFromGoMod(goModPath string, packageName string, directOnly bool) (string, string, error) { + goModContents, err := os.ReadFile(goModPath) + if err != nil { + return "", "", fmt.Errorf("could not read go.mod file %s. Error: ", goModPath, err) + } + goModFile, err := modfile.ParseLax(goModPath, goModContents, nil) + if err != nil { + return "", "", fmt.Errorf("could not parse go.mod file %s. Error: ", goModPath, err) + } + + for _, dep := range goModFile.Require { + if directOnly && dep.Indirect { + continue + } + if strings.HasPrefix(packageName, dep.Mod.Path) { + return dep.Mod.Path, dep.Mod.Version, nil + } + } + + return "", "", fmt.Errorf("could not find dependency %s in %s", packageName, goModPath) +} + +// getProjectDir retrieves the full path of k8s-snap/src/k8s. +// For simplicity, we assume that the executable is placed in +// k8s-snap/src/k8s/bin/(static|dynamic). +// This will mostly be used to generate the project documentation using +// "make go.doc". +func getProjectDir() (string, error) { + exec, err := os.Executable() + if err != nil { + return "", fmt.Errorf("couldn't retrieve executable path, error: %v", err) + } + + projDir := path.Join(filepath.Dir(exec), "..", "..") + return filepath.Abs(projDir) +} + +func getGoModPath() (string, error) { + projDir, err := getProjectDir() + if err != nil { + return "", err + } + + return path.Join(projDir, "go.mod"), nil +} + +func getGoPackageDir(packageName string) (string, error) { + if packageName == "" { + return "", fmt.Errorf("could not retrieve package dir, no package name specified.") + } + + if strings.HasPrefix(packageName, "github.com/canonical/k8s/") { + projDir, err := getProjectDir() + if err != nil { + return "", err + } + + return strings.Replace(packageName, "github.com/canonical/k8s", projDir, 1), nil + } + + // Dependency, need to retrieve its version from go.mod + goModPath, err := getGoModPath() + if err != nil { + return "", err + } + + basePackageName, version, err := getDependencyVersionFromGoMod(goModPath, packageName, false) + if err != nil { + return "", err + } + + basePath, err := getGoDepModulePath(basePackageName, version) + if err != nil { + return "", err + } + + subPath := strings.TrimPrefix(packageName, basePackageName) + return path.Join(basePath, subPath), nil +} diff --git a/src/k8s/pkg/docgen/json_struct.go b/src/k8s/pkg/docgen/json_struct.go new file mode 100755 index 000000000..43423d217 --- /dev/null +++ b/src/k8s/pkg/docgen/json_struct.go @@ -0,0 +1,109 @@ +package docgen + +import ( + "fmt" + "os" + "reflect" + "strings" +) + +type JsonTag struct { + Name string + Options []string +} + +type Field struct { + Name string + TypeName string + JsonTag JsonTag + FullJsonPath string + Docstring string +} + +// Generate Markdown documentation for a JSON or YAML based on +// the Go structure definition, parsing field annotations. +func MarkdownFromJsonStruct(i any) (string, error) { + fields, err := ParseStruct(i) + if err != nil { + return "", err + } + + entryTemplate := `## %s +**Type:** `+"`%s`" +`
+ +%s +` + + var out strings.Builder + for _, field := range fields { + outFieldType := strings.Replace(field.TypeName, "*", "", -1) + entry := fmt.Sprintf(entryTemplate, field.FullJsonPath, outFieldType, field.Docstring) + out.WriteString(entry) + } + + return out.String(), nil +} + +func getJsonTag(field reflect.StructField) JsonTag { + jsonTag := JsonTag{} + + jsonTagStr := field.Tag.Get("json") + if jsonTagStr == "" { + // Use yaml tags as fallback, which have the same format. + jsonTagStr = field.Tag.Get("yaml") + } + if jsonTagStr != "" { + jsonTagSlice := strings.Split(jsonTagStr, ",") + if len(jsonTagSlice) > 0 { + jsonTag.Name = jsonTagSlice[0] + } + if len(jsonTagSlice) > 1 { + jsonTag.Options = jsonTagSlice[1:] + } + } + + return jsonTag +} + +func ParseStruct(i any) ([]Field, error) { + inType := reflect.TypeOf(i) + + if inType.Kind() != reflect.Struct { + return nil, fmt.Errorf("structure parsing failed, not a structure: %s", inType.Name) + } + + outFields := []Field{} + fields := reflect.VisibleFields(inType) + for _, field := range fields { + jsonTag := getJsonTag(field) + docstring, err := getFieldDocstring(i, field) + if err != nil { + fmt.Fprintf(os.Stderr, "WARNING: could not retrieve field docstring: %s.%s, error: %v", + inType.Name, field.Name, err) + } + + if field.Type.Kind() == reflect.Struct { + fieldIface := reflect.ValueOf(i).FieldByName(field.Name).Interface() + nestedFields, err := ParseStruct(fieldIface) + if err != nil { + return nil, fmt.Errorf("couldn't parse %s.%s, error: %v", inType, field.Name, err) + } + for _, nestedField := range nestedFields { + // Update the json paths of the nested fields based on the field name. + nestedField.FullJsonPath = jsonTag.Name + "." + nestedField.FullJsonPath + outFields = append(outFields, nestedField) + } + } else { + outField := Field{ + Name: field.Name, + TypeName: field.Type.String(), + JsonTag: jsonTag, + FullJsonPath: jsonTag.Name, + Docstring: docstring, + } + outFields = append(outFields, outField) + } + } + + return outFields, nil +}