Skip to content

add hidden 'get join-command' command for embedded-cluster #5280

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 172 additions & 0 deletions cmd/kots/cli/get-joincommand.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
package cli

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"

"github.com/replicatedhq/kots/pkg/api/handlers/types"
"github.com/replicatedhq/kots/pkg/auth"
"github.com/replicatedhq/kots/pkg/k8sutil"
"github.com/spf13/cobra"
"github.com/spf13/viper"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
)

func GetJoinCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "join-command",
Short: "Get embedded cluster join command",
Long: "",
SilenceUsage: false,
SilenceErrors: false,
Hidden: true,
PreRun: func(cmd *cobra.Command, args []string) {
viper.BindPFlags(cmd.Flags())
},
RunE: func(cmd *cobra.Command, args []string) error {
v := viper.GetViper()

clientset, err := k8sutil.GetClientset()
if err != nil {
return fmt.Errorf("failed to get clientset: %w", err)
}

namespace, err := getNamespaceOrDefault(v.GetString("namespace"))
if err != nil {
return fmt.Errorf("failed to get namespace: %w", err)
}

joinCmd, err := getJoinCommandCmd(cmd.Context(), clientset, namespace)
if err != nil {
return err
}
fmt.Println(joinCmd)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think we should also support a -ojson output as people will want to use this command mostly in ci

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

return nil
},
}

return cmd
}

func getJoinCommandCmd(ctx context.Context, clientset kubernetes.Interface, namespace string) (string, error) {
// determine the IP address and port of the kotsadm service
// this only runs inside an embedded cluster and so we don't need to setup port forwarding
svc, err := clientset.CoreV1().Services(namespace).Get(ctx, "kotsadm", metav1.GetOptions{})
if err != nil {
return "", fmt.Errorf("unable to get kotsadm service: %w", err)
}
kotsadmIP := svc.Spec.ClusterIP
if kotsadmIP == "" {
return "", fmt.Errorf("kotsadm service ip was empty")
}

if len(svc.Spec.Ports) == 0 {
return "", fmt.Errorf("kotsadm service ports were empty")
}
kotsadmPort := svc.Spec.Ports[0].Port

authSlug, err := auth.GetOrCreateAuthSlug(clientset, namespace)
if err != nil {
return "", fmt.Errorf("failed to get kotsadm auth slug: %w", err)
}

url := fmt.Sprintf("http://%s:%d/api/v1/embedded-cluster/roles", kotsadmIP, kotsadmPort)
roles, err := getRoles(url, authSlug)
if err != nil {
return "", fmt.Errorf("failed to get roles: %w", err)
}

controllerRole := roles.ControllerRoleName
if controllerRole == "" && len(roles.Roles) > 0 {
controllerRole = roles.Roles[0]
}
if controllerRole == "" {
return "", fmt.Errorf("unable to determine controller role name")
}

// get a join command with the controller role with a post to /api/v1/embedded-cluster/generate-node-join-command
url = fmt.Sprintf("http://%s:%d/api/v1/embedded-cluster/generate-node-join-command", kotsadmIP, kotsadmPort)
joinCommand, err := getJoinCommand(url, authSlug, []string{controllerRole})
if err != nil {
return "", fmt.Errorf("failed to get join command: %w", err)
}

return strings.Join(joinCommand.Command, " "), nil
}

// determine the embedded cluster roles list from /api/v1/embedded-cluster/roles
func getRoles(url string, authSlug string) (*types.GetEmbeddedClusterRolesResponse, error) {
newReq, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
newReq.Header.Add("Content-Type", "application/json")
newReq.Header.Add("Authorization", authSlug)

resp, err := http.DefaultClient.Do(newReq)
if err != nil {
return nil, fmt.Errorf("failed to execute request: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}

b, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}

roles := &types.GetEmbeddedClusterRolesResponse{}
if err := json.Unmarshal(b, roles); err != nil {
return nil, fmt.Errorf("failed to unmarshal roles: %w", err)
}

return roles, nil
}

func getJoinCommand(url string, authSlug string, roles []string) (*types.GenerateEmbeddedClusterNodeJoinCommandResponse, error) {
payload := types.GenerateEmbeddedClusterNodeJoinCommandRequest{
Roles: roles,
}
b, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("failed to marshal roles: %w", err)
}

newReq, err := http.NewRequest("POST", url, bytes.NewBuffer(b))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
newReq.Header.Add("Content-Type", "application/json")
newReq.Header.Add("Authorization", authSlug)

resp, err := http.DefaultClient.Do(newReq)
if err != nil {
return nil, fmt.Errorf("failed to execute request: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}

fullResponse, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}

joinCommand := &types.GenerateEmbeddedClusterNodeJoinCommandResponse{}
if err := json.Unmarshal(fullResponse, joinCommand); err != nil {
return nil, fmt.Errorf("failed to unmarshal roles: %w", err)
}

return joinCommand, nil
}
214 changes: 214 additions & 0 deletions cmd/kots/cli/get-joincommand_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
package cli

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"testing"

"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/kubernetes/fake"
)

