Skip to content

Commit 54ee800

Browse files
committedJul 12, 2023
implemented 'Open Dashboard' action (#617)
Signed-off-by: Andre Dietisheim <[email protected]>
1 parent bf78497 commit 54ee800

File tree

16 files changed

+862
-13
lines changed

16 files changed

+862
-13
lines changed
 
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/*******************************************************************************
2+
* Copyright (c) 2023 Red Hat, Inc.
3+
* Distributed under license by Red Hat, Inc. All rights reserved.
4+
* This program is made available under the terms of the
5+
* Eclipse Public License v2.0 which accompanies this distribution,
6+
* and is available at http://www.eclipse.org/legal/epl-v20.html
7+
*
8+
* Contributors:
9+
* Red Hat, Inc. - initial API and implementation
10+
******************************************************************************/
11+
package com.redhat.devtools.intellij.kubernetes.actions
12+
13+
import com.intellij.ide.BrowserUtil
14+
import com.intellij.openapi.actionSystem.AnActionEvent
15+
import com.intellij.openapi.diagnostic.logger
16+
import com.redhat.devtools.intellij.common.actions.StructureTreeAction
17+
import com.redhat.devtools.intellij.kubernetes.model.Notification
18+
import com.redhat.devtools.intellij.kubernetes.model.context.IActiveContext
19+
import com.redhat.devtools.intellij.kubernetes.telemetry.TelemetryService
20+
import com.redhat.devtools.intellij.kubernetes.telemetry.TelemetryService.PROP_RESOURCE_KIND
21+
import javax.swing.tree.TreePath
22+
23+
class OpenDashboardAction: StructureTreeAction() {
24+
25+
override fun actionPerformed(event: AnActionEvent?, path: Array<out TreePath>?, selected: Array<out Any>?) {
26+
val model = getResourceModel() ?: return
27+
val currentContext = model.getCurrentContext() ?: return
28+
run("Opening Dashboard...", true)
29+
{
30+
val telemetry = TelemetryService.instance.action("open dashboard")
31+
.property(PROP_RESOURCE_KIND, currentContext.name)
32+
try {
33+
val url = currentContext.getDashboardUrl()
34+
BrowserUtil.open(url.toString())
35+
telemetry.success().send()
36+
} catch (e: Exception) {
37+
logger<OpenDashboardAction>().warn("Could not open Dashboard", e)
38+
Notification().error("Dashboard Error", e.message ?: "")
39+
telemetry.error(e).send()
40+
}
41+
}
42+
}
43+
44+
override fun actionPerformed(event: AnActionEvent?, path: TreePath?, selected: Any?) {
45+
// not called
46+
}
47+
48+
override fun isVisible(selected: Array<out Any>?): Boolean {
49+
return selected?.any { isVisible(it) }
50+
?: false
51+
}
52+
53+
override fun isVisible(selected: Any?): Boolean {
54+
val context = selected?.getElement<IActiveContext<*,*>>()
55+
return context != null
56+
}
57+
}

‎src/main/kotlin/com/redhat/devtools/intellij/kubernetes/model/context/ActiveContext.kt

+5-2
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import com.redhat.devtools.intellij.kubernetes.model.context.IActiveContext.Reso
2323
import com.redhat.devtools.intellij.kubernetes.model.context.IActiveContext.ResourcesIn.ANY_NAMESPACE
2424
import com.redhat.devtools.intellij.kubernetes.model.context.IActiveContext.ResourcesIn.CURRENT_NAMESPACE
2525
import com.redhat.devtools.intellij.kubernetes.model.context.IActiveContext.ResourcesIn.NO_NAMESPACE
26+
import com.redhat.devtools.intellij.kubernetes.model.dashboard.IDashboard
2627
import com.redhat.devtools.intellij.kubernetes.model.resource.INamespacedResourceOperator
2728
import com.redhat.devtools.intellij.kubernetes.model.resource.INonNamespacedResourceOperator
2829
import com.redhat.devtools.intellij.kubernetes.model.resource.IResourceOperator
@@ -52,8 +53,9 @@ import java.net.URL
5253
abstract class ActiveContext<N : HasMetadata, C : KubernetesClient>(
5354
context: NamedContext,
5455
private val modelChange: IResourceModelObservable,
55-
override val client: ClientAdapter<out C>,
56-
private var singleResourceOperator: NonCachingSingleResourceOperator = NonCachingSingleResourceOperator(client)
56+
val client: ClientAdapter<out C>,
57+
protected open val dashboard: IDashboard,
58+
private var singleResourceOperator: NonCachingSingleResourceOperator = NonCachingSingleResourceOperator(client),
5759
) : Context(context), IActiveContext<N, C> {
5860

5961
override val active: Boolean = true
@@ -470,6 +472,7 @@ abstract class ActiveContext<N : HasMetadata, C : KubernetesClient>(
470472
override fun close() {
471473
logger<ActiveContext<*, *>>().debug("Closing context ${context.name}.")
472474
watch.close()
475+
dashboard.close()
473476
}
474477

475478
private inline fun <R: HasMetadata, reified O: Any> getResourceOperator(resource: R): O? {

‎src/main/kotlin/com/redhat/devtools/intellij/kubernetes/model/context/IActiveContext.kt

+6-2
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,6 @@ interface IActiveContext<N: HasMetadata, C: KubernetesClient>: IContext {
4848
}
4949
}
5050

51-
val client: ClientAdapter<out KubernetesClient>
52-
5351
/**
5452
* The scope in which resources may exist.
5553
*/
@@ -66,6 +64,10 @@ interface IActiveContext<N: HasMetadata, C: KubernetesClient>: IContext {
6664
}
6765
}
6866

67+
val name: String?
68+
get() {
69+
return context.name
70+
}
6971
/**
7072
* The master url for this context. This is the url of the cluster for this context.
7173
*/
@@ -242,6 +244,8 @@ interface IActiveContext<N: HasMetadata, C: KubernetesClient>: IContext {
242244
*/
243245
fun replaced(resource: HasMetadata): Boolean
244246

247+
fun getDashboardUrl(): String?
248+
245249
/**
246250
* Closes and disposes this context.
247251
*/

‎src/main/kotlin/com/redhat/devtools/intellij/kubernetes/model/context/KubernetesContext.kt

+14-1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ package com.redhat.devtools.intellij.kubernetes.model.context
1313
import com.redhat.devtools.intellij.kubernetes.model.IResourceModelObservable
1414
import com.redhat.devtools.intellij.kubernetes.model.client.ClientAdapter
1515
import com.redhat.devtools.intellij.kubernetes.model.client.KubeClientAdapter
16+
import com.redhat.devtools.intellij.kubernetes.model.dashboard.KubernetesDashboard
1617
import com.redhat.devtools.intellij.kubernetes.model.resource.IResourceOperator
1718
import com.redhat.devtools.intellij.kubernetes.model.resource.OperatorFactory
1819
import com.redhat.devtools.intellij.kubernetes.model.resource.ResourceKind
@@ -26,7 +27,16 @@ open class KubernetesContext(
2627
context: NamedContext,
2728
modelChange: IResourceModelObservable,
2829
client: KubeClientAdapter,
29-
) : ActiveContext<Namespace, KubernetesClient>(context, modelChange, client) {
30+
) : ActiveContext<Namespace, KubernetesClient>(
31+
context,
32+
modelChange,
33+
client,
34+
KubernetesDashboard(
35+
client.get(),
36+
context.name,
37+
client.get().masterUrl.toExternalForm()
38+
)
39+
) {
3040

3141
override val namespaceKind : ResourceKind<Namespace> = NamespacesOperator.KIND
3242

@@ -36,4 +46,7 @@ open class KubernetesContext(
3646
}
3747

3848
override fun isOpenShift() = false
49+
override fun getDashboardUrl(): String? {
50+
return dashboard.get()
51+
}
3952
}

‎src/main/kotlin/com/redhat/devtools/intellij/kubernetes/model/context/OpenShiftContext.kt

+17-4
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ package com.redhat.devtools.intellij.kubernetes.model.context
1313
import com.redhat.devtools.intellij.kubernetes.model.IResourceModelObservable
1414
import com.redhat.devtools.intellij.kubernetes.model.client.ClientAdapter
1515
import com.redhat.devtools.intellij.kubernetes.model.client.OSClientAdapter
16+
import com.redhat.devtools.intellij.kubernetes.model.dashboard.OpenShiftDashboard
1617
import com.redhat.devtools.intellij.kubernetes.model.resource.IResourceOperator
1718
import com.redhat.devtools.intellij.kubernetes.model.resource.OperatorFactory
1819
import com.redhat.devtools.intellij.kubernetes.model.resource.openshift.ProjectsOperator
@@ -22,10 +23,19 @@ import io.fabric8.openshift.api.model.Project
2223
import io.fabric8.openshift.client.OpenShiftClient
2324

2425
open class OpenShiftContext(
25-
context: NamedContext,
26-
modelChange: IResourceModelObservable,
27-
client: OSClientAdapter,
28-
) : ActiveContext<Project, OpenShiftClient>(context, modelChange, client) {
26+
context: NamedContext,
27+
modelChange: IResourceModelObservable,
28+
client: OSClientAdapter,
29+
) : ActiveContext<Project, OpenShiftClient>(
30+
context,
31+
modelChange,
32+
client,
33+
OpenShiftDashboard(
34+
client.get(),
35+
context.name,
36+
client.get().masterUrl.toExternalForm()
37+
)
38+
) {
2939

3040
override val namespaceKind = ProjectsOperator.KIND
3141

@@ -34,4 +44,7 @@ open class OpenShiftContext(
3444
}
3545

3646
override fun isOpenShift() = true
47+
override fun getDashboardUrl(): String? {
48+
return dashboard.get()
49+
}
3750
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/*******************************************************************************
2+
* Copyright (c) 2023 Red Hat, Inc.
3+
* Distributed under license by Red Hat, Inc. All rights reserved.
4+
* This program is made available under the terms of the
5+
* Eclipse Public License v2.0 which accompanies this distribution,
6+
* and is available at http://www.eclipse.org/legal/epl-v20.html
7+
*
8+
* Contributors:
9+
* Red Hat, Inc. - initial API and implementation
10+
******************************************************************************/
11+
package com.redhat.devtools.intellij.kubernetes.model.dashboard
12+
13+
import com.redhat.devtools.intellij.kubernetes.model.util.PluginException
14+
import io.fabric8.kubernetes.client.KubernetesClient
15+
import io.fabric8.kubernetes.client.http.HttpStatusMessage
16+
17+
18+
/**
19+
* An abstract factory that can determine the url of the dashboard for a cluster.
20+
*/
21+
abstract class AbstractDashboard<C : KubernetesClient>(
22+
protected val client: C,
23+
private val contextName: String,
24+
private val clusterUrl: String?,
25+
/** for testing purposes */
26+
protected val httpRequest: HttpRequest
27+
): IDashboard {
28+
29+
private var url: String? = null
30+
31+
override fun get(): String {
32+
val url = this.url ?: connect()
33+
this.url = url
34+
return url
35+
}
36+
37+
private fun connect(): String {
38+
val status = try {
39+
doConnect()
40+
} catch (e: Exception) {
41+
throw PluginException("Could not find Dashboard for cluster $contextName at $clusterUrl: ${e.message}")
42+
} ?: throw PluginException("Could not find Dashboard for cluster $contextName at $clusterUrl")
43+
44+
if (status.isSuccessful
45+
|| status.isForbidden
46+
) {
47+
return status.url
48+
} else {
49+
throw PluginException(
50+
"Could not reach dashboard for cluster $contextName ${
51+
if (clusterUrl.isNullOrEmpty()) {
52+
""
53+
} else {
54+
"at $clusterUrl"
55+
}
56+
}" + "${
57+
if (status.status == null) {
58+
""
59+
} else {
60+
". Responded with ${
61+
HttpStatusMessage.getMessageForStatus(status.status)
62+
}"
63+
}
64+
}."
65+
)
66+
}
67+
}
68+
69+
protected abstract fun doConnect(): HttpRequest.HttpStatusCode?
70+
71+
override fun close() {
72+
// noop default impl
73+
}
74+
}
75+
76+
interface IDashboard {
77+
fun get(): String
78+
fun close()
79+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
/*******************************************************************************
2+
* Copyright (c) 2023 Red Hat, Inc.
3+
* Distributed under license by Red Hat, Inc. All rights reserved.
4+
* This program is made available under the terms of the
5+
* Eclipse Public License v2.0 which accompanies this distribution,
6+
* and is available at http://www.eclipse.org/legal/epl-v20.html
7+
*
8+
* Contributors:
9+
* Red Hat, Inc. - initial API and implementation
10+
******************************************************************************/
11+
package com.redhat.devtools.intellij.kubernetes.model.dashboard
12+
13+
import io.fabric8.kubernetes.client.http.HttpResponse
14+
import java.net.HttpURLConnection
15+
import java.security.SecureRandom
16+
import java.security.cert.CertificateException
17+
import java.security.cert.CertificateParsingException
18+
import java.security.cert.X509Certificate
19+
import javax.net.ssl.SSLContext
20+
import javax.net.ssl.SSLHandshakeException
21+
import javax.net.ssl.TrustManager
22+
import javax.net.ssl.X509TrustManager
23+
import okhttp3.OkHttpClient
24+
import okhttp3.Request
25+
import okhttp3.Response
26+
27+
class HttpRequest {
28+
29+
companion object {
30+
private val trustAllTrustManager = object : X509TrustManager {
31+
32+
@Throws(CertificateException::class)
33+
override fun checkServerTrusted(chain: Array<X509Certificate>?, authType: String?) {
34+
// ignore aka trust
35+
}
36+
37+
override fun getAcceptedIssuers(): Array<X509Certificate> {
38+
return emptyArray()
39+
}
40+
41+
@Throws(CertificateException::class)
42+
override fun checkClientTrusted(chain: Array<X509Certificate>?, authType: String?) {
43+
// ignore aka trust
44+
}
45+
}
46+
}
47+
48+
fun request(url: String): HttpStatusCode {
49+
return requestHttpStatusCode(url)
50+
}
51+
52+
fun request(host: String, port: Int): HttpStatusCode {
53+
var status = requestHttpStatusCode("http://$host:$port")
54+
if (status.isSuccessful) {
55+
return status
56+
} else {
57+
status = requestHttpStatusCode("https://$host:$port")
58+
if (status.isSuccessful) {
59+
return status
60+
}
61+
}
62+
return status
63+
}
64+
65+
/**
66+
* Requests the https status code for the given url.
67+
* Return [HttpStatusCode] and throws if connecting fails.
68+
*
69+
* @param url the url to request the http status code for
70+
*
71+
* The implementation is ignores (private) SSL certificates and doesn't verify the hostname.
72+
* All that matters is whether we can connect successfully or not.
73+
* OkHttp is used because it allows to set a [javax.net.ssl.HostnameVerifier] on a per connection base.
74+
*/
75+
private fun requestHttpStatusCode(url: String): HttpStatusCode {
76+
val sslContext = createSSLContext()
77+
var response: Response? = null
78+
try {
79+
response = OkHttpClient.Builder()
80+
.sslSocketFactory(sslContext.socketFactory, trustAllTrustManager)
81+
.hostnameVerifier { _, _ -> true }
82+
.build()
83+
.newCall(
84+
Request.Builder()
85+
.url(url)
86+
.build()
87+
)
88+
.execute()
89+
return HttpStatusCode(url, response.code)
90+
} catch (e: SSLHandshakeException) {
91+
if (e.cause is CertificateParsingException) {
92+
/**
93+
* Fake 200 OK response in case ssl handshake certificate could not be parsed.
94+
* This happens with azure dashboard where a certificate is used that the jdk cannot handle:
95+
* ```
96+
* javax.net.ssl.SSLHandshakeException: Failed to parse server certificates
97+
* java.security.cert.CertificateParsingException: Empty issuer DN not allowed in X509Certificates
98+
* ```
99+
* @see [Stackoverflow question 65692099](https://stackoverflow.com/questions/65692099/java-empty-issuer-dn-not-allowed-in-x509certificate-libimobiledevice-implementa)
100+
* @see [kubernetes cert-manager issue #3634](https://github.com/cert-manager/cert-manager/issues/3634)
101+
*/
102+
return HttpStatusCode(url, HttpURLConnection.HTTP_OK)
103+
} else {
104+
throw e
105+
}
106+
} finally {
107+
response?.close()
108+
}
109+
}
110+
111+
private fun createSSLContext(): SSLContext {
112+
val sslContext: SSLContext = SSLContext.getDefault()
113+
sslContext.init(null, arrayOf<TrustManager>(trustAllTrustManager), SecureRandom())
114+
return sslContext
115+
}
116+
117+
class HttpStatusCode(val url: String, val status: Int?) {
118+
val isSuccessful: Boolean
119+
get() {
120+
return status != null
121+
&& HttpResponse.isSuccessful(status)
122+
}
123+
val isForbidden: Boolean
124+
get() {
125+
return status != null
126+
&& HttpURLConnection.HTTP_FORBIDDEN == status
127+
}
128+
}
129+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/*******************************************************************************
2+
* Copyright (c) 2023 Red Hat, Inc.
3+
* Distributed under license by Red Hat, Inc. All rights reserved.
4+
* This program is made available under the terms of the
5+
* Eclipse Public License v2.0 which accompanies this distribution,
6+
* and is available at http://www.eclipse.org/legal/epl-v20.html
7+
*
8+
* Contributors:
9+
* Red Hat, Inc. - initial API and implementation
10+
******************************************************************************/
11+
package com.redhat.devtools.intellij.kubernetes.model.dashboard
12+
13+
import com.redhat.devtools.intellij.kubernetes.model.resource.PodForService
14+
import io.fabric8.kubernetes.api.model.Pod
15+
import io.fabric8.kubernetes.api.model.Service
16+
import io.fabric8.kubernetes.client.KubernetesClient
17+
import io.fabric8.kubernetes.client.LocalPortForward
18+
19+
/**
20+
* A factory that can determine the url the dashboard for a given Kubernetes cluster.
21+
* Based on the implementation in minikube 1.30.1 at https://github.com/kubernetes/minikube/blob/master/cmd/minikube/cmd/dashboard.go#L206
22+
*/
23+
class KubernetesDashboard(
24+
client: KubernetesClient,
25+
contextName: String,
26+
clusterUrl: String?,
27+
/* for testing purposes */
28+
httpRequest: HttpRequest = HttpRequest()
29+
): AbstractDashboard<KubernetesClient>(client, contextName, clusterUrl, httpRequest) {
30+
31+
companion object {
32+
private const val NAMESPACE = "kubernetes-dashboard"
33+
private const val SERVICE_NAME = "kubernetes-dashboard"
34+
}
35+
36+
private var portForward: LocalPortForward? = null
37+
38+
override fun doConnect(): HttpRequest.HttpStatusCode? {
39+
val service = getDashboardService(client) ?: return null
40+
val pod = getPod(service, client) ?: return null
41+
val portForward = portForward(pod, client) ?: return null
42+
this.portForward = portForward
43+
return httpRequest.request("localhost", portForward.localPort)
44+
}
45+
46+
private fun getDashboardService(client: KubernetesClient): Service? {
47+
return client
48+
.services()
49+
.inNamespace(NAMESPACE)
50+
.withName(SERVICE_NAME)
51+
.get()
52+
}
53+
54+
private fun getPod(dashboard: Service, client: KubernetesClient): Pod? {
55+
return client
56+
.pods()
57+
.inNamespace(NAMESPACE)
58+
.list().items
59+
.find { pod -> PodForService(dashboard).test(pod) }
60+
}
61+
62+
private fun portForward(pod: Pod, client: KubernetesClient): LocalPortForward? {
63+
val containerPort = pod.spec?.containers?.get(0)?.ports?.get(0)?.containerPort ?: return null
64+
val portForward = client
65+
.pods()
66+
.inNamespace(pod.metadata.namespace)
67+
.withName(pod.metadata.name)
68+
.portForward(containerPort)
69+
val serverError = portForward.serverThrowables.firstOrNull()
70+
if (serverError != null) {
71+
throw serverError
72+
}
73+
val clientError = portForward.serverThrowables.firstOrNull()
74+
if (clientError != null) {
75+
throw clientError
76+
}
77+
return portForward
78+
}
79+
80+
override fun close() {
81+
portForward?.close()
82+
}
83+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/*******************************************************************************
2+
* Copyright (c) 2023 Red Hat, Inc.
3+
* Distributed under license by Red Hat, Inc. All rights reserved.
4+
* This program is made available under the terms of the
5+
* Eclipse Public License v2.0 which accompanies this distribution,
6+
* and is available at http://www.eclipse.org/legal/epl-v20.html
7+
*
8+
* Contributors:
9+
* Red Hat, Inc. - initial API and implementation
10+
******************************************************************************/
11+
package com.redhat.devtools.intellij.kubernetes.model.dashboard
12+
13+
import com.intellij.openapi.diagnostic.logger
14+
import io.fabric8.kubernetes.api.model.ConfigMap
15+
import io.fabric8.kubernetes.client.KubernetesClient
16+
import io.fabric8.kubernetes.client.KubernetesClientException
17+
import io.fabric8.openshift.client.OpenShiftClient
18+
19+
20+
/**
21+
* A factory that can determine the url of the dashboard for a given OpenShift cluster.
22+
*
23+
* Inspired by the implementation in minikube 1.30.1 at https://github.com/kubernetes/minikube/blob/master/cmd/minikube/cmd/dashboard.go#L206
24+
* Tested with:
25+
* - Red Hat CodeReady Containers
26+
* - Red Hat Developer Sandbox
27+
* - Red Hat OpenShift 3.11
28+
*/
29+
class OpenShiftDashboard(
30+
client: OpenShiftClient,
31+
contextName: String,
32+
clusterUrl: String?,
33+
/* for testing purposes */
34+
httpRequest: HttpRequest = HttpRequest()
35+
) : AbstractDashboard<OpenShiftClient>(client, contextName, clusterUrl, httpRequest) {
36+
37+
companion object {
38+
private const val NAMESPACE = "openshift-config-managed"
39+
private const val CONFIGMAP_NAME = "console-public"
40+
private const val CONFIGMAP_PROPERTY = "consoleURL"
41+
}
42+
43+
override fun doConnect(): HttpRequest.HttpStatusCode? {
44+
return try {
45+
// OpenShift 4
46+
val configMap = getDashboardConfigMap(client) ?: return null
47+
val url = configMap.data[CONFIGMAP_PROPERTY] ?: return null
48+
httpRequest.request(url)
49+
} catch (e: KubernetesClientException) {
50+
// OpenShift 3
51+
logger<OpenShiftDashboard>().debug(
52+
"Could not access config map $CONFIGMAP_NAME in namespace $NAMESPACE.", e
53+
)
54+
val hostName = client.masterUrl.toExternalForm() ?: return null
55+
httpRequest.request("$hostName")
56+
}
57+
}
58+
59+
private fun getDashboardConfigMap(client: KubernetesClient): ConfigMap? {
60+
return client
61+
.configMaps()
62+
.inNamespace(NAMESPACE)
63+
.withName(CONFIGMAP_NAME)
64+
.get()
65+
}
66+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/*******************************************************************************
2+
* Copyright (c) 2023 Red Hat, Inc.
3+
* Distributed under license by Red Hat, Inc. All rights reserved.
4+
* This program is made available under the terms of the
5+
* Eclipse Public License v2.0 which accompanies this distribution,
6+
* and is available at http://www.eclipse.org/legal/epl-v20.html
7+
*
8+
* Contributors:
9+
* Red Hat, Inc. - initial API and implementation
10+
******************************************************************************/
11+
package com.redhat.devtools.intellij.kubernetes.model.util
12+
13+
open class PluginException(message: String?, cause: Throwable? = null)
14+
: RuntimeException(message, cause)

‎src/main/kotlin/com/redhat/devtools/intellij/kubernetes/model/util/ResourceException.kt

+5-1
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,8 @@ package com.redhat.devtools.intellij.kubernetes.model.util
1212

1313
import io.fabric8.kubernetes.api.model.HasMetadata
1414

15-
class ResourceException(message: String?, cause: Throwable? = null, val resources: List<HasMetadata> = emptyList()): RuntimeException(message, cause)
15+
class ResourceException(
16+
message: String?,
17+
cause: Throwable? = null,
18+
val resources: List<HasMetadata> = emptyList())
19+
: PluginException(message, cause)

‎src/main/kotlin/com/redhat/devtools/intellij/kubernetes/model/util/ResourceUtils.kt

+15
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,17 @@ fun <R: HasMetadata> hasName(resource: R): Boolean {
355355
return true == resource.metadata.name?.isNotEmpty()
356356
}
357357

358+
/**
359+
* Return `true` if the name of the given resource starts with the given value.
360+
* Returns `false` otherwise.
361+
*
362+
* @param startsWith the string that the name should start with
363+
* @param resource the resource whose name should start with the given value
364+
* @return `true` if the name of the resource starts with the given value
365+
*/
366+
fun <R: HasMetadata> nameStartsWith(startsWith: String, resource: R): Boolean {
367+
return resource.metadata?.name?.startsWith(startsWith) ?: false
368+
}
358369

359370
/**
360371
* Returns a string stating kinds and names for the given resources.
@@ -401,3 +412,7 @@ private fun toName(resource: HasMetadata) =
401412

402413
fun toKindAndName(resource: HasMetadata) =
403414
"${resource.kind} '${resource.metadata.name ?: resource.metadata.generateName}'"
415+
416+
fun <R: HasMetadata> hasLabel(value: String, label: String, resource: R): Boolean {
417+
return value == resource.metadata?.labels?.get(label)
418+
}

‎src/main/resources/META-INF/plugin.xml

+4
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,10 @@
229229
id="com.redhat.devtools.intellij.kubernetes.actions.TerminalAction"
230230
text="Terminal"
231231
icon="Icons.terminal"/>
232+
<action class="com.redhat.devtools.intellij.kubernetes.actions.OpenDashboardAction"
233+
id="com.redhat.devtools.intellij.kubernetes.actions.OpenDashboardAction"
234+
text="Open Dashboard"
235+
icon="AllIcons.Nodes.EmptyNode"/>
232236
</group>
233237

234238
<group id="Kubernetes.Editor.Toolbar" class="com.intellij.openapi.actionSystem.DefaultActionGroup">

‎src/test/kotlin/com/redhat/devtools/intellij/kubernetes/model/context/KubernetesContextTest.kt

+23-1
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import com.redhat.devtools.intellij.kubernetes.model.ResourceWatch
2828
import com.redhat.devtools.intellij.kubernetes.model.client.ClientAdapter
2929
import com.redhat.devtools.intellij.kubernetes.model.client.KubeClientAdapter
3030
import com.redhat.devtools.intellij.kubernetes.model.context.IActiveContext.ResourcesIn
31+
import com.redhat.devtools.intellij.kubernetes.model.dashboard.IDashboard
3132
import com.redhat.devtools.intellij.kubernetes.model.mocks.ClientMocks.NAMESPACE1
3233
import com.redhat.devtools.intellij.kubernetes.model.mocks.ClientMocks.NAMESPACE2
3334
import com.redhat.devtools.intellij.kubernetes.model.mocks.ClientMocks.NAMESPACE3
@@ -37,6 +38,7 @@ import com.redhat.devtools.intellij.kubernetes.model.mocks.ClientMocks.POD3
3738
import com.redhat.devtools.intellij.kubernetes.model.mocks.ClientMocks.client
3839
import com.redhat.devtools.intellij.kubernetes.model.mocks.ClientMocks.customResource
3940
import com.redhat.devtools.intellij.kubernetes.model.mocks.ClientMocks.customResourceDefinition
41+
import com.redhat.devtools.intellij.kubernetes.model.mocks.ClientMocks.namedContext
4042
import com.redhat.devtools.intellij.kubernetes.model.mocks.ClientMocks.resource
4143
import com.redhat.devtools.intellij.kubernetes.model.mocks.Mocks.namespacedResourceOperator
4244
import com.redhat.devtools.intellij.kubernetes.model.mocks.Mocks.nonNamespacedResourceOperator
@@ -181,7 +183,7 @@ class KubernetesContextTest {
181183
private fun createContext(internalResourcesOperators: List<IResourceOperator<*>>, extensionResourceOperators: List<IResourceOperator<*>>): TestableKubernetesContext {
182184
return spy(
183185
TestableKubernetesContext(
184-
mock(),
186+
namedContext("death star"),
185187
modelChange,
186188
this@KubernetesContextTest.client,
187189
internalResourcesOperators,
@@ -904,6 +906,24 @@ class KubernetesContextTest {
904906
verify(client.get(), never()).close()
905907
}
906908

909+
@Test
910+
fun `#close should close watch`() {
911+
// given
912+
// when
913+
context.close()
914+
// then
915+
verify(context.watch).close()
916+
}
917+
918+
@Test
919+
fun `#close should close dashboard`() {
920+
// given
921+
// when
922+
context.close()
923+
// then
924+
verify(context.dashboard).close()
925+
}
926+
907927
private fun givenCustomResourceOperatorInContext(
908928
definition: CustomResourceDefinition,
909929
definitionOperator: IResourceOperator<CustomResourceDefinition>,
@@ -945,6 +965,8 @@ class KubernetesContextTest {
945965
override val notification: Notification)
946966
: KubernetesContext(context, observable, client) {
947967

968+
public override val dashboard: IDashboard = mock()
969+
948970
public override val namespacedOperators
949971
: MutableMap<ResourceKind<out HasMetadata>, INamespacedResourceOperator<out HasMetadata, KubernetesClient>>
950972
get() {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,344 @@
1+
/*******************************************************************************
2+
* Copyright (c) 2023 Red Hat, Inc.
3+
* Distributed under license by Red Hat, Inc. All rights reserved.
4+
* This program is made available under the terms of the
5+
* Eclipse Public License v2.0 which accompanies this distribution,
6+
* and is available at http://www.eclipse.org/legal/epl-v20.html
7+
*
8+
* Contributors:
9+
* Red Hat, Inc. - initial API and implementation
10+
******************************************************************************/
11+
package com.redhat.devtools.intellij.kubernetes.model.dashboard
12+
13+
import com.nhaarman.mockitokotlin2.any
14+
import com.nhaarman.mockitokotlin2.doReturn
15+
import com.nhaarman.mockitokotlin2.doThrow
16+
import com.nhaarman.mockitokotlin2.mock
17+
import com.nhaarman.mockitokotlin2.verify
18+
import com.nhaarman.mockitokotlin2.whenever
19+
import com.redhat.devtools.intellij.kubernetes.model.mocks.ClientMocks.NAMESPACE1
20+
import com.redhat.devtools.intellij.kubernetes.model.mocks.ClientMocks.NAMESPACE2
21+
import com.redhat.devtools.intellij.kubernetes.model.mocks.ClientMocks.NAMESPACE3
22+
import com.redhat.devtools.intellij.kubernetes.model.mocks.ClientMocks.POD1
23+
import com.redhat.devtools.intellij.kubernetes.model.mocks.ClientMocks.POD2
24+
import com.redhat.devtools.intellij.kubernetes.model.mocks.ClientMocks.POD3
25+
import com.redhat.devtools.intellij.kubernetes.model.mocks.ClientMocks.client
26+
import com.redhat.devtools.intellij.kubernetes.model.mocks.ClientMocks.resource
27+
import com.redhat.devtools.intellij.kubernetes.model.util.PluginException
28+
import io.fabric8.kubernetes.api.model.Container
29+
import io.fabric8.kubernetes.api.model.ContainerPort
30+
import io.fabric8.kubernetes.api.model.ObjectMeta
31+
import io.fabric8.kubernetes.api.model.Pod
32+
import io.fabric8.kubernetes.api.model.PodList
33+
import io.fabric8.kubernetes.api.model.PodSpec
34+
import io.fabric8.kubernetes.api.model.Service
35+
import io.fabric8.kubernetes.api.model.ServiceList
36+
import io.fabric8.kubernetes.api.model.ServiceSpec
37+
import io.fabric8.kubernetes.client.KubernetesClient
38+
import io.fabric8.kubernetes.client.KubernetesClientException
39+
import io.fabric8.kubernetes.client.LocalPortForward
40+
import io.fabric8.kubernetes.client.dsl.MixedOperation
41+
import io.fabric8.kubernetes.client.dsl.NonNamespaceOperation
42+
import io.fabric8.kubernetes.client.dsl.PodResource
43+
import io.fabric8.kubernetes.client.dsl.ServiceResource
44+
import java.net.HttpURLConnection
45+
import org.assertj.core.api.Assertions.assertThat
46+
import org.junit.Before
47+
import org.junit.Test
48+
49+
50+
class KubernetesDashboardTest {
51+
52+
companion object {
53+
private const val DASHBOARD_NAMESPACE = "kubernetes-dashboard"
54+
private const val DASHBOARD_SERVICE_NAME = "kubernetes-dashboard"
55+
private const val DASHBOARD_POD_NAME = "kubernetes-dashboard"
56+
private const val DASHBOARD_APP_KEY = "k8s-app"
57+
private const val DASHBOARD_LABEL = "kubernetes-dashboard"
58+
}
59+
60+
private var client: KubernetesClient = mock()
61+
private val dashboard = KubernetesDashboard(client, "yoda", "https://localhost")
62+
63+
@Before
64+
fun before() {
65+
this.client = client(NAMESPACE2.metadata.name, arrayOf(NAMESPACE1, NAMESPACE2, NAMESPACE3))
66+
}
67+
68+
@Test
69+
fun `#get should NOT connect a 2nd time if 1st connect was successful`() {
70+
// given
71+
val dashboard = FixedResponseDashboard(HttpURLConnection.HTTP_OK)
72+
dashboard.get()
73+
// when
74+
dashboard.get()
75+
// then
76+
assertThat(dashboard.connectInvoked).isEqualTo(1)
77+
}
78+
79+
@Test
80+
fun `#get should connect a 2nd time if 1st connect was unsuccessful`() {
81+
// given
82+
val dashboard = FixedResponseDashboard(HttpURLConnection.HTTP_NOT_FOUND)
83+
try {
84+
dashboard.get()
85+
} catch(e: PluginException) {
86+
// will throw because unsuccessful. Ignore it.
87+
}
88+
// when
89+
try {
90+
dashboard.get()
91+
} catch(e: PluginException) {
92+
// will throw because unsuccessful. Ignore it.
93+
}
94+
// then
95+
assertThat(dashboard.connectInvoked).isEqualTo(2)
96+
}
97+
98+
@Test(expected = PluginException::class)
99+
fun `#get should throw if accessing cluster throws`() {
100+
// given
101+
doThrow(KubernetesClientException::class)
102+
.whenever(client).services()
103+
// when
104+
dashboard.get()
105+
// then
106+
}
107+
108+
@Test(expected = PluginException::class)
109+
fun `#get should throw if service cannot be found`() {
110+
// given
111+
// service doesn't exist
112+
// when
113+
dashboard.get()
114+
// then
115+
}
116+
117+
@Test(expected = PluginException::class)
118+
fun `#get should throw if pod cannot be found`() {
119+
// given
120+
// service exists, pod doesn't exist
121+
val dashboardService = dashboardService()
122+
mockDashboardService(dashboardService, client)
123+
124+
// when
125+
dashboard.get()
126+
// then
127+
}
128+
129+
@Test(expected=PluginException::class)
130+
fun `#get should return url if port forward has server errors`() {
131+
// given
132+
val expectedPort = 9090
133+
val expectedUrl = "https://localhost:$expectedPort"
134+
val dashboardHttpStatusCode = HttpURLConnection.HTTP_FORBIDDEN
135+
136+
val dashboardService = dashboardService()
137+
mockDashboardService(dashboardService, client)
138+
139+
val dashboardPod = dashboardPod(8080)
140+
val serverError = KubernetesClientException("dark side of the force was used")
141+
val portForward = portForward(expectedPort, listOf(serverError))
142+
mockDashboardPodAndPortForward(dashboardPod, portForward, client)
143+
144+
val dashboard = createKubernetesDashboard(expectedUrl, dashboardHttpStatusCode)
145+
146+
// when
147+
dashboard.get()
148+
// then
149+
}
150+
151+
@Test
152+
fun `#get should return url if dashboard responds with 200 OK`() {
153+
// given
154+
val expectedPort = 9090
155+
val expectedUrl = "https://localhost:$expectedPort"
156+
val dashboardHttpStatusCode = HttpURLConnection.HTTP_OK
157+
158+
val dashboardService = dashboardService()
159+
mockDashboardService(dashboardService, client)
160+
161+
val dashboardPod = dashboardPod(8080)
162+
val portForward = portForward(expectedPort)
163+
mockDashboardPodAndPortForward(dashboardPod, portForward, client)
164+
165+
val dashboard = createKubernetesDashboard(expectedUrl, dashboardHttpStatusCode)
166+
// when
167+
val effectiveUrl = dashboard.get()
168+
// then
169+
assertThat(effectiveUrl).isEqualTo(expectedUrl)
170+
}
171+
172+
@Test
173+
fun `#get should return url if dashboard responds with 403 FORBIDDEN`() {
174+
// given
175+
val expectedPort = 9090
176+
val expectedUrl = "https://localhost:$expectedPort"
177+
val dashboardHttpStatusCode = HttpURLConnection.HTTP_FORBIDDEN
178+
179+
val dashboardService = dashboardService()
180+
mockDashboardService(dashboardService, client)
181+
182+
val dashboardPod = dashboardPod(8080)
183+
val portForward = portForward(expectedPort)
184+
mockDashboardPodAndPortForward(dashboardPod, portForward, client)
185+
186+
val dashboard = createKubernetesDashboard(expectedUrl, dashboardHttpStatusCode)
187+
188+
// when
189+
val effectiveUrl = dashboard.get()
190+
// then
191+
assertThat(effectiveUrl).isEqualTo(expectedUrl)
192+
}
193+
194+
@Test(expected = PluginException::class)
195+
fun `#get should throw if dashboard responds with 500 Internal Server Error`() {
196+
// given
197+
val expectedPort = 9090
198+
val expectedUrl = "https://localhost:$expectedPort"
199+
val dashboardHttpStatusCode = HttpURLConnection.HTTP_INTERNAL_ERROR
200+
201+
val dashboardService = dashboardService()
202+
mockDashboardService(dashboardService, client)
203+
204+
val dashboardPod = dashboardPod(8080)
205+
val portForward = portForward(expectedPort)
206+
mockDashboardPodAndPortForward(dashboardPod, portForward, client)
207+
208+
val dashboard = createKubernetesDashboard(expectedUrl, dashboardHttpStatusCode)
209+
// when
210+
dashboard.get()
211+
// then throws
212+
}
213+
214+
@Test
215+
fun `#close should close existing portforward`() {
216+
// given
217+
val expectedPort = 9090
218+
val expectedUrl = "https://localhost:$expectedPort"
219+
val dashboardHttpStatusCode = HttpURLConnection.HTTP_OK
220+
221+
val dashboardService = dashboardService()
222+
mockDashboardService(dashboardService, client)
223+
224+
val dashboardPod = dashboardPod(8080)
225+
val portForward = portForward(expectedPort)
226+
mockDashboardPodAndPortForward(dashboardPod, portForward, client)
227+
228+
val dashboard = createKubernetesDashboard(expectedUrl, dashboardHttpStatusCode)
229+
dashboard.get() // create port forward
230+
// when
231+
dashboard.close()
232+
// then throws
233+
verify(portForward).close()
234+
}
235+
236+
private fun mockDashboardService(dashboardService: Service, client: KubernetesClient) {
237+
val serviceResource: ServiceResource<Service> = mock {
238+
on { get() } doReturn dashboardService
239+
}
240+
val servicesInNamespace: NonNamespaceOperation<Service, ServiceList, ServiceResource<Service>> = mock {
241+
on { withName(DASHBOARD_SERVICE_NAME) } doReturn serviceResource
242+
}
243+
val services: MixedOperation<Service, ServiceList, ServiceResource<Service>> = mock {
244+
on { inNamespace(DASHBOARD_NAMESPACE) } doReturn servicesInNamespace
245+
}
246+
whenever(client.services())
247+
.thenReturn(services)
248+
}
249+
250+
private fun mockDashboardPodAndPortForward(
251+
dashboardPod: Pod,
252+
portForward: LocalPortForward,
253+
client: KubernetesClient
254+
) {
255+
val allPods: PodList = mock {
256+
on { items } doReturn listOf(POD1, POD2, dashboardPod, POD3)
257+
}
258+
val podResource: PodResource = mock {
259+
on { portForward(any()) } doReturn portForward
260+
}
261+
val podsInNamespace: NonNamespaceOperation<Pod, PodList, PodResource> = mock {
262+
on { list() } doReturn allPods
263+
on { withName(any()) } doReturn podResource
264+
}
265+
val pods: MixedOperation<Pod, PodList, PodResource> = mock {
266+
on { inNamespace(any()) } doReturn podsInNamespace
267+
}
268+
whenever(client.pods())
269+
.thenReturn(pods)
270+
}
271+
272+
private fun portForward(
273+
localPort: Int,
274+
serverThrowables: List<Throwable> = emptyList(),
275+
clientThrowables: List<Throwable> = emptyList()
276+
): LocalPortForward {
277+
return mock {
278+
on { getLocalPort() } doReturn localPort
279+
on { getServerThrowables() } doReturn serverThrowables
280+
on { getClientThrowables() } doReturn clientThrowables
281+
}
282+
}
283+
284+
private fun dashboardService(): Service {
285+
val spec: ServiceSpec = mock {
286+
on { selector } doReturn mapOf(DASHBOARD_APP_KEY to DASHBOARD_LABEL)
287+
}
288+
return resource<Service>(DASHBOARD_SERVICE_NAME, DASHBOARD_NAMESPACE).apply {
289+
doReturn(spec)
290+
.whenever(this).spec
291+
}
292+
}
293+
294+
private fun dashboardPod(port: Int): Pod {
295+
val containerPort: ContainerPort = mock {
296+
on { containerPort } doReturn port
297+
}
298+
val container: Container = mock {
299+
on { ports } doReturn listOf(containerPort)
300+
}
301+
val podSpec: PodSpec = mock {
302+
on { containers } doReturn listOf(container)
303+
}
304+
val metadata: ObjectMeta = mock {
305+
on { name } doReturn DASHBOARD_POD_NAME
306+
on { namespace } doReturn DASHBOARD_NAMESPACE
307+
on { labels } doReturn mapOf(DASHBOARD_APP_KEY to DASHBOARD_LABEL)
308+
}
309+
return mock {
310+
on { getMetadata() } doReturn metadata
311+
on { spec } doReturn podSpec
312+
}
313+
}
314+
315+
private fun createKubernetesDashboard(expectedUrl: String, httpStatusCode: Int): KubernetesDashboard {
316+
val httpRequest: HttpRequest = mockHttpRequest(expectedUrl, httpStatusCode)
317+
return KubernetesDashboard(client, "yoda", "Dagobah", httpRequest)
318+
}
319+
320+
private fun mockHttpRequest(url: String, code: Int): HttpRequest {
321+
val httpStatusCode: HttpRequest.HttpStatusCode = HttpRequest.HttpStatusCode(
322+
url,
323+
code
324+
)
325+
val httpRequest: HttpRequest = mock {
326+
on { request(any(), any()) } doReturn httpStatusCode
327+
}
328+
return httpRequest
329+
}
330+
331+
private class FixedResponseDashboard(private val httpStatusCode: Int): AbstractDashboard<KubernetesClient>(
332+
mock(),
333+
"luke",
334+
"Tatooine",
335+
mock<HttpRequest>()
336+
) {
337+
var connectInvoked = 0
338+
override fun doConnect(): HttpRequest.HttpStatusCode? {
339+
connectInvoked++
340+
// success
341+
return HttpRequest.HttpStatusCode("http://localhost", httpStatusCode)
342+
}
343+
}
344+
}

‎src/test/kotlin/com/redhat/devtools/intellij/kubernetes/model/mocks/ClientMocks.kt

+1-2
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ object ClientMocks {
228228
return namedContext(name, context)
229229
}
230230

231-
fun namedContext(name: String, context: Context): NamedContext {
231+
fun namedContext(name: String, context: Context? = null): NamedContext {
232232
return mock {
233233
on { this.name } doReturn name
234234
on { this.context } doReturn context
@@ -446,7 +446,6 @@ object ClientMocks {
446446
return job
447447
}
448448

449-
450449
fun statusDetails(num: Int): List<StatusDetails> {
451450
return (1..num)
452451
.map { mock<StatusDetails>() }

0 commit comments

Comments
 (0)
Please sign in to comment.