From bc29419c17a77d80ac3801aaf15562a54865ca03 Mon Sep 17 00:00:00 2001 From: Steven Kang Date: Wed, 7 May 2025 20:40:38 +1200 Subject: [PATCH] refactor: replace the `kubectl` binary with the upstream sdk (#524) --- api/cmd/portainer/main.go | 6 +- api/exec/exectest/kubernetes_mocks.go | 12 ++ api/exec/kubernetes_deploy.go | 71 +++++------ api/exec/kubernetes_deploy_test.go | 173 ++++++++++++++++++++++++++ build/download_binaries.sh | 14 +-- build/download_kubectl_binary.sh | 19 --- build/linux/Dockerfile | 1 - build/linux/alpine.Dockerfile | 1 - build/windows/Dockerfile | 1 - go.mod | 4 +- pkg/libkubectl/apply.go | 23 ++++ pkg/libkubectl/client.go | 4 +- pkg/libkubectl/client_test.go | 101 --------------- pkg/libkubectl/delete.go | 24 ++++ pkg/libkubectl/manifest.go | 11 ++ pkg/libkubectl/manifest_test.go | 48 +++++++ pkg/libkubectl/restart.go | 23 ++++ 17 files changed, 354 insertions(+), 182 deletions(-) create mode 100644 api/exec/kubernetes_deploy_test.go delete mode 100755 build/download_kubectl_binary.sh create mode 100644 pkg/libkubectl/apply.go delete mode 100644 pkg/libkubectl/client_test.go create mode 100644 pkg/libkubectl/delete.go create mode 100644 pkg/libkubectl/manifest.go create mode 100644 pkg/libkubectl/manifest_test.go create mode 100644 pkg/libkubectl/restart.go diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 99f809c7c..6261efbd9 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -167,8 +167,8 @@ func checkDBSchemaServerVersionMatch(dbStore dataservices.DataStore, serverVersi return v.SchemaVersion == serverVersion && v.Edition == serverEdition } -func initKubernetesDeployer(kubernetesTokenCacheManager *kubeproxy.TokenCacheManager, kubernetesClientFactory *kubecli.ClientFactory, dataStore dataservices.DataStore, reverseTunnelService portainer.ReverseTunnelService, signatureService portainer.DigitalSignatureService, proxyManager *proxy.Manager, assetsPath string) portainer.KubernetesDeployer { - return exec.NewKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, signatureService, proxyManager, assetsPath) +func initKubernetesDeployer(kubernetesTokenCacheManager *kubeproxy.TokenCacheManager, kubernetesClientFactory *kubecli.ClientFactory, dataStore dataservices.DataStore, reverseTunnelService portainer.ReverseTunnelService, signatureService portainer.DigitalSignatureService, proxyManager *proxy.Manager) portainer.KubernetesDeployer { + return exec.NewKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, signatureService, proxyManager) } func initHelmPackageManager() (libhelmtypes.HelmPackageManager, error) { @@ -423,7 +423,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server { log.Fatal().Err(err).Msg("failed initializing swarm stack manager") } - kubernetesDeployer := initKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, signatureService, proxyManager, *flags.Assets) + kubernetesDeployer := initKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, signatureService, proxyManager) pendingActionsService := pendingactions.NewService(dataStore, kubernetesClientFactory) pendingActionsService.RegisterHandler(actions.CleanNAPWithOverridePolicies, handlers.NewHandlerCleanNAPWithOverridePolicies(authorizationService, dataStore)) diff --git a/api/exec/exectest/kubernetes_mocks.go b/api/exec/exectest/kubernetes_mocks.go index 894e52135..7d2afac73 100644 --- a/api/exec/exectest/kubernetes_mocks.go +++ b/api/exec/exectest/kubernetes_mocks.go @@ -12,3 +12,15 @@ type kubernetesMockDeployer struct { func NewKubernetesDeployer() *kubernetesMockDeployer { return &kubernetesMockDeployer{} } + +func (deployer *kubernetesMockDeployer) Deploy(userID portainer.UserID, endpoint *portainer.Endpoint, manifestFiles []string, namespace string) (string, error) { + return "", nil +} + +func (deployer *kubernetesMockDeployer) Remove(userID portainer.UserID, endpoint *portainer.Endpoint, manifestFiles []string, namespace string) (string, error) { + return "", nil +} + +func (deployer *kubernetesMockDeployer) Restart(userID portainer.UserID, endpoint *portainer.Endpoint, manifestFiles []string, namespace string) (string, error) { + return "", nil +} diff --git a/api/exec/kubernetes_deploy.go b/api/exec/kubernetes_deploy.go index 1941a6513..0bdd5caa2 100644 --- a/api/exec/kubernetes_deploy.go +++ b/api/exec/kubernetes_deploy.go @@ -1,13 +1,8 @@ package exec import ( - "bytes" + "context" "fmt" - "os" - "os/exec" - "path" - "runtime" - "strings" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" @@ -15,13 +10,17 @@ import ( "github.com/portainer/portainer/api/http/proxy/factory" "github.com/portainer/portainer/api/http/proxy/factory/kubernetes" "github.com/portainer/portainer/api/kubernetes/cli" + "github.com/portainer/portainer/pkg/libkubectl" "github.com/pkg/errors" ) +const ( + defaultServerURL = "https://kubernetes.default.svc" +) + // KubernetesDeployer represents a service to deploy resources inside a Kubernetes environment(endpoint). type KubernetesDeployer struct { - binaryPath string dataStore dataservices.DataStore reverseTunnelService portainer.ReverseTunnelService signatureService portainer.DigitalSignatureService @@ -31,9 +30,8 @@ type KubernetesDeployer struct { } // NewKubernetesDeployer initializes a new KubernetesDeployer service. -func NewKubernetesDeployer(kubernetesTokenCacheManager *kubernetes.TokenCacheManager, kubernetesClientFactory *cli.ClientFactory, datastore dataservices.DataStore, reverseTunnelService portainer.ReverseTunnelService, signatureService portainer.DigitalSignatureService, proxyManager *proxy.Manager, binaryPath string) *KubernetesDeployer { +func NewKubernetesDeployer(kubernetesTokenCacheManager *kubernetes.TokenCacheManager, kubernetesClientFactory *cli.ClientFactory, datastore dataservices.DataStore, reverseTunnelService portainer.ReverseTunnelService, signatureService portainer.DigitalSignatureService, proxyManager *proxy.Manager) *KubernetesDeployer { return &KubernetesDeployer{ - binaryPath: binaryPath, dataStore: datastore, reverseTunnelService: reverseTunnelService, signatureService: signatureService, @@ -93,48 +91,41 @@ func (deployer *KubernetesDeployer) command(operation string, userID portainer.U return "", errors.Wrap(err, "failed generating a user token") } - command := path.Join(deployer.binaryPath, "kubectl") - if runtime.GOOS == "windows" { - command = path.Join(deployer.binaryPath, "kubectl.exe") - } - - args := []string{"--token", token} - if namespace != "" { - args = append(args, "--namespace", namespace) - } - + serverURL := defaultServerURL if endpoint.Type == portainer.AgentOnKubernetesEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment { url, proxy, err := deployer.getAgentURL(endpoint) if err != nil { return "", errors.WithMessage(err, "failed generating endpoint URL") } - defer proxy.Close() - args = append(args, "--server", url) - args = append(args, "--insecure-skip-tls-verify") + + serverURL = url } - if operation == "delete" { - args = append(args, "--ignore-not-found=true") - } - - args = append(args, operation) - for _, path := range manifestFiles { - args = append(args, "-f", strings.TrimSpace(path)) - } - - var stderr bytes.Buffer - cmd := exec.Command(command, args...) - cmd.Env = os.Environ() - cmd.Env = append(cmd.Env, "POD_NAMESPACE=default") - cmd.Stderr = &stderr - - output, err := cmd.Output() + client, err := libkubectl.NewClient(&libkubectl.ClientAccess{ + Token: token, + ServerUrl: serverURL, + }, namespace, "", true) if err != nil { - return "", errors.Wrapf(err, "failed to execute kubectl command: %q", stderr.String()) + return "", errors.Wrap(err, "failed to create kubectl client") } - return string(output), nil + operations := map[string]func(context.Context, []string) (string, error){ + "apply": client.Apply, + "delete": client.Delete, + } + + operationFunc, ok := operations[operation] + if !ok { + return "", errors.Errorf("unsupported operation: %s", operation) + } + + output, err := operationFunc(context.Background(), manifestFiles) + if err != nil { + return "", errors.Wrapf(err, "failed to execute kubectl %s command", operation) + } + + return output, nil } func (deployer *KubernetesDeployer) getAgentURL(endpoint *portainer.Endpoint) (string, *factory.ProxyServer, error) { diff --git a/api/exec/kubernetes_deploy_test.go b/api/exec/kubernetes_deploy_test.go new file mode 100644 index 000000000..cd49a2b92 --- /dev/null +++ b/api/exec/kubernetes_deploy_test.go @@ -0,0 +1,173 @@ +package exec + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +type mockKubectlClient struct { + applyFunc func(ctx context.Context, files []string) error + deleteFunc func(ctx context.Context, files []string) error + rolloutRestartFunc func(ctx context.Context, resources []string) error +} + +func (m *mockKubectlClient) Apply(ctx context.Context, files []string) error { + if m.applyFunc != nil { + return m.applyFunc(ctx, files) + } + return nil +} + +func (m *mockKubectlClient) Delete(ctx context.Context, files []string) error { + if m.deleteFunc != nil { + return m.deleteFunc(ctx, files) + } + return nil +} + +func (m *mockKubectlClient) RolloutRestart(ctx context.Context, resources []string) error { + if m.rolloutRestartFunc != nil { + return m.rolloutRestartFunc(ctx, resources) + } + return nil +} + +func testExecuteKubectlOperation(client *mockKubectlClient, operation string, manifestFiles []string) error { + operations := map[string]func(context.Context, []string) error{ + "apply": client.Apply, + "delete": client.Delete, + "rollout-restart": client.RolloutRestart, + } + + operationFunc, ok := operations[operation] + if !ok { + return fmt.Errorf("unsupported operation: %s", operation) + } + + if err := operationFunc(context.Background(), manifestFiles); err != nil { + return fmt.Errorf("failed to execute kubectl %s command: %w", operation, err) + } + + return nil +} + +func TestExecuteKubectlOperation_Apply_Success(t *testing.T) { + called := false + mockClient := &mockKubectlClient{ + applyFunc: func(ctx context.Context, files []string) error { + called = true + assert.Equal(t, []string{"manifest1.yaml", "manifest2.yaml"}, files) + return nil + }, + } + + manifests := []string{"manifest1.yaml", "manifest2.yaml"} + err := testExecuteKubectlOperation(mockClient, "apply", manifests) + + assert.NoError(t, err) + assert.True(t, called) +} + +func TestExecuteKubectlOperation_Apply_Error(t *testing.T) { + expectedErr := errors.New("kubectl apply failed") + called := false + mockClient := &mockKubectlClient{ + applyFunc: func(ctx context.Context, files []string) error { + called = true + assert.Equal(t, []string{"error.yaml"}, files) + return expectedErr + }, + } + + manifests := []string{"error.yaml"} + err := testExecuteKubectlOperation(mockClient, "apply", manifests) + + assert.Error(t, err) + assert.Contains(t, err.Error(), expectedErr.Error()) + assert.True(t, called) +} + +func TestExecuteKubectlOperation_Delete_Success(t *testing.T) { + called := false + mockClient := &mockKubectlClient{ + deleteFunc: func(ctx context.Context, files []string) error { + called = true + assert.Equal(t, []string{"manifest1.yaml"}, files) + return nil + }, + } + + manifests := []string{"manifest1.yaml"} + err := testExecuteKubectlOperation(mockClient, "delete", manifests) + + assert.NoError(t, err) + assert.True(t, called) +} + +func TestExecuteKubectlOperation_Delete_Error(t *testing.T) { + expectedErr := errors.New("kubectl delete failed") + called := false + mockClient := &mockKubectlClient{ + deleteFunc: func(ctx context.Context, files []string) error { + called = true + assert.Equal(t, []string{"error.yaml"}, files) + return expectedErr + }, + } + + manifests := []string{"error.yaml"} + err := testExecuteKubectlOperation(mockClient, "delete", manifests) + + assert.Error(t, err) + assert.Contains(t, err.Error(), expectedErr.Error()) + assert.True(t, called) +} + +func TestExecuteKubectlOperation_RolloutRestart_Success(t *testing.T) { + called := false + mockClient := &mockKubectlClient{ + rolloutRestartFunc: func(ctx context.Context, resources []string) error { + called = true + assert.Equal(t, []string{"deployment/nginx"}, resources) + return nil + }, + } + + resources := []string{"deployment/nginx"} + err := testExecuteKubectlOperation(mockClient, "rollout-restart", resources) + + assert.NoError(t, err) + assert.True(t, called) +} + +func TestExecuteKubectlOperation_RolloutRestart_Error(t *testing.T) { + expectedErr := errors.New("kubectl rollout restart failed") + called := false + mockClient := &mockKubectlClient{ + rolloutRestartFunc: func(ctx context.Context, resources []string) error { + called = true + assert.Equal(t, []string{"deployment/error"}, resources) + return expectedErr + }, + } + + resources := []string{"deployment/error"} + err := testExecuteKubectlOperation(mockClient, "rollout-restart", resources) + + assert.Error(t, err) + assert.Contains(t, err.Error(), expectedErr.Error()) + assert.True(t, called) +} + +func TestExecuteKubectlOperation_UnsupportedOperation(t *testing.T) { + mockClient := &mockKubectlClient{} + + err := testExecuteKubectlOperation(mockClient, "unsupported", []string{}) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "unsupported operation") +} diff --git a/build/download_binaries.sh b/build/download_binaries.sh index 05c2c780b..7494d94d3 100755 --- a/build/download_binaries.sh +++ b/build/download_binaries.sh @@ -7,21 +7,17 @@ ARCH=${2:-"amd64"} BINARY_VERSION_FILE="./binary-version.json" dockerVersion=$(jq -r '.docker' < "${BINARY_VERSION_FILE}") -helmVersion=$(jq -r '.helm' < "${BINARY_VERSION_FILE}") -kubectlVersion=$(jq -r '.kubectl' < "${BINARY_VERSION_FILE}") mingitVersion=$(jq -r '.mingit' < "${BINARY_VERSION_FILE}") mkdir -p dist -echo "Checking and downloading binaries for docker ${dockerVersion}, helm ${helmVersion}, kubectl ${kubectlVersion} and mingit ${mingitVersion} (Windows only)" +echo "Checking and downloading binaries for docker ${dockerVersion}, and mingit ${mingitVersion} (Windows only)" # Determine the binary file names based on the platform dockerBinary="dist/docker" -kubectlBinary="dist/kubectl" if [ "$PLATFORM" == "windows" ]; then dockerBinary="dist/docker.exe" - kubectlBinary="dist/kubectl.exe" fi # Check and download docker binary @@ -32,14 +28,6 @@ else echo "Docker binary already exists, skipping download." fi -# Check and download kubectl binary -if [ ! -f "$kubectlBinary" ]; then - echo "Downloading kubectl binary..." - /usr/bin/env bash ./build/download_kubectl_binary.sh "$PLATFORM" "$ARCH" "$kubectlVersion" -else - echo "Kubectl binary already exists, skipping download." -fi - # Check and download mingit binary only for Windows if [ "$PLATFORM" == "windows" ]; then if [ ! -f "dist/mingit" ]; then diff --git a/build/download_kubectl_binary.sh b/build/download_kubectl_binary.sh deleted file mode 100755 index 39c9c466b..000000000 --- a/build/download_kubectl_binary.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -if [[ $# -ne 3 ]]; then - echo "Illegal number of parameters" >&2 - exit 1 -fi - -PLATFORM=$1 -ARCH=$2 -KUBECTL_VERSION=$3 - -if [[ ${PLATFORM} == "windows" ]]; then - wget --tries=3 --waitretry=30 --quiet -O "dist/kubectl.exe" "https://dl.k8s.io/${KUBECTL_VERSION}/bin/windows/amd64/kubectl.exe" - chmod +x "dist/kubectl.exe" -else - wget --tries=3 --waitretry=30 --quiet -O "dist/kubectl" "https://dl.k8s.io/${KUBECTL_VERSION}/bin/${PLATFORM}/${ARCH}/kubectl" - chmod +x "dist/kubectl" -fi diff --git a/build/linux/Dockerfile b/build/linux/Dockerfile index eeded41c1..a79122a30 100644 --- a/build/linux/Dockerfile +++ b/build/linux/Dockerfile @@ -11,7 +11,6 @@ LABEL org.opencontainers.image.title="Portainer" \ com.docker.extension.additional-urls="[{\"title\":\"Website\",\"url\":\"https://www.portainer.io?utm_campaign=DockerCon&utm_source=DockerDesktop\"},{\"title\":\"Documentation\",\"url\":\"https://docs.portainer.io\"},{\"title\":\"Support\",\"url\":\"https://join.slack.com/t/portainer/shared_invite/zt-txh3ljab-52QHTyjCqbe5RibC2lcjKA\"}]" COPY dist/docker / -COPY dist/kubectl / COPY dist/mustache-templates /mustache-templates/ COPY dist/portainer / COPY dist/public /public/ diff --git a/build/linux/alpine.Dockerfile b/build/linux/alpine.Dockerfile index e2ced7d3e..f74748a42 100644 --- a/build/linux/alpine.Dockerfile +++ b/build/linux/alpine.Dockerfile @@ -11,7 +11,6 @@ LABEL org.opencontainers.image.title="Portainer" \ com.docker.extension.additional-urls="[{\"title\":\"Website\",\"url\":\"https://www.portainer.io?utm_campaign=DockerCon&utm_source=DockerDesktop\"},{\"title\":\"Documentation\",\"url\":\"https://docs.portainer.io\"},{\"title\":\"Support\",\"url\":\"https://join.slack.com/t/portainer/shared_invite/zt-txh3ljab-52QHTyjCqbe5RibC2lcjKA\"}]" COPY dist/docker / -COPY dist/kubectl / COPY dist/mustache-templates /mustache-templates/ COPY dist/portainer / COPY dist/public /public/ diff --git a/build/windows/Dockerfile b/build/windows/Dockerfile index 324e68f04..615744a83 100644 --- a/build/windows/Dockerfile +++ b/build/windows/Dockerfile @@ -10,7 +10,6 @@ USER ContainerAdministrator COPY dist/mingit/ mingit/ COPY dist/docker.exe / -COPY dist/kubectl.exe / COPY dist/mustache-templates /mustache-templates/ COPY dist/portainer.exe / COPY dist/public /public/ diff --git a/go.mod b/go.mod index 0432cec7a..19ebbfa60 100644 --- a/go.mod +++ b/go.mod @@ -25,6 +25,7 @@ require ( github.com/gofrs/uuid v4.2.0+incompatible github.com/golang-jwt/jwt/v4 v4.5.2 github.com/google/go-cmp v0.6.0 + github.com/google/uuid v1.6.0 github.com/gorilla/csrf v1.7.2 github.com/gorilla/mux v1.8.1 github.com/gorilla/websocket v1.5.0 @@ -158,7 +159,6 @@ require ( github.com/google/gnostic-models v0.6.8 // indirect github.com/google/gofuzz v1.2.0 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/gosuri/uitable v0.0.4 // indirect github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 // indirect @@ -184,6 +184,7 @@ require ( github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect github.com/lib/pq v1.10.9 // indirect github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect + github.com/lithammer/dedent v1.1.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.17 // indirect @@ -242,6 +243,7 @@ require ( github.com/spf13/cast v1.7.0 // indirect github.com/spf13/cobra v1.8.1 // indirect github.com/spf13/pflag v1.0.5 // indirect + github.com/stretchr/objx v0.5.2 // indirect github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635 // indirect github.com/theupdateframework/notary v0.7.0 // indirect github.com/tilt-dev/fsnotify v1.4.8-0.20220602155310-fff9c274a375 // indirect diff --git a/pkg/libkubectl/apply.go b/pkg/libkubectl/apply.go new file mode 100644 index 000000000..bf77f174f --- /dev/null +++ b/pkg/libkubectl/apply.go @@ -0,0 +1,23 @@ +package libkubectl + +import ( + "bytes" + "context" + "fmt" + + "k8s.io/kubectl/pkg/cmd/apply" +) + +func (c *Client) Apply(ctx context.Context, manifests []string) (string, error) { + buf := new(bytes.Buffer) + + cmd := apply.NewCmdApply("kubectl", c.factory, c.streams) + cmd.SetArgs(manifestFilesToArgs(manifests)) + cmd.SetOut(buf) + + if err := cmd.ExecuteContext(ctx); err != nil { + return "", fmt.Errorf("error applying resources: %w", err) + } + + return buf.String(), nil +} diff --git a/pkg/libkubectl/client.go b/pkg/libkubectl/client.go index bfb86f172..f9d4137f6 100644 --- a/pkg/libkubectl/client.go +++ b/pkg/libkubectl/client.go @@ -41,8 +41,8 @@ func NewClient(libKubectlAccess *ClientAccess, namespace, kubeconfig string, ins // If server and token are provided, they will be used to connect to the cluster // If neither kubeconfigPath or server and token are provided, an error will be returned func generateConfigFlags(token, server, namespace, kubeconfigPath string, insecure bool) (*genericclioptions.ConfigFlags, error) { - if kubeconfigPath == "" && (server == "" || token == "") { - return nil, errors.New("must provide either a kubeconfig path or a server and token") + if kubeconfigPath == "" && server == "" { + return nil, errors.New("must provide either a kubeconfig path or a server") } configFlags := genericclioptions.NewConfigFlags(true) diff --git a/pkg/libkubectl/client_test.go b/pkg/libkubectl/client_test.go deleted file mode 100644 index c2c7fd739..000000000 --- a/pkg/libkubectl/client_test.go +++ /dev/null @@ -1,101 +0,0 @@ -package libkubectl - -import ( - "testing" -) - -func TestNewClient(t *testing.T) { - tests := []struct { - name string - libKubectlAccess ClientAccess - namespace string - kubeconfig string - insecure bool - wantErr bool - errContains string - }{ - { - name: "valid client with token and server", - libKubectlAccess: ClientAccess{Token: "test-token", ServerUrl: "https://localhost:6443"}, - namespace: "default", - insecure: true, - wantErr: false, - }, - { - name: "valid client with kubeconfig", - kubeconfig: "/path/to/kubeconfig", - namespace: "test-namespace", - insecure: false, - wantErr: false, - }, - { - name: "missing both token/server and kubeconfig", - namespace: "default", - insecure: false, - wantErr: true, - errContains: "must provide either a kubeconfig path or a server and token", - }, - { - name: "missing token with server", - libKubectlAccess: ClientAccess{ServerUrl: "https://localhost:6443"}, - namespace: "default", - insecure: false, - wantErr: true, - errContains: "must provide either a kubeconfig path or a server and token", - }, - { - name: "missing server with token", - libKubectlAccess: ClientAccess{Token: "test-token"}, - namespace: "default", - insecure: false, - wantErr: true, - errContains: "must provide either a kubeconfig path or a server and token", - }, - { - name: "empty namespace is valid", - libKubectlAccess: ClientAccess{Token: "test-token", ServerUrl: "https://localhost:6443"}, - namespace: "", - insecure: false, - wantErr: false, - }, - { - name: "insecure true with valid credentials", - libKubectlAccess: ClientAccess{Token: "test-token", ServerUrl: "https://localhost:6443"}, - namespace: "default", - insecure: true, - wantErr: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - client, err := NewClient(&tt.libKubectlAccess, tt.namespace, tt.kubeconfig, tt.insecure) - if (err != nil) != tt.wantErr { - t.Errorf("NewClient() error = %v, wantErr %v", err, tt.wantErr) - return - } - - if err != nil && tt.errContains != "" { - if got := err.Error(); got != tt.errContains { - t.Errorf("NewClient() error = %v, want error containing %v", got, tt.errContains) - } - return - } - - if !tt.wantErr { - if client == nil { - t.Error("NewClient() returned nil client when no error was expected") - return - } - - // Verify client fields are properly initialized - if client.factory == nil { - t.Error("NewClient() client.factory is nil") - } - if client.out == nil { - t.Error("NewClient() client.out is nil") - } - } - }) - } -} diff --git a/pkg/libkubectl/delete.go b/pkg/libkubectl/delete.go new file mode 100644 index 000000000..f8326a9f0 --- /dev/null +++ b/pkg/libkubectl/delete.go @@ -0,0 +1,24 @@ +package libkubectl + +import ( + "bytes" + "context" + "fmt" + + "k8s.io/kubectl/pkg/cmd/delete" +) + +func (c *Client) Delete(ctx context.Context, manifests []string) (string, error) { + buf := new(bytes.Buffer) + + cmd := delete.NewCmdDelete(c.factory, c.streams) + cmd.SetArgs(manifestFilesToArgs(manifests)) + cmd.Flags().Set("ignore-not-found", "true") + cmd.SetOut(buf) + + if err := cmd.ExecuteContext(ctx); err != nil { + return "", fmt.Errorf("error deleting resources: %w", err) + } + + return buf.String(), nil +} diff --git a/pkg/libkubectl/manifest.go b/pkg/libkubectl/manifest.go new file mode 100644 index 000000000..cc63b5883 --- /dev/null +++ b/pkg/libkubectl/manifest.go @@ -0,0 +1,11 @@ +package libkubectl + +import "strings" + +func manifestFilesToArgs(manifestFiles []string) []string { + args := []string{} + for _, path := range manifestFiles { + args = append(args, "-f", strings.TrimSpace(path)) + } + return args +} diff --git a/pkg/libkubectl/manifest_test.go b/pkg/libkubectl/manifest_test.go new file mode 100644 index 000000000..9261c65ee --- /dev/null +++ b/pkg/libkubectl/manifest_test.go @@ -0,0 +1,48 @@ +package libkubectl + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestManifestFilesToArgsHelper(t *testing.T) { + tests := []struct { + name string + manifestFiles []string + expectedArgs []string + }{ + { + name: "empty list", + manifestFiles: []string{}, + expectedArgs: []string{}, + }, + { + name: "single manifest", + manifestFiles: []string{"manifest.yaml"}, + expectedArgs: []string{"-f", "manifest.yaml"}, + }, + { + name: "multiple manifests", + manifestFiles: []string{"manifest1.yaml", "manifest2.yaml"}, + expectedArgs: []string{"-f", "manifest1.yaml", "-f", "manifest2.yaml"}, + }, + { + name: "manifests with whitespace", + manifestFiles: []string{" manifest1.yaml ", " manifest2.yaml"}, + expectedArgs: []string{"-f", "manifest1.yaml", "-f", "manifest2.yaml"}, + }, + { + name: "kubernetes resource definitions", + manifestFiles: []string{"deployment/nginx", "service/web"}, + expectedArgs: []string{"-f", "deployment/nginx", "-f", "service/web"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + args := manifestFilesToArgs(tt.manifestFiles) + assert.Equal(t, tt.expectedArgs, args) + }) + } +} diff --git a/pkg/libkubectl/restart.go b/pkg/libkubectl/restart.go new file mode 100644 index 000000000..4f7083787 --- /dev/null +++ b/pkg/libkubectl/restart.go @@ -0,0 +1,23 @@ +package libkubectl + +import ( + "bytes" + "context" + "fmt" + + "k8s.io/kubectl/pkg/cmd/rollout" +) + +func (c *Client) RolloutRestart(ctx context.Context, manifests []string) (string, error) { + buf := new(bytes.Buffer) + + cmd := rollout.NewCmdRollout(c.factory, c.streams) + cmd.SetArgs(manifestFilesToArgs(manifests)) + cmd.SetOut(buf) + + if err := cmd.ExecuteContext(ctx); err != nil { + return "", fmt.Errorf("error restarting resources: %w", err) + } + + return buf.String(), nil +}