feat: add web api (#2591)

Signed-off-by: joyceliu <joyceliu@yunify.com>
This commit is contained in:
liujian 2025-05-26 17:36:13 +08:00 committed by GitHub
parent 8c84ea7a33
commit 9c87926929
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
45 changed files with 1884 additions and 149 deletions

View File

@ -73,7 +73,7 @@ linters:
# - nlreturn
- noctx
- nolintlint
- nonamedreturns
# - nonamedreturns
- nosprintfhostport
# - paralleltest
- perfsprint
@ -154,6 +154,10 @@ linters-settings:
- k8s.io
- oras.land/oras-go
- sigs.k8s.io
- github.com/emicklei/go-restful/v3
- github.com/google/go-cmp/cmp
- github.com/emicklei/go-restful-openapi/v2
- github.com/go-openapi/spec
forbidigo:
# Forbid the following identifiers (list of regexp).
# Default: ["^(fmt\\.Print(|f|ln)|print|println)$"]
@ -186,6 +190,8 @@ linters-settings:
# Ignore comments when counting lines.
# Default false
ignore-comments: true
gocognit:
min-complexity: 50
gocritic:
enabled-tags:
- experimental

View File

@ -0,0 +1,8 @@
- name: Check Connect
hosts:
- all
gather_facts: true
tasks:
- name: get_arch
debug:
msg: "{{ .os.architecture }}"

View File

@ -140,7 +140,6 @@ func (o *CommonOptions) Run(ctx context.Context, playbook *kkcorev1.Playbook) er
}
return manager.NewCommandManager(manager.CommandManagerOptions{
Workdir: o.Workdir,
Playbook: playbook,
Config: o.Config,
Inventory: o.Inventory,

51
cmd/kk/app/options/web.go Normal file
View File

@ -0,0 +1,51 @@
package options
import (
"fmt"
"os"
"path/filepath"
cliflag "k8s.io/component-base/cli/flag"
"k8s.io/klog/v2"
)
// defaultPort defines the default port number for the web server
const (
defaultPort = 80
)
// KubeKeyWebOptions contains configuration options for the KubeKey web server
type KubeKeyWebOptions struct {
Port int // Port specifies the port number for the web server
Workdir string // Workdir specifies the base directory for KubeKey
JSONSchema string
}
// NewKubeKeyWebOptions creates and returns a new KubeKeyWebOptions instance with default values
func NewKubeKeyWebOptions() *KubeKeyWebOptions {
o := &KubeKeyWebOptions{
Port: defaultPort,
}
// Set the working directory to the current directory joined with "kubekey".
wd, err := os.Getwd()
if err != nil {
klog.ErrorS(err, "get current dir error")
o.Workdir = "/root/kubekey"
} else {
o.Workdir = filepath.Join(wd, "kubekey")
}
return o
}
// Flags returns a NamedFlagSets object containing command-line flags for configuring the web server
func (o *KubeKeyWebOptions) Flags() cliflag.NamedFlagSets {
fss := cliflag.NamedFlagSets{}
wfs := fss.FlagSet("web flags")
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.JSONSchema, "json-schema", o.JSONSchema, "the json schema to render web ui.")
return fss
}

View File

@ -17,20 +17,10 @@ limitations under the License.
package app
import (
"context"
"path/filepath"
"github.com/cockroachdb/errors"
kkcorev1 "github.com/kubesphere/kubekey/api/core/v1"
"github.com/spf13/cobra"
"k8s.io/client-go/rest"
ctrlclient "sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/manager/signals"
"github.com/kubesphere/kubekey/v4/cmd/kk/app/options"
_const "github.com/kubesphere/kubekey/v4/pkg/const"
"github.com/kubesphere/kubekey/v4/pkg/manager"
"github.com/kubesphere/kubekey/v4/pkg/proxy"
)
var internalCommand = make([]*cobra.Command, 0)
@ -62,50 +52,9 @@ func NewRootCommand() *cobra.Command {
cmd.AddCommand(newRunCommand())
cmd.AddCommand(newPlaybookCommand())
cmd.AddCommand(newVersionCommand())
cmd.AddCommand(newWebCommand())
// internal command
cmd.AddCommand(internalCommand...)
return cmd
}
// CommandRunE executes the main command logic for the application.
// It sets up the necessary configurations, creates the inventory and playbook
// resources, and then runs the command manager.
//
// Parameters:
// - ctx: The context for controlling the execution flow.
// - workdir: The working directory path.
// - playbook: The playbook resource to be created and managed.
// - config: The configuration resource.
// - inventory: The inventory resource to be created.
//
// Returns:
// - error: An error if any step in the process fails, otherwise nil.
func CommandRunE(ctx context.Context, workdir string, playbook *kkcorev1.Playbook, config *kkcorev1.Config, inventory *kkcorev1.Inventory) error {
restconfig := &rest.Config{}
if err := proxy.RestConfig(filepath.Join(workdir, _const.RuntimeDir), restconfig); err != nil {
return err
}
client, err := ctrlclient.New(restconfig, ctrlclient.Options{
Scheme: _const.Scheme,
})
if err != nil {
return errors.Wrap(err, "failed to get runtime-client")
}
// create inventory
if err := client.Create(ctx, inventory); err != nil {
return errors.Wrap(err, "failed to create inventory")
}
// create playbook
if err := client.Create(ctx, playbook); err != nil {
return errors.Wrap(err, "failed to create playbook")
}
return manager.NewCommandManager(manager.CommandManagerOptions{
Workdir: workdir,
Playbook: playbook,
Config: config,
Inventory: inventory,
Client: client,
}).Run(ctx)
}

47
cmd/kk/app/web.go Normal file
View File

@ -0,0 +1,47 @@
package app
import (
"path/filepath"
"github.com/spf13/cobra"
"k8s.io/client-go/rest"
ctrlclient "sigs.k8s.io/controller-runtime/pkg/client"
"github.com/kubesphere/kubekey/v4/cmd/kk/app/options"
_const "github.com/kubesphere/kubekey/v4/pkg/const"
"github.com/kubesphere/kubekey/v4/pkg/manager"
"github.com/kubesphere/kubekey/v4/pkg/proxy"
)
func newWebCommand() *cobra.Command {
o := options.NewKubeKeyWebOptions()
cmd := &cobra.Command{
Use: "web",
Short: "start a http server with web UI.",
RunE: func(cmd *cobra.Command, args []string) error {
restconfig := &rest.Config{}
if err := proxy.RestConfig(filepath.Join(o.Workdir, _const.RuntimeDir), restconfig); err != nil {
return err
}
client, err := ctrlclient.New(restconfig, ctrlclient.Options{
Scheme: _const.Scheme,
})
if err != nil {
return err
}
return manager.NewWebManager(manager.WebManagerOptions{
Workdir: o.Workdir,
Port: o.Port,
Client: client,
Config: restconfig,
}).Run(cmd.Context())
},
}
for _, f := range o.Flags().FlagSets {
cmd.Flags().AddFlagSet(f)
}
return cmd
}

9
config/file.go Normal file
View File

@ -0,0 +1,9 @@
package config
import "embed"
// Swagger embeds the swagger-ui directory containing the OpenAPI/Swagger documentation UI
// This allows serving the Swagger UI directly from the binary without needing external files
//
//go:embed swagger-ui
var Swagger embed.FS

Binary file not shown.

After

Width:  |  Height:  |  Size: 665 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 628 B

View File

@ -0,0 +1,16 @@
html {
box-sizing: border-box;
overflow: -moz-scrollbars-vertical;
overflow-y: scroll;
}
*,
*:before,
*:after {
box-sizing: inherit;
}
body {
margin: 0;
background: #fafafa;
}

View File

@ -0,0 +1,19 @@
<!-- HTML for static distribution bundle build -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Swagger UI</title>
<link rel="stylesheet" type="text/css" href="./swagger-ui.css" />
<link rel="stylesheet" type="text/css" href="index.css" />
<link rel="icon" type="image/png" href="./favicon-32x32.png" sizes="32x32" />
<link rel="icon" type="image/png" href="./favicon-16x16.png" sizes="16x16" />
</head>
<body>
<div id="swagger-ui"></div>
<script src="./swagger-ui-bundle.js" charset="UTF-8"> </script>
<script src="./swagger-ui-standalone-preset.js" charset="UTF-8"> </script>
<script src="./swagger-initializer.js" charset="UTF-8"> </script>
</body>
</html>

View File

@ -0,0 +1,79 @@
<!doctype html>
<html lang="en-US">
<head>
<title>Swagger UI: OAuth2 Redirect</title>
</head>
<body>
<script>
'use strict';
function run () {
var oauth2 = window.opener.swaggerUIRedirectOauth2;
var sentState = oauth2.state;
var redirectUrl = oauth2.redirectUrl;
var isValid, qp, arr;
if (/code|token|error/.test(window.location.hash)) {
qp = window.location.hash.substring(1).replace('?', '&');
} else {
qp = location.search.substring(1);
}
arr = qp.split("&");
arr.forEach(function (v,i,_arr) { _arr[i] = '"' + v.replace('=', '":"') + '"';});
qp = qp ? JSON.parse('{' + arr.join() + '}',
function (key, value) {
return key === "" ? value : decodeURIComponent(value);
}
) : {};
isValid = qp.state === sentState;
if ((
oauth2.auth.schema.get("flow") === "accessCode" ||
oauth2.auth.schema.get("flow") === "authorizationCode" ||
oauth2.auth.schema.get("flow") === "authorization_code"
) && !oauth2.auth.code) {
if (!isValid) {
oauth2.errCb({
authId: oauth2.auth.name,
source: "auth",
level: "warning",
message: "Authorization may be unsafe, passed state was changed in server. The passed state wasn't returned from auth server."
});
}
if (qp.code) {
delete oauth2.state;
oauth2.auth.code = qp.code;
oauth2.callback({auth: oauth2.auth, redirectUrl: redirectUrl});
} else {
let oauthErrorMsg;
if (qp.error) {
oauthErrorMsg = "["+qp.error+"]: " +
(qp.error_description ? qp.error_description+ ". " : "no accessCode received from the server. ") +
(qp.error_uri ? "More info: "+qp.error_uri : "");
}
oauth2.errCb({
authId: oauth2.auth.name,
source: "auth",
level: "error",
message: oauthErrorMsg || "[Authorization failed]: no accessCode received from the server."
});
}
} else {
oauth2.callback({auth: oauth2.auth, token: qp, isValid: isValid, redirectUrl: redirectUrl});
}
window.close();
}
if (document.readyState !== 'loading') {
run();
} else {
document.addEventListener('DOMContentLoaded', function () {
run();
});
}
</script>
</body>
</html>

View File

@ -0,0 +1,20 @@
window.onload = function() {
//<editor-fold desc="Changeable Configuration Block">
// the following lines will be replaced by docker/configurator, when it runs in a docker-container
window.ui = SwaggerUIBundle({
url: "/apidocs.json",
dom_id: '#swagger-ui',
deepLinking: true,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset
],
plugins: [
SwaggerUIBundle.plugins.DownloadUrl
],
layout: "StandaloneLayout"
});
//</editor-fold>
};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

