kubekey/pkg/web/service.go
liujian 348c9b2d15
feat: enhance precheck tasks for image registry and network validation (#2676)
* feat: enhance precheck tasks for image registry and network validation

- Added a task to ensure successful authentication to the image registry.
- Updated existing tasks to provide clearer failure messages for required configurations.
- Improved validation for network interfaces and CIDR configurations, ensuring dual-stack support.
- Enhanced error handling in the resource handler for playbook creation.

Signed-off-by: joyceliu <joyceliu@yunify.com>

* feat: enhance configuration and query handling

- Added `-trimpath` flag to Go build configuration for improved binary paths.
- Updated REST configuration to set QPS and Burst limits for better performance.
- Refactored query handling to use string types for field and value, improving type consistency.
- Enhanced error handling in resource configuration updates and improved parsing of request bodies.

Signed-off-by: joyceliu <joyceliu@yunify.com>

* feat: check inventory when it's changed

Signed-off-by: joyceliu <joyceliu@yunify.com>

* feat: enhance playbook execution and query handling

- Added a new optional query parameter `promise` to the playbook and inventory endpoints, allowing for asynchronous execution control.
- Introduced a new result state `ResultPending` to indicate ongoing operations.
- Refactored the executor function to handle the `promise` parameter, enabling conditional execution of playbooks.
- Improved error handling and logging during playbook execution.

Signed-off-by: joyceliu <joyceliu@yunify.com>

---------

Signed-off-by: joyceliu <joyceliu@yunify.com>
2025-08-04 15:27:22 +08:00

312 lines
16 KiB
Go

package web
import (
"io/fs"
"net/http"
"os"
"strings"
"github.com/cockroachdb/errors"
restfulspec "github.com/emicklei/go-restful-openapi/v2"
"github.com/emicklei/go-restful/v3"
"github.com/go-openapi/spec"
kkcorev1 "github.com/kubesphere/kubekey/api/core/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/rest"
"k8s.io/klog/v2"
ctrlclient "sigs.k8s.io/controller-runtime/pkg/client"
"github.com/kubesphere/kubekey/v4/config"
_const "github.com/kubesphere/kubekey/v4/pkg/const"
"github.com/kubesphere/kubekey/v4/pkg/web/api"
"github.com/kubesphere/kubekey/v4/pkg/web/handler"
"github.com/kubesphere/kubekey/v4/pkg/web/query"
"github.com/kubesphere/kubekey/v4/version"
)
// NewCoreService creates and configures a new RESTful web service for managing inventories and playbooks.
// It sets up routes for CRUD operations on inventories and playbooks, including pagination, sorting, and filtering.
func NewCoreService(workdir string, client ctrlclient.Client, restconfig *rest.Config) *restful.WebService {
ws := new(restful.WebService).
// the GroupVersion might be empty, we need to remove the final /
Path(strings.TrimRight(_const.CoreAPIPath+kkcorev1.SchemeGroupVersion.String(), "/"))
inventoryHandler := handler.NewInventoryHandler(workdir, restconfig, client)
// Inventory management routes
ws.Route(ws.POST("/inventories").To(inventoryHandler.Post).
Metadata(restfulspec.KeyOpenAPITags, []string{_const.KubeKeyTag}).
Doc("create a inventory.").Operation("createInventory").
Consumes(restful.MIME_JSON).Produces(restful.MIME_JSON).
Reads(kkcorev1.Inventory{}).
Returns(http.StatusOK, _const.StatusOK, kkcorev1.Inventory{}))
ws.Route(ws.PATCH("/namespaces/{namespace}/inventories/{inventory}").To(inventoryHandler.Patch).
Metadata(restfulspec.KeyOpenAPITags, []string{_const.KubeKeyTag}).
Doc("patch a inventory.").Operation("patchInventory").
Consumes(string(types.JSONPatchType), string(types.MergePatchType), string(types.ApplyPatchType)).Produces(restful.MIME_JSON).
Reads(kkcorev1.Inventory{}).
Param(ws.PathParameter("namespace", "the namespace of the inventory")).
Param(ws.PathParameter("inventory", "the name of the inventory")).
Param(ws.QueryParameter("promise", "promise to execute playbook").Required(false).DefaultValue("true")).
Returns(http.StatusOK, _const.StatusOK, kkcorev1.Inventory{}))
ws.Route(ws.GET("/inventories").To(inventoryHandler.List).
Metadata(restfulspec.KeyOpenAPITags, []string{_const.KubeKeyTag}).
Doc("list all inventories.").Operation("listInventory").
Produces(restful.MIME_JSON).
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=createTime")).
Returns(http.StatusOK, _const.StatusOK, api.ListResult[kkcorev1.Inventory]{}))
ws.Route(ws.GET("/namespaces/{namespace}/inventories").To(inventoryHandler.List).
Metadata(restfulspec.KeyOpenAPITags, []string{_const.KubeKeyTag}).
Doc("list all inventories in a namespace.").
Produces(restful.MIME_JSON).Operation("listInventoryInNamespace").
Param(ws.PathParameter("namespace", "the namespace of the inventory")).
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=createTime")).
Returns(http.StatusOK, _const.StatusOK, api.ListResult[kkcorev1.Inventory]{}))
ws.Route(ws.GET("/namespaces/{namespace}/inventories/{inventory}").To(inventoryHandler.Info).
Metadata(restfulspec.KeyOpenAPITags, []string{_const.KubeKeyTag}).
Doc("get a inventory in a namespace.").Operation("getInventory").
Produces(restful.MIME_JSON).
Param(ws.PathParameter("namespace", "the namespace of the inventory")).
Param(ws.PathParameter("inventory", "the name of the inventory")).
Returns(http.StatusOK, _const.StatusOK, kkcorev1.Inventory{}))
ws.Route(ws.GET("/namespaces/{namespace}/inventories/{inventory}/hosts").To(inventoryHandler.ListHosts).
Metadata(restfulspec.KeyOpenAPITags, []string{_const.KubeKeyTag}).
Doc("list all hosts in a inventory.").Operation("listInventoryHosts").
Produces(restful.MIME_JSON).
Param(ws.PathParameter("namespace", "the namespace of the inventory")).
Param(ws.PathParameter("inventory", "the name of the inventory")).
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=createTime")).
Returns(http.StatusOK, _const.StatusOK, api.ListResult[api.InventoryHostTable]{}))
playbookHandler := handler.NewPlaybookHandler(workdir, restconfig, client)
// Playbook management routes
ws.Route(ws.POST("/playbooks").To(playbookHandler.Post).
Metadata(restfulspec.KeyOpenAPITags, []string{_const.KubeKeyTag}).
Doc("create a playbook.").Operation("createPlaybook").
Param(ws.QueryParameter("promise", "promise to execute playbook").Required(false).DefaultValue("true")).
Consumes(restful.MIME_JSON).Produces(restful.MIME_JSON).
Reads(kkcorev1.Playbook{}).
Returns(http.StatusOK, _const.StatusOK, kkcorev1.Playbook{}))
ws.Route(ws.GET("/playbooks").To(playbookHandler.List).
Metadata(restfulspec.KeyOpenAPITags, []string{_const.KubeKeyTag}).
Doc("list all playbooks.").Operation("listPlaybook").
Produces(restful.MIME_JSON).
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=createTime")).
Returns(http.StatusOK, _const.StatusOK, api.ListResult[kkcorev1.Playbook]{}))
ws.Route(ws.GET("/namespaces/{namespace}/playbooks").To(playbookHandler.List).
Metadata(restfulspec.KeyOpenAPITags, []string{_const.KubeKeyTag}).
Doc("list all playbooks in a namespace.").Operation("listPlaybookInNamespace").
Produces(restful.MIME_JSON).
Param(ws.PathParameter("namespace", "the namespace of the playbook")).
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=createTime")).
Returns(http.StatusOK, _const.StatusOK, api.ListResult[kkcorev1.Playbook]{}))
ws.Route(ws.GET("/namespaces/{namespace}/playbooks/{playbook}").To(playbookHandler.Info).
Metadata(restfulspec.KeyOpenAPITags, []string{_const.KubeKeyTag}).
Doc("get or watch a playbook in a namespace.").Operation("getPlaybook").
Produces(restful.MIME_JSON).
Param(ws.PathParameter("namespace", "the namespace of the playbook")).
Param(ws.PathParameter("playbook", "the name of the playbook")).
Param(ws.QueryParameter("watch", "set to true to watch this playbook")).
Returns(http.StatusOK, _const.StatusOK, kkcorev1.Playbook{}))
ws.Route(ws.GET("/namespaces/{namespace}/playbooks/{playbook}/log").To(playbookHandler.Log).
Metadata(restfulspec.KeyOpenAPITags, []string{_const.KubeKeyTag}).
Doc("get a playbook execute log.").Operation("getPlaybookLog").
Produces("text/plain").
Param(ws.PathParameter("namespace", "the namespace of the playbook")).
Param(ws.PathParameter("playbook", "the name of the playbook")).
Returns(http.StatusOK, _const.StatusOK, ""))
ws.Route(ws.DELETE("/namespaces/{namespace}/playbooks/{playbook}").To(playbookHandler.Delete).
Metadata(restfulspec.KeyOpenAPITags, []string{_const.KubeKeyTag}).
Doc("delete a playbook.").Operation("deletePlaybook").
Produces(restful.MIME_JSON).
Param(ws.PathParameter("namespace", "the namespace of the playbook")).
Param(ws.PathParameter("playbook", "the name of the playbook")).
Returns(http.StatusOK, _const.StatusOK, api.Result{}))
return ws
}
// NewSchemaService creates a new WebService that serves schema files from the specified root path.
// It sets up a route that handles GET requests to /resources/schema/{subpath} and serves files from the rootPath directory.
// The {subpath:*} parameter allows for matching any path under /resources/schema/.
func NewSchemaService(rootPath string, workdir string, client ctrlclient.Client) *restful.WebService {
ws := new(restful.WebService)
ws.Path(strings.TrimRight(_const.ResourcesAPIPath, "/")).
Produces(restful.MIME_JSON, "text/plain")
resourceHandler := handler.NewResourceHandler(rootPath, workdir, client)
ws.Route(ws.GET("/ip").To(resourceHandler.ListIP).
Doc("list available ip from ip cidr").
Metadata(restfulspec.KeyOpenAPITags, []string{_const.ResourceTag}).
Param(ws.QueryParameter("cidr", "the cidr for ip").Required(true)).
Param(ws.QueryParameter("sshPort", "the ssh port for ip").Required(false)).
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=ip").Required(false).DefaultValue("ip")).
Returns(http.StatusOK, _const.StatusOK, api.ListResult[api.IPTable]{}))
ws.Route(ws.GET("/schema/{subpath:*}").To(resourceHandler.SchemaInfo).
Metadata(restfulspec.KeyOpenAPITags, []string{_const.ResourceTag}))
ws.Route(ws.GET("/schema").To(resourceHandler.ListSchema).
Doc("list all schema as table").
Metadata(restfulspec.KeyOpenAPITags, []string{_const.ResourceTag}).
Param(ws.QueryParameter("cluster", "The namespace where the cluster resides").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(resourceHandler.PostConfig).
Doc("storing user-defined configuration information").
Reads(struct{}{}).
Param(ws.QueryParameter("cluster", "The namespace where the cluster resides").Required(false).DefaultValue("default")).
Param(ws.QueryParameter("inventory", "the inventory of the playbook").Required(false).DefaultValue("default")).
Metadata(restfulspec.KeyOpenAPITags, []string{_const.ResourceTag}))
ws.Route(ws.GET("/schema/config").To(resourceHandler.ConfigInfo).
Doc("get user-defined configuration information").
Metadata(restfulspec.KeyOpenAPITags, []string{_const.ResourceTag}))
return ws
}
// NewUIService creates a new WebService that serves the static web UI and handles SPA routing.
// - 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(path string) *restful.WebService {
ws := new(restful.WebService)
ws.Path("/")
// Create a sub-filesystem for the embedded web UI assets
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(os.DirFS(path), "index.html")
if err != nil {
_ = resp.WriteError(http.StatusNotFound, err)
return
}
resp.AddHeader("Content-Type", "text/html")
_, _ = resp.Write(data)
}))
// Serve all subpaths:
// - If the path matches an API prefix, return 404 to let other WebServices handle it
// - If the path looks like a static asset (contains a dot), serve the file
// - Otherwise, serve index.html for SPA routing
ws.Route(ws.GET("/{subpath:*}").To(func(req *restful.Request, resp *restful.Response) {
requestedPath := req.PathParameter("subpath")
// If the path matches any API route, return 404 so other WebServices can handle it
if strings.HasPrefix(requestedPath, strings.TrimLeft(_const.CoreAPIPath, "/")) ||
strings.HasPrefix(requestedPath, strings.TrimLeft(_const.SwaggerAPIPath, "/")) ||
strings.HasPrefix(requestedPath, strings.TrimLeft(_const.ResourcesAPIPath, "/")) {
_ = resp.WriteError(http.StatusNotFound, errors.New("not found"))
return
}
// If the path looks like a static asset (e.g., .js, .css, .ico, .png, etc.), serve it
if strings.Contains(requestedPath, ".") {
fileServer.ServeHTTP(resp.ResponseWriter, req.Request)
return
}
// For all other paths, serve index.html (SPA client-side routing)
data, err := fs.ReadFile(os.DirFS(path), "index.html")
if err != nil {
_ = resp.WriteError(http.StatusInternalServerError, err)
return
}
resp.AddHeader("Content-Type", "text/html")
_, _ = resp.Write(data)
}))
return ws
}
// NewSwaggerUIService creates a new WebService that serves the Swagger UI interface
// It mounts the embedded swagger-ui files and handles requests to display the API documentation
func NewSwaggerUIService() *restful.WebService {
ws := new(restful.WebService)
ws.Path(strings.TrimRight(_const.SwaggerAPIPath, "/"))
subFS, err := fs.Sub(config.Swagger, "swagger-ui")
if err != nil {
panic(err)
}
fileServer := http.StripPrefix("/swagger-ui/", http.FileServer(http.FS(subFS)))
ws.Route(ws.GET("").To(func(req *restful.Request, resp *restful.Response) {
data, err := fs.ReadFile(subFS, "index.html")
if err != nil {
_ = resp.WriteError(http.StatusNotFound, err)
return
}
resp.AddHeader("Content-Type", "text/html")
_, _ = resp.Write(data)
}).Metadata(restfulspec.KeyOpenAPITags, []string{_const.OpenAPITag}))
ws.Route(ws.GET("/{subpath:*}").To(func(req *restful.Request, resp *restful.Response) {
fileServer.ServeHTTP(resp.ResponseWriter, req.Request)
}).Metadata(restfulspec.KeyOpenAPITags, []string{_const.OpenAPITag}))
return ws
}
// NewAPIService creates a new WebService that serves the OpenAPI/Swagger specification
// It takes a list of WebServices and generates the API documentation
func NewAPIService(webservice []*restful.WebService) *restful.WebService {
restconfig := restfulspec.Config{
WebServices: webservice, // you control what services are visible
APIPath: "/apidocs.json",
PostBuildSwaggerObjectHandler: func(swo *spec.Swagger) {
swo.Info = &spec.Info{
InfoProps: spec.InfoProps{
Title: "KubeKey Web API",
Description: "KubeKey Web OpenAPI",
Version: version.Get().String(),
Contact: &spec.ContactInfo{
ContactInfoProps: spec.ContactInfoProps{
Name: "KubeKey",
URL: "https://github.com/kubesphere/kubekey",
},
},
},
}
}}
for _, ws := range webservice {
klog.V(2).Infof("%s", ws.RootPath())
}
return restfulspec.NewOpenAPIService(restconfig)
}