diff --git a/docs/src/_parts/bootstrap_config.md b/docs/src/_parts/bootstrap_config.md
new file mode 100644
index 0000000000..fa7ef5c3ed
--- /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 047622c5d9..4cc53764f0 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 4e7d331543..389a050d5c 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 10a8ea7158..53a95409b4 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 ca4a888d66..45ee278b8d 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 0000000000..a875c32512
--- /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 0000000000..8b621ec692
--- /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 0000000000..43423d217f
--- /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
+}