16
go.mod
View File

@ -6,8 +6,12 @@ require (
github.com/Masterminds/sprig/v3 v3.3.0
github.com/cockroachdb/errors v1.11.3
github.com/containerd/containerd v1.7.27
github.com/emicklei/go-restful-openapi/v2 v2.11.0
github.com/emicklei/go-restful/v3 v3.12.2
github.com/fsnotify/fsnotify v1.7.0
github.com/go-git/go-git/v5 v5.11.0
github.com/go-openapi/spec v0.21.0
github.com/google/go-cmp v0.6.0
github.com/google/gops v0.3.28
github.com/kubesphere/kubekey/api v0.0.0-00010101000000-000000000000
github.com/opencontainers/image-spec v1.1.0
@ -15,7 +19,7 @@ require (
github.com/schollz/progressbar/v3 v3.14.5
github.com/spf13/cobra v1.8.1
github.com/spf13/pflag v1.0.5
github.com/stretchr/testify v1.9.0
github.com/stretchr/testify v1.10.0
golang.org/x/crypto v0.31.0
gopkg.in/yaml.v3 v3.0.1
k8s.io/api v0.31.3
@ -54,7 +58,6 @@ require (
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
github.com/cyphar/filepath-securejoin v0.2.4 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/emicklei/go-restful/v3 v3.12.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/evanphx/json-patch/v5 v5.9.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
@ -64,16 +67,15 @@ require (
github.com/go-git/go-billy/v5 v5.5.0 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/jsonreference v0.20.2 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/go-openapi/jsonpointer v0.21.1 // indirect
github.com/go-openapi/jsonreference v0.21.0 // indirect
github.com/go-openapi/swag v0.23.1 // indirect
github.com/gobuffalo/flect v1.0.3 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/cel-go v0.20.1 // indirect
github.com/google/gnostic-models v0.6.9 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/google/gofuzz v1.2.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 // indirect
@ -89,7 +91,7 @@ require (
github.com/kr/fs v0.1.0 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect

46
go.sum
View File

@ -69,8 +69,11 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a h1:mATvB/9r/3gvcejNsXKSkQ6lcIaNec2nyfOdlTBR2lU=
github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM=
github.com/emicklei/go-restful/v3 v3.12.1 h1:PJMDIM/ak7btuL8Ex0iYET9hxM3CI2sjZtzpL63nKAU=
github.com/emicklei/go-restful/v3 v3.12.1/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/emicklei/go-restful-openapi/v2 v2.11.0 h1:Ur+yGxoOH/7KRmcj/UoMFqC3VeNc9VOe+/XidumxTvk=
github.com/emicklei/go-restful-openapi/v2 v2.11.0/go.mod h1:4CTuOXHFg3jkvCpnXN+Wkw5prVUnP8hIACssJTYorWo=
github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU=
github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/evanphx/json-patch v5.7.0+incompatible h1:vgGkfT/9f8zE6tvSCe74nfpAVDQ2tG6yudJd8LBksgI=
@ -106,14 +109,20 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ=
github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg=
github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic=
github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk=
github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo=
github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=
github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4=
github.com/go-openapi/spec v0.20.9/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA=
github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY=
github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk=
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU=
github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4=
@ -179,15 +188,17 @@ github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQs
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ=
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw=
@ -202,6 +213,7 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/onsi/ginkgo/v2 v2.22.0 h1:Yed107/8DjTr0lKCNt7Dn8yQ6ybuDRQoMGrNFKzMfHg=
github.com/onsi/ginkgo/v2 v2.22.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
github.com/onsi/gomega v1.36.0 h1:Pb12RlruUtj4XUuPUqeEWc6j5DkVVVA49Uf6YLfC95Y=
@ -264,12 +276,13 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75 h1:6fotK7otjonDflCTK0BCfls4SPy3NcCVb5dqqmbRknE=
github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75/go.mod h1:KO6IkyS8Y3j8OdNO85qEYBsRPuteD+YciPomcXdrMnk=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
@ -423,7 +436,9 @@ google.golang.org/grpc v1.65.1/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjr
google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io=
google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4=
@ -440,6 +455,7 @@ gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -31,6 +31,11 @@ var (
CapkkVolumeProject = Environment{env: "CAPKK_VOLUME_PROJECT"}
// CapkkVolumeWorkdir specifies the working directory for capkk playbook
CapkkVolumeWorkdir = Environment{env: "CAPKK_VOLUME_WORKDIR"}
// TaskNameGatherFacts the task name for gather_facts in playbook
TaskNameGatherFacts = Environment{env: "TASK_GATHER_FACTS", def: "gather_facts"}
// TaskNameGetArch the task name for get_arch in playbook, used to get host architecture
TaskNameGetArch = Environment{env: "", def: "get_arch"}
)
// Getenv retrieves the value of the environment variable. If the environment variable is not set,

45
pkg/const/web.go Normal file
View File

@ -0,0 +1,45 @@
package _const
import "k8s.io/apimachinery/pkg/runtime"
const (
// APIPath defines the base path for API endpoints in the KubeKey API server.
// This path is used as the prefix for all API routes, including those for
// managing inventories, playbooks, and other KubeKey resources.
APIPath = "/kapis/"
// KubeKeyTag is the tag used for KubeKey related resources
// This tag is used to identify and categorize KubeKey-specific resources
// in the system, making it easier to filter and manage them
KubeKeyTag = "kubekey"
// OpenAPITag is the tag used for OpenAPI documentation
// This tag helps organize and identify OpenAPI/Swagger documentation
// related to the KubeKey API endpoints
OpenAPITag = "api"
// StatusOK represents a successful operation status
// Used to indicate that an API operation completed successfully
// without any errors or issues
StatusOK = "ok"
)
// SUCCESS is a predefined successful result
// This is a convenience variable that provides a standard success response
// for API operations that don't need to return specific data
var SUCCESS = Result{Message: "success"}
// Result represents a generic API response with a message
// This type is used for simple API responses that only need to convey
// a status message, such as success or error notifications
type Result struct {
Message string `description:"error message" json:"message"`
}
// ListResult represents a paginated list response containing items and total count
// This type is used for API responses that return a list of items with pagination
// support, allowing clients to handle large datasets efficiently
type ListResult struct {
Items []runtime.Object `json:"items"` // The list of items in the current page
TotalItems int `json:"totalItems"` // The total number of items across all pages
}

View File

@ -18,7 +18,11 @@ package executor
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"time"
"github.com/cockroachdb/errors"
kkcorev1 "github.com/kubesphere/kubekey/api/core/v1"
@ -28,6 +32,7 @@ import (
"k8s.io/klog/v2"
ctrlclient "sigs.k8s.io/controller-runtime/pkg/client"
_const "github.com/kubesphere/kubekey/v4/pkg/const"
"github.com/kubesphere/kubekey/v4/pkg/converter"
"github.com/kubesphere/kubekey/v4/pkg/project"
"github.com/kubesphere/kubekey/v4/pkg/variable"
@ -60,7 +65,21 @@ type playbookExecutor struct {
}
// Exec playbook. covert playbook to block and executor it.
func (e playbookExecutor) Exec(ctx context.Context) error {
func (e playbookExecutor) Exec(ctx context.Context) (retErr error) {
defer e.syncStatus(ctx, retErr)
fmt.Fprint(e.logOutput, `
_ __ _ _ __
| | / / | | | | / /
| |/ / _ _| |__ ___| |/ / ___ _ _
| \| | | | '_ \ / _ \ \ / _ \ | | |
| |\ \ |_| | |_) | __/ |\ \ __/ |_| |
\_| \_/\__,_|_.__/ \___\_| \_/\___|\__, |
__/ |
|___/
`)
fmt.Fprintf(e.logOutput, "%s [Playbook %s] start\n", time.Now().Format(time.TimeOnly+" MST"), ctrlclient.ObjectKeyFromObject(e.playbook))
klog.V(5).InfoS("deal project", "playbook", ctrlclient.ObjectKeyFromObject(e.playbook))
pj, err := project.New(ctx, *e.playbook, true)
if err != nil {
@ -103,6 +122,36 @@ func (e playbookExecutor) Exec(ctx context.Context) error {
return nil
}
func (e playbookExecutor) syncStatus(ctx context.Context, err error) {
cp := e.playbook.DeepCopy()
if err != nil {
e.playbook.Status.Phase = kkcorev1.PlaybookPhaseFailed
e.playbook.Status.FailureReason = kkcorev1.PlaybookFailedReasonTaskFailed
e.playbook.Status.FailureMessage = err.Error()
} else {
e.playbook.Status.Phase = kkcorev1.PlaybookPhaseSucceeded
}
fmt.Fprintf(e.logOutput, "%s [Playbook %s] finish. total: %v,success: %v,ignored: %v,failed: %v\n", time.Now().Format(time.TimeOnly+" MST"), ctrlclient.ObjectKeyFromObject(e.playbook),
e.playbook.Status.TaskResult.Total, e.playbook.Status.TaskResult.Success, e.playbook.Status.TaskResult.Ignored, e.playbook.Status.TaskResult.Failed)
// update playbook status
if err := e.client.Status().Patch(ctx, e.playbook, ctrlclient.MergeFrom(cp)); err != nil {
klog.ErrorS(err, "update playbook error", "playbook", ctrlclient.ObjectKeyFromObject(e.playbook))
}
go func() {
if !e.playbook.Spec.Debug && e.playbook.Status.Phase == kkcorev1.PlaybookPhaseSucceeded {
<-ctx.Done()
fmt.Fprintf(e.logOutput, "%s [Playbook %s] clean runtime directory\n", time.Now().Format(time.TimeOnly+" MST"), ctrlclient.ObjectKeyFromObject(e.playbook))
// clean runtime directory
if err := os.RemoveAll(filepath.Join(_const.GetWorkdirFromConfig(e.playbook.Spec.Config), _const.RuntimeDir)); err != nil {
klog.ErrorS(err, "clean runtime directory error", "playbook", ctrlclient.ObjectKeyFromObject(e.playbook), "runtime_dir", filepath.Join(_const.GetWorkdirFromConfig(e.playbook.Spec.Config), _const.RuntimeDir))
}
}
}()
}
// execBatchHosts executor block in play order by: "pre_tasks" > "roles" > "tasks" > "post_tasks"
func (e playbookExecutor) execBatchHosts(ctx context.Context, play kkprojectv1.Play, batchHosts [][]string) error {
// generate and execute task.

View File

@ -18,22 +18,15 @@ package manager
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"time"
kkcorev1 "github.com/kubesphere/kubekey/api/core/v1"
"k8s.io/klog/v2"
ctrlclient "sigs.k8s.io/controller-runtime/pkg/client"
_const "github.com/kubesphere/kubekey/v4/pkg/const"
"github.com/kubesphere/kubekey/v4/pkg/executor"
)
type commandManager struct {
workdir string
*kkcorev1.Playbook
*kkcorev1.Inventory
@ -44,48 +37,5 @@ type commandManager struct {
// Run command Manager. print log and run playbook executor.
func (m *commandManager) Run(ctx context.Context) error {
fmt.Fprint(m.logOutput, `
_ __ _ _ __
| | / / | | | | / /
| |/ / _ _| |__ ___| |/ / ___ _ _
| \| | | | '_ \ / _ \ \ / _ \ | | |
| |\ \ |_| | |_) | __/ |\ \ __/ |_| |
\_| \_/\__,_|_.__/ \___\_| \_/\___|\__, |
__/ |
|___/
`)
fmt.Fprintf(m.logOutput, "%s [Playbook %s] start\n", time.Now().Format(time.TimeOnly+" MST"), ctrlclient.ObjectKeyFromObject(m.Playbook))
cp := m.Playbook.DeepCopy()
defer func() {
fmt.Fprintf(m.logOutput, "%s [Playbook %s] finish. total: %v,success: %v,ignored: %v,failed: %v\n", time.Now().Format(time.TimeOnly+" MST"), ctrlclient.ObjectKeyFromObject(m.Playbook),
m.Playbook.Status.TaskResult.Total, m.Playbook.Status.TaskResult.Success, m.Playbook.Status.TaskResult.Ignored, m.Playbook.Status.TaskResult.Failed)
go func() {
if !m.Playbook.Spec.Debug && m.Playbook.Status.Phase == kkcorev1.PlaybookPhaseSucceeded {
<-ctx.Done()
fmt.Fprintf(m.logOutput, "%s [Playbook %s] clean runtime directory\n", time.Now().Format(time.TimeOnly+" MST"), ctrlclient.ObjectKeyFromObject(m.Playbook))
// clean runtime directory
if err := os.RemoveAll(filepath.Join(m.workdir, _const.RuntimeDir)); err != nil {
klog.ErrorS(err, "clean runtime directory error", "playbook", ctrlclient.ObjectKeyFromObject(m.Playbook), "runtime_dir", filepath.Join(m.workdir, _const.RuntimeDir))
}
}
}()
// update playbook status
if err := m.Client.Status().Patch(ctx, m.Playbook, ctrlclient.MergeFrom(cp)); err != nil {
klog.ErrorS(err, "update playbook error", "playbook", ctrlclient.ObjectKeyFromObject(m.Playbook))
}
}()
if err := executor.NewPlaybookExecutor(ctx, m.Client, m.Playbook, m.logOutput).Exec(ctx); err != nil {
klog.ErrorS(err, "executor tasks error", "playbook", ctrlclient.ObjectKeyFromObject(m.Playbook))
m.Playbook.Status.Phase = kkcorev1.PlaybookPhaseFailed
m.Playbook.Status.FailureReason = kkcorev1.PlaybookFailedReasonTaskFailed
m.Playbook.Status.FailureMessage = err.Error()
return err
}
m.Playbook.Status.Phase = kkcorev1.PlaybookPhaseSucceeded
return nil
return executor.NewPlaybookExecutor(ctx, m.Client, m.Playbook, m.logOutput).Exec(ctx)
}

View File

@ -21,20 +21,20 @@ import (
"os"
kkcorev1 "github.com/kubesphere/kubekey/api/core/v1"
"k8s.io/client-go/rest"
ctrlclient "sigs.k8s.io/controller-runtime/pkg/client"
"github.com/kubesphere/kubekey/v4/cmd/controller-manager/app/options"
)
// Manager shared dependencies such as Addr and , and provides them to Runnable.
// Manager defines the interface for different types of managers that can run operations
type Manager interface {
// Run the driver
// Run executes the manager's main functionality with the given context
Run(ctx context.Context) error
}
// CommandManagerOptions for NewCommandManager
// CommandManagerOptions contains the configuration options for creating a new command manager
type CommandManagerOptions struct {
Workdir string
*kkcorev1.Playbook
*kkcorev1.Config
*kkcorev1.Inventory
@ -42,10 +42,9 @@ type CommandManagerOptions struct {
ctrlclient.Client
}
// NewCommandManager return a new commandManager
// NewCommandManager creates and returns a new command manager instance with the provided options
func NewCommandManager(o CommandManagerOptions) Manager {
return &commandManager{
workdir: o.Workdir,
Playbook: o.Playbook,
Inventory: o.Inventory,
Client: o.Client,
@ -53,9 +52,27 @@ func NewCommandManager(o CommandManagerOptions) Manager {
}
}
// NewControllerManager return a new controllerManager
// NewControllerManager creates and returns a new controller manager instance with the provided options
func NewControllerManager(o *options.ControllerManagerServerOptions) Manager {
return &controllerManager{
ControllerManagerServerOptions: o,
}
}
// WebManagerOptions contains the configuration options for creating a new web manager
type WebManagerOptions struct {
Workdir string
Port int
ctrlclient.Client
*rest.Config
}
// NewWebManager creates and returns a new web manager instance with the provided options
func NewWebManager(o WebManagerOptions) Manager {
return &webManager{
workdir: o.Workdir,
port: o.Port,
Client: o.Client,
Config: o.Config,
}
}

116
pkg/manager/web_manager.go Normal file
View File

@ -0,0 +1,116 @@
package manager
import (
"bytes"
"context"
"fmt"
"net"
"net/http"
"runtime"
"time"
"github.com/emicklei/go-restful/v3"
"k8s.io/client-go/rest"
"k8s.io/klog/v2"
ctrlclient "sigs.k8s.io/controller-runtime/pkg/client"
"github.com/kubesphere/kubekey/v4/pkg/web"
)
// webManager handles the web server functionality for the application
type webManager struct {
port int
workdir string
ctrlclient.Client
*rest.Config
}
// Run starts the web server and handles incoming requests
func (m webManager) Run(ctx context.Context) error {
container := restful.DefaultContainer
container.Filter(logRequestAndResponse)
container.RecoverHandler(func(panicReason any, httpWriter http.ResponseWriter) {
logStackOnRecover(panicReason, httpWriter)
})
container.Add(web.NewWebService(ctx, m.workdir, m.Client, m.Config)).
// openapi
Add(web.NewSwaggerUIService()).
Add(web.NewAPIService(container.RegisteredWebServices()))
server := &http.Server{
Addr: fmt.Sprintf(":%d", m.port),
Handler: container,
ReadHeaderTimeout: 10 * time.Second, // Prevent Slowloris attack by timing out slow headers
}
shutdownCtx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
<-ctx.Done()
_ = server.Shutdown(shutdownCtx)
}()
return server.ListenAndServe()
}
// logStackOnRecover handles panic recovery and logs the stack trace
func logStackOnRecover(panicReason any, w http.ResponseWriter) {
var buf bytes.Buffer
buf.WriteString(fmt.Sprintf("recover from panic: %v\n", panicReason))
for i := 2; ; i++ {
_, file, line, ok := runtime.Caller(i)
if !ok {
break
}
buf.WriteString(fmt.Sprintf(" %s:%d\n", file, line))
}
klog.Errorln(buf.String())
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("Internal Server Error"))
}
// logRequestAndResponse logs HTTP request and response details
func logRequestAndResponse(req *restful.Request, resp *restful.Response, chain *restful.FilterChain) {
start := time.Now()
chain.ProcessFilter(req, resp)
// Always log error response
logWithVerbose := klog.V(4)
if resp.StatusCode() > http.StatusBadRequest {
logWithVerbose = klog.V(0)
}
logWithVerbose.Infof("%s - \"%s %s %s\" %d %d %dms",
remoteIP(req.Request),
req.Request.Method,
req.Request.URL,
req.Request.Proto,
resp.StatusCode(),
resp.ContentLength(),
time.Since(start)/time.Millisecond,
)
}
// remoteIP extracts the client IP address from the request, handling various proxy headers
func remoteIP(req *http.Request) string {
remoteAddr := req.RemoteAddr
if ip := req.Header.Get("X-Client-Ip"); ip != "" {
remoteAddr = ip
} else if ip := req.Header.Get("X-Real-IP"); ip != "" {
remoteAddr = ip
} else if ip = req.Header.Get("X-Forwarded-For"); ip != "" {
remoteAddr = ip
} else {
remoteAddr, _, _ = net.SplitHostPort(remoteAddr)
}
if remoteAddr == "::1" {
remoteAddr = "127.0.0.1"
}
return remoteAddr
}

View File

@ -29,15 +29,16 @@ import (
"github.com/kubesphere/kubekey/v4/pkg/variable"
)
// message for stdout
const (
// StdoutSuccess message for common module
// StdoutSuccess is the standard message indicating a successful module execution.
StdoutSuccess = "success"
// StdoutFailed is the standard message indicating a failed module execution.
StdoutFailed = "failed"
// StdoutSkip is the standard message indicating a skipped module execution.
StdoutSkip = "skip"
// StdoutTrue for bool module
// StdoutTrue is the standard message indicating a boolean true result (used in bool/assert modules).
StdoutTrue = "True"
// StdoutFalse for bool module
// StdoutFalse is the standard message indicating a boolean false result (used in bool/assert modules).
StdoutFalse = "False"
)

View File

@ -32,17 +32,17 @@ func ModuleSetup(ctx context.Context, options ExecOptions) (string, string) {
// get connector
conn, err := getConnector(ctx, options.Host, options.Variable)
if err != nil {
return "", fmt.Sprintf("failed to connector of %q error: %v", options.Host, err)
return StdoutFailed, fmt.Sprintf("failed to connector of %q error: %v", options.Host, err)
}
defer conn.Close(ctx)
if gf, ok := conn.(connector.GatherFacts); ok {
remoteInfo, err := gf.HostInfo(ctx)
if err != nil {
return "", err.Error()
return StdoutFailed, err.Error()
}
if err := options.Variable.Merge(variable.MergeRemoteVariable(remoteInfo, options.Host)); err != nil {
return "", err.Error()
return StdoutFailed, err.Error()
}
}

View File

@ -169,11 +169,20 @@ func GetAttrs(obj runtime.Object) (labels.Set, fields.Set, error) {
// ToSelectableFields returns a field set that represents the object
func ToSelectableFields(task *kkcorev1alpha1.Task) fields.Set {
// The purpose of allocation with a given number of elements is to reduce
// amount of allocations needed to create the fields.Set. If you add any
// field here or the number of object-meta related fields changes, this should
// be adjusted.
return apigeneric.AddObjectMetaFieldsSet(apigeneric.ObjectMetaFieldsSet(&task.ObjectMeta, true), &task.ObjectMeta, true)
objectMetaFieldsSet := apigeneric.ObjectMetaFieldsSet(&task.ObjectMeta, true)
taskSpecificFieldsSet := fields.Set{
"spec.name": task.Spec.Name,
}
for _, reference := range task.OwnerReferences {
if reference.Kind == playbookKind {
taskSpecificFieldsSet["playbook.name"] = reference.Name
taskSpecificFieldsSet["playbook.uid"] = string(reference.UID)
break
}
}
return apigeneric.MergeFieldsSets(taskSpecificFieldsSet, objectMetaFieldsSet)
}
// NameTriggerFunc returns value metadata.namespace of given object.

93
pkg/web/api/helper.go Normal file
View File

@ -0,0 +1,93 @@
/*
Copyright 2020 The KubeSphere Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package api
import (
"errors"
"net/http"
"runtime"
"strings"
"github.com/emicklei/go-restful/v3"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/klog/v2"
)
// sanitizer is a string replacer that converts HTML special characters to their escaped equivalents
// to prevent XSS attacks in error messages. It replaces &, <, and > with their HTML entities.
var sanitizer = strings.NewReplacer(`&`, "&amp;", `<`, "&lt;", `>`, "&gt;")
// HandleInternalError handles internal server errors (500) by logging the error and sending an appropriate response
func HandleInternalError(response *restful.Response, req *restful.Request, err error) {
handle(http.StatusInternalServerError, response, req, err)
}
// HandleBadRequest handles bad request errors (400) by logging the error and sending an appropriate response
func HandleBadRequest(response *restful.Response, req *restful.Request, err error) {
handle(http.StatusBadRequest, response, req, err)
}
// HandleNotFound handles not found errors (404) by logging the error and sending an appropriate response
func HandleNotFound(response *restful.Response, req *restful.Request, err error) {
handle(http.StatusNotFound, response, req, err)
}
// HandleForbidden handles forbidden errors (403) by logging the error and sending an appropriate response
func HandleForbidden(response *restful.Response, req *restful.Request, err error) {
handle(http.StatusForbidden, response, req, err)
}
// HandleUnauthorized handles unauthorized errors (401) by logging the error and sending an appropriate response
func HandleUnauthorized(response *restful.Response, req *restful.Request, err error) {
handle(http.StatusUnauthorized, response, req, err)
}
// HandleTooManyRequests handles rate limiting errors (429) by logging the error and sending an appropriate response
func HandleTooManyRequests(response *restful.Response, req *restful.Request, err error) {
handle(http.StatusTooManyRequests, response, req, err)
}
// HandleConflict handles conflict errors (409) by logging the error and sending an appropriate response
func HandleConflict(response *restful.Response, req *restful.Request, err error) {
handle(http.StatusConflict, response, req, err)
}
// HandleError handles various types of errors by determining the appropriate status code
// and sending a response with the corresponding HTTP status code
func HandleError(response *restful.Response, req *restful.Request, err error) {
var statusCode int
var apiStatus apierrors.APIStatus
var serviceError restful.ServiceError
switch {
case errors.As(err, &apiStatus):
statusCode = int(apiStatus.Status().Code)
case errors.As(err, &serviceError):
statusCode = serviceError.Code
default:
statusCode = http.StatusInternalServerError
}
handle(statusCode, response, req, err)
}
// handle is an internal helper function that logs the error and sends an HTTP error response
// with the sanitized error message and specified status code
func handle(statusCode int, response *restful.Response, _ *restful.Request, err error) {
_, fn, line, _ := runtime.Caller(2)
klog.Errorf("%s:%d %v", fn, line, err)
http.Error(response, sanitizer.Replace(err.Error()), statusCode)
}

48
pkg/web/api/result.go Normal file
View File

@ -0,0 +1,48 @@
/*
Copyright 2019 The KubeSphere Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package api
// SUCCESS represents a successful operation result with a default success message
var SUCCESS = Result{Message: "success"}
// Result represents a basic API response with a message field
type Result struct {
Message string `description:"error message" json:"message"`
}
// ListResult represents a paginated list response containing items and total count
type ListResult[T any] struct {
Items []T `json:"items"`
TotalItems int `json:"totalItems"`
}
// InventoryHostTable represents a host entry in an inventory with its configuration details
// It includes network information, SSH credentials, and group membership
type InventoryHostTable struct {
Name string `json:"name"` // Hostname of the inventory host
Status string `json:"status"` // Current status of the host
InternalIPV4 string `json:"internalIPV4"` // IPv4 address of the host
InternalIPV6 string `json:"internalIPV6"` // IPv6 address of the host
SSHHost string `json:"sshHost"` // SSH hostname for connection
SSHPort string `json:"sshPort"` // SSH port for connection
SSHUser string `json:"sshUser"` // SSH username for authentication
SSHPassword string `json:"sshPassword"` // SSH password for authentication
SSHPrivateKey string `json:"sshPrivateKey"` // SSH private key for authentication
Vars map[string]any `json:"vars"` // Additional host variables
Groups []string `json:"groups"` // Groups the host belongs to
Arch string `json:"arch"` // Architecture of the host
}

459
pkg/web/handler.go Normal file
View File

@ -0,0 +1,459 @@
package web
import (
"bufio"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"reflect"
"slices"
"time"
"github.com/emicklei/go-restful/v3"
kkcorev1 "github.com/kubesphere/kubekey/api/core/v1"
kkcorev1alpha1 "github.com/kubesphere/kubekey/api/core/v1alpha1"
"k8s.io/apimachinery/pkg/api/meta"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/fields"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/rest"
"k8s.io/klog/v2"
ctrlclient "sigs.k8s.io/controller-runtime/pkg/client"
_const "github.com/kubesphere/kubekey/v4/pkg/const"
"github.com/kubesphere/kubekey/v4/pkg/executor"
"github.com/kubesphere/kubekey/v4/pkg/variable"
"github.com/kubesphere/kubekey/v4/pkg/web/api"
"github.com/kubesphere/kubekey/v4/pkg/web/query"
)
// newHandler creates a new handler instance with the given workdir, client and config
// workdir: Base directory for storing work files
// client: Kubernetes client for API operations
// config: Kubernetes REST client configuration
func newHandler(workdir string, client ctrlclient.Client, config *rest.Config) *handler {
return &handler{workdir: workdir, client: client, restconfig: config}
}
// handler implements HTTP handlers for managing inventories and playbooks
// It provides methods for CRUD operations on inventories and playbooks
type handler struct {
workdir string // Base directory for storing work files
restconfig *rest.Config // Kubernetes REST client configuration
client ctrlclient.Client // Kubernetes client for API operations
}
// createInventory creates a new inventory resource
// It reads the inventory from the request body and creates it in the Kubernetes cluster
func (h handler) createInventory(request *restful.Request, response *restful.Response) {
inventory := &kkcorev1.Inventory{}
if err := request.ReadEntity(inventory); err != nil {
api.HandleBadRequest(response, request, err)
return
}
if err := h.client.Create(request.Request.Context(), inventory); err != nil {
api.HandleBadRequest(response, request, err)
return
}
_ = response.WriteEntity(inventory)
}
// patchInventory patches an existing inventory resource
// It reads the patch data from the request body and applies it to the specified inventory
func (h handler) patchInventory(request *restful.Request, response *restful.Response) {
namespace := request.PathParameter("namespace")
name := request.PathParameter("inventory")
data, err := io.ReadAll(request.Request.Body)
if err != nil {
api.HandleBadRequest(response, request, err)
return
}
patchType := request.HeaderParameter("Content-Type")
// get old inventory
inventory := &kkcorev1.Inventory{}
if err := h.client.Get(request.Request.Context(), ctrlclient.ObjectKey{Namespace: namespace, Name: name}, inventory); err != nil {
api.HandleBadRequest(response, request, err)
return
}
if err := h.client.Patch(request.Request.Context(), inventory, ctrlclient.RawPatch(types.PatchType(patchType), data)); err != nil {
api.HandleBadRequest(response, request, err)
return
}
_ = response.WriteEntity(inventory)
}
// listInventories lists all inventory resources with optional filtering and sorting
// It supports field selectors and label selectors for filtering the results
func (h handler) listInventories(request *restful.Request, response *restful.Response) {
queryParam := query.ParseQueryParameter(request)
var fieldselector fields.Selector
if v, ok := queryParam.Filters[query.ParameterFieldSelector]; ok {
fs, err := fields.ParseSelector(string(v))
if err != nil {
api.HandleError(response, request, err)
return
}
fieldselector = fs
}
namespace := request.PathParameter("namespace")
inventoryList := &kkcorev1.InventoryList{}
err := h.client.List(request.Request.Context(), inventoryList, &ctrlclient.ListOptions{Namespace: namespace, LabelSelector: queryParam.Selector(), FieldSelector: fieldselector})
if err != nil {
api.HandleError(response, request, err)
return
}
results := query.DefaultList(inventoryList.Items, queryParam, func(left, right kkcorev1.Inventory, sortBy query.Field) bool {
leftMeta, err := meta.Accessor(left)
if err != nil {
return false
}
rightMeta, err := meta.Accessor(right)
if err != nil {
return false
}
return query.DefaultObjectMetaCompare(leftMeta, rightMeta, sortBy)
}, func(o kkcorev1.Inventory, filter query.Filter) bool {
// skip fieldselector
if filter.Field == query.ParameterFieldSelector {
return true
}
objectMeta, err := meta.Accessor(o)
if err != nil {
return false
}
return query.DefaultObjectMetaFilter(objectMeta, filter)
})
_ = response.WriteEntity(results)
}
// getInventory retrieves a specific inventory resource
// It returns the inventory with the specified name in the given namespace
func (h handler) inventoryInfo(request *restful.Request, response *restful.Response) {
namespace := request.PathParameter("namespace")
name := request.PathParameter("inventory")
inventory := &kkcorev1.Inventory{}
err := h.client.Get(request.Request.Context(), ctrlclient.ObjectKey{Namespace: namespace, Name: name}, inventory)
if err != nil {
api.HandleError(response, request, err)
return
}
_ = response.WriteEntity(inventory)
}
// listInventoryHosts lists all hosts in an inventory with their details
// It includes information about SSH configuration, IP addresses, and group membership
func (h handler) listInventoryHosts(request *restful.Request, response *restful.Response) {
queryParam := query.ParseQueryParameter(request)
namespace := request.PathParameter("namespace")
name := request.PathParameter("inventory")
inventory := &kkcorev1.Inventory{}
err := h.client.Get(request.Request.Context(), ctrlclient.ObjectKey{Namespace: namespace, Name: name}, inventory)
if err != nil {
api.HandleError(response, request, err)
return
}
taskList := &kkcorev1alpha1.TaskList{}
_ = h.client.List(request.Request.Context(), taskList, ctrlclient.InNamespace(namespace), ctrlclient.MatchingFields{
"playbook.name": inventory.Annotations[kkcorev1.HostCheckPlaybookAnnotation],
})
buildHostItem := func(hostname string, raw runtime.RawExtension) api.InventoryHostTable {
vars := variable.Extension2Variables(raw)
internalIPV4, _ := variable.StringVar(nil, vars, _const.VariableIPv4)
internalIPV6, _ := variable.StringVar(nil, vars, _const.VariableIPv6)
sshHost, _ := variable.StringVar(nil, vars, _const.VariableConnector, _const.VariableConnectorHost)
sshPort, _ := variable.StringVar(nil, vars, _const.VariableConnector, _const.VariableConnectorPort)
sshUser, _ := variable.StringVar(nil, vars, _const.VariableConnector, _const.VariableConnectorUser)
sshPassword, _ := variable.StringVar(nil, vars, _const.VariableConnector, _const.VariableConnectorPassword)
sshPrivateKey, _ := variable.StringVar(nil, vars, _const.VariableConnector, _const.VariableConnectorPrivateKey)
delete(vars, _const.VariableIPv4)
delete(vars, _const.VariableIPv6)
delete(vars, _const.VariableConnector)
return api.InventoryHostTable{
Name: hostname,
InternalIPV4: internalIPV4,
InternalIPV6: internalIPV6,
SSHHost: sshHost,
SSHPort: sshPort,
SSHUser: sshUser,
SSHPassword: sshPassword,
SSHPrivateKey: sshPrivateKey,
Vars: vars,
Groups: []string{},
}
}
groups := variable.ConvertGroup(*inventory)
fillGroups := func(item *api.InventoryHostTable) {
for groupName, hosts := range groups {
if groupName == _const.VariableGroupsAll || groupName == _const.VariableUnGrouped {
continue
}
if slices.Contains(hosts, item.Name) {
item.Groups = append(item.Groups, groupName)
}
}
}
fillTaskInfo := func(item *api.InventoryHostTable) {
for _, task := range taskList.Items {
switch task.Name {
case _const.Getenv(_const.TaskNameGatherFacts):
for _, result := range task.Status.HostResults {
if result.Host == item.Name {
item.Status = result.Stdout
break
}
}
case _const.Getenv(_const.TaskNameGetArch):
for _, result := range task.Status.HostResults {
if result.Host == item.Name {
item.Arch = result.Stdout
break
}
}
}
}
}
less := func(left, right api.InventoryHostTable, sortBy query.Field) bool {
leftVal := left.Name
if val := reflect.ValueOf(left).FieldByName(string(sortBy)); val.Kind() == reflect.String {
leftVal = val.String()
}
rightVal := right.Name
if val := reflect.ValueOf(right).FieldByName(string(sortBy)); val.Kind() == reflect.String {
rightVal = val.String()
}
return leftVal > rightVal
}
filter := func(o api.InventoryHostTable, f query.Filter) bool {
if f.Field == query.ParameterFieldSelector {
return true
}
objectMeta, err := meta.Accessor(o)
if err != nil {
return false
}
return query.DefaultObjectMetaFilter(objectMeta, f)
}
hostTable := make([]api.InventoryHostTable, 0)
for hostname, raw := range inventory.Spec.Hosts {
item := buildHostItem(hostname, raw)
fillGroups(&item)
fillTaskInfo(&item)
hostTable = append(hostTable, item)
}
results := query.DefaultList(hostTable, queryParam, less, filter)
_ = response.WriteEntity(results)
}
// createPlaybook handles the creation of a new playbook resource
func (h handler) createPlaybook(request *restful.Request, response *restful.Response) {
playbook := &kkcorev1.Playbook{}
if err := request.ReadEntity(playbook); err != nil {
api.HandleBadRequest(response, request, err)
return
}
// set workdir to playbook
if err := unstructured.SetNestedField(playbook.Spec.Config.Value(), h.workdir, _const.Workdir); err != nil {
api.HandleBadRequest(response, request, err)
return
}
if err := h.client.Create(request.Request.Context(), playbook); err != nil {
api.HandleBadRequest(response, request, err)
return
}
go func() {
// create playbook log
filename := filepath.Join(_const.GetWorkdirFromConfig(playbook.Spec.Config), _const.RuntimeDir, kkcorev1.SchemeGroupVersion.Group, kkcorev1.SchemeGroupVersion.Version, "playbooks", playbook.Namespace, playbook.Name, playbook.Name+".log")
if _, err := os.Stat(filepath.Dir(filename)); err != nil {
if !os.IsNotExist(err) {
api.HandleBadRequest(response, request, err)
return
}
// if dir is not exist, create it.
if err := os.MkdirAll(filepath.Dir(filename), os.ModePerm); err != nil {
api.HandleBadRequest(response, request, err)
return
}
}
file, err := os.Create(filename)
if err != nil {
klog.ErrorS(err, "failed to open file", "file", filename)
return
}
defer file.Close()
if err := executor.NewPlaybookExecutor(request.Request.Context(), h.client, playbook, file).Exec(request.Request.Context()); err != nil {
klog.ErrorS(err, "failed to exec playbook", "playbook", playbook.Name)
}
}()
// for web ui. it not run in kubernetes. executor playbook right now
_ = response.WriteEntity(playbook)
}
// listPlaybooks handles listing playbook resources with filtering and pagination
func (h handler) listPlaybooks(request *restful.Request, response *restful.Response) {
queryParam := query.ParseQueryParameter(request)
var fieldselector fields.Selector
if v, ok := queryParam.Filters[query.ParameterFieldSelector]; ok {
fs, err := fields.ParseSelector(string(v))
if err != nil {
api.HandleError(response, request, err)
return
}
fieldselector = fs
}
namespace := request.PathParameter("namespace")
playbookList := &kkcorev1.PlaybookList{}
err := h.client.List(request.Request.Context(), playbookList, &ctrlclient.ListOptions{Namespace: namespace, LabelSelector: queryParam.Selector(), FieldSelector: fieldselector})
if err != nil {
api.HandleError(response, request, err)
return
}
results := query.DefaultList(playbookList.Items, queryParam, func(left, right kkcorev1.Playbook, sortBy query.Field) bool {
leftMeta, err := meta.Accessor(left)
if err != nil {
return false
}
rightMeta, err := meta.Accessor(right)
if err != nil {
return false
}
return query.DefaultObjectMetaCompare(leftMeta, rightMeta, sortBy)
}, func(o kkcorev1.Playbook, filter query.Filter) bool {
// skip fieldselector
if filter.Field == query.ParameterFieldSelector {
return true
}
objectMeta, err := meta.Accessor(o)
if err != nil {
return false
}
return query.DefaultObjectMetaFilter(objectMeta, filter)
})
_ = response.WriteEntity(results)
}
// playbookInfo handles retrieving a single playbook or watching for changes
func (h handler) playbookInfo(request *restful.Request, response *restful.Response) {
namespace := request.PathParameter("namespace")
name := request.PathParameter("playbook")
watch := request.QueryParameter("watch")
playbook := &kkcorev1.Playbook{}
if watch == "true" {
h.restconfig.GroupVersion = &kkcorev1.SchemeGroupVersion
client, err := rest.RESTClientFor(h.restconfig)
if err != nil {
api.HandleError(response, request, err)
return
}
watchInterface, err := client.Get().Namespace(namespace).Resource("playbooks").Name(name).Param("watch", "true").Watch(request.Request.Context())
if err != nil {
api.HandleError(response, request, err)
return
}
defer watchInterface.Stop()
response.AddHeader("Content-Type", "application/json")
flusher, ok := response.ResponseWriter.(http.Flusher)
if !ok {
http.Error(response.ResponseWriter, "Streaming unsupported", http.StatusInternalServerError)
return
}
encoder := json.NewEncoder(response.ResponseWriter)
for event := range watchInterface.ResultChan() {
if err := encoder.Encode(event.Object); err != nil {
break
}
flusher.Flush()
}
return
}
err := h.client.Get(request.Request.Context(), ctrlclient.ObjectKey{Namespace: namespace, Name: name}, playbook)
if err != nil {
api.HandleError(response, request, err)
return
}
_ = response.WriteEntity(playbook)
}
// logPlaybook handles streaming the log file for a playbook
func (h handler) logPlaybook(request *restful.Request, response *restful.Response) {
namespace := request.PathParameter("namespace")
name := request.PathParameter("playbook")
playbook := &kkcorev1.Playbook{}
err := h.client.Get(request.Request.Context(), ctrlclient.ObjectKey{Namespace: namespace, Name: name}, playbook)
if err != nil {
api.HandleError(response, request, err)
return
}
filename := filepath.Join(_const.GetWorkdirFromConfig(playbook.Spec.Config), _const.RuntimeDir, kkcorev1.SchemeGroupVersion.Group, kkcorev1.SchemeGroupVersion.Version, "playbooks", playbook.Namespace, playbook.Name, playbook.Name+".log")
file, err := os.Open(filename)
if err != nil {
api.HandleError(response, request, err)
return
}
defer file.Close()
response.AddHeader("Content-Type", "text/plain; charset=utf-8")
writer := response.ResponseWriter
flusher, ok := writer.(http.Flusher)
if !ok {
http.Error(writer, "Streaming unsupported", http.StatusInternalServerError)
return
}
reader := bufio.NewReader(file)
for {
line, err := reader.ReadString('\n')
if err != nil {
if err == io.EOF {
time.Sleep(500 * time.Millisecond)
continue
}
break
}
fmt.Fprint(writer, line)
flusher.Flush()
}
}

77
pkg/web/openapi.go Normal file
View File

@ -0,0 +1,77 @@
package web
import (
"io/fs"
"net/http"
restfulspec "github.com/emicklei/go-restful-openapi/v2"
"github.com/emicklei/go-restful/v3"
"github.com/go-openapi/spec"
"k8s.io/klog/v2"
"github.com/kubesphere/kubekey/v4/config"
_const "github.com/kubesphere/kubekey/v4/pkg/const"
"github.com/kubesphere/kubekey/v4/version"
)
// 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("/swagger-ui")
ws.Produces(restful.MIME_JSON)
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: enrichSwaggerObject}
for _, ws := range webservice {
klog.V(2).Infof("%s", ws.RootPath())
}
return restfulspec.NewOpenAPIService(restconfig)
}
// enrichSwaggerObject customizes the Swagger documentation with KubeKey-specific information
// It sets the API title, description, version and contact information
func enrichSwaggerObject(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",
},
},
},
}
}

52
pkg/web/query/field.go Normal file
View File

@ -0,0 +1,52 @@
/*
Copyright 2020 The KubeSphere Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package query
// Field represents a query field name used for filtering and sorting
type Field string
// Value represents a query field value used for filtering
type Value string
const (
// FieldName represents the name field of a resource
FieldName = "name"
// FieldNames represents multiple names field of resources
FieldNames = "names"
// FieldUID represents the unique identifier field of a resource
FieldUID = "uid"
// FieldCreationTimeStamp represents the creation timestamp field of a resource
FieldCreationTimeStamp = "creationTimestamp"
// FieldCreateTime represents the creation time field of a resource
FieldCreateTime = "createTime"
// FieldLastUpdateTimestamp represents the last update timestamp field of a resource
FieldLastUpdateTimestamp = "lastUpdateTimestamp"
// FieldUpdateTime represents the update time field of a resource
FieldUpdateTime = "updateTime"
// FieldLabel represents the label field of a resource
FieldLabel = "label"
// FieldAnnotation represents the annotation field of a resource
FieldAnnotation = "annotation"
// FieldNamespace represents the namespace field of a resource
FieldNamespace = "namespace"
// FieldStatus represents the status field of a resource
FieldStatus = "status"
// FieldOwnerReference represents the owner reference field of a resource
FieldOwnerReference = "ownerReference"
// FieldOwnerKind represents the owner kind field of a resource
FieldOwnerKind = "ownerKind"
)

176
pkg/web/query/helper.go Normal file
View File

@ -0,0 +1,176 @@
/*
Copyright 2020 KubeSphere Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package query
import (
"sort"
"strings"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/klog/v2"
"github.com/kubesphere/kubekey/v4/pkg/web/api"
)
// CompareFunc is a generic function type that compares two objects of type T
// Returns true if left is greater than right
type CompareFunc[T any] func(T, T, Field) bool
// FilterFunc is a generic function type that filters objects of type T
// Returns true if the object matches the filter criteria
type FilterFunc[T any] func(T, Filter) bool
// TransformFunc is a generic function type that transforms objects of type T
// Returns the transformed object
type TransformFunc[T any] func(T) T
// DefaultList processes a list of objects with filtering, sorting, and pagination
// Parameters:
// - objects: The list of objects to process
// - q: Query parameters including filters, sorting, and pagination
// - compareFunc: Function to compare objects for sorting
// - filterFunc: Function to filter objects
// - transformFuncs: Optional functions to transform objects
//
// Returns a ListResult containing the processed objects
func DefaultList[T any](objects []T, q *Query, compareFunc CompareFunc[T], filterFunc FilterFunc[T], transformFuncs ...TransformFunc[T]) *api.ListResult[T] {
// selected matched ones
filtered := make([]T, 0)
for _, object := range objects {
selected := true
for field, value := range q.Filters {
if !filterFunc(object, Filter{Field: field, Value: value}) {
selected = false
break
}
}
if selected {
for _, transform := range transformFuncs {
object = transform(object)
}
filtered = append(filtered, object)
}
}
// sort by sortBy field
sort.Slice(filtered, func(i, j int) bool {
if !q.Ascending {
return compareFunc(filtered[i], filtered[j], q.SortBy)
}
return !compareFunc(filtered[i], filtered[j], q.SortBy)
})
total := len(filtered)
if q.Pagination == nil {
q.Pagination = NoPagination
}
start, end := q.Pagination.GetValidPagination(total)
return &api.ListResult[T]{
TotalItems: len(filtered),
Items: filtered[start:end],
}
}
// DefaultObjectMetaCompare compares two metav1.Object instances
// Returns true if left is greater than right based on the specified sort field
// Supports sorting by name or creation timestamp
func DefaultObjectMetaCompare(left, right metav1.Object, sortBy Field) bool {
switch sortBy {
// ?sortBy=name
case FieldName:
return left.GetName() > right.GetName()
// ?sortBy=creationTimestamp
default:
// compare by name if creation timestamp is equal
if left.GetCreationTimestamp().Time.Equal(right.GetCreationTimestamp().Time) {
return left.GetName() > right.GetName()
}
return left.GetCreationTimestamp().After(right.GetCreationTimestamp().Time)
}
}
// DefaultObjectMetaFilter filters metav1.Object instances based on various criteria
// Supports filtering by:
// - Names: Exact match against a comma-separated list of names
// - Name: Partial match against object name
// - UID: Exact match against object UID
// - Namespace: Exact match against namespace
// - OwnerReference: Match against owner reference UID
// - OwnerKind: Match against owner reference kind
// - Annotation: Match against annotations using label selector syntax
// - Label: Match against labels using label selector syntax
func DefaultObjectMetaFilter(item metav1.Object, filter Filter) bool {
switch filter.Field {
case FieldNames:
for _, name := range strings.Split(string(filter.Value), ",") {
if item.GetName() == name {
return true
}
}
return false
// /namespaces?page=1&limit=10&name=default
case FieldName:
return strings.Contains(item.GetName(), string(filter.Value))
// /namespaces?page=1&limit=10&uid=a8a8d6cf-f6a5-4fea-9c1b-e57610115706
case FieldUID:
return string(item.GetUID()) == string(filter.Value)
// /deployments?page=1&limit=10&namespace=kubesphere-system
case FieldNamespace:
return item.GetNamespace() == string(filter.Value)
// /namespaces?page=1&limit=10&ownerReference=a8a8d6cf-f6a5-4fea-9c1b-e57610115706
case FieldOwnerReference:
for _, ownerReference := range item.GetOwnerReferences() {
if string(ownerReference.UID) == string(filter.Value) {
return true
}
}
return false
// /namespaces?page=1&limit=10&ownerKind=Workspace
case FieldOwnerKind:
for _, ownerReference := range item.GetOwnerReferences() {
if ownerReference.Kind == string(filter.Value) {
return true
}
}
return false
// /namespaces?page=1&limit=10&annotation=openpitrix_runtime
case FieldAnnotation:
return labelMatch(item.GetAnnotations(), string(filter.Value))
// /namespaces?page=1&limit=10&label=kubesphere.io/workspace:system-workspace
case FieldLabel:
return labelMatch(item.GetLabels(), string(filter.Value))
// not supported filter
default:
return true
}
}
// labelMatch checks if a map of labels/annotations matches a label selector
// Returns true if the map matches the selector, false otherwise
func labelMatch(m map[string]string, filter string) bool {
labelSelector, err := labels.Parse(filter)
if err != nil {
klog.Warningf("invalid labelSelector %s: %s", filter, err)
return false
}
return labelSelector.Matches(labels.Set(m))
}

180
pkg/web/query/types.go Normal file
View File

@ -0,0 +1,180 @@
/*
Copyright 2020 The KubeSphere Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package query
import (
"slices"
"strconv"
"github.com/emicklei/go-restful/v3"
"k8s.io/apimachinery/pkg/labels"
)
// Common query parameter names used in API requests
const (
ParameterName = "name" // Name parameter for filtering by resource name
ParameterLabelSelector = "labelSelector" // Label selector parameter for filtering by labels
ParameterFieldSelector = "fieldSelector" // Field selector parameter for filtering by fields
ParameterPage = "page" // Page number parameter for pagination
ParameterLimit = "limit" // Items per page parameter for pagination
ParameterOrderBy = "sortBy" // Field to sort results by
ParameterAscending = "ascending" // Sort direction parameter (true for ascending, false for descending)
)
// Query represents API search terms and filtering options
type Query struct {
Pagination *Pagination // Pagination settings for the query results
// SortBy specifies which field to sort results by, defaults to FieldCreationTimeStamp
SortBy Field
// Ascending determines sort direction, defaults to descending (false)
Ascending bool
// Filters contains field-value pairs for filtering results
Filters map[Field]Value
// LabelSelector contains the label selector string for filtering by labels
LabelSelector string
}
// Pagination represents pagination settings for query results
type Pagination struct {
Limit int // Number of items per page
Offset int // Starting offset for the current page
}
// NoPagination represents a query without pagination
var NoPagination = newPagination(-1, 0)
// newPagination creates a new Pagination instance with the given limit and offset
func newPagination(limit int, offset int) *Pagination {
return &Pagination{
Limit: limit,
Offset: offset,
}
}
// Selector returns a labels.Selector for the query's label selector
func (q *Query) Selector() labels.Selector {
selector, err := labels.Parse(q.LabelSelector)
if err != nil {
return labels.Everything()
}
return selector
}
// AppendLabelSelector adds additional label selectors to the existing query
func (q *Query) AppendLabelSelector(ls map[string]string) error {
labelsMap, err := labels.ConvertSelectorToLabelsMap(q.LabelSelector)
if err != nil {
return err
}
q.LabelSelector = labels.Merge(labelsMap, ls).String()
return nil
}
// GetValidPagination calculates valid start and end indices for pagination
func (p *Pagination) GetValidPagination(total int) (startIndex, endIndex int) {
// Return all items if no pagination is specified
if p.Limit == NoPagination.Limit {
return 0, total
}
// Return empty range if pagination parameters are invalid
if p.Limit < 0 || p.Offset < 0 || p.Offset > total {
return 0, 0
}
startIndex = p.Offset
endIndex = startIndex + p.Limit
// Adjust end index if it exceeds total items
if endIndex > total {
endIndex = total
}
return startIndex, endIndex
}
// New creates a new Query instance with default values
func New() *Query {
return &Query{
Pagination: NoPagination,
SortBy: "",
Ascending: false,
Filters: map[Field]Value{},
}
}
// Filter represents a single field-value filter pair
type Filter struct {
Field Field `json:"field"` // Field to filter on
Value Value `json:"value"` // Value to filter by
}
// ParseQueryParameter parses query parameters from a RESTful request into a Query struct
func ParseQueryParameter(request *restful.Request) *Query {
query := New()
// Parse pagination parameters
limit, err := strconv.Atoi(request.QueryParameter(ParameterLimit))
if err != nil {
limit = -1 // Use default value if undefined
}
page, err := strconv.Atoi(request.QueryParameter(ParameterPage))
if err != nil {
page = 1 // Use default value if undefined
}
query.Pagination = newPagination(limit, (page-1)*limit)
// Parse sorting parameters
query.SortBy = Field(defaultString(request.QueryParameter(ParameterOrderBy), FieldCreationTimeStamp))
ascending, err := strconv.ParseBool(defaultString(request.QueryParameter(ParameterAscending), "false"))
if err != nil {
query.Ascending = false
} else {
query.Ascending = ascending
}
// Parse label selector
query.LabelSelector = request.QueryParameter(ParameterLabelSelector)
// Parse additional filters
for key, values := range request.Request.URL.Query() {
if !slices.Contains([]string{ParameterPage, ParameterLimit, ParameterOrderBy, ParameterAscending, ParameterLabelSelector}, key) {
value := ""
if len(values) > 0 {
value = values[0]
}
query.Filters[Field(key)] = Value(value)
}
}
return query
}
// defaultString returns the default value if the input string is empty
func defaultString(value, defaultValue string) string {
if value == "" {
return defaultValue
}
return value
}

View File

@ -0,0 +1,80 @@
/*
Copyright 2020 The KubeSphere Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package query
import (
"context"
"net/http"
"testing"
"github.com/emicklei/go-restful/v3"
"github.com/google/go-cmp/cmp"
)
func TestParseQueryParameter(t *testing.T) {
tests := []struct {
description string
queryString string
expected *Query
}{
{
"test normal case",
"label=app.kubernetes.io/name=book&name=foo&status=Running&page=1&limit=10&ascending=true",
&Query{
Pagination: newPagination(10, 0),
SortBy: FieldCreationTimeStamp,
Ascending: true,
Filters: map[Field]Value{
FieldLabel: Value("app.kubernetes.io/name=book"),
FieldName: Value("foo"),
FieldStatus: Value("Running"),
},
},
},
{
"test bad case",
"xxxx=xxxx&dsfsw=xxxx&page=abc&limit=add&ascending=ssss",
&Query{
Pagination: NoPagination,
SortBy: FieldCreationTimeStamp,
Ascending: false,
Filters: map[Field]Value{
Field("xxxx"): Value("xxxx"),
Field("dsfsw"): Value("xxxx"),
},
},
},
}
for _, test := range tests {
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://localhost?"+test.queryString, http.NoBody)
if err != nil {
t.Fatal(err)
}
request := restful.NewRequest(req)
t.Run(test.description, func(t *testing.T) {
got := ParseQueryParameter(request)
if diff := cmp.Diff(got, test.expected); diff != "" {
t.Errorf("%T differ (-got, +want): %s", test.expected, diff)
return
}
})
}
}

137
pkg/web/service.go Normal file
View File

@ -0,0 +1,137 @@
package web
import (
"context"
"net/http"
"strings"
restfulspec "github.com/emicklei/go-restful-openapi/v2"
"github.com/emicklei/go-restful/v3"
kkcorev1 "github.com/kubesphere/kubekey/api/core/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/rest"
ctrlclient "sigs.k8s.io/controller-runtime/pkg/client"
_const "github.com/kubesphere/kubekey/v4/pkg/const"
"github.com/kubesphere/kubekey/v4/pkg/web/api"
"github.com/kubesphere/kubekey/v4/pkg/web/query"
)
// NewWebService 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.
// Parameters:
// - ctx: Context for the web service
// - workdir: Working directory for file operations
// - client: Kubernetes controller client
// - config: REST configuration
//
// Returns a configured WebService instance
func NewWebService(ctx context.Context, workdir string, client ctrlclient.Client, config *rest.Config) *restful.WebService {
ws := new(restful.WebService)
// the GroupVersion might be empty, we need to remove the final /
ws.Path(strings.TrimRight(_const.APIPath+kkcorev1.SchemeGroupVersion.String(), "/")).
Produces(restful.MIME_JSON).Consumes(
string(types.JSONPatchType),
string(types.MergePatchType),
string(types.StrategicMergePatchType),
string(types.ApplyPatchType),
restful.MIME_JSON)
h := newHandler(workdir, client, config)
// Inventory management routes
ws.Route(ws.POST("/inventories").To(h.createInventory).
Metadata(restfulspec.KeyOpenAPITags, []string{_const.KubeKeyTag}).
Doc("create a inventory.").
Reads(kkcorev1.Inventory{}).
Returns(http.StatusOK, _const.StatusOK, kkcorev1.Inventory{}))
ws.Route(ws.PATCH("/namespaces/{namespace}/inventories/{inventory}").To(h.patchInventory).
Metadata(restfulspec.KeyOpenAPITags, []string{_const.KubeKeyTag}).
Doc("patch a inventory.").
Param(ws.PathParameter("namespace", "the namespace of the inventory")).
Param(ws.PathParameter("inventory", "the name of the inventory")).
Reads(kkcorev1.Inventory{}).
Returns(http.StatusOK, _const.StatusOK, kkcorev1.Inventory{}))
ws.Route(ws.GET("/inventories").To(h.listInventories).
Metadata(restfulspec.KeyOpenAPITags, []string{_const.KubeKeyTag}).
Doc("list all inventories.").
Param(ws.QueryParameter(query.ParameterPage, "page").Required(false).DataFormat("page=%d").DefaultValue("page=1")).
Param(ws.QueryParameter(query.ParameterLimit, "limit").Required(false)).
Param(ws.QueryParameter(query.ParameterAscending, "sort parameters, e.g. reverse=true").Required(false).DefaultValue("ascending=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(h.listInventories).
Metadata(restfulspec.KeyOpenAPITags, []string{_const.KubeKeyTag}).
Doc("list all inventories in a namespace.").
Param(ws.PathParameter("namespace", "the namespace of the inventory")).
Param(ws.QueryParameter(query.ParameterPage, "page").Required(false).DataFormat("page=%d").DefaultValue("page=1")).
Param(ws.QueryParameter(query.ParameterLimit, "limit").Required(false)).
Param(ws.QueryParameter(query.ParameterAscending, "sort parameters, e.g. reverse=true").Required(false).DefaultValue("ascending=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(h.inventoryInfo).
Metadata(restfulspec.KeyOpenAPITags, []string{_const.KubeKeyTag}).
Doc("get a inventory in a namespace.").
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(h.listInventoryHosts).
Metadata(restfulspec.KeyOpenAPITags, []string{_const.KubeKeyTag}).
Doc("list all hosts in a inventory.").
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").DefaultValue("page=1")).
Param(ws.QueryParameter(query.ParameterLimit, "limit").Required(false)).
Param(ws.QueryParameter(query.ParameterAscending, "sort parameters, e.g. reverse=true").Required(false).DefaultValue("ascending=false")).
Param(ws.QueryParameter(query.ParameterOrderBy, "sort parameters, e.g. orderBy=createTime")).
Returns(http.StatusOK, _const.StatusOK, api.ListResult[api.InventoryHostTable]{}))
// Playbook management routes
ws.Route(ws.POST("/playbooks").To(h.createPlaybook).
Metadata(restfulspec.KeyOpenAPITags, []string{_const.KubeKeyTag}).
Doc("create a playbook.").
Reads(kkcorev1.Playbook{}).
Returns(http.StatusOK, _const.StatusOK, kkcorev1.Playbook{}))
ws.Route(ws.GET("/playbooks").To(h.listPlaybooks).
Metadata(restfulspec.KeyOpenAPITags, []string{_const.KubeKeyTag}).
Doc("list all playbooks.").
Reads(kkcorev1.Playbook{}).
Param(ws.QueryParameter(query.ParameterPage, "page").Required(false).DataFormat("page=%d").DefaultValue("page=1")).
Param(ws.QueryParameter(query.ParameterLimit, "limit").Required(false)).
Param(ws.QueryParameter(query.ParameterAscending, "sort parameters, e.g. reverse=true").Required(false).DefaultValue("ascending=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(h.listInventories).
Metadata(restfulspec.KeyOpenAPITags, []string{_const.KubeKeyTag}).
Doc("list all playbooks in a namespace.").
Param(ws.PathParameter("namespace", "the namespace of the playbook")).
Param(ws.QueryParameter(query.ParameterPage, "page").Required(false).DataFormat("page=%d").DefaultValue("page=1")).
Param(ws.QueryParameter(query.ParameterLimit, "limit").Required(false)).
Param(ws.QueryParameter(query.ParameterAscending, "sort parameters, e.g. reverse=true").Required(false).DefaultValue("ascending=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(h.playbookInfo).
Metadata(restfulspec.KeyOpenAPITags, []string{_const.KubeKeyTag}).
Doc("get or watch a playbook in a namespace.").
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(h.logPlaybook).
Metadata(restfulspec.KeyOpenAPITags, []string{_const.KubeKeyTag}).
Doc("watch a playbook in a namespace.").
Param(ws.PathParameter("namespace", "the namespace of the playbook")).
Param(ws.PathParameter("playbook", "the name of the playbook")).
Returns(http.StatusOK, _const.StatusOK, "text/plain"))
return ws
}