diff --git a/frontend/packages/console-app/src/components/cloud-shell/CloudShellTerminal.tsx b/frontend/packages/console-app/src/components/cloud-shell/CloudShellTerminal.tsx index 0b0fd959ff4..8b602d73e58 100644 --- a/frontend/packages/console-app/src/components/cloud-shell/CloudShellTerminal.tsx +++ b/frontend/packages/console-app/src/components/cloud-shell/CloudShellTerminal.tsx @@ -12,12 +12,13 @@ import { } from '@console/shared'; import { FLAG_V1ALPHA2DEVWORKSPACE } from '../../consts'; import { v1alpha1WorkspaceModel, WorkspaceModel } from '../../models'; -import { TerminalInitData, initTerminal } from './cloud-shell-utils'; +import { TerminalInitData, initTerminal, startWorkspace } from './cloud-shell-utils'; import CloudshellExec from './CloudShellExec'; import { CLOUD_SHELL_NAMESPACE, CLOUD_SHELL_NAMESPACE_CONFIG_STORAGE_KEY } from './const'; import CloudShellAdminSetup from './setup/CloudShellAdminSetup'; import CloudShellDeveloperSetup from './setup/CloudShellDeveloperSetup'; import TerminalLoadingBox from './TerminalLoadingBox'; +import useCloudShellNamespace from './useCloudShellNamespace'; import useCloudShellWorkspace from './useCloudShellWorkspace'; import './CloudShellTerminal.scss'; @@ -39,6 +40,7 @@ const CloudShellTerminal: React.FC { + const [operatorNamespace, namespaceLoadError] = useCloudShellNamespace(); const [initData, setInitData] = React.useState(); const [initError, setInitError] = React.useState(); const [isAdmin, isAdminCheckLoading] = useAccessReview2({ @@ -63,6 +65,38 @@ const CloudShellTerminal: React.FC { + if (namespaceLoadError) { + setInitError(namespaceLoadError); + } + }, [namespaceLoadError]); + + // start the workspace if no unrecoverable errors were found + React.useEffect(() => { + if ( + operatorNamespace && + !unrecoverableErrorFound && + workspace?.spec && + !workspace.spec.started + ) { + startWorkspace(workspace); + } + // Run this effect if the workspace name or namespace changes. + // This effect should only be run once per workspace. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + operatorNamespace, + unrecoverableErrorFound, + // eslint-disable-next-line react-hooks/exhaustive-deps + workspace?.metadata?.name, + // eslint-disable-next-line react-hooks/exhaustive-deps + workspace?.metadata?.namespace, + ]); + // save the namespace once the workspace has loaded React.useEffect(() => { if (loaded && !loadError) { @@ -71,11 +105,13 @@ const CloudShellTerminal: React.FC { - setInitData(undefined); - setInitError(undefined); - }, [username, workspaceName, workspaceNamespace]); + if (!unrecoverableErrorFound) { + setInitData(undefined); + setInitError(undefined); + } + }, [unrecoverableErrorFound, username, workspaceName, workspaceNamespace]); // initialize the terminal once it is Running React.useEffect(() => { @@ -132,7 +168,7 @@ const CloudShellTerminal: React.FC; } @@ -165,6 +201,7 @@ const CloudShellTerminal: React.FC ); } @@ -177,6 +214,7 @@ const CloudShellTerminal: React.FC { setNamespace(ns); }} + operatorNamespace={operatorNamespace} /> ); }; diff --git a/frontend/packages/console-app/src/components/cloud-shell/__tests__/CloudShellTerminal.spec.tsx b/frontend/packages/console-app/src/components/cloud-shell/__tests__/CloudShellTerminal.spec.tsx index 47143c71cbd..c45ded4d358 100644 --- a/frontend/packages/console-app/src/components/cloud-shell/__tests__/CloudShellTerminal.spec.tsx +++ b/frontend/packages/console-app/src/components/cloud-shell/__tests__/CloudShellTerminal.spec.tsx @@ -5,6 +5,7 @@ import { useFlag } from '@console/shared'; import { InternalCloudShellTerminal } from '../CloudShellTerminal'; import CloudShellDeveloperSetup from '../setup/CloudShellDeveloperSetup'; import TerminalLoadingBox from '../TerminalLoadingBox'; +import useCloudShellNamespace from '../useCloudShellNamespace'; import useCloudShellWorkspace from '../useCloudShellWorkspace'; import { user } from './cloud-shell-test-data'; @@ -12,6 +13,10 @@ jest.mock('../useCloudShellWorkspace', () => ({ default: jest.fn(), })); +jest.mock('../useCloudShellNamespace', () => ({ + default: jest.fn(), +})); + jest.mock('@console/internal/components/utils/rbac', () => ({ useAccessReview2: () => [false, false], })); @@ -36,6 +41,7 @@ describe('CloudShellTerminal', () => { it('should display loading box', () => { useFlagMock.mockReturnValue(true); (useCloudShellWorkspace as jest.Mock).mockReturnValueOnce([null, false]); + (useCloudShellNamespace as jest.Mock).mockReturnValueOnce(['sample-namespace', '']); const wrapper = shallow( { it('should display error statusBox', () => { useFlagMock.mockReturnValue(true); (useCloudShellWorkspace as jest.Mock).mockReturnValueOnce([null, false, true]); + (useCloudShellNamespace as jest.Mock).mockReturnValueOnce(['sample-namespace', '']); const wrapper = shallow( { it('should display form if loaded and no workspace', () => { useFlagMock.mockReturnValue(true); (useCloudShellWorkspace as jest.Mock).mockReturnValueOnce([[], true]); + (useCloudShellNamespace as jest.Mock).mockReturnValueOnce(['sample-namespace', '']); const wrapper = shallow( { const namespace = 'default'; const kind = 'DevWorkspace'; - const newResource = newCloudShellWorkSpace(name, namespace, 'v1alpha2'); + const newResource = newCloudShellWorkSpace(name, namespace, namespace, 'v1alpha2'); expect(newResource.kind).toEqual(kind); expect(newResource.metadata.name).toEqual(name); expect(newResource.metadata.namespace).toEqual(namespace); diff --git a/frontend/packages/console-app/src/components/cloud-shell/cloud-shell-utils.ts b/frontend/packages/console-app/src/components/cloud-shell/cloud-shell-utils.ts index 3768be9b711..4db4041debf 100644 --- a/frontend/packages/console-app/src/components/cloud-shell/cloud-shell-utils.ts +++ b/frontend/packages/console-app/src/components/cloud-shell/cloud-shell-utils.ts @@ -62,13 +62,13 @@ const v1alpha1DevworkspaceComponent = [ }, ]; -const devWorkspaceComponent = [ +const devWorkspaceComponent = (namespace: string) => [ { name: 'web-terminal-tooling', plugin: { kubernetes: { name: 'web-terminal-tooling', - namespace: 'openshift-operators', + namespace, }, }, }, @@ -77,7 +77,7 @@ const devWorkspaceComponent = [ plugin: { kubernetes: { name: 'web-terminal-exec', - namespace: 'openshift-operators', + namespace, }, }, }, @@ -85,14 +85,15 @@ const devWorkspaceComponent = [ export const newCloudShellWorkSpace = ( name: string, - namespace: string, + workspaceNamespace: string, + operatorNamespace: string, version: string, ): CloudShellResource => ({ apiVersion: `workspace.devfile.io/${version}`, kind: 'DevWorkspace', metadata: { name, - namespace, + namespace: workspaceNamespace, labels: { [CLOUD_SHELL_LABEL]: 'true', }, @@ -107,7 +108,7 @@ export const newCloudShellWorkSpace = ( components: version === v1alpha1WorkspaceModel.apiVersion ? v1alpha1DevworkspaceComponent - : devWorkspaceComponent, + : devWorkspaceComponent(operatorNamespace), }, }, }); @@ -153,3 +154,5 @@ export const checkTerminalAvailable = () => coFetch('/api/terminal/available'); export const getCloudShellCR = (workspaceModel: K8sKind, name: string, ns: string) => { return k8sGet(workspaceModel, name, ns); }; + +export const getTerminalInstalledNamespace = () => coFetch('/api/terminal/installedNamespace'); diff --git a/frontend/packages/console-app/src/components/cloud-shell/setup/CloudShellAdminSetup.tsx b/frontend/packages/console-app/src/components/cloud-shell/setup/CloudShellAdminSetup.tsx index 5891ef5bb77..311240ec896 100644 --- a/frontend/packages/console-app/src/components/cloud-shell/setup/CloudShellAdminSetup.tsx +++ b/frontend/packages/console-app/src/components/cloud-shell/setup/CloudShellAdminSetup.tsx @@ -14,9 +14,14 @@ import TerminalLoadingBox from '../TerminalLoadingBox'; type Props = { onInitialize: (namespace: string) => void; workspaceModel: K8sKind; + operatorNamespace: string; }; -const CloudShellAdminSetup: React.FunctionComponent = ({ onInitialize, workspaceModel }) => { +const CloudShellAdminSetup: React.FunctionComponent = ({ + onInitialize, + workspaceModel, + operatorNamespace, +}) => { const { t } = useTranslation(); const [initError, setInitError] = React.useState(); @@ -48,6 +53,7 @@ const CloudShellAdminSetup: React.FunctionComponent = ({ onInitialize, wo newCloudShellWorkSpace( createCloudShellResourceName(), CLOUD_SHELL_PROTECTED_NAMESPACE, + operatorNamespace, workspaceModel.apiVersion, ), ); diff --git a/frontend/packages/console-app/src/components/cloud-shell/setup/CloudShellDeveloperSetup.tsx b/frontend/packages/console-app/src/components/cloud-shell/setup/CloudShellDeveloperSetup.tsx index 19b200cb874..0a33c011702 100644 --- a/frontend/packages/console-app/src/components/cloud-shell/setup/CloudShellDeveloperSetup.tsx +++ b/frontend/packages/console-app/src/components/cloud-shell/setup/CloudShellDeveloperSetup.tsx @@ -23,11 +23,13 @@ type Props = StateProps & { onSubmit?: (namespace: string) => void; onCancel?: () => void; workspaceModel: K8sKind; + operatorNamespace: string; }; const CloudShellDeveloperSetup: React.FunctionComponent = ({ activeNamespace, workspaceModel, + operatorNamespace, onSubmit, onCancel, }) => { @@ -53,6 +55,7 @@ const CloudShellDeveloperSetup: React.FunctionComponent = ({ newCloudShellWorkSpace( createCloudShellResourceName(), namespace, + operatorNamespace, workspaceModel.apiVersion, ), ); diff --git a/frontend/packages/console-app/src/components/cloud-shell/useCloudShellNamespace.ts b/frontend/packages/console-app/src/components/cloud-shell/useCloudShellNamespace.ts new file mode 100644 index 00000000000..7206412f836 --- /dev/null +++ b/frontend/packages/console-app/src/components/cloud-shell/useCloudShellNamespace.ts @@ -0,0 +1,27 @@ +import * as React from 'react'; +import { useSafetyFirst } from '@console/internal/components/safety-first'; +import { getTerminalInstalledNamespace } from './cloud-shell-utils'; + +const useCloudShellNamespace = (): [string, string] => { + const [terminalNamespace, setTerminalNamespace] = useSafetyFirst(undefined); + const [fetchError, setFetchError] = useSafetyFirst(undefined); + React.useEffect(() => { + const fetchNamespace = async () => { + try { + if (!terminalNamespace) { + const namespaceRequest = await getTerminalInstalledNamespace(); + const namespace = await namespaceRequest.text(); + setTerminalNamespace(namespace); + } + } catch (e) { + const errorMessage = await e.response.text(); + setFetchError(errorMessage); + } + }; + fetchNamespace(); + }, [setFetchError, setTerminalNamespace, terminalNamespace]); + + return [terminalNamespace, fetchError]; +}; + +export default useCloudShellNamespace; diff --git a/frontend/packages/console-app/src/components/cloud-shell/useCloudShellWorkspace.ts b/frontend/packages/console-app/src/components/cloud-shell/useCloudShellWorkspace.ts index 9425730e928..ae1ab3b3c04 100644 --- a/frontend/packages/console-app/src/components/cloud-shell/useCloudShellWorkspace.ts +++ b/frontend/packages/console-app/src/components/cloud-shell/useCloudShellWorkspace.ts @@ -10,7 +10,6 @@ import { CLOUD_SHELL_CREATOR_LABEL, CloudShellResource, CLOUD_SHELL_RESTRICTED_ANNOTATION, - startWorkspace, CLOUD_SHELL_PROTECTED_NAMESPACE, } from './cloud-shell-utils'; @@ -158,15 +157,6 @@ const useCloudShellWorkspace = ( workspaceModel, ]); - React.useEffect(() => { - if (workspace?.spec && !workspace.spec.started) { - startWorkspace(workspace); - } - // Run this effect if the workspace name or namespace changes. - // This effect should only be run once per workspace. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [workspace?.metadata?.name, workspace?.metadata?.namespace]); - return [ workspace, // loaded if we have a resource loaded and currently not searching diff --git a/pkg/server/server.go b/pkg/server/server.go index 219c5ca3e03..238dfcefa45 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -274,6 +274,7 @@ func (s *Server) HTTPHandler() http.Handler { handle(terminal.ProxyEndpoint, authHandlerWithUser(terminalProxy.HandleProxy)) handleFunc(terminal.AvailableEndpoint, terminalProxy.HandleProxyEnabled) + handleFunc(terminal.InstalledNamespaceEndpoint, terminalProxy.HandleTerminalInstalledNamespace) graphQLSchema, err := ioutil.ReadFile("pkg/graphql/schema.graphql") if err != nil { diff --git a/pkg/terminal/operator.go b/pkg/terminal/operator.go index c734e299b73..406bb9ab363 100644 --- a/pkg/terminal/operator.go +++ b/pkg/terminal/operator.go @@ -2,9 +2,10 @@ package terminal import ( "context" - - "k8s.io/apimachinery/pkg/api/errors" + "errors" + k8sErrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/dynamic" "k8s.io/client-go/kubernetes" @@ -12,7 +13,7 @@ import ( ) var ( - OperatorAPIResource = &schema.GroupVersionResource{ + OperatorAPISubscriptionResource = &schema.GroupVersionResource{ Group: "operators.coreos.com", Version: "v1alpha1", Resource: "subscriptions", @@ -27,7 +28,6 @@ var ( const ( webhookName = "controller.devfile.io" webTerminalOperatorName = "web-terminal" - operatorsNamespace = "openshift-operators" ) // checkWebTerminalOperatorIsRunning checks if the workspace operator is running and webhooks are enabled, @@ -44,7 +44,7 @@ func checkWebTerminalOperatorIsRunning() (bool, error) { _, err = client.AdmissionregistrationV1().MutatingWebhookConfigurations().Get(context.TODO(), webhookName, metav1.GetOptions{}) if err != nil { - if errors.IsNotFound(err) { + if k8sErrors.IsNotFound(err) { return false, nil } return false, err @@ -52,7 +52,7 @@ func checkWebTerminalOperatorIsRunning() (bool, error) { _, err = client.AdmissionregistrationV1().ValidatingWebhookConfigurations().Get(context.TODO(), webhookName, metav1.GetOptions{}) if err != nil { - if errors.IsNotFound(err) { + if k8sErrors.IsNotFound(err) { return false, nil } return false, err @@ -62,28 +62,48 @@ func checkWebTerminalOperatorIsRunning() (bool, error) { // checkWebTerminalOperatorIsInstalled checks to see that a web-terminal-operator is installed on the cluster func checkWebTerminalOperatorIsInstalled() (bool, error) { - config, err := rest.InClusterConfig() + + _, err := getWebTerminalSubscriptions() if err != nil { + // Web Terminal subscription is not found but it's technically not a real error so we don't want to propogate it. Just say that the operator is not installed + if k8sErrors.IsNotFound(err) { + return false, nil + } + return false, err } + return true, nil +} + +func getWebTerminalSubscriptions() (*unstructured.UnstructuredList, error) { + config, err := rest.InClusterConfig() + if err != nil { + return nil, err + } config.GroupVersion = OperatorGroupVersion config.APIPath = "apis" client, err := dynamic.NewForConfig(config) if err != nil { - return false, err + return nil, err } - - _, err = client.Resource(*OperatorAPIResource).Namespace(operatorsNamespace).Get(context.TODO(), webTerminalOperatorName, metav1.GetOptions{}) + subs, err := client.Resource(*OperatorAPISubscriptionResource).List(context.TODO(), metav1.ListOptions{ + FieldSelector: "metadata.name=" + webTerminalOperatorName, + }) if err != nil { - // Web Terminal subscription is not found but it's technically not a real error so we don't want to propogate it. Just say that the operator is not installed - if errors.IsNotFound(err) { - return false, nil - } + return subs, err + } + return subs, err +} - return false, err +func getWebTerminalNamespace(subs *unstructured.UnstructuredList) (string, error) { + if len(subs.Items) > 1 { + return "", errors.New("found multiple subscriptions for web-terminal when only one should be found") } - return true, nil + webTerminalSubscription := subs.Items[0] + namespace := webTerminalSubscription.GetNamespace() + + return namespace, nil } diff --git a/pkg/terminal/proxy.go b/pkg/terminal/proxy.go index 0b0669e39fa..090e7069bc5 100644 --- a/pkg/terminal/proxy.go +++ b/pkg/terminal/proxy.go @@ -25,6 +25,8 @@ const ( ProxyEndpoint = "/api/terminal/proxy/" // AvailableEndpoint path used to check if functionality is enabled AvailableEndpoint = "/api/terminal/available/" + // InstalledNamespaceEndpoint path used to get the namespace where the controller is installed + InstalledNamespaceEndpoint = "/api/terminal/installedNamespace" // WorkspaceInitEndpoint is used to initialize a kubeconfig in the workspace WorkspaceInitEndpoint = "exec/init" // WorkspaceActivityEndpoint is used to prevent idle timeout in a workspace @@ -205,6 +207,32 @@ func (p *Proxy) HandleProxyEnabled(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +func (p *Proxy) HandleTerminalInstalledNamespace(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + w.Header().Set("Allow", "GET") + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + subscription, err := getWebTerminalSubscriptions() + if err != nil { + klog.Errorf("Failed to check the web terminal subscription: %s", err) + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(err.Error())) + return + } + + operatorNamespace, err := getWebTerminalNamespace(subscription) + if err != nil { + klog.Errorf("Failed to get the namespace of the web terminal subscription: %s", err) + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(err.Error())) + return + } + + w.Write([]byte(operatorNamespace)) +} + func (p *Proxy) handleExecInit(host *url.URL, token string, r *http.Request, w http.ResponseWriter) { body, err := ioutil.ReadAll(r.Body) if err != nil {