diff --git a/.gitignore b/.gitignore index a2f58b2f08..8c7172b678 100644 --- a/.gitignore +++ b/.gitignore @@ -20,4 +20,4 @@ k8s_*.txt /docs/tools/.sphinx/warnings.txt /docs/tools/.sphinx/.wordlist.dic /docs/tools/.sphinx/.doctrees/ -/docs/tools/.sphinx/node_modules \ No newline at end of file +/docs/tools/.sphinx/node_modules diff --git a/src/k8s/cmd/k8s/k8s_config.go b/src/k8s/cmd/k8s/k8s_config.go index 5a16acbdc9..bd0a2b190c 100644 --- a/src/k8s/cmd/k8s/k8s_config.go +++ b/src/k8s/cmd/k8s/k8s_config.go @@ -32,10 +32,14 @@ func newKubeConfigCmd(env cmdutil.ExecutionEnvironment) *cobra.Command { return } - if _, err := client.NodeStatus(cmd.Context()); err != nil { + if _, isBootstrapped, err := cmdutil.GetNodeStatus(cmd.Context(), client, env); !isBootstrapped { cmd.PrintErrln("Error: The node is not part of a Kubernetes cluster. You can bootstrap a new cluster with:\n\n sudo k8s bootstrap") env.Exit(1) return + } else if err != nil { + cmd.PrintErrf("Error: Failed to retrieve the node status.\n\nThe error was: %v\n", err) + env.Exit(1) + return } ctx, cancel := context.WithTimeout(cmd.Context(), opts.timeout) diff --git a/src/k8s/cmd/k8s/k8s_helm.go b/src/k8s/cmd/k8s/k8s_helm.go index 7ae707a512..383749a58c 100644 --- a/src/k8s/cmd/k8s/k8s_helm.go +++ b/src/k8s/cmd/k8s/k8s_helm.go @@ -24,10 +24,14 @@ func newHelmCmd(env cmdutil.ExecutionEnvironment) *cobra.Command { return } - if status, err := client.NodeStatus(cmd.Context()); err != nil { + if status, isBootstrapped, err := cmdutil.GetNodeStatus(cmd.Context(), client, env); !isBootstrapped { cmd.PrintErrln("Error: The node is not part of a Kubernetes cluster. You can bootstrap a new cluster with:\n\n sudo k8s bootstrap") env.Exit(1) return + } else if err != nil { + cmd.PrintErrf("Error: Failed to retrieve the node status.\n\nThe error was: %v\n", err) + env.Exit(1) + return } else if status.ClusterRole == apiv1.ClusterRoleWorker { cmd.PrintErrln("Error: k8s helm commands are not allowed on worker nodes.") env.Exit(1) diff --git a/src/k8s/cmd/k8s/k8s_kubectl.go b/src/k8s/cmd/k8s/k8s_kubectl.go index 28aeeff0e8..93d191d7db 100644 --- a/src/k8s/cmd/k8s/k8s_kubectl.go +++ b/src/k8s/cmd/k8s/k8s_kubectl.go @@ -24,10 +24,14 @@ func newKubectlCmd(env cmdutil.ExecutionEnvironment) *cobra.Command { return } - if status, err := client.NodeStatus(cmd.Context()); err != nil { + if status, isBootstrapped, err := cmdutil.GetNodeStatus(cmd.Context(), client, env); !isBootstrapped { cmd.PrintErrln("Error: The node is not part of a Kubernetes cluster. You can bootstrap a new cluster with:\n\n sudo k8s bootstrap") env.Exit(1) return + } else if err != nil { + cmd.PrintErrf("Error: Failed to retrieve the node status.\n\nThe error was: %v\n", err) + env.Exit(1) + return } else if status.ClusterRole == apiv1.ClusterRoleWorker { cmd.PrintErrln("Error: k8s kubectl commands are not allowed on worker nodes.") env.Exit(1) diff --git a/src/k8s/cmd/k8s/k8s_local_node_status.go b/src/k8s/cmd/k8s/k8s_local_node_status.go index 81422feb9d..16c7ded138 100644 --- a/src/k8s/cmd/k8s/k8s_local_node_status.go +++ b/src/k8s/cmd/k8s/k8s_local_node_status.go @@ -22,9 +22,13 @@ func newLocalNodeStatusCommand(env cmdutil.ExecutionEnvironment) *cobra.Command return } - status, err := client.NodeStatus(cmd.Context()) - if err != nil { - cmd.PrintErrf("Error: Failed to get the status of the local node.\n\nThe error was: %v\n", err) + status, isBootstrapped, err := cmdutil.GetNodeStatus(cmd.Context(), client, env) + if !isBootstrapped { + cmd.PrintErrln("Error: The node is not part of a Kubernetes cluster. You can bootstrap a new cluster with:\n\n sudo k8s bootstrap") + env.Exit(1) + return + } else if err != nil { + cmd.PrintErrf("Error: Failed to retrieve the local node status.\n\nThe error was: %v\n", err) env.Exit(1) return } diff --git a/src/k8s/cmd/k8s/k8s_status.go b/src/k8s/cmd/k8s/k8s_status.go index e5ffdec5bd..a6f107f06f 100644 --- a/src/k8s/cmd/k8s/k8s_status.go +++ b/src/k8s/cmd/k8s/k8s_status.go @@ -35,10 +35,14 @@ func newStatusCmd(env cmdutil.ExecutionEnvironment) *cobra.Command { ctx, cancel := context.WithTimeout(cmd.Context(), opts.timeout) cobra.OnFinalize(cancel) - if _, err := client.NodeStatus(cmd.Context()); err != nil { + if _, isBootstrapped, err := cmdutil.GetNodeStatus(cmd.Context(), client, env); !isBootstrapped { cmd.PrintErrln("Error: The node is not part of a Kubernetes cluster. You can bootstrap a new cluster with:\n\n sudo k8s bootstrap") env.Exit(1) return + } else if err != nil { + cmd.PrintErrf("Error: Failed to retrieve the node status.\n\nThe error was: %v\n", err) + env.Exit(1) + return } status, err := client.ClusterStatus(ctx, opts.waitReady) diff --git a/src/k8s/cmd/util/node_status.go b/src/k8s/cmd/util/node_status.go new file mode 100644 index 0000000000..e5e4892e00 --- /dev/null +++ b/src/k8s/cmd/util/node_status.go @@ -0,0 +1,37 @@ +package cmdutil + +import ( + "context" + "errors" + "net/http" + + apiv1 "github.com/canonical/k8s/api/v1" + "github.com/canonical/k8s/pkg/client/k8sd" + + "github.com/canonical/lxd/shared/api" +) + +// GetNodeStatus retrieves the NodeStatus from k8sd server. If the daemon is not initialized, it exits with an error +// describing that the cluster should be bootstrapped. In case of any other errors it exits and shows the error. +func GetNodeStatus(ctx context.Context, client k8sd.Client, env ExecutionEnvironment) (status apiv1.NodeStatus, isBootstrapped bool, err error) { + status, err = client.NodeStatus(ctx) + if err == nil { + return status, true, nil + } + + if errors.As(err, &api.StatusError{}) { + // the returned `ok` can be ignored since we're using errors.As() + // on the same type immediately before it + statusErr, _ := err.(api.StatusError) + + // if we get an `http.StatusServiceUnavailable` it will be (most likely) because + // the handler we're trying to reach is not `AllowedBeforeInit` and hence we can understand that + // the daemon is not yet initialized (this statement should be available + // in the `statusErr.Error()` explicitly but for the sake of decoupling we don't rely on that) + if statusErr.Status() == http.StatusServiceUnavailable { + return status, false, err + } + } + + return status, true, err +} diff --git a/src/k8s/cmd/util/node_status_test.go b/src/k8s/cmd/util/node_status_test.go new file mode 100644 index 0000000000..c60de4d41e --- /dev/null +++ b/src/k8s/cmd/util/node_status_test.go @@ -0,0 +1,90 @@ +package cmdutil_test + +import ( + "context" + "errors" + "net/http" + "testing" + + apiv1 "github.com/canonical/k8s/api/v1" + cmdutil "github.com/canonical/k8s/cmd/util" + k8sdmock "github.com/canonical/k8s/pkg/client/k8sd/mock" + snapmock "github.com/canonical/k8s/pkg/snap/mock" + "github.com/canonical/lxd/shared/api" + + . "github.com/onsi/gomega" +) + +func TestGetNodeStatusUtil(t *testing.T) { + type testcase struct { + expIsBootstrapped bool + name string + nodeStatusErr error + nodeStatusResult apiv1.NodeStatus + } + + tests := []testcase{ + { + name: "NoError", + expIsBootstrapped: true, + nodeStatusResult: apiv1.NodeStatus{ + Name: "name", Address: "addr", + ClusterRole: apiv1.ClusterRoleControlPlane, DatastoreRole: apiv1.DatastoreRoleVoter, + }, + }, + { + name: "DaemonNotInitialized", + nodeStatusErr: api.StatusErrorf(http.StatusServiceUnavailable, "Daemon not yet initialized"), + expIsBootstrapped: false, + }, + { + name: "RandomError", + nodeStatusErr: errors.New("something went bad"), + expIsBootstrapped: true, + }, + { + name: "ContextCanceled", + nodeStatusErr: context.Canceled, + expIsBootstrapped: true, + }, + { + name: "ContextDeadlineExceeded", + nodeStatusErr: context.DeadlineExceeded, + expIsBootstrapped: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + g := NewWithT(t) + + var ( + mockClient = &k8sdmock.Mock{ + NodeStatusErr: test.nodeStatusErr, + NodeStatusResult: test.nodeStatusResult, + } + env = cmdutil.ExecutionEnvironment{ + Getuid: func() int { return 0 }, + Snap: &snapmock.Snap{ + Mock: snapmock.Mock{ + K8sdClient: mockClient, + }, + }, + } + ) + + status, isBootstrapped, err := cmdutil.GetNodeStatus(context.TODO(), mockClient, env) + + g.Expect(isBootstrapped).To(Equal(test.expIsBootstrapped)) + if test.nodeStatusErr == nil { + g.Expect(err).To(BeNil()) + } else { + g.Expect(err).To(MatchError(test.nodeStatusErr)) + } + + if err == nil { + g.Expect(status).To(Equal(test.nodeStatusResult)) + } + }) + } +} diff --git a/src/k8s/pkg/k8sd/api/node.go b/src/k8s/pkg/k8sd/api/node.go index c19c495b4e..0d6bae9a4c 100644 --- a/src/k8s/pkg/k8sd/api/node.go +++ b/src/k8s/pkg/k8sd/api/node.go @@ -14,7 +14,7 @@ func (e *Endpoints) getNodeStatus(s state.State, r *http.Request) response.Respo status, err := impl.GetLocalNodeStatus(r.Context(), s, snap) if err != nil { - response.InternalError(err) + return response.InternalError(err) } result := apiv1.GetNodeStatusResponse{