diff --git a/cmd/kk/app/options/web.go b/cmd/kk/app/options/web.go index 463fb00f..a3d5b14b 100644 --- a/cmd/kk/app/options/web.go +++ b/cmd/kk/app/options/web.go @@ -20,6 +20,7 @@ type KubeKeyWebOptions struct { Workdir string // Workdir specifies the base directory for KubeKey SchemaPath string + UIPath string } // NewKubeKeyWebOptions creates and returns a new KubeKeyWebOptions instance with default values @@ -46,6 +47,7 @@ func (o *KubeKeyWebOptions) Flags() cliflag.NamedFlagSets { wfs.IntVar(&o.Port, "port", o.Port, fmt.Sprintf("the server port of kubekey web default is: %d", o.Port)) wfs.StringVar(&o.Workdir, "workdir", o.Workdir, "the base Dir for kubekey. Default current dir. ") wfs.StringVar(&o.SchemaPath, "schema-path", o.SchemaPath, "the json schema dir path to render web ui.") + wfs.StringVar(&o.UIPath, "ui-path", o.SchemaPath, "the web ui package path.") return fss } diff --git a/cmd/kk/app/web.go b/cmd/kk/app/web.go index 91f1113f..a7d2b4cc 100644 --- a/cmd/kk/app/web.go +++ b/cmd/kk/app/web.go @@ -42,6 +42,7 @@ func newWebCommand() *cobra.Command { Workdir: o.Workdir, Port: o.Port, SchemaPath: o.SchemaPath, + UIPath: o.UIPath, Client: client, Config: restconfig, }).Run(cmd.Context()) diff --git a/config/file.go b/config/file.go index 4bc4de1b..4b000a0c 100644 --- a/config/file.go +++ b/config/file.go @@ -7,9 +7,3 @@ import "embed" // //go:embed swagger-ui var Swagger embed.FS - -// WebUI embeds the web directory containing the static web UI assets -// This allows serving the web UI directly from the binary without needing external files -// -//go:embed all:web -var WebUI embed.FS diff --git a/config/web/index.html b/config/web/index.html deleted file mode 100644 index 50ed8ab6..00000000 --- a/config/web/index.html +++ /dev/null @@ -1,10 +0,0 @@ - - - - - Hello World - - -

Hello World

- - diff --git a/pkg/manager/manager.go b/pkg/manager/manager.go index e2ddce57..4ba3a286 100644 --- a/pkg/manager/manager.go +++ b/pkg/manager/manager.go @@ -64,6 +64,7 @@ type WebManagerOptions struct { Workdir string Port int SchemaPath string + UIPath string ctrlclient.Client *rest.Config } @@ -74,6 +75,7 @@ func NewWebManager(o WebManagerOptions) Manager { workdir: o.Workdir, port: o.Port, schemaPath: o.SchemaPath, + uiPath: o.UIPath, Client: o.Client, Config: o.Config, } diff --git a/pkg/manager/web_manager.go b/pkg/manager/web_manager.go index a2216876..b116565d 100644 --- a/pkg/manager/web_manager.go +++ b/pkg/manager/web_manager.go @@ -23,6 +23,7 @@ type webManager struct { workdir string schemaPath string + uiPath string ctrlclient.Client *rest.Config @@ -40,7 +41,7 @@ func (m webManager) Run(ctx context.Context) error { // openapi Add(web.NewSwaggerUIService()). Add(web.NewAPIService(container.RegisteredWebServices())). - Add(web.NewUIService()) + Add(web.NewUIService(m.uiPath)) server := &http.Server{ Addr: fmt.Sprintf(":%d", m.port), diff --git a/pkg/web/api/result.go b/pkg/web/api/result.go index 3a7380e0..7092c60e 100644 --- a/pkg/web/api/result.go +++ b/pkg/web/api/result.go @@ -19,6 +19,11 @@ package api const ( // SchemaLabelSubfix is the label key used to indicate which schema a playbook belongs to. SchemaLabelSubfix = "kubekey.kubesphere.io/schema" + + // SchemaProductFile is the predefined file name for storing product information. + SchemaProductFile = "product.json" + // SchemaConfigFile is the predefined file name for caching configuration. + SchemaConfigFile = "config.json" ) const ( @@ -70,24 +75,26 @@ type InventoryHostGroups struct { Index int `json:"index"` // the index of groups which hosts belong to } -// SchemaTable represents schema metadata for a resource. -// It includes fields such as name, type, title, description, version, namespace, logo, priority, and associated playbooks. -// The Playbook field is a slice of SchemaTablePlaybook, each representing a playbook reference. -type SchemaTable struct { - Name string `json:"name"` // Name of schema, defined by filename - SchemaType string `json:"schemaType"` // Type of the schema (e.g., CRD, built-in) - Title string `json:"title"` // Title of the schema - Description string `json:"description"` // Description of the schema - Version string `json:"version"` // Version of the schema - Namespace string `json:"namespace"` // Namespace of the schema - Logo string `json:"logo"` // Logo URL or identifier - Priority int `json:"priority"` // Priority for display or ordering - Playbook SchemaTablePlaybook `json:"playbook"` // List of reference playbooks - PlaybookPath map[string]string `json:"playbookPath,omitempty"` // PlaybookPath for current schema +// SchemaFileDataSchema represents the metadata section of a schema file as used in the API layer. +// It contains the main data schema metadata such as title, description, version, namespace, logo, and priority. +type SchemaFileDataSchema struct { + Title string `json:"title"` // Title of the schema + Description string `json:"description"` // Description of the schema + Version string `json:"version"` // Version of the schema + Namespace string `json:"namespace"` // Namespace of the schema + Logo string `json:"logo"` // Logo URL or identifier + Priority int `json:"priority"` // Priority for display or ordering } -// SchemaTablePlaybook represents a reference to a playbook associated with a schema. -// It includes the playbook's name, namespace, and phase. +// SchemaFile represents the structure of a schema file as used in the API layer. +// It contains the main data schema metadata and a mapping of playbook labels to their paths. +type SchemaFile struct { + DataSchema SchemaFileDataSchema `json:"dataSchema"` // Metadata of the schema file + PlaybookPath map[string]string `json:"playbookPath"` // Mapping of playbook labels to their file paths +} + +// SchemaTablePlaybook represents the details of a playbook associated with a schema in the response table. +// It includes the path, name, namespace, phase, and result of the playbook. type SchemaTablePlaybook struct { Path string `json:"path"` // Path of playbook template. Name string `json:"name"` // Name of the playbook @@ -96,6 +103,20 @@ type SchemaTablePlaybook struct { Result any `json:"result"` // Result of the playbook } +// SchemaTable represents the response table constructed from a schema file. +// It includes metadata fields such as name, title, description, version, namespace, logo, and priority. +// The Playbook field is a map of playbook labels to SchemaTablePlaybook, each representing a playbook reference. +type SchemaTable struct { + Name string `json:"name"` // Name of schema, defined by filename + Title string `json:"title"` // Title of the schema + Description string `json:"description"` // Description of the schema + Version string `json:"version"` // Version of the schema + Namespace string `json:"namespace"` // Namespace of the schema + Logo string `json:"logo"` // Logo URL or identifier + Priority int `json:"priority"` // Priority for display or ordering + Playbook map[string]SchemaTablePlaybook `json:"playbook"` // Map of playbook labels to playbook details +} + // IPTable represents an IP address entry and its SSH status information. // It indicates whether the IP is a localhost, if SSH is reachable, and if SSH authorization is present. type IPTable struct { @@ -105,3 +126,27 @@ type IPTable struct { SSHReachable bool `json:"sshReachable"` // Whether SSH port is reachable on this IP SSHAuthorized bool `json:"sshAuthorized"` // Whether SSH is authorized for this IP } + +// SchemaFile2Table converts a SchemaFile and its filename into a SchemaTable structure. +// It initializes the SchemaTable fields from the SchemaFile's DataSchema and sets up the Playbook map +// with playbook labels and their corresponding paths. Other playbook fields are left empty for later population. +func SchemaFile2Table(schemaFile SchemaFile, filename string) SchemaTable { + table := SchemaTable{ + Name: filename, + Title: schemaFile.DataSchema.Title, + Description: schemaFile.DataSchema.Description, + Version: schemaFile.DataSchema.Version, + Namespace: schemaFile.DataSchema.Namespace, + Logo: schemaFile.DataSchema.Logo, + Priority: schemaFile.DataSchema.Priority, + Playbook: make(map[string]SchemaTablePlaybook), + } + // Populate the Playbook map with playbook labels and their paths from the schema file. + for k, v := range schemaFile.PlaybookPath { + table.Playbook[k] = SchemaTablePlaybook{ + Path: v, + } + } + + return table +} diff --git a/pkg/web/openapi.go b/pkg/web/openapi.go index 6b7a7ab6..d778c8ad 100644 --- a/pkg/web/openapi.go +++ b/pkg/web/openapi.go @@ -3,6 +3,7 @@ package web import ( "io/fs" "net/http" + "os" "strings" "github.com/cockroachdb/errors" @@ -20,20 +21,16 @@ import ( // - Serves "/" with index.html // - Serves static assets (e.g., .js, .css, .png) from the embedded web directory // - Forwards all other non-API paths to index.html for SPA client-side routing -func NewUIService() *restful.WebService { +func NewUIService(path string) *restful.WebService { ws := new(restful.WebService) ws.Path("/") // Create a sub-filesystem for the embedded web UI assets - subFS, err := fs.Sub(config.WebUI, "web") - if err != nil { - panic(err) - } - fileServer := http.FileServer(http.FS(subFS)) + fileServer := http.FileServer(http.FS(os.DirFS(path))) // Serve the root path "/" with index.html ws.Route(ws.GET("").To(func(req *restful.Request, resp *restful.Response) { - data, err := fs.ReadFile(subFS, "index.html") + data, err := fs.ReadFile(os.DirFS(path), "index.html") if err != nil { _ = resp.WriteError(http.StatusNotFound, err) return @@ -64,7 +61,7 @@ func NewUIService() *restful.WebService { } // For all other paths, serve index.html (SPA client-side routing) - data, err := fs.ReadFile(subFS, "index.html") + data, err := fs.ReadFile(os.DirFS(path), "index.html") if err != nil { _ = resp.WriteError(http.StatusInternalServerError, err) return diff --git a/pkg/web/resources.go b/pkg/web/resources.go index d8894c52..27adda1d 100644 --- a/pkg/web/resources.go +++ b/pkg/web/resources.go @@ -3,6 +3,7 @@ package web import ( "encoding/json" "fmt" + "io" "net" "net/http" "net/netip" @@ -58,15 +59,22 @@ func NewSchemaService(rootPath string, workdir string, client ctrlclient.Client) ws.Route(ws.GET("/schema").To(h.allSchema). Doc("list all schema as table"). Metadata(restfulspec.KeyOpenAPITags, []string{_const.ResourceTag}). - Param(ws.QueryParameter("schemaType", "the type of schema json").Required(false)). - Param(ws.QueryParameter("playbookLabel", "the reference playbook of schema. eg: \"install.kubekey.kubesphere.io/schema\", \"check.kubekey.kubesphere.io/schema\" "+ - "if empty will not return any reference playbook").Required(false)). + Param(ws.PathParameter("namespace", "the namespace of the playbook").Required(false).DefaultValue("default")). Param(ws.QueryParameter(query.ParameterPage, "page").Required(false).DataFormat("page=%d")). Param(ws.QueryParameter(query.ParameterLimit, "limit").Required(false)). Param(ws.QueryParameter(query.ParameterAscending, "sort parameters, e.g. reverse=true").Required(false).DefaultValue("false")). Param(ws.QueryParameter(query.ParameterOrderBy, "sort parameters, e.g. orderBy=priority")). Returns(http.StatusOK, _const.StatusOK, api.ListResult[api.SchemaTable]{})) + ws.Route(ws.POST("/schema/config").To(h.storeConfig). + Doc("storing user-defined configuration information"). + Reads(struct{}{}). + Metadata(restfulspec.KeyOpenAPITags, []string{_const.ResourceTag})) + + ws.Route(ws.GET("/schema/config").To(h.configInfo). + Doc("get user-defined configuration information"). + Metadata(restfulspec.KeyOpenAPITags, []string{_const.ResourceTag})) + return ws } @@ -82,6 +90,35 @@ type schemaHandler struct { client ctrlclient.Client } +func (h schemaHandler) configInfo(request *restful.Request, response *restful.Response) { + file, err := os.Open(filepath.Join(h.rootPath, api.SchemaConfigFile)) + if err != nil { + if os.IsNotExist(err) { + _ = response.WriteError(http.StatusNotFound, err) + } else { + _ = response.WriteError(http.StatusInternalServerError, err) + } + return + } + defer file.Close() + + _, _ = io.Copy(response.ResponseWriter, file) +} + +func (h schemaHandler) storeConfig(request *restful.Request, response *restful.Response) { + file, err := os.OpenFile(filepath.Join(h.rootPath, api.SchemaConfigFile), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644) + if err != nil { + _ = response.WriteError(http.StatusInternalServerError, err) + return + } + defer file.Close() + _, err = io.Copy(file, request.Request.Body) + if err != nil { + _ = response.WriteError(http.StatusInternalServerError, err) + return + } +} + func (h schemaHandler) listIP(request *restful.Request, response *restful.Response) { queryParam := query.ParseQueryParameter(request) cidr, ok := queryParam.Filters["cidr"] @@ -193,8 +230,7 @@ func (h schemaHandler) schemaInfo(request *restful.Request, response *restful.Re // It supports filtering, sorting, and pagination via query parameters. func (h schemaHandler) allSchema(request *restful.Request, response *restful.Response) { queryParam := query.ParseQueryParameter(request) - playbookLabel := string(queryParam.Filters["playbookLabel"]) - // Get all entries in the rootPath directory. + // Read all entries in the rootPath directory. entries, err := os.ReadDir(h.rootPath) if err != nil { api.HandleBadRequest(response, request, err) @@ -202,9 +238,10 @@ func (h schemaHandler) allSchema(request *restful.Request, response *restful.Res } schemaTable := make([]api.SchemaTable, 0) for _, entry := range entries { - if entry.IsDir() || // Skip directories. - !strings.HasSuffix(entry.Name(), ".json") || // Only process files with .json suffix. - entry.Name() == "product.json" { // "product.json" is agreed file name + // Skip directories, non-JSON files, and special schema files. + if entry.IsDir() || + !strings.HasSuffix(entry.Name(), ".json") || + entry.Name() == api.SchemaProductFile || entry.Name() == api.SchemaConfigFile { continue } // Read the JSON file. @@ -213,50 +250,49 @@ func (h schemaHandler) allSchema(request *restful.Request, response *restful.Res api.HandleBadRequest(response, request, err) return } - schema := api.SchemaTable{Name: entry.Name()} + var schemaFile api.SchemaFile // Unmarshal the JSON data into a SchemaTable struct. - if err := json.Unmarshal(data, &schema); err != nil { + if err := json.Unmarshal(data, &schemaFile); err != nil { api.HandleBadRequest(response, request, err) return } - // get reference playbook - if playbookLabel != "" { - playbookList := &kkcorev1.PlaybookList{} - if err := h.client.List(request.Request.Context(), playbookList, ctrlclient.MatchingLabels{ - playbookLabel: entry.Name(), - }); err != nil { - api.HandleBadRequest(response, request, err) - return - } - switch len(playbookList.Items) { - case 0: // skip - case 1: - item := &playbookList.Items[0] - var result any - if len(item.Status.Result.Raw) != 0 { - if err := json.Unmarshal(item.Status.Result.Raw, &result); err != nil { - api.HandleBadRequest(response, request, errors.Errorf("failed to unmarshal result from playbook of schema %q", schema.Name)) + // Get all playbooks in the given namespace. + playbookList := &kkcorev1.PlaybookList{} + if err := h.client.List(request.Request.Context(), playbookList, ctrlclient.InNamespace(request.PathParameter("namespace"))); err != nil { + api.HandleBadRequest(response, request, err) + return + } + schema := api.SchemaFile2Table(schemaFile, entry.Name()) + // For each playbook, if it matches a label in schema.Playbook and the label value equals schema.Name, add its info. + for _, playbook := range playbookList.Items { + for label, schemaName := range playbook.Labels { + // Only process playbooks whose label is defined in schema.Playbook and value matches schema.Name. + if _, ok := schema.Playbook[label]; ok && schemaName == schema.Name { + // If a playbook for this label already exists, return an error. + if schema.Playbook[label].Name != "" { + api.HandleBadRequest(response, request, errors.Errorf("schema %q has multiple playbooks of label %q", entry.Name(), label)) return } + var result any + // If the playbook has a result, unmarshal it. + if len(playbook.Status.Result.Raw) != 0 { + if err := json.Unmarshal(playbook.Status.Result.Raw, &result); err != nil { + api.HandleBadRequest(response, request, errors.Errorf("failed to unmarshal result from playbook of schema %q", schema.Name)) + return + } + } + // Fill in playbook info for this label. + schema.Playbook[label] = api.SchemaTablePlaybook{ + Path: schema.Playbook[label].Path, + Name: playbook.Name, + Namespace: playbook.Namespace, + Phase: string(playbook.Status.Phase), + Result: result, + } } - schema.Playbook = api.SchemaTablePlaybook{ - Path: schema.PlaybookPath[playbookLabel], - Name: item.Name, - Namespace: item.Namespace, - Phase: string(item.Status.Phase), - Result: result, - } - default: - playbookNames := make([]string, 0, len(playbookList.Items)) - for _, playbook := range playbookList.Items { - playbookNames = append(playbookNames, playbook.Name) - } - api.HandleBadRequest(response, request, errors.Errorf("schema %q has multiple playbooks: %q", entry.Name(), playbookNames)) - return } } - // clear PlaybookPath - schema.PlaybookPath = nil + // Add the processed schema to the schemaTable slice. schemaTable = append(schemaTable, schema) } // less is a comparison function for sorting SchemaTable items by a given field. @@ -269,6 +305,7 @@ func (h schemaHandler) allSchema(request *restful.Request, response *restful.Res case reflect.Int, reflect.Int64: return leftVal.Int() > rightVal.Int() default: + // If the field is not a string or int, sort by Priority as a fallback. return left.Priority > right.Priority } } @@ -290,6 +327,7 @@ func (h schemaHandler) allSchema(request *restful.Request, response *restful.Res } // Use the DefaultList function to apply filtering, sorting, and pagination. + // The results variable contains the filtered, sorted, and paginated schemaTable. results := query.DefaultList(schemaTable, queryParam, less, filter) _ = response.WriteEntity(results) }