func TestGetJoinCommand(t *testing.T) {
tests := []struct {
name string
service *corev1.Service
secret *corev1.Secret
handler http.HandlerFunc
expectedError string
expectedCmd string
}{
{
name: "successful join command generation",
service: &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "kotsadm",
Namespace: "kotsadm",
},
Spec: corev1.ServiceSpec{
ClusterIP: "127.0.0.1",
Ports: []corev1.ServicePort{
{},
},
},
},
secret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "kotsadm-authstring",
Namespace: "kotsadm",
},
Data: map[string][]byte{
"kotsadm-authstring": []byte("test-auth-token"),
},
},
handler: func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
require.Equal(t, "/api/v1/embedded-cluster/roles", r.URL.Path)
require.Equal(t, "test-auth-token", r.Header.Get("Authorization"))

response := map[string]interface{}{
"roles": []string{"controller-role-name-normally-not-different", "worker"},
"controllerRoleName": "test-controller-role-name",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
case "POST":
require.Equal(t, "/api/v1/embedded-cluster/generate-node-join-command", r.URL.Path)
require.Equal(t, "test-auth-token", r.Header.Get("Authorization"))
require.Equal(t, "application/json", r.Header.Get("Content-Type"))

var requestBody struct {
Roles []string `json:"roles"`
}
err := json.NewDecoder(r.Body).Decode(&requestBody)
require.NoError(t, err)
require.Equal(t, []string{"test-controller-role-name"}, requestBody.Roles)

response := map[string][]string{
"command": {"embedded-cluster", "join", "--token", "test-token"},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
},
expectedCmd: "embedded-cluster join --token test-token",
},
{
name: "missing service",
service: nil,
expectedError: "unable to get kotsadm service",
},
{
name: "server returns error status when fetching roles",
service: &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "kotsadm",
Namespace: "kotsadm",
},
Spec: corev1.ServiceSpec{
ClusterIP: "127.0.0.1",
Ports: []corev1.ServicePort{
{},
},
},
},
secret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "kotsadm-authstring",
Namespace: "kotsadm",
},
Data: map[string][]byte{
"kotsadm-authstring": []byte("test-auth-token"),
},
},
handler: func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
response := map[string]string{
"error": "internal server error",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
},
expectedError: "failed to get roles: unexpected status code: 500",
},
{
name: "server returns error status when creating token",
service: &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "kotsadm",
Namespace: "kotsadm",
},
Spec: corev1.ServiceSpec{
ClusterIP: "127.0.0.1",
Ports: []corev1.ServicePort{
{},
},
},
},
secret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "kotsadm-authstring",
Namespace: "kotsadm",
},
Data: map[string][]byte{
"kotsadm-authstring": []byte("test-auth-token"),
},
},

handler: func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
require.Equal(t, "/api/v1/embedded-cluster/roles", r.URL.Path)
require.Equal(t, "test-auth-token", r.Header.Get("Authorization"))

response := map[string]interface{}{
"roles": []string{"controller-role-name-normally-not-different", "worker"},
"controllerRoleName": "test-controller-role-name",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
case "POST":
w.WriteHeader(http.StatusInternalServerError)
response := map[string]string{
"error": "internal server error",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
},
expectedError: "failed to get join command: unexpected status code: 500",
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
// Create a test server if we have a handler
var server *httptest.Server
if test.handler != nil {
server = httptest.NewServer(test.handler)
defer server.Close()

// Update the service IP and port to match the test server
serverURL, err := url.Parse(server.URL)
require.NoError(t, err)

host := serverURL.Hostname()
port, err := strconv.ParseInt(serverURL.Port(), 10, 32)
require.NoError(t, err)

test.service.Spec.ClusterIP = host
test.service.Spec.Ports[0].Port = int32(port)
}

// Create fake client with test objects
var objects []runtime.Object
if test.service != nil {
objects = append(objects, test.service)
}
if test.secret != nil {
objects = append(objects, test.secret)
}
fakeClient := fake.NewSimpleClientset(objects...)

// Call GetJoinCommand
cmd, err := getJoinCommandCmd(context.Background(), fakeClient, "kotsadm")

// Verify results
if test.expectedError != "" {
require.Error(t, err)
require.Contains(t, err.Error(), test.expectedError)
} else {
require.NoError(t, err)
require.Equal(t, test.expectedCmd, cmd)
}
})
}
}
1 change: 1 addition & 0 deletions cmd/kots/cli/get.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ kubectl kots get apps`,
cmd.AddCommand(GetVersionsCmd())
cmd.AddCommand(GetConfigCmd())
cmd.AddCommand(GetRestoresCmd())
cmd.AddCommand(GetJoinCmd())

return cmd
}
Loading
Loading