+
+- [OCM VScode Extension](#ocm-vscode-extension)
+ - [Prerequisites](#prerequisites)
+ - [Installation](#installation)
+ - [Install Latest version](#install-latest-version)
+ - [Install specific version](#install-specific-version)
+ - [Features](#features)
+ - [Resources Snippets](#resources-snippets)
+ - [Bootstrap Project](#bootstrap-project)
+ - [Create Local Environment](#create-local-environment)
+ - [Manage Existing Resources](#manage-existing-resources)
+ - [Recommendations](#recommendations)
+ - [Contributing](#contributing)
+
+
+
+## Prerequisites
+
+You must meet the following prerequisites to install OCM VScode Extension:
+
+* Ensure your [`Visual Studio Code`][install-vscode] release is at least `v1.71.0`.
+
+You must meet some additional prerequisites to utilize OCM VScode Extension features:
+
+| Feature | Prerequisites |
+| ------- | ------------ |
+| Resources Snippets | None |
+| Bootstrap Project | None |
+| Manage Existing Resources | [kubectl][install-kubectl] |
+| Create Local Environment | [kubectl][install-kubectl], [clusteradm][install-clusteradm], [kind][install-kind] |
+
+
+
+## Installation
+
+### Install Latest version
+In your VScode instance, go to the _Extensions Marketplace_ (by clicking on the _Extensions_ icon in the composite bar on the left hand side or simply pressing Ctrl+Shift+X on your keyboard).
+
+Search for _OCM Extension_ and click install.
+
+### Install specific version
+To download a specific version of VScode Extension visit the [releases page][releases], expand the _Assets_ section of the target version,
+and download the version for your operating system.
+
+In your VScode instance, go to the _Extensions Marketplace_ (by clicking on the _Extensions_ icon in the composite bar on the left hand side or simply pressing Ctrl+Shift+X on your keyboard).
+
+Click the **...** at the top right corner of the palette, select the _Install from VSIX..._ action, and browse for the specific _VSIX_ file you downloaded.
+
+Select a _VSIX_ file and hit install.
+
+
+
+## Features
+
+### Resources Snippets
+
+OCM VScode Extension allows users to load Custom Resource (CR) snippets from the command palette.
+
+The resources available as snippets are:
+ * Subscription
+ * Placement
+ * Channel
+ * ManagedClusterSet
+ * ManagedClusterSetBinding
+
+Subscription snippets are available specifically tailored to either Git, Helm or ObjectBucket.
+
+To exercise this feature you must take the following steps:
+ 1. Create a new yaml file in a desired path
+ 2. Open the file and press Ctrl+Shift+P on your keyboard
+ 3. In the search container at the top of your screen choose _Snippets: Insert Snippet_
+ 4. Choose a desired snippet from list
+
+
+
+
+
+### Bootstrap Project
+
+Another valuable feature of OCM VScode Extension is to create a Bootstrap project for the various channel types.
+
+The Bootstrap project is basically a collection of snippets, customized to fit one of three channel types - Git, Helm or ObjectBucket, that form a project template for you to build upon.
+
+To exercise this feature you must take the following steps:
+ 1. Press Ctrl+Shift+P on your keyboard
+ 2. In the search container at the top of your screen choose _OCM: Create an application-manager project_
+ 3. Choose a desired channel type
+ 4. Type in a project name and hit enter
+
+
+
+
+
+
+### Create Local Environment
+
+The Create Local Environment feature allows you to create a quick OCM multi-cluster control plane on a local [kind][kind] environment with only a click of a button.
+
+The feature does it all for you:
+ - Verifies the existence of the required tools.
+ - Creates as many [kind][kind] clusters as you need (customizable).
+ - Initializes the _hub cluster_ by installing a [Cluster Manager][cluster-manager] using [clusteradm][clusteradm], and [kubectl][install-kubectl].
+ - Installs a [Klusterlet][klusterlet] agent on the _managed clusters_ and sends join requests to the _hub cluster_ using [clusteradm][clusteradm], and [kubectl][install-kubectl].
+ - Accepts the join requests from the _hub cluster_ using [clusteradm][clusteradm], and [kubectl][install-kubectl].
+
+To exercise this feature you must take the following steps:
+ 1. Click on the _OCM_ icon in the composite bar on the left hand side to open OCM VScode Extension control pane
+ 2. Locate the _Create Local Environment_ button in the _Developers Tools_ section and give it a click
+ 3. Notice that at the top of your screen an action container will prompt you to choose between default or custom configuration
+ 4. After configuration was specified, a progress bar at the botom right of your screen will provide feadback on the build process
+
+
+
+
+
+### Manage Existing Resources
+
+The Manage Existing Resources feature provides a wide and detailed overview of all the resources residing in any one cluster.
+
+To exercise this feature you must take the following steps:
+ 1. Access the _Cluster Details_ tab either by:
+ * Pressing Ctrl+Shift+P on your keyboard and choosing _OCM-View: Cluster Details_ in the search container at the top of your screen
+ * Clicking on the _OCM_ icon in the composite bar on the left hand side to open OCM VScode Extension control pane, locating the _Connected Clusters_ section and selecting any cluster in the list
+ 2. Inside the _Cluster Details_ tab, use the dropdown to select any cluster from the list of availabe clusters in order to view the resources within it
+
+
+
+
+
+## Recommendations
+
+Take a look at some other great VScode extensions to improve your OCM experience!
+
+* [Visual Studio Code Kubernetes Tools][ext-kubernetes]
+* [OpenShift Toolkit][ext-openshift-toolkit]
+* [YAML Language Support][ext-yaml]
+
+To install the recommended VScode extensions, please visit the _Extensions Marketplace_ (by clicking on the _Extensions_ icon in the composite bar on the left hand side or simply pressing Ctrl+Shift+X on your keyboard).
+
+
+
+## Contributing
+
+See our [Contributing Guidelines][repo-contribute] for more information.
+
+
+
+
+[ocm-io]: https://open-cluster-management.io/
+[install-vscode]: https://code.visualstudio.com/download
+[install-kubectl]: https://kubernetes.io/docs/tasks/tools/install-kubectl
+[install-kind]: https://kind.sigs.k8s.io/docs/user/quick-start/
+[install-clusteradm]:https://github.com/open-cluster-management-io/clusteradm
+[releases]: https://github.com/open-cluster-management-io/ocm-vscode-extension/releases
+[kind]: https://kind.sigs.k8s.io/
+[cluster-manager]: https://operatorhub.io/operator/cluster-manager
+[clusteradm]: https://github.com/open-cluster-management-io/clusteradm
+[klusterlet]: https://operatorhub.io/operator/klusterlet
+[repo-contribute]: https://github.com/open-cluster-management-io/ocm-vscode-extension/contribute
+[ext-yaml]:https://marketplace.visualstudio.com/items?itemName=redhat.vscode-yaml
+[ext-openshift-toolkit]:https://marketplace.visualstudio.com/items?itemName=redhat.vscode-openshift-connector
+[ext-kubernetes]:https://marketplace.visualstudio.com/items?itemName=ms-kubernetes-tools.vscode-kubernetes-tools
+[youtube-ocm-vscode-ext-intro]:https://www.youtube.com/watch?v=3WATGbheqbQ
\ No newline at end of file
diff --git a/vscode-extension/package.json b/vscode-extension/package.json
new file mode 100644
index 00000000..45f1e9d3
--- /dev/null
+++ b/vscode-extension/package.json
@@ -0,0 +1,225 @@
+{
+ "name": "ocm-vscode-extension",
+ "version": "1.3.0",
+ "displayName": "OCM Extension",
+ "description": "OCM VSCode extension contributing commands and snippets",
+ "publisher": "OpenClusterManagement",
+ "license": "Apache-2.0",
+ "homepage": "https://github.com/open-cluster-management-io/ocm-vscode-extension/blob/main/README.md",
+ "bugs": {
+ "url": "https://github.com/open-cluster-management-io/ocm-vscode-extension/issues"
+ },
+ "icon": "images/icon.png",
+ "private": true,
+ "keywords": [
+ "ocm",
+ "open-cluster-management",
+ "kubernetes",
+ "openshift"
+ ],
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/open-cluster-management-io/ocm-vscode-extension.git"
+ },
+ "engines": {
+ "vscode": "^1.71.0"
+ },
+ "categories": [
+ "Snippets",
+ "Other"
+ ],
+ "activationEvents": [
+ "onCommand:ocm-vscode-extension.argoProject",
+ "onCommand:ocm-vscode-extension.ocmNewProject",
+ "onCommand:ocm-vscode-extension.verifyTools",
+ "onCommand:ocm-vscode-extension.createLocalEnvironment",
+ "onCommand:ocm-vscode-extension.showContextDetails",
+ "onView:ocm-vscode-extension.connectedContextsView"
+ ],
+ "main": "./out/src/extension.js",
+ "contributes": {
+ "commands": [
+ {
+ "command": "ocm-vscode-extension.argoProject",
+ "title": "Create an ArgoCD project",
+ "category": "OCM"
+ },
+ {
+ "command": "ocm-vscode-extension.ocmNewProject",
+ "title": "Create an application-manager project",
+ "category": "OCM"
+ },
+ {
+ "command": "ocm-vscode-extension.verifyTools",
+ "title": "Verify required tools existence",
+ "category": "OCM"
+ },
+ {
+ "command": "ocm-vscode-extension.createLocalEnvironment",
+ "title": "Build local environment",
+ "category": "OCM",
+ "icon": {
+ "light": "images/light/refresh.svg",
+ "dark": "images/dark/refresh.svg"
+ }
+ },
+ {
+ "command": "ocm-vscode-extension.showContextDetails",
+ "title": "Context Details",
+ "category": "OCM-View",
+ "icon": {
+ "light": "images/light/exclamation.svg",
+ "dark": "images/dark/exclamation-white.svg"
+ }
+ },
+ {
+ "command": "ocm-vscode-extension.connectedContextsView.refresh",
+ "title": "Refresh connected contexts view",
+ "icon": {
+ "light": "images/light/refresh.svg",
+ "dark": "images/dark/refresh.svg"
+ }
+ }
+ ],
+ "viewsContainers": {
+ "activitybar": [
+ {
+ "id": "ocmView",
+ "title": "OCM",
+ "icon": "images/light/ocm.svg"
+ }
+ ]
+ },
+ "views": {
+ "ocmView": [
+ {
+ "id": "ocm-vscode-extension.forDevelopersView",
+ "name": "Developers Tools"
+ },
+ {
+ "id": "ocm-vscode-extension.connectedContextsView",
+ "name": "Connected Contexts"
+ }
+ ]
+ },
+ "viewsWelcome": [
+ {
+ "view": "ocm-vscode-extension.forDevelopersView",
+ "contents": "[Verify Required Tools](command:ocm-vscode-extension.verifyTools)\n[Create Local Environment](command:ocm-vscode-extension.createLocalEnvironment)"
+ }
+ ],
+ "menus": {
+ "view/title": [
+ {
+ "command": "ocm-vscode-extension.connectedContextsView.refresh",
+ "when": "view == ocm-vscode-extension.connectedContextsView",
+ "group": "navigation"
+ }
+ ],
+ "view/item/context": [
+ {
+ "command": "ocm-vscode-extension.showContextDetails",
+ "group": "inline"
+ }
+ ]
+ },
+ "snippets": [
+ {
+ "language": "yaml",
+ "path": "./snippets/channel.json"
+ },
+ {
+ "language": "yaml",
+ "path": "./snippets/managedclusterset.json"
+ },
+ {
+ "language": "yaml",
+ "path": "./snippets/managedclustersetbinding.json"
+ },
+ {
+ "language": "yaml",
+ "path": "./snippets/placement.json"
+ },
+ {
+ "language": "yaml",
+ "path": "./snippets/subscription_git.json"
+ },
+ {
+ "language": "yaml",
+ "path": "./snippets/subscription_objectbucket.json"
+ },
+ {
+ "language": "yaml",
+ "path": "./snippets/subscription_helmrepo.json"
+ }
+ ]
+ },
+ "workspaces": [
+ "webview-ui"
+ ],
+ "scripts": {
+ "precompile": "npm run clean:build",
+ "compile": "tsc -p ./",
+ "prewatch": "npm run clean:build",
+ "watch": "tsc -w -p ./",
+ "lint": "eslint src test --ext ts",
+ "lint:fix": "eslint src test --ext ts --fix",
+ "clean": "npm run clean:build && npm run clean:webview-build",
+ "clean:build": "rm -rf out",
+ "clean:webview-build": "rm -rf ./webview-ui/build",
+ "clean:sandbox": "rm -rf .vscode-test",
+ "clean:test-ws": "find ./test/test-workspace/ -mindepth 1 ! -name '.gitkeep' -delete",
+ "prebuild": "npm run compile",
+ "build": "npm run build:webview",
+ "build:webview": "npm --workspace webview-ui run build",
+ "pretest": "npm run compile",
+ "test": "node ./out/test/run.js",
+ "test:quick": "QUICK_TEST=\"true\" npm test",
+ "cov": "nyc report --check-coverage --lines 80 --clean --compact false --reporter=text",
+ "cov:rep": "rm -rf coverage/* && nyc report --reporter=lcov",
+ "vscode:prepublish": "npm run clean && npm run build",
+ "vsce:package": "vsce package",
+ "vsce:publish": "vsce publish"
+ },
+ "dependencies": {
+ "@kubernetes/client-node": "^0.17.1",
+ "@types/lodash": "^4.14.185",
+ "@types/shelljs": "^0.8.11",
+ "@vscode/webview-ui-toolkit": "^1.0.1",
+ "fs-extra": "^10.1.0",
+ "http2": "^2.7.1",
+ "lodash": "^4.17.21",
+ "ocm-webview": "file:webview-ui",
+ "shelljs": "0.8.5"
+ },
+ "devDependencies": {
+ "@types/chai": "^4.3.3",
+ "@types/chai-as-promised": "^7.1.5",
+ "@types/chai-things": "^0.0.35",
+ "@types/fs-extra": "^9.0.13",
+ "@types/glob": "^8.0.0",
+ "@types/js-yaml": "^4.0.5",
+ "@types/mocha": "^9.1.1",
+ "@types/node": "^18.x",
+ "@types/sinon": "^10.0.13",
+ "@types/sinon-chai": "^3.2.8",
+ "@types/vscode": "^1.71.0",
+ "@typescript-eslint/eslint-plugin": "^5.37.0",
+ "@typescript-eslint/parser": "^5.37.0",
+ "@vscode/test-electron": "^2.1.5",
+ "chai": "^4.3.6",
+ "chai-as-promised": "^7.1.1",
+ "chai-exclude": "^2.1.0",
+ "chai-things": "^0.2.0",
+ "eslint": "^8.23.1",
+ "glob": "^8.0.3",
+ "js-yaml": "^4.1.0",
+ "mocha": "^10.0.0",
+ "nyc": "^15.1.0",
+ "openid-client": "^5.3.1",
+ "sinon": "^14.0.0",
+ "sinon-chai": "^3.7.0",
+ "typescript": "^4.8.3",
+ "vsce": "^2.11.0"
+ }
+}
diff --git a/vscode-extension/snippets/argocd.json b/vscode-extension/snippets/argocd.json
new file mode 100644
index 00000000..5fd69d2e
--- /dev/null
+++ b/vscode-extension/snippets/argocd.json
@@ -0,0 +1,19 @@
+{
+ "argocd-application-set": {
+ "scope": "yaml",
+ "prefix": "OCM Argocd ApplicationSet",
+ "description": "apply the same set of manifests across multiple namespaces or clusters with different configurations",
+ "body": [
+ "apiVersion: argoproj.io/v1alpha1",
+ "kind: ApplicationSet",
+ "metadata:",
+ " - clusterDecisionResource:",
+ " namespace: ${4:}",
+ "spec:",
+ " generators:",
+ " pathname: ${5:} # i.e, https://github.com//",
+ " secretRef: # secret ref is optional. if no credentials are needed, delete this section",
+ " name: ${6:}"
+ ]
+ }
+}
diff --git a/vscode-extension/snippets/channel.json b/vscode-extension/snippets/channel.json
new file mode 100644
index 00000000..926d5bd1
--- /dev/null
+++ b/vscode-extension/snippets/channel.json
@@ -0,0 +1,21 @@
+{
+ "channel-v1": {
+ "scope": "yaml",
+ "prefix": "OCM Channel v1",
+ "description": "The channel configures the source of truth for the subscription.",
+ "body": [
+ "---",
+ "apiVersion: apps.open-cluster-management.io/v1",
+ "kind: Channel",
+ "metadata:",
+ " name: ${1:}",
+ " namespace: ${2:}",
+ "spec:",
+ " type: ${3|git,helmrepo,objectbucket|}",
+ " pathname: ${4:} # i.e, https://github.com//",
+ "# secret ref is optional. if no credentials are needed, delete this section",
+ " secretRef:",
+ " name: ${5:}"
+ ]
+ }
+}
diff --git a/vscode-extension/snippets/managedclusterset.json b/vscode-extension/snippets/managedclusterset.json
new file mode 100644
index 00000000..94a314ae
--- /dev/null
+++ b/vscode-extension/snippets/managedclusterset.json
@@ -0,0 +1,14 @@
+{
+ "managedclusterset-v1beta2": {
+ "scope": "yaml",
+ "prefix": "OCM ManagedClusterSet v1beta2",
+ "description": "The managed cluster set represents a set of clusters to select from.",
+ "body": [
+ "---",
+ "apiVersion: cluster.open-cluster-management.io/v1beta2",
+ "kind: ManagedClusterSet",
+ "metadata:",
+ " name: ${1:}"
+ ]
+ }
+}
diff --git a/vscode-extension/snippets/managedclustersetbinding.json b/vscode-extension/snippets/managedclustersetbinding.json
new file mode 100644
index 00000000..3527d832
--- /dev/null
+++ b/vscode-extension/snippets/managedclustersetbinding.json
@@ -0,0 +1,17 @@
+{
+ "managedclustersetbinding-v1beta2": {
+ "scope": "yaml",
+ "prefix": "OCM ManagedClusterSetBinding v1beta2",
+ "description": "The managed clusters set binding is used to bind the managed clusters set to a namespace.",
+ "body": [
+ "---",
+ "apiVersion: cluster.open-cluster-management.io/v1beta2",
+ "kind: ManagedClusterSetBinding",
+ "metadata:",
+ " name: ${1:} # should be identical to the underlying ManagedClusterSet",
+ " namespace: ${2:}",
+ "spec:",
+ " clusterSet: ${1:}"
+ ]
+ }
+}
diff --git a/vscode-extension/snippets/placement.json b/vscode-extension/snippets/placement.json
new file mode 100644
index 00000000..bc4a0ddc
--- /dev/null
+++ b/vscode-extension/snippets/placement.json
@@ -0,0 +1,70 @@
+{
+ "placement-v1alpha1": {
+ "scope": "yaml",
+ "prefix": "OCM Placement v1alpha1",
+ "description": "The placement is used to select clusters from a clusterset based on pre-defined predicates.",
+ "body": [
+ "---",
+ "apiVersion: cluster.open-cluster-management.io/v1alpha1",
+ "kind: Placement",
+ "metadata:",
+ " name: ${1:}",
+ " namespace: ${2:}",
+ "spec:",
+ " clusterReplicas: ${3:}",
+ " clusterSets:",
+ " - ${4:}",
+ " predicates:",
+ " - requiredClusterSelector:",
+ " labelSelector:",
+ " matchLabels:",
+ " ${5:} # usage: development",
+ "# claimSelector:",
+ "# matchExpressions:",
+ "# - key: platform.open-cluster-management.io",
+ "# operator: In",
+ "# values:",
+ "# - aws"
+ ]
+ },
+ "placement-v1beta1": {
+ "scope": "yaml",
+ "prefix": "OCM Placement v1beta1",
+ "description": "The placement is used to select clusters from a clusterset based on pre-defined predicates.",
+ "body": [
+ "---",
+ "apiVersion: cluster.open-cluster-management.io/v1beta1",
+ "kind: Placement",
+ "metadata:",
+ " name: ${1:}",
+ " namespace: ${2:}",
+ "spec:",
+ " numberOfClusters: ${3:}",
+ " clusterSets:",
+ " - ${4:}",
+ " predicates:",
+ " - requiredClusterSelector:",
+ " labelSelector:",
+ " matchLabels:",
+ " ${5:} # usage: development",
+ "# claimSelector:",
+ "# matchExpressions:",
+ "# - key: platform.open-cluster-management.io",
+ "# operator: In",
+ "# values:",
+ "# - aws",
+ "# tolerations: # are applied to placements, and allow (but do not require) the managed clusters with certain taints to be selected by placements with matching tolerations.",
+ "# - key: cluster.open-cluster-management.io/unreachable",
+ "# operator: Exists",
+ "# tolerationSeconds: 300",
+ "# prioritizerPolicy: #defines the policy of the prioritizers. If this field is unset, then default prioritizer mode and configurations are used. Referring to PrioritizerPolicy to see more description about Mode and Configurations.",
+ "# configurations:",
+ "# - scoreCoordinate:",
+ "# builtIn: ${11|Steady,Balance,ResourceAllocatableCPU,ResourceAllocatableMemory|} ",
+ "# weight: 2",
+ "# - scoreCoordinate:",
+ "# builtIn: ResourceAllocatableMemory",
+ "# weight: 2"
+ ]
+ }
+}
diff --git a/vscode-extension/snippets/policy.json b/vscode-extension/snippets/policy.json
new file mode 100644
index 00000000..52ca2bd0
--- /dev/null
+++ b/vscode-extension/snippets/policy.json
@@ -0,0 +1,38 @@
+{
+ "policy-v1": {
+ "scope": "yaml",
+ "prefix": "OCM Policy v1",
+ "description": "A Policy is a grouping mechanism for Policy Templates and is the smallest deployable unit on the hub cluster. Embedded Policy Templates are distributed to applicable managed clusters and acted upon by the appropriate policy controller. The compliance state and status of a Policy represents all embedded Policy Templates in the Policy. The distribution of Policy objects is controlled by a Placement.",
+ "body": [
+ "---",
+ "apiVersion: policy.open-cluster-management.io/v1",
+ "kind: Policy",
+ "metadata:",
+ " name: ${1:}",
+ " namespace: ${2:}",
+ " annotations:",
+ " policy.open-cluster-management.io/standards: ${3:}",
+ " policy.open-cluster-management.io/categories: ${4:}",
+ " policy.open-cluster-management.io/controls: ${5:}",
+ "spec:",
+ " remediationAction: ${6|enforce,inform|}",
+ " disabled: ${7|false,true|}",
+ " policy-templates:",
+ " - objectDefinition:",
+ " apiVersion: policy.open-cluster-management.io/v1",
+ " kind: ConfigurationPolicy",
+ " metadata:",
+ " name: ${8:}",
+ " spec:",
+ " remediationAction: ${9|enforce,inform|}",
+ " severity: ${10|low,medium,high|}",
+ " object-templates:",
+ " - complianceType: ${11|mustonlyhave,musthave,mustnothave|}",
+ " objectDefinition:",
+ " kind: Namespace # In this example, it must have a namespace 'prod'. Change it with the required object",
+ " apiVersion: v1",
+ " metadata:",
+ " name: prod"
+ ]
+ }
+}
diff --git a/vscode-extension/snippets/subscription_git.json b/vscode-extension/snippets/subscription_git.json
new file mode 100644
index 00000000..85868d4a
--- /dev/null
+++ b/vscode-extension/snippets/subscription_git.json
@@ -0,0 +1,27 @@
+{
+ "subscription-git-v1": {
+ "scope": "yaml",
+ "prefix": "OCM Subscription Git v1",
+ "description": "A subscription for a git type channel.",
+ "body": [
+ "---",
+ "apiVersion: apps.open-cluster-management.io/v1",
+ "kind: Subscription",
+ "metadata:",
+ " name: ${1:}",
+ " namespace: ${2:}",
+ " annotations:",
+ " apps.open-cluster-management.io/git-branch: ${3:}",
+ " apps.open-cluster-management.io/git-path: ${4:} # optional, defaults to root",
+ " apps.open-cluster-management.io/git-clone-depth: \"${5:20}\" # optional",
+ " apps.open-cluster-management.io/reconcile-option: ${6|merge,replace|} # optional",
+ " apps.open-cluster-management.io/reconcile-rate: ${7|medium,low,high,off|} # optional",
+ "spec:",
+ " channel: ${8:} # namespace/name",
+ " placement:",
+ " placementRef:",
+ " kind: Placement",
+ " name: ${9:}"
+ ]
+ }
+}
diff --git a/vscode-extension/snippets/subscription_helmrepo.json b/vscode-extension/snippets/subscription_helmrepo.json
new file mode 100644
index 00000000..097f5cbd
--- /dev/null
+++ b/vscode-extension/snippets/subscription_helmrepo.json
@@ -0,0 +1,28 @@
+{
+ "subscription-helm-repo-v1": {
+ "scope": "yaml",
+ "prefix": "OCM Subscription HelmRepo v1",
+ "description": "A subscription for a helmrepo type channel.",
+ "body": [
+ "---",
+ "apiVersion: apps.open-cluster-management.io/v1",
+ "kind: Subscription",
+ "metadata:",
+ " name: ${1:}",
+ " namespace: ${2:}",
+ " annotations:",
+ " apps.open-cluster-management.io/reconcile-option: ${3|merge,replace|} # optional",
+ " apps.open-cluster-management.io/reconcile-rate: ${4|medium,low,high,off|} # optional",
+ "spec:",
+ " channel: ${5:} # namespace/name",
+ " name: ${6:}",
+ " packageOverrides:",
+ " - packageName: ${7:}",
+ " packageAlias: ${8:}",
+ " placement:",
+ " placementRef:",
+ " kind: Placement",
+ " name: ${9:}"
+ ]
+ }
+}
diff --git a/vscode-extension/snippets/subscription_objectbucket.json b/vscode-extension/snippets/subscription_objectbucket.json
new file mode 100644
index 00000000..4cece42e
--- /dev/null
+++ b/vscode-extension/snippets/subscription_objectbucket.json
@@ -0,0 +1,25 @@
+{
+ "subscription-object-storage-v1": {
+ "scope": "yaml",
+ "prefix": "OCM Subscription ObjectBucket v1",
+ "description": "A subscription for an objectbucket type channel.",
+ "body": [
+ "---",
+ "apiVersion: apps.open-cluster-management.io/v1",
+ "kind: Subscription",
+ "metadata:",
+ " name: ${1:}",
+ " namespace: ${2:}",
+ " annotations:",
+ " apps.open-cluster-management.io/bucket-path: ${3:} # optional",
+ " apps.open-cluster-management.io/reconcile-option: ${4|merge,replace|} # optional",
+ " apps.open-cluster-management.io/reconcile-rate: ${5|medium,low,high,off|} # optional",
+ "spec:",
+ " channel: ${6:} # namespace/name",
+ " placement:",
+ " placementRef:",
+ " kind: Placement",
+ " name: ${7:}"
+ ]
+ }
+}
diff --git a/vscode-extension/src/commands/argoProject.ts b/vscode-extension/src/commands/argoProject.ts
new file mode 100644
index 00000000..95a96dbf
--- /dev/null
+++ b/vscode-extension/src/commands/argoProject.ts
@@ -0,0 +1,31 @@
+
+import * as filesystem from '../utils/filesystem';
+import * as vscode from 'vscode';
+
+// create a template project based on the user input
+export async function create(): Promise {
+
+ // get the project name from the user
+ let projectName: string = await vscode.window.showInputBox({
+ placeHolder: `insert project name, default: ${filesystem.defaultArgoProjectName}`,
+ }) || filesystem.defaultArgoProjectName;
+
+ // verify inside workspace
+ if (vscode.workspace.workspaceFolders === undefined) {
+ vscode.window.showWarningMessage(
+ 'OCM extension, no workspace folder, please open a project or create a workspace');
+ return;
+ }
+ // prepare project folder path
+ let workspaceFolder: string = vscode.workspace.workspaceFolders[0].uri.path;
+ if (process.platform === 'win32') {
+ // the workspaceFolder is a uri path, it includes the initial forward slash
+ // behind the scenes, this is used to start at root, but this breaks for windows
+ workspaceFolder = workspaceFolder.substring(1);
+ }
+
+ // create the project
+ filesystem.createProjectFromTemplate(workspaceFolder, projectName, filesystem.defaultArgoTemplate)
+ .then((msg: string) => vscode.window.showInformationMessage(msg))
+ .catch((msg: string) => vscode.window.showErrorMessage(msg));
+}
diff --git a/vscode-extension/src/commands/createEnvironment.ts b/vscode-extension/src/commands/createEnvironment.ts
new file mode 100644
index 00000000..2185f42f
--- /dev/null
+++ b/vscode-extension/src/commands/createEnvironment.ts
@@ -0,0 +1,151 @@
+import * as build from '../utils/build';
+import * as environment from '../utils/environment';
+import * as lodash from 'lodash';
+import * as vscode from 'vscode';
+
+export enum YesNo {
+ yes = 'Yes',
+ no = 'No'
+}
+
+async function gatherClustersInfo(): Promise {
+ return new Promise(async (resolve, _reject) => {
+
+ // clusters array for creation
+ let clusters: build.Cluster[] = [];
+
+ // get the hub cluster name from the user
+ let hubClusterName: string = await vscode.window.showInputBox({
+ title: 'Choose a name for the hub cluster',
+ value: 'hub',
+ ignoreFocusOut: true,
+ validateInput: (input: string) => {
+ if (input.trim().length === 0) {
+ return 'Input cannot be empty';
+ }
+ }
+ }) || '';
+ if (!hubClusterName) { return; }
+
+ // add a hub cluster to the clusters array
+ clusters.push({
+ name: hubClusterName,
+ context: `kind-${hubClusterName}`,
+ type: build.ClusterType.hub
+ });
+
+ // ask the user how many managed clusters are required
+ let managedClusterTotal: number = Number(await vscode.window.showInputBox({
+ title: 'Choose the number of managed clusters to be created',
+ value: '2',
+ ignoreFocusOut: true,
+ validateInput: (input: string) => {
+ if (input.trim().length === 0) {
+ return 'Input cannot be empty';
+ }
+ if (Number.isNaN(Number(input.trim()))) {
+ return `Input must be a number`; // only accept numbers
+ }
+ return undefined; // return undefined/null/empty for validation.
+ }
+ }));
+ if (!managedClusterTotal) { return; }
+
+ // offer to use standard naming for the managed clusters, ie cluster1..cluster37
+ let standardNaming: string = await vscode.window.showQuickPick(
+ [YesNo.yes, YesNo.no], {
+ title: 'Name managed clusters by index (clusterX)?',
+ placeHolder: YesNo.yes,
+ ignoreFocusOut: true
+ }) || '';
+ if (!standardNaming) { return; }
+
+ if (standardNaming === YesNo.yes) {
+ // add managed clusters with default names to the cluster array
+ for (let idx = 1; idx <= managedClusterTotal; idx++) {
+ let clusterName = `cluster${idx}`;
+ clusters.push({
+ name: clusterName,
+ context: `kind-${clusterName}`,
+ type: build.ClusterType.managed
+ });
+ }
+ } else {
+ // get managed clusters names from the user and add to clusters array
+ for (let idx = 1; idx <= managedClusterTotal; idx++) {
+ let defaultName = `cluster${idx}`;
+ let clusterName: string = await vscode.window.showInputBox({
+ title: `Choose a name for managed cluster number ${idx}`,
+ value: defaultName,
+ ignoreFocusOut: true,
+ validateInput: (input: string) => {
+ if (input.trim().length === 0) {
+ return 'Input cannot be empty';
+ }
+ }
+ }) || '';
+ if (!clusterName) { return; }
+
+ clusters.push({
+ name: clusterName,
+ context: `kind-${clusterName}`,
+ type: build.ClusterType.managed
+ });
+ }
+ }
+
+ // remove clusters with same name on the way out
+ resolve(lodash.uniqBy(clusters, c => c.name));
+ });
+}
+
+export async function createLocalEnvironment(): Promise {
+ // offer the user to use the default 3 cluster configuration,
+ // 1 hub cluster named hub and 2 managed cluster named cluster1/2
+ let useDefaults: string = await vscode.window.showQuickPick(
+ [YesNo.yes, YesNo.no], {
+ title: `Use default configuration, 1 hub and 2 managed clusters?`,
+ placeHolder: YesNo.yes,
+ ignoreFocusOut: true
+ }) || '';
+ if (!useDefaults) { return; }
+
+ // clusters array for creation
+ let clusters: build.Cluster[] = [];
+
+ if (useDefaults === YesNo.yes) {
+ // use default cluster configuration
+ clusters = build.defaultClusters;
+ } else {
+ // gather cluster configuration from the user
+ clusters = await gatherClustersInfo();
+ }
+
+ // build the local environment
+ await vscode.window.withProgress(
+ {
+ location: vscode.ProgressLocation.Notification,
+ title: 'Creating local environment',
+ cancellable: false
+ },
+ async (progress) => {
+ progress.report({increment: 0, message: 'verifying the required tools existence' });
+ // verify the required tool exists
+ await environment.verifyTools(...environment.requiredTools)
+ .then(async () => {
+ progress.report({increment: 20, message: 'starting to build your local environment'});
+ // build the environment
+ await build.buildLocalEnv(clusters, (r: build.ProgressReport) => progress.report(r))
+ .then((msg: string) => vscode.window.showInformationMessage(msg))
+ .catch((err: string | Error) =>
+ vscode.window.showErrorMessage(err instanceof Error ? err.name : err));
+ })
+ .catch(() => {
+ progress.report({increment: 100, message: 'unable to verify the existence of the required tools' });
+ vscode.window.showErrorMessage(
+ 'OCM extension, unable to verify the existence of the required tools'
+ );
+ });
+ }
+ );
+}
diff --git a/vscode-extension/src/commands/newProject.ts b/vscode-extension/src/commands/newProject.ts
new file mode 100644
index 00000000..d4732ba5
--- /dev/null
+++ b/vscode-extension/src/commands/newProject.ts
@@ -0,0 +1,36 @@
+
+import * as filesystem from '../utils/filesystem';
+import * as vscode from 'vscode';
+
+// create a template project based on the user input
+export async function create(): Promise {
+ // get template type from the user
+ let templateType: string = await vscode.window.showQuickPick(
+ filesystem.availableTemplates, {
+ placeHolder: `template type, default: ${filesystem.defaultTemplate}`,
+ }) || filesystem.defaultTemplate;
+
+ // get the project name from the user
+ let projectName: string = await vscode.window.showInputBox({
+ placeHolder: `insert project name, default: ${filesystem.defaultProjectName}`,
+ }) || filesystem.defaultProjectName;
+
+ // verify inside workspace
+ if (vscode.workspace.workspaceFolders === undefined) {
+ vscode.window.showWarningMessage(
+ 'OCM extension, no workspace folder, please open a project or create a workspace');
+ return;
+ }
+ // prepare project folder path
+ let workspaceFolder: string = vscode.workspace.workspaceFolders[0].uri.path;
+ if (process.platform === 'win32') {
+ // the workspaceFolder is a uri path, it includes the initial forward slash
+ // behind the scenes, this is used to start at root, but this breaks for windows
+ workspaceFolder = workspaceFolder.substring(1);
+ }
+
+ // create the project
+ filesystem.createProjectFromTemplate(workspaceFolder, projectName, templateType)
+ .then((msg: string) => vscode.window.showInformationMessage(msg))
+ .catch((msg: string) => vscode.window.showErrorMessage(msg));
+}
diff --git a/vscode-extension/src/commands/verifyEnvironment.ts b/vscode-extension/src/commands/verifyEnvironment.ts
new file mode 100644
index 00000000..097b534f
--- /dev/null
+++ b/vscode-extension/src/commands/verifyEnvironment.ts
@@ -0,0 +1,15 @@
+import * as environment from '../utils/environment';
+import * as vscode from 'vscode';
+
+// verify the required tools exists
+export async function verifyTools(): Promise {
+ environment.verifyTools(...environment.requiredTools)
+ // @ts-ignore
+ .then((msg: string) => vscode.window.showInformationMessage(msg))
+ .catch((msg: string[]) => vscode.window.showErrorMessage(msg[0], 'Install Instructions')
+ .then(answer => {
+ if (answer === 'Install Instructions') {
+ vscode.env.openExternal(vscode.Uri.parse(msg[1]));
+ }
+ }));
+}
diff --git a/vscode-extension/src/data/builder.ts b/vscode-extension/src/data/builder.ts
new file mode 100644
index 00000000..c57d0074
--- /dev/null
+++ b/vscode-extension/src/data/builder.ts
@@ -0,0 +1,83 @@
+import * as k8s from '@kubernetes/client-node';
+
+// our connected context component is used for encapsulating a kubectl context
+export class ConnectedContext {
+ readonly kontext: k8s.Context;
+ readonly name: string;
+ readonly cluster: ConnectedCluster;
+ readonly user: ConnectedUser;
+
+ constructor(kontext: k8s.Context, cluster: ConnectedCluster, user: ConnectedUser) {
+ this.kontext = kontext;
+ this.name = kontext.name;
+ this.cluster = cluster;
+ this.user = user;
+ }
+}
+
+// our connected cluster component is used for encapsulating a kubectl cluster
+export class ConnectedCluster {
+ readonly kluster: k8s.Cluster;
+ readonly name: string;
+ readonly server: string;
+
+ constructor(cluster: k8s.Cluster) {
+ this.kluster = cluster;
+ this.name = cluster.name;
+ this.server = cluster.server;
+ }
+}
+
+// our connected user component is used for encapsulating a kubectl user
+export class ConnectedUser {
+ readonly kuser: k8s.User;
+ readonly name: string;
+
+ constructor(user: k8s.User) {
+ this.kuser = user;
+ this.name = user.name;
+ }
+}
+
+// a builder class used for building our component
+export class Build {
+ private config: k8s.KubeConfig;
+
+ constructor(config: k8s.KubeConfig) {
+ this.config = config;
+ }
+
+ // build a context component
+ context(item: string | k8s.Context): ConnectedContext | undefined {
+ let connectedContext;
+ let kontext = typeof item === 'string' ? this.config.getContextObject(item) : item;
+ if (kontext) {
+ let cluster = this.cluster(kontext.cluster);
+ let user = this.user(kontext.user);
+ if (cluster && user) {
+ connectedContext = new ConnectedContext(kontext, cluster, user);
+ }
+ }
+ return connectedContext;
+ }
+
+ // build a cluster component
+ cluster(item: string | k8s.Cluster): ConnectedCluster | undefined{
+ let connectedCluster;
+ let kluster = typeof item === 'string' ? this.config.getCluster(item) : item;
+ if (kluster) {
+ connectedCluster = new ConnectedCluster(kluster);
+ }
+ return connectedCluster;
+ }
+
+ // build a user component
+ user(item: string | k8s.User): ConnectedUser | undefined {
+ let connectedUser;
+ let kuser = typeof item === 'string' ? this.config.getUser(item) : item;
+ if (kuser) {
+ connectedUser = new ConnectedUser(kuser);
+ }
+ return connectedUser;
+ }
+}
diff --git a/vscode-extension/src/data/distributor.ts b/vscode-extension/src/data/distributor.ts
new file mode 100644
index 00000000..7304af5e
--- /dev/null
+++ b/vscode-extension/src/data/distributor.ts
@@ -0,0 +1,60 @@
+import * as loader from './loader';
+import { ConnectedContext } from './builder';
+
+// the ManagedCluster kind is used in hub clusters for managing spoke clusters
+const hubManagedClusterKind = 'ManagedCluster';
+// these are the kinds we expect and support for running on a hub cluster
+const hubKinds = ['ManifestWork', 'Placement', 'PlacementDecision', 'ManagedClusterSet', 'ManagedClusterAddOn', 'ClusterManager', 'SubscriptionReport'];
+// the Klusterlet kind is used in spoke clusters for communicating with the hub cluster
+const spokeKlusterletKind = 'Klusterlet';
+// these are the kinds we expect and support for running on a spoke cluster
+const spokeKinds = ['AppliedManifestWork', 'SubscriptionStatus'];
+
+// listeners for messages posted here resides in the components package in the react module
+// will distribute two types of messages to the parameterized consumer:
+// {selectedContext: ...ConnectedContext...} - for every invocation
+// { crsDistribution: { kind: X, crs: ...[OcmResource]...}} - for every relevant kind
+export async function distributeMessages(selectedContext: ConnectedContext, consumer: CallableFunction): Promise {
+ // get the loader instance
+ let load = loader.Load.getLoader();
+
+ // retrieve all managedclusters and klusterlets resources
+ let managedClusters = await load.getCrs(hubManagedClusterKind);
+ let klusterlets = await load.getCrs(spokeKlusterletKind);
+
+ // populate the SelectedContext ui component
+ consumer({selectedContext: JSON.stringify(selectedContext)});
+
+ let crsDistributions: Promise[] = [];
+
+ // a cluster with ManagedCluster resources, acts as a hub cluster
+ if (managedClusters.length > 0 ){
+ // populate the ManagedClusters ui component
+ crsDistributions.push(distributeCrs(hubManagedClusterKind, consumer, managedClusters));
+ // populate common crs used by the hub cluster
+ hubKinds.forEach(kind => crsDistributions.push(distributeCrsForKind(kind, load, consumer)));
+ }
+
+ // a cluster with Klusterlet resources, acts as a spoke cluster
+ if (klusterlets.length > 0 ){
+ // populate the Klusterlets ui component
+ crsDistributions.push(distributeCrs(spokeKlusterletKind, consumer, klusterlets));
+ // populate common crs used by the spoke cluster
+ spokeKinds.forEach(kind => crsDistributions.push(distributeCrsForKind(kind, load, consumer)));
+ }
+
+ await Promise.allSettled(crsDistributions);
+}
+
+// load crs for a kind, and distribute the crs message to the consumer
+async function distributeCrsForKind(kind: string, load: loader.Load, consumer: CallableFunction): Promise {
+ let crs = await load.getCrs(kind);
+ await distributeCrs(kind, consumer, crs);
+}
+
+// distribute the crs message to the consumer
+async function distributeCrs(kind: string, consumer: CallableFunction, crs: loader.OcmResource[]): Promise {
+ if (crs.length > 0) {
+ consumer({ crsDistribution: { kind: kind, crs: JSON.stringify(crs)}});
+ }
+}
diff --git a/vscode-extension/src/data/loader.ts b/vscode-extension/src/data/loader.ts
new file mode 100644
index 00000000..e1e3274c
--- /dev/null
+++ b/vscode-extension/src/data/loader.ts
@@ -0,0 +1,200 @@
+import * as builder from './builder';
+import * as k8s from '@kubernetes/client-node';
+
+const OCM_GROUP = 'open-cluster-management';
+
+// our ocm resource component is used for encapsulating an ocm-related k8s resource
+export class OcmResource {
+ readonly kr: any;
+ readonly crd: OcmResourceDefinition;
+ readonly name: string;
+ readonly namespace?: string;
+
+ constructor(kr: any, crd: OcmResourceDefinition, namespace?: string) {
+ this.kr = kr;
+ this.crd = crd;
+ this.name = kr.metadata.name;
+ this.namespace = namespace;
+ }
+}
+
+// our ocm resource definition component is used for encapsulating an ocm-related k8s resource definition
+export class OcmResourceDefinition {
+ readonly krd: k8s.V1CustomResourceDefinition;
+ readonly name: string;
+ readonly plural: string;
+ readonly namespaced: boolean;
+ readonly kind: string;
+ readonly version: string;
+ readonly group: string;
+
+ constructor(krd: k8s.V1CustomResourceDefinition) {
+ this.krd = krd;
+ this.name = krd.metadata?.name as string;
+ this.plural = krd.spec.names.plural;
+ this.namespaced = krd.spec.scope === 'Namespaced';
+ this.kind = krd.spec.names.kind;
+ this.version = krd.spec.versions.find(v => v.storage)?.name as string;
+ this.group = krd.spec.group;
+ }
+}
+
+// use this class for obtaining our loader singleton used for accessing k8s via kubectl configuration
+export class Load {
+ private static loader: Load;
+ private config: k8s.KubeConfig;
+ private build: builder.Build;
+ private extApi?: k8s.ApiextensionsV1Api;
+ private objApi?: k8s.CustomObjectsApi;
+ private coreApi?: k8s.CoreV1Api;
+
+ // serve (and build) our loader instance
+ static getLoader(): Load {
+ if (!Load.loader) {
+ let config = new k8s.KubeConfig();
+ let build = new builder.Build(config);
+ Load.loader = new Load(config, build);
+ }
+ return Load.loader;
+ }
+
+ private constructor(config: k8s.KubeConfig, build: builder.Build) {
+ this.config = config;
+ this.build = build;
+ this.refresh();
+ }
+
+ // use the refresh function to load the configuration from kube-config and refresh our api clients
+ refresh(): void {
+ this.config.loadFromDefault();
+ this.refreshClients();
+ }
+
+ // refresh our api clients for existing contexts
+ private refreshClients(): void {
+ if (this.getContext()) {
+ this.extApi = this.config.makeApiClient(k8s.ApiextensionsV1Api);
+ this.objApi = this.config.makeApiClient(k8s.CustomObjectsApi);
+ this.coreApi = this.config.makeApiClient(k8s.CoreV1Api);
+ }
+ }
+
+ // verify the cluster listed in the current context is reachable
+ async verifyReachability(): Promise {
+ try {
+ let r = await this.coreApi?.listNode();
+ if (!r) {
+ return Promise.reject('Cluster is not accessible');
+ }
+ if (r.response.statusCode !== 200) {
+ return Promise.reject(`Cluster is not accessible, ${String(r.response.statusCode)}`);
+ }
+ } catch (e: any) {
+ return Promise.reject(`Cluster is not accessible, ${String(e)}`);
+ }
+ }
+
+ // set the current context and refresh the api client accordingly
+ setContext(context: string | builder.ConnectedContext): void {
+ this.config.setCurrentContext(typeof context === 'string' ? context : context.name);
+ this.refreshClients();
+ }
+
+ // get the current context if one configured
+ getContext(): builder.ConnectedContext | undefined {
+ return this.build.context(this.config.currentContext);
+ }
+
+ // get all contexts from the loaded kube-config
+ getContexts(): builder.ConnectedContext[] {
+ return this.config.contexts
+ .map(kontext => this.build.context(kontext))
+ .filter((context): context is builder.ConnectedContext => !!context);
+ }
+
+ // get a cluster from the loaded kube-config
+ getCluster(name: string): builder.ConnectedCluster | undefined {
+ return this.build.cluster(name);
+ }
+
+ // get all clusters from the loaded kube-config
+ getClusters(): builder.ConnectedCluster[] {
+ return this.config.clusters
+ .map(kluster => this.build.cluster(kluster))
+ .filter((cluster): cluster is builder.ConnectedCluster => !!cluster);
+ }
+
+ // get a user from the loaded kube-config
+ getUser(name: string): builder.ConnectedUser | undefined {
+ return this.build.user(name);
+ }
+
+ // get all users from the loaded kube-config
+ getUsers(): builder.ConnectedUser[] {
+ return this.config.users
+ .map(kuser => this.build.user(kuser))
+ .filter((user): user is builder.ConnectedUser => !!user);
+ }
+
+ // fetch a crd for a kind
+ async getCrd(kind: string): Promise {
+ return (await this.getCrds()).filter(c => c.kind === kind)[0];
+ }
+
+ // fetch all crds for the open-cluster-management group
+ async getCrds(): Promise {
+ let retCrds: OcmResourceDefinition[] = [];
+ let response = await this.extApi?.listCustomResourceDefinition();
+ if (response && response.response.statusCode === 200) {
+ retCrds.push(...response.body.items.filter(item => item.spec.group.includes(OCM_GROUP)).map(crd => new OcmResourceDefinition(crd)));
+ }
+ return retCrds;
+ }
+
+ // get all crs for a crd
+ async getCrs(item: string | OcmResourceDefinition): Promise {
+ let retRes: OcmResource[] = [];
+ let crd = typeof item === 'string' ? await this.getCrd(item) : item;
+ if (crd) {
+ let crs = crd.namespaced ? await this.getNamespacedCrs(crd) : await this.getClusterCrs(crd);
+ retRes.push(...crs);
+ }
+ return retRes;
+ }
+
+ // get clustered crs for a clustered crd
+ private async getClusterCrs(crd: OcmResourceDefinition): Promise {
+ let retRes: OcmResource[] = [];
+ let response = await this.objApi?.listClusterCustomObject(crd.group, crd.version, crd.plural);
+ if (response && response.response.statusCode === 200) {
+ // @ts-ignore
+ retRes.push(...response.body.items.map(item => new OcmResource(item, crd)));
+ }
+ return retRes;
+ }
+
+ // get namespaced crs for a namespaced crd
+ private async getNamespacedCrs(crd: OcmResourceDefinition): Promise {
+ let retRes: OcmResource[] = [];
+ let nsResponse = await this.coreApi?.listNamespace();
+ if (nsResponse && nsResponse.response.statusCode === 200) {
+ let objectPromises: Promise[] = [];
+ nsResponse.body.items.forEach(async nsitem => {
+ let namespace = nsitem.metadata?.name as string;
+ let objectPromise = this.objApi?.listNamespacedCustomObject(crd.group, crd.version, namespace, crd.plural)
+ .then(objResponse => {
+ // @ts-ignore
+ if (objResponse && objResponse.response.statusCode === 200 && objResponse.body.items.length > 0) {
+ // @ts-ignore
+ retRes.push(...objResponse.body.items.map(item => new OcmResource(item, crd, namespace)));
+ }
+ });
+ if (objectPromise) {
+ objectPromises.push(objectPromise);
+ }
+ });
+ await Promise.all(objectPromises);
+ }
+ return retRes;
+ }
+}
diff --git a/vscode-extension/src/extension.ts b/vscode-extension/src/extension.ts
new file mode 100644
index 00000000..666db7ab
--- /dev/null
+++ b/vscode-extension/src/extension.ts
@@ -0,0 +1,31 @@
+import * as argoProject from './commands/argoProject';
+import * as createEnvironment from './commands/createEnvironment';
+import * as newProject from './commands/newProject';
+import * as verifyEnvironment from './commands/verifyEnvironment';
+import * as vscode from 'vscode';
+import { ConnectedContext } from './data/builder';
+import { ConnectedContextWebProvider } from './providers/contextWebProvider';
+import { ConnectedContextsTreeProvider } from './providers/contextsTreeProvider';
+import { Load } from './data/loader';
+
+export function activate(extensionContext: vscode.ExtensionContext): void {
+ let dataLoader = Load.getLoader();
+ let connectedContextsTreeProvider = new ConnectedContextsTreeProvider(dataLoader);
+ extensionContext.subscriptions.push(
+ vscode.commands.registerCommand(
+ 'ocm-vscode-extension.ocmNewProject', () => newProject.create()),
+ vscode.commands.registerCommand(
+ 'ocm-vscode-extension.argoProject', () => argoProject.create()),
+ vscode.commands.registerCommand(
+ 'ocm-vscode-extension.verifyTools', () => verifyEnvironment.verifyTools()),
+ vscode.commands.registerCommand(
+ 'ocm-vscode-extension.createLocalEnvironment', () => createEnvironment.createLocalEnvironment()),
+ vscode.window.registerTreeDataProvider(
+ 'ocm-vscode-extension.connectedContextsView', connectedContextsTreeProvider),
+ vscode.commands.registerCommand(
+ 'ocm-vscode-extension.connectedContextsView.refresh', () => connectedContextsTreeProvider.refresh()),
+ vscode.commands.registerCommand(
+ 'ocm-vscode-extension.showContextDetails',
+ (selectedContext: ConnectedContext) => ConnectedContextWebProvider.render(extensionContext.extensionUri, selectedContext))
+ );
+}
diff --git a/vscode-extension/src/providers/contextWebProvider.ts b/vscode-extension/src/providers/contextWebProvider.ts
new file mode 100644
index 00000000..3d2ae246
--- /dev/null
+++ b/vscode-extension/src/providers/contextWebProvider.ts
@@ -0,0 +1,114 @@
+/* eslint-disable @typescript-eslint/restrict-template-expressions */
+import * as distributor from '../data/distributor';
+import * as loader from '../data/loader';
+import { Disposable, Uri, ViewColumn, Webview, WebviewPanel, window } from "vscode";
+import { ConnectedContext } from '../data/builder';
+
+// class used for management and encapsulation of the webview panel
+export class ConnectedContextWebProvider {
+ public static currentPanel?: ConnectedContextWebProvider;
+ private readonly panel: WebviewPanel;
+ private disposables: Disposable[] = [];
+
+ // static function used for rendering a web view
+ public static async render(extensionUri: Uri, context: ConnectedContext | undefined): Promise {
+ // get the loader instance
+ let load = loader.Load.getLoader();
+
+ // when invoked from the tree view, the connectedContext will be provided
+ // when invoked from the command palette we need to ask the user for the context
+ let selectedContext = context ? context : await this.selectContext(load.getContexts());
+ if (!selectedContext) {
+ return;
+ }
+
+ // set the context to work with
+ load.setContext(selectedContext);
+
+ // verify the context's cluster is reachable
+ try {
+ await load.verifyReachability();
+ } catch (e: any) {
+ window.showErrorMessage(e);
+ return;
+ }
+
+ // if a webview panel already exists dispose of it
+ if (ConnectedContextWebProvider.currentPanel) {
+ ConnectedContextWebProvider.currentPanel.panel.dispose();
+ }
+
+ // create and show a new web view panel
+ let panel = window.createWebviewPanel(
+ "contextDetails",
+ "Context Details",
+ ViewColumn.One,
+ {
+ enableScripts: true,
+ retainContextWhenHidden: true,
+ }
+ );
+
+ // instantiate the provider and set it as the current one for future disposing
+ ConnectedContextWebProvider.currentPanel = new ConnectedContextWebProvider(panel, extensionUri, selectedContext);
+ }
+
+ // collect context from the user
+ private static async selectContext(knownContexts: ConnectedContext[]): Promise {
+ let selectedContext = await window.showQuickPick(
+ knownContexts.map(context => context.name),
+ {
+ title: 'Choose a context',
+ ignoreFocusOut: true
+ }
+ ) || undefined;
+
+ if (selectedContext) {
+ return knownContexts.filter(context => context.name === selectedContext)[0];
+ }
+ return undefined;
+ }
+
+ // generate the html and distribute message for the react module
+ private constructor(panel: WebviewPanel, extensionUri: Uri, selectedContext: ConnectedContext) {
+ this.panel = panel;
+ // eslint-disable-next-line @typescript-eslint/unbound-method
+ this.panel.onDidDispose(this.dispose, null, this.disposables);
+ this.panel.webview.html = this.generateHtml(this.panel.webview, extensionUri);
+ distributor.distributeMessages(selectedContext, (msg: any) => this.panel.webview.postMessage(msg));
+ }
+
+ public dispose():void {
+ ConnectedContextWebProvider.currentPanel = undefined;
+ this.panel.dispose();
+ while (this.disposables.length) {
+ let disposable = this.disposables.pop();
+ if (disposable) {
+ disposable.dispose();
+ }
+ }
+ }
+
+ private generateHtml(webview: Webview, extensionUri: Uri):string {
+ // js from the react build output
+ let scriptUri = webview.asWebviewUri(
+ Uri.joinPath(extensionUri, "webview-ui", "build", "static", "js", "main.js"));
+
+ return /*html*/ `
+
+
+
+
+
+
+ Context Details
+
+
+
+
+
+
+
+ `;
+ }
+}
diff --git a/vscode-extension/src/providers/contextsTreeProvider.ts b/vscode-extension/src/providers/contextsTreeProvider.ts
new file mode 100644
index 00000000..f557540a
--- /dev/null
+++ b/vscode-extension/src/providers/contextsTreeProvider.ts
@@ -0,0 +1,107 @@
+import * as builder from '../data/builder';
+import * as loader from '../data/loader';
+import * as path from 'path';
+import * as vscode from 'vscode';
+
+const LAUNCH_WEBVIEW_TITLE = 'Context Info';
+const LAUNCH_WEBVIEW_CMD = 'ocm-vscode-extension.showContextDetails';
+const ICON_CONTEXT = 'k8s';
+const ICON_CRD = 'ocm';
+
+type TreeElement = TreeContext | TreeCrd | TreeCr;
+
+// tree view element for encapsulating a context representing a cluster and a user
+export class TreeContext extends vscode.TreeItem {
+ readonly context: builder.ConnectedContext;
+
+ constructor(context: builder.ConnectedContext) {
+ super(context.name, vscode.TreeItemCollapsibleState.Collapsed);
+ this.context = context;
+ this.tooltip = context.cluster.name;
+ // this command launces the webview with the encapsulated context as an argument
+ this.command = {
+ title: LAUNCH_WEBVIEW_TITLE,
+ command: LAUNCH_WEBVIEW_CMD,
+ arguments: [context]
+ };
+ }
+
+ iconPath = {
+ light: path.join(__dirname, '..', '..', '..', 'images', 'light', `${ICON_CONTEXT}.svg`),
+ dark: path.join(__dirname, '..', '..', '..', 'images', 'dark', `${ICON_CONTEXT}.svg`),
+ };
+}
+
+// tree view element for encapsulating a custom resource definition
+export class TreeCrd extends vscode.TreeItem {
+ readonly crd: loader.OcmResourceDefinition;
+
+ constructor(crd: loader.OcmResourceDefinition) {
+ super(crd.kind, vscode.TreeItemCollapsibleState.Collapsed);
+ this.crd = crd;
+ this.tooltip = crd.group;
+ }
+
+ iconPath = {
+ light: path.join(__dirname, '..', '..', '..', 'images', 'light', `${ICON_CRD}.svg`),
+ dark: path.join(__dirname, '..', '..', '..', 'images', 'dark', `${ICON_CRD}.svg`),
+ };
+}
+
+// tree view element for encapsulating a custom resource
+export class TreeCr extends vscode.TreeItem {
+ readonly cr: loader.OcmResource;
+
+ constructor(cr: loader.OcmResource) {
+ super(cr.name, vscode.TreeItemCollapsibleState.None);
+ this.cr = cr;
+ this.tooltip = cr.crd.version;
+ }
+}
+
+// implementation of tree view, provides contexts as highest entity:
+// contexts > crds > crs
+export class ConnectedContextsTreeProvider implements vscode.TreeDataProvider {
+ private load: loader.Load;
+
+ private _onDidChangeTreeData: vscode.EventEmitter = new vscode.EventEmitter();
+ readonly onDidChangeTreeData: vscode.Event = this._onDidChangeTreeData.event;
+
+
+ constructor(load: loader.Load) {
+ this.load = load;
+ this.refresh();
+ }
+
+ refresh(): void {
+ this.load.refresh();
+ this._onDidChangeTreeData.fire();
+ }
+
+ getTreeItem(element: TreeElement): TreeElement {
+ return element;
+ }
+
+ // (highest) contexts > crds > crs (lowest)
+ async getChildren(element?: TreeElement): Promise {
+ let elements: TreeElement[] = [];
+ if (element) {
+ if (element instanceof TreeContext) {
+ // children of a contexts are crds
+ this.load.setContext(element.context);
+ let crds = await this.load.getCrds();
+ elements.push(...crds.map(crd => new TreeCrd(crd)));
+ }
+ if (element instanceof TreeCrd) {
+ // children of an crds are crs
+ let crs = await this.load.getCrs(element.crd);
+ elements.push(...crs.map(cr => new TreeCr(cr)));
+ }
+ } else {
+ // top level children are the contexts
+ let contexts = this.load.getContexts();
+ elements.push(...contexts.map(context => new TreeContext(context)));
+ }
+ return elements;
+ }
+}
diff --git a/vscode-extension/src/utils/build.ts b/vscode-extension/src/utils/build.ts
new file mode 100644
index 00000000..ad5e5993
--- /dev/null
+++ b/vscode-extension/src/utils/build.ts
@@ -0,0 +1,140 @@
+
+import * as shellTools from './shell';
+
+const blankLines = new RegExp(/(^[ \t]*\r?\n)/, "gm");
+
+export enum ClusterType {
+ hub, managed
+}
+
+export interface Cluster {
+ name: string,
+ context: string
+ type: ClusterType
+}
+
+export interface ProgressReport {
+ increment: number,
+ message: string
+}
+
+export interface ProgressReporter {
+ (r: ProgressReport): void
+}
+
+export const defaultClusters: Cluster[] = [
+ {
+ name: 'hub',
+ context: 'kind-hub',
+ type: ClusterType.hub
+ },
+ {
+ name: 'cluster1',
+ context: 'kind-cluster1',
+ type: ClusterType.managed
+ },
+ {
+ name: 'cluster2',
+ context: 'kind-cluster2',
+ type: ClusterType.managed
+ },
+];
+
+// create a kind cluster, fulfilled with stdout/stderr
+function createKindCluster(cluster: Cluster): Promise {
+ console.debug(`creating a kind cluster named ${cluster.name}`);
+ return shellTools.executeShellCommand(`kind create cluster --name ${cluster.name}`);
+}
+
+// create kind clusters
+async function createKindClusters(clusters: Cluster[], reporter: ProgressReporter): Promise {
+ console.debug(`creating ${clusters.length} kind clusters`);
+ reporter({increment: 0 , message: `creating ${clusters.length} kind clusters`});
+ let clusterPromises = clusters.map(cluster => createKindCluster(cluster));
+ await Promise.allSettled(clusterPromises)
+ .then((results: PromiseSettledResult[]) => {
+ if (results.filter((r) => r.status === 'rejected').length > 0) {
+ return Promise.reject('failed creating kind clusters');
+ }
+ });
+}
+
+// initialize the hub cluster, resolves with the join command, rejects with the error message
+async function initializeHubCluster(hubCluster: Cluster, reporter: ProgressReporter): Promise {
+ console.debug(`initializing the hub cluster named ${hubCluster.name}`);
+ reporter({increment: 20 , message: `initializing the Hub cluster named ${hubCluster.name}`});
+ return shellTools.executeShellCommand(`kubectl config use ${hubCluster.context}`)
+ .then(() => shellTools.executeShellCommand('clusteradm init --use-bootstrap-token'))
+ .then((stdout: string) => stdout.replace(blankLines, '').split(/\r?\n/)[2].trim())
+ .catch(() => Promise.reject('failed initializing the hub cluster'));
+}
+
+// issue a join command from a managed cluster using a join command
+async function issueJoinRequest(managedCluster: Cluster, joinCmd: string): Promise {
+ console.debug(`issuing a join request from the managed cluster named ${managedCluster.name}`);
+ let fixedJoinCmd = joinCmd.replace('', managedCluster.name).trim();
+ return shellTools.executeShellCommand(`kubectl config use ${managedCluster.context}`)
+ .then(() => shellTools.executeShellCommand(`${fixedJoinCmd} --force-internal-endpoint-lookup --wait`));
+}
+
+// issue join requests from the managed clusters to the hub
+async function sendJoinRequests(
+ joinCmd: string, managedClusters: Cluster[], reporter: ProgressReporter): Promise {
+
+ console.debug(`issuing join requests for ${managedClusters.length} managed clusters`);
+ reporter({increment: 20 , message: 'issuing join requests for the managed clusters'});
+ return managedClusters.reduce(
+ (previousPromise, currentPromise) =>
+ previousPromise.then(() => issueJoinRequest(currentPromise, joinCmd)),
+ Promise.resolve('initial value')
+ )
+ .then(() => Promise.resolve())
+ .catch(() => Promise.reject('failed to issue join requests'));
+}
+
+// approve join request made to the hub cluster by the managed clusters
+async function acceptJoinRequest(hubCluster: Cluster, requesters: string) : Promise {
+ console.debug(`accepting join requests from ${requesters}`);
+ return shellTools.executeShellCommand(`kubectl config use ${hubCluster.context}`)
+ .then(() => shellTools.executeShellCommand(`clusteradm accept --clusters ${requesters} --wait`));
+}
+
+// accept the issued join commands by the managed clusters from the hub cluster
+async function acceptAllJoinRequests(
+ hubCluster: Cluster, managedClusters: Cluster[], reporter: ProgressReporter): Promise {
+
+ console.debug(`accepting ${managedClusters.length} on behalf of the ${hubCluster.name}`);
+ reporter({increment: 20 , message: 'accepting the managed clusters join request from the hub cluster'});
+ return acceptJoinRequest(hubCluster, managedClusters.map(mc => mc.name).join())
+ .then(() => Promise.resolve('successfully created your local environment, have fun'))
+ .catch(() => Promise.reject('failed to accept join requests'));
+}
+
+// log, report, and fulfil the build process
+function fulfilBuild(msg: string, reporter: ProgressReporter, fulfil: (s: string) => void): void {
+ console.debug(msg);
+ reporter({increment: 100 , message: msg});
+ fulfil(`OCM extension, ${msg}`);
+}
+
+// starts a local OCM kind env and return a promise
+export async function buildLocalEnv(clusters: Cluster[], reporter: ProgressReporter): Promise {
+ return new Promise((resolve, reject) => {
+ //Verify Clusters Info
+ let hubClusters = clusters.filter(c => c.type === ClusterType.hub);
+ let managedClusters = clusters.filter(c => c.type === ClusterType.managed);
+
+ if (hubClusters.length !== 1 || managedClusters.length < 1) {
+ let errMsg = `required 1 hub and at least 1 managed cluster, found ${hubClusters.length} and ${managedClusters.length}`;
+ fulfilBuild(errMsg, reporter, reject);
+ } else {
+ let hubCluster = hubClusters[0];
+ createKindClusters(clusters, reporter) // create the kind clusters
+ .then(() => initializeHubCluster(hubCluster, reporter)) // initialize the hub cluster
+ .then((joinCmd: string) => sendJoinRequests(joinCmd, managedClusters, reporter)) // send join requests
+ .then(() => acceptAllJoinRequests(hubCluster, managedClusters, reporter)) // accept join requests
+ .then((msg: string) => fulfilBuild(msg, reporter, resolve)) // resolve build
+ .catch((msg: string) => fulfilBuild(msg, reporter, reject)); // reject build
+ }
+ });
+}
diff --git a/vscode-extension/src/utils/environment.ts b/vscode-extension/src/utils/environment.ts
new file mode 100644
index 00000000..059b278e
--- /dev/null
+++ b/vscode-extension/src/utils/environment.ts
@@ -0,0 +1,33 @@
+import * as shell from './shell';
+
+export interface RequiredTool {
+ name: string,
+ installUrl: string
+}
+
+export const requiredTools: RequiredTool[] = [
+ {
+ 'name': 'kubectl',
+ 'installUrl': 'https://kubernetes.io/docs/tasks/tools/#kubectl'
+ },
+ {
+ 'name': 'clusteradm',
+ 'installUrl': 'https://github.com/open-cluster-management-io/clusteradm#install-the-clusteradm-command-line'
+ },
+ {
+ 'name': 'kind',
+ 'installUrl': 'https://kind.sigs.k8s.io/docs/user/quick-start/#installation'
+ }
+];
+
+// verify the the existence of the required tools in the environment's shell
+// will be resolved with a string or rejected with a string[]
+export async function verifyTools(...tools: RequiredTool[]): Promise {
+ let executionPromises: Promise[] = tools.map(
+ tool => shell.checkToolExists(tool.name).catch(
+ () => Promise.reject([`OCM extension, ${tool.name} is missing, please install it`, tool.installUrl])
+ )
+ );
+ return Promise.all(executionPromises)
+ .then(() => Promise.resolve('OCM extension, all tools are accessible, we\'re good to go'));
+}
diff --git a/vscode-extension/src/utils/filesystem.ts b/vscode-extension/src/utils/filesystem.ts
new file mode 100644
index 00000000..f9f8f570
--- /dev/null
+++ b/vscode-extension/src/utils/filesystem.ts
@@ -0,0 +1,41 @@
+import * as fse from 'fs-extra';
+import * as path from 'path';
+
+export const availableTemplates = ['Git', 'HelmRepo', 'ObjectBucket'];
+export const argoTemplates = ['Argo'];
+
+export const defaultTemplate = 'Git';
+export const defaultArgoTemplate = 'Argo';
+export const defaultProjectName = 'ocm-application';
+export const defaultArgoProjectName = 'ocm-argo-application';
+
+// create a template project
+export async function createProjectFromTemplate(
+ workspaceFolder: string, projectName: string, templateType: string): Promise {
+
+ let projectFolder = path.join(workspaceFolder, projectName); // destination
+ let templatesFolder = path.resolve(__dirname, `../../../templates/${templateType}`); // source
+
+ return new Promise((resolve, reject) => {
+ // verify project folder doesn't exists
+ fse.pathExists(projectFolder)
+ .then((exists: boolean) => {
+ if (exists) {
+ reject(`OCM extension, project folder ${projectName} exists, please use another`);
+ } else {
+ // create project folder
+ fse.ensureDir(projectFolder)
+ // copy templates to project folder
+ .then(() => fse.copy(templatesFolder, projectFolder)
+ .then(() => resolve(`OCM extension, project ${projectName} created`))
+ .catch((reason: string) => reject(
+ `OCM extension, failed creating project ${projectName}, ${reason}`)
+ )
+ )
+ .catch((reason: string) => reject(
+ `OCM extension, failed to create project folder ${projectName}, ${reason}`
+ ));
+ }
+ });
+ });
+}
diff --git a/vscode-extension/src/utils/shell.ts b/vscode-extension/src/utils/shell.ts
new file mode 100644
index 00000000..06aee0fb
--- /dev/null
+++ b/vscode-extension/src/utils/shell.ts
@@ -0,0 +1,26 @@
+
+import * as shell from 'shelljs';
+
+shell.config.execPath = String(shell.which('node'));
+
+// execute a command and return a promise of the output as string
+export function executeShellCommand(command: string): Promise {
+ return new Promise((resolve, reject) => {
+ shell.exec(command, (code, stdout, stderr) => {
+ if (code === 0) {
+ resolve(stdout);
+ }
+ reject(stderr);
+ });
+ });
+}
+
+// check if a cli tool exists and return a promise
+export function checkToolExists(tool: string): Promise {
+ return new Promise((resolve, reject) => {
+ if (shell.which(tool)?.code === 0) {
+ resolve();
+ }
+ reject();
+ });
+}
diff --git a/vscode-extension/templates/Argo/00-namespaces.yaml b/vscode-extension/templates/Argo/00-namespaces.yaml
new file mode 100644
index 00000000..174d3e06
--- /dev/null
+++ b/vscode-extension/templates/Argo/00-namespaces.yaml
@@ -0,0 +1,6 @@
+---
+apiVersion: v1
+kind: Namespace
+metadata:
+ name: argocd
+
diff --git a/vscode-extension/templates/Argo/README.md b/vscode-extension/templates/Argo/README.md
new file mode 100644
index 00000000..64f2e1a8
--- /dev/null
+++ b/vscode-extension/templates/Argo/README.md
@@ -0,0 +1,46 @@
+# OCM argcd application Project
+
+Applying these resource files will create the following resources:
+
+| Kind | Namespace | Name |
+| ------------------------ | -------------- | ------------------------------- |
+| Namespace | x | argocd |
+| Role | argcd | ocm-placement-consumer |
+| RoleBinding | argcd | ocm-placement-consumer:argocd |
+| ConfigMap | argcd | ocm-placement-generator |
+| Placement | argcd | guestbook-app-placement |
+| ApplicationSet | argcd | guestbook-app |
+
+## Prerequisites
+
+[ArgoCD][0] should be installed and configured.
+
+## Make it your own
+
+- Go into _application-set.yaml_ and update the _repoURL_ with your repository.
+- in the _placement.yaml_ update the spec according to your needs. more example could be found [here][1]
+
+## Run and verify
+When you're done with the _YAML_ files, apply them onto your hub cluster, and watch the magic takes place.
+```
+kubectl get applications -n argocd
+```
+
+you can also login to the argocd ui to watch deployment progress
+- port forward argo
+```
+kubectl port-forward svc/argocd-server -n argocd 8080:443
+```
+- get admin secret password
+```
+kubectl -n argocd get secret argocd-initial-admin-secret -o=jsonpath='{.data.password}' | base64 -d
+```
+- login to the argoUI and user name _admin_ and the _password_ retrieved from previous step
+https://localhost:8080
+
+for more details click [here][2]
+
+
+[0]: https://github.com/open-cluster-management-io/ocm/tree/main/solutions/deploy-argocd-apps
+[1]: https://open-cluster-management.io/concepts/placement/
+[2]: https://open-cluster-management.io/scenarios/integration-with-argocd
\ No newline at end of file
diff --git a/vscode-extension/templates/Argo/application-set.yaml b/vscode-extension/templates/Argo/application-set.yaml
new file mode 100644
index 00000000..0710806c
--- /dev/null
+++ b/vscode-extension/templates/Argo/application-set.yaml
@@ -0,0 +1,30 @@
+apiVersion: argoproj.io/v1alpha1
+kind: ApplicationSet
+metadata:
+ name: guestbook-app
+ namespace: argocd
+spec:
+ generators:
+ - clusterDecisionResource:
+ configMapRef: ocm-placement-generator
+ labelSelector:
+ matchLabels:
+ cluster.open-cluster-management.io/placement: guestbook-app-placement
+ requeueAfterSeconds: 30
+ template:
+ metadata:
+ name: '{{clusterName}}-guestbook-app'
+ spec:
+ project: default
+ source:
+ repoURL: 'https://github.com/argoproj/argocd-example-apps.git'
+ targetRevision: HEAD
+ path: guestbook
+ destination:
+ name: '{{clusterName}}'
+ namespace: guestbook
+ syncPolicy:
+ automated:
+ prune: true
+ syncOptions:
+ - CreateNamespace=true
\ No newline at end of file
diff --git a/vscode-extension/templates/Argo/argo-configmap.yaml b/vscode-extension/templates/Argo/argo-configmap.yaml
new file mode 100644
index 00000000..de0ca525
--- /dev/null
+++ b/vscode-extension/templates/Argo/argo-configmap.yaml
@@ -0,0 +1,10 @@
+kind: ConfigMap
+apiVersion: v1
+metadata:
+ name: ocm-placement-generator
+ namespace: argocd
+data:
+ apiVersion: cluster.open-cluster-management.io/v1beta1
+ kind: placementdecisions
+ statusListKey: decisions
+ matchKey: clusterName
\ No newline at end of file
diff --git a/vscode-extension/templates/Argo/placement.yaml b/vscode-extension/templates/Argo/placement.yaml
new file mode 100644
index 00000000..5b6260a7
--- /dev/null
+++ b/vscode-extension/templates/Argo/placement.yaml
@@ -0,0 +1,7 @@
+apiVersion: cluster.open-cluster-management.io/v1beta1
+kind: Placement
+metadata:
+ name: guestbook-app-placement
+ namespace: argocd
+spec:
+ numberOfClusters: 2
\ No newline at end of file
diff --git a/vscode-extension/templates/Argo/role.yaml b/vscode-extension/templates/Argo/role.yaml
new file mode 100644
index 00000000..8caa6433
--- /dev/null
+++ b/vscode-extension/templates/Argo/role.yaml
@@ -0,0 +1,9 @@
+kind: Role
+apiVersion: rbac.authorization.k8s.io/v1
+metadata:
+ name: ocm-placement-consumer
+ namespace: argocd
+rules:
+ - apiGroups: ['cluster.open-cluster-management.io']
+ resources: ['placementdecisions']
+ verbs: ['get', 'list']
\ No newline at end of file
diff --git a/vscode-extension/templates/Argo/rolebinding.yaml b/vscode-extension/templates/Argo/rolebinding.yaml
new file mode 100644
index 00000000..7f07defe
--- /dev/null
+++ b/vscode-extension/templates/Argo/rolebinding.yaml
@@ -0,0 +1,15 @@
+
+kind: RoleBinding
+apiVersion: rbac.authorization.k8s.io/v1
+metadata:
+ name: ocm-placement-consumer:argocd
+ namespace: argocd
+roleRef:
+ apiGroup: rbac.authorization.k8s.io
+ kind: Role
+ name: ocm-placement-consumer
+subjects:
+ - kind: ServiceAccount
+ namespace: argocd
+ name: argocd-applicationset-controller
+
diff --git a/vscode-extension/templates/Git/00-namespaces.yaml b/vscode-extension/templates/Git/00-namespaces.yaml
new file mode 100644
index 00000000..7175c2c6
--- /dev/null
+++ b/vscode-extension/templates/Git/00-namespaces.yaml
@@ -0,0 +1,10 @@
+---
+apiVersion: v1
+kind: Namespace
+metadata:
+ name: helloworld-chn
+---
+apiVersion: v1
+kind: Namespace
+metadata:
+ name: helloworld-app
diff --git a/vscode-extension/templates/Git/README.md b/vscode-extension/templates/Git/README.md
new file mode 100644
index 00000000..60c5eeab
--- /dev/null
+++ b/vscode-extension/templates/Git/README.md
@@ -0,0 +1,35 @@
+# OCM Git-type Application Lifecycle Project
+
+Applying these resource files will create the following resources:
+
+| Kind | Namespace | Name |
+| ------------------------ | -------------- | ----------------------- |
+| Namespace | x | helloworld-chn |
+| Namespace | x | helloworld-app |
+| Channel | helloworld-chn | helloworld-channel |
+| ManagedClusterSet | x | helloworld-clusterset |
+| ManagedClusterSetBinding | helloworld-app | helloworld-clusterset |
+| Placement | helloworld-app | helloworld-placement |
+| Subscription | helloworld-app | helloworld-subscription |
+
+## Prerequisites
+
+The [Application Lifecycle Management Addon][0] should be installed and enabled in your hub/managed clusters.
+
+## Make it your own
+
+- Go into _channel.yaml_ and update the _pathname_ of your repository.
+- Go into _subscription.yaml_ and update the configuration via annotations based on your needs.
+- Label the _ManagedClusters_ which should be part of the _ManagedClusterSet_ with the following label:
+ `cluster.open-cluster-management.io/clusterset=helloworld-clusterset`.
+- Label the _ManagedClusters_ you want to be selected by the _Placement_ with the following label:
+ `usage: development`.
+
+It is also advised to modify the various CRs names,
+but if you're ok with _helloworld_, have at it. :v:
+
+## Run and verify
+
+When you're done with the _YAML_ files, apply them onto your hub cluster, and watch the magic takes place.
+
+[0]: https://open-cluster-management.io/getting-started/integration/app-lifecycle/
diff --git a/vscode-extension/templates/Git/channel.yaml b/vscode-extension/templates/Git/channel.yaml
new file mode 100755
index 00000000..bb0d41b2
--- /dev/null
+++ b/vscode-extension/templates/Git/channel.yaml
@@ -0,0 +1,9 @@
+---
+apiVersion: apps.open-cluster-management.io/v1
+kind: Channel
+metadata:
+ name: helloworld-channel
+ namespace: helloworld-chn
+spec:
+ type: Git
+ pathname: https://github.com//
diff --git a/vscode-extension/templates/Git/clusterset.yaml b/vscode-extension/templates/Git/clusterset.yaml
new file mode 100644
index 00000000..095db24b
--- /dev/null
+++ b/vscode-extension/templates/Git/clusterset.yaml
@@ -0,0 +1,5 @@
+---
+apiVersion: cluster.open-cluster-management.io/v1beta2
+kind: ManagedClusterSet
+metadata:
+ name: helloworld-clusterset
diff --git a/vscode-extension/templates/Git/clustersetbinding.yaml b/vscode-extension/templates/Git/clustersetbinding.yaml
new file mode 100644
index 00000000..9cbd0444
--- /dev/null
+++ b/vscode-extension/templates/Git/clustersetbinding.yaml
@@ -0,0 +1,8 @@
+---
+apiVersion: cluster.open-cluster-management.io/v1beta2
+kind: ManagedClusterSetBinding
+metadata:
+ name: helloworld-clusterset # the name should be identical to the underlying ManagedClusterSet
+ namespace: helloworld-app
+spec:
+ clusterSet: helloworld-clusterset
diff --git a/vscode-extension/templates/Git/placement.yaml b/vscode-extension/templates/Git/placement.yaml
new file mode 100755
index 00000000..cd2e9617
--- /dev/null
+++ b/vscode-extension/templates/Git/placement.yaml
@@ -0,0 +1,15 @@
+---
+apiVersion: cluster.open-cluster-management.io/v1beta1
+kind: Placement
+metadata:
+ name: helloworld-placement
+ namespace: helloworld-app
+spec:
+ numberOfClusters: 2
+ clusterSets:
+ - helloworld-clusterset
+ predicates:
+ - requiredClusterSelector:
+ labelSelector:
+ matchLabels:
+ usage: development
diff --git a/vscode-extension/templates/Git/policy.yaml b/vscode-extension/templates/Git/policy.yaml
new file mode 100644
index 00000000..79e08372
--- /dev/null
+++ b/vscode-extension/templates/Git/policy.yaml
@@ -0,0 +1,29 @@
+---
+apiVersion: policy.open-cluster-management.io/v1
+kind: Policy
+metadata:
+ name: policy-namespace
+ namespace: policies
+ annotations:
+ policy.open-cluster-management.io/standards: NIST SP 800-53
+ policy.open-cluster-management.io/categories: CM Configuration Management
+ policy.open-cluster-management.io/controls: CM-2 Baseline Configuration
+spec:
+ remediationAction: enforce
+ disabled: false
+ policy-templates:
+ - objectDefinition:
+ apiVersion: policy.open-cluster-management.io/v1
+ kind: ConfigurationPolicy
+ metadata:
+ name: policy-namespace-example
+ spec:
+ remediationAction: inform
+ severity: low
+ object-templates:
+ - complianceType: MustHave
+ objectDefinition:
+ kind: Namespace # must have namespace 'prod'
+ apiVersion: v1
+ metadata:
+ name: prod
diff --git a/vscode-extension/templates/Git/subscription.yaml b/vscode-extension/templates/Git/subscription.yaml
new file mode 100755
index 00000000..fbaaf9dd
--- /dev/null
+++ b/vscode-extension/templates/Git/subscription.yaml
@@ -0,0 +1,20 @@
+---
+apiVersion: apps.open-cluster-management.io/v1
+kind: Subscription
+metadata:
+ annotations:
+ apps.open-cluster-management.io/git-branch: main
+ apps.open-cluster-management.io/git-path: # optional
+ apps.open-cluster-management.io/git-tag: # optional
+ apps.open-cluster-management.io/git-desired-commit: # optional, takes precedence to git-tag
+ apps.open-cluster-management.io/git-clone-depth: "20" # optional, mandatory when using git-desired-commit
+ apps.open-cluster-management.io/reconcile-option: merge # optional can be replace/merge
+ apps.open-cluster-management.io/reconcile-rate: medium # optional, can be off/low/medium/high
+ name: helloworld-subscription
+ namespace: helloworld-app
+spec:
+ channel: helloworld-chn/helloworld-channel
+ placement:
+ placementRef:
+ kind: Placement
+ name: helloworld-placement
diff --git a/vscode-extension/templates/HelmRepo/00-namespaces.yaml b/vscode-extension/templates/HelmRepo/00-namespaces.yaml
new file mode 100644
index 00000000..7175c2c6
--- /dev/null
+++ b/vscode-extension/templates/HelmRepo/00-namespaces.yaml
@@ -0,0 +1,10 @@
+---
+apiVersion: v1
+kind: Namespace
+metadata:
+ name: helloworld-chn
+---
+apiVersion: v1
+kind: Namespace
+metadata:
+ name: helloworld-app
diff --git a/vscode-extension/templates/HelmRepo/README.md b/vscode-extension/templates/HelmRepo/README.md
new file mode 100644
index 00000000..b61b95f3
--- /dev/null
+++ b/vscode-extension/templates/HelmRepo/README.md
@@ -0,0 +1,35 @@
+# OCM HelmRepo-type Application Lifecycle Project
+
+Applying these resource files will create the following resources:
+
+| Kind | Namespace | Name |
+| ------------------------ | -------------- | ----------------------- |
+| Namespace | x | helloworld-chn |
+| Namespace | x | helloworld-app |
+| Channel | helloworld-chn | helloworld-channel |
+| ManagedClusterSet | x | helloworld-clusterset |
+| ManagedClusterSetBinding | helloworld-app | helloworld-clusterset |
+| Placement | helloworld-app | helloworld-placement |
+| Subscription | helloworld-app | helloworld-subscription |
+
+## Prerequisites
+
+The [Application Lifecycle Management Addon][0] should be installed and enabled in your hub/managed clusters.
+
+## Make it your own
+
+- Go into _channel.yaml_ and update the _pathname_ of your repository.
+- Go into _subscription.yaml_ and update the configuration based on your needs.
+- Label the _ManagedClusters_ which should be part of the _ManagedClusterSet_ with the following label:
+ `cluster.open-cluster-management.io/clusterset=helloworld-clusterset`.
+- Label the _ManagedClusters_ you want to be selected by the _Placement_ with the following label:
+ `usage: development`.
+
+It is also advised to modify the various CRs names,
+but if you're ok with _helloworld_, have at it. :v:
+
+## Run and verify
+
+When you're done with the _YAML_ files, apply them onto your hub cluster, and watch the magic takes place.
+
+[0]: https://open-cluster-management.io/getting-started/integration/app-lifecycle/
diff --git a/vscode-extension/templates/HelmRepo/channel.yaml b/vscode-extension/templates/HelmRepo/channel.yaml
new file mode 100644
index 00000000..5b45486a
--- /dev/null
+++ b/vscode-extension/templates/HelmRepo/channel.yaml
@@ -0,0 +1,10 @@
+---
+apiVersion: apps.open-cluster-management.io/v1
+kind: Channel
+metadata:
+ name: helloworld-channel
+ namespace: helloworld-chn
+spec:
+ type: HelmRepo
+ pathname: https://charts.helm.sh/stable/ # replace with the path to the Helm repository
+
diff --git a/vscode-extension/templates/HelmRepo/clusterset.yaml b/vscode-extension/templates/HelmRepo/clusterset.yaml
new file mode 100644
index 00000000..095db24b
--- /dev/null
+++ b/vscode-extension/templates/HelmRepo/clusterset.yaml
@@ -0,0 +1,5 @@
+---
+apiVersion: cluster.open-cluster-management.io/v1beta2
+kind: ManagedClusterSet
+metadata:
+ name: helloworld-clusterset
diff --git a/vscode-extension/templates/HelmRepo/clustersetbinding.yaml b/vscode-extension/templates/HelmRepo/clustersetbinding.yaml
new file mode 100644
index 00000000..9cbd0444
--- /dev/null
+++ b/vscode-extension/templates/HelmRepo/clustersetbinding.yaml
@@ -0,0 +1,8 @@
+---
+apiVersion: cluster.open-cluster-management.io/v1beta2
+kind: ManagedClusterSetBinding
+metadata:
+ name: helloworld-clusterset # the name should be identical to the underlying ManagedClusterSet
+ namespace: helloworld-app
+spec:
+ clusterSet: helloworld-clusterset
diff --git a/vscode-extension/templates/HelmRepo/placement.yaml b/vscode-extension/templates/HelmRepo/placement.yaml
new file mode 100644
index 00000000..301cab05
--- /dev/null
+++ b/vscode-extension/templates/HelmRepo/placement.yaml
@@ -0,0 +1,15 @@
+---
+apiVersion: cluster.open-cluster-management.io/v1beta1
+kind: Placement
+metadata:
+ name: helloworld-placement
+ namespace: helloworld-app
+spec:
+ numberOfClusters: 1
+ clusterSets:
+ - helloworld-clusterset
+ predicates:
+ - requiredClusterSelector:
+ labelSelector:
+ matchLabels:
+ usage: development
diff --git a/vscode-extension/templates/HelmRepo/policy.yaml b/vscode-extension/templates/HelmRepo/policy.yaml
new file mode 100644
index 00000000..79e08372
--- /dev/null
+++ b/vscode-extension/templates/HelmRepo/policy.yaml
@@ -0,0 +1,29 @@
+---
+apiVersion: policy.open-cluster-management.io/v1
+kind: Policy
+metadata:
+ name: policy-namespace
+ namespace: policies
+ annotations:
+ policy.open-cluster-management.io/standards: NIST SP 800-53
+ policy.open-cluster-management.io/categories: CM Configuration Management
+ policy.open-cluster-management.io/controls: CM-2 Baseline Configuration
+spec:
+ remediationAction: enforce
+ disabled: false
+ policy-templates:
+ - objectDefinition:
+ apiVersion: policy.open-cluster-management.io/v1
+ kind: ConfigurationPolicy
+ metadata:
+ name: policy-namespace-example
+ spec:
+ remediationAction: inform
+ severity: low
+ object-templates:
+ - complianceType: MustHave
+ objectDefinition:
+ kind: Namespace # must have namespace 'prod'
+ apiVersion: v1
+ metadata:
+ name: prod
diff --git a/vscode-extension/templates/HelmRepo/subscription.yaml b/vscode-extension/templates/HelmRepo/subscription.yaml
new file mode 100644
index 00000000..bcdec6a0
--- /dev/null
+++ b/vscode-extension/templates/HelmRepo/subscription.yaml
@@ -0,0 +1,17 @@
+apiVersion: apps.open-cluster-management.io/v1
+kind: Subscription
+metadata:
+ name: helloworld-subscription
+ namespace: helloworld-app
+ annotations:
+ apps.open-cluster-management.io/reconcile-option: merge # optional can be replace/merge
+ apps.open-cluster-management.io/reconcile-rate: medium # optional, can be off/low/medium/high
+spec:
+ channel: helloworld-chn/helloworld-channel
+ packageOverrides:
+ - packageName:
+ packageAlias:
+ placement:
+ placementRef:
+ kind: Placement
+ name: helloworld-placement
diff --git a/vscode-extension/templates/ObjectBucket/00-namespaces.yaml b/vscode-extension/templates/ObjectBucket/00-namespaces.yaml
new file mode 100644
index 00000000..7175c2c6
--- /dev/null
+++ b/vscode-extension/templates/ObjectBucket/00-namespaces.yaml
@@ -0,0 +1,10 @@
+---
+apiVersion: v1
+kind: Namespace
+metadata:
+ name: helloworld-chn
+---
+apiVersion: v1
+kind: Namespace
+metadata:
+ name: helloworld-app
diff --git a/vscode-extension/templates/ObjectBucket/README.md b/vscode-extension/templates/ObjectBucket/README.md
new file mode 100644
index 00000000..525f58e9
--- /dev/null
+++ b/vscode-extension/templates/ObjectBucket/README.md
@@ -0,0 +1,35 @@
+# OCM ObjectBucket-type Application Lifecycle Project
+
+Applying these resource files will create the following resources:
+
+| Kind | Namespace | Name |
+| ------------------------ | -------------- | ----------------------- |
+| Namespace | x | helloworld-chn |
+| Namespace | x | helloworld-app |
+| Channel | helloworld-chn | helloworld-channel |
+| ManagedClusterSet | x | helloworld-clusterset |
+| ManagedClusterSetBinding | helloworld-app | helloworld-clusterset |
+| Placement | helloworld-app | helloworld-placement |
+| Subscription | helloworld-app | helloworld-subscription |
+
+## Prerequisites
+
+The [Application Lifecycle Management Addon][0] should be installed and enabled in your hub/managed clusters.
+
+## Make it your own
+
+- Go into _channel.yaml_ and update the _pathname_ of your repository.
+- Go into _subscription.yaml_ and update the configuration via annotations based on your needs.
+- Label the _ManagedClusters_ which should be part of the _ManagedClusterSet_ with the following label:
+ `cluster.open-cluster-management.io/clusterset=helloworld-clusterset`.
+- Label the _ManagedClusters_ you want to be selected by the _Placement_ with the following label:
+ `usage: development`.
+
+It is also advised to modify the various CRs names,
+but if you're ok with _helloworld_, have at it. :v:
+
+## Run and verify
+
+When you're done with the _YAML_ files, apply them onto your hub cluster, and watch the magic takes place.
+
+[0]: https://open-cluster-management.io/getting-started/integration/app-lifecycle/
diff --git a/vscode-extension/templates/ObjectBucket/channel.yaml b/vscode-extension/templates/ObjectBucket/channel.yaml
new file mode 100644
index 00000000..43fbc226
--- /dev/null
+++ b/vscode-extension/templates/ObjectBucket/channel.yaml
@@ -0,0 +1,9 @@
+---
+apiVersion: apps.open-cluster-management.io/v1
+kind: Channel
+metadata:
+ name: helloworld-channel
+ namespace: helloworld-chn
+spec:
+ type: ObjectBucket
+ pathname: https://address-of-bucket>/helloworld-channel # bucket name should matche the CR name
diff --git a/vscode-extension/templates/ObjectBucket/clusterset.yaml b/vscode-extension/templates/ObjectBucket/clusterset.yaml
new file mode 100644
index 00000000..095db24b
--- /dev/null
+++ b/vscode-extension/templates/ObjectBucket/clusterset.yaml
@@ -0,0 +1,5 @@
+---
+apiVersion: cluster.open-cluster-management.io/v1beta2
+kind: ManagedClusterSet
+metadata:
+ name: helloworld-clusterset
diff --git a/vscode-extension/templates/ObjectBucket/clustersetbinding.yaml b/vscode-extension/templates/ObjectBucket/clustersetbinding.yaml
new file mode 100644
index 00000000..9cbd0444
--- /dev/null
+++ b/vscode-extension/templates/ObjectBucket/clustersetbinding.yaml
@@ -0,0 +1,8 @@
+---
+apiVersion: cluster.open-cluster-management.io/v1beta2
+kind: ManagedClusterSetBinding
+metadata:
+ name: helloworld-clusterset # the name should be identical to the underlying ManagedClusterSet
+ namespace: helloworld-app
+spec:
+ clusterSet: helloworld-clusterset
diff --git a/vscode-extension/templates/ObjectBucket/placement.yaml b/vscode-extension/templates/ObjectBucket/placement.yaml
new file mode 100644
index 00000000..301cab05
--- /dev/null
+++ b/vscode-extension/templates/ObjectBucket/placement.yaml
@@ -0,0 +1,15 @@
+---
+apiVersion: cluster.open-cluster-management.io/v1beta1
+kind: Placement
+metadata:
+ name: helloworld-placement
+ namespace: helloworld-app
+spec:
+ numberOfClusters: 1
+ clusterSets:
+ - helloworld-clusterset
+ predicates:
+ - requiredClusterSelector:
+ labelSelector:
+ matchLabels:
+ usage: development
diff --git a/vscode-extension/templates/ObjectBucket/policy.yaml b/vscode-extension/templates/ObjectBucket/policy.yaml
new file mode 100644
index 00000000..79e08372
--- /dev/null
+++ b/vscode-extension/templates/ObjectBucket/policy.yaml
@@ -0,0 +1,29 @@
+---
+apiVersion: policy.open-cluster-management.io/v1
+kind: Policy
+metadata:
+ name: policy-namespace
+ namespace: policies
+ annotations:
+ policy.open-cluster-management.io/standards: NIST SP 800-53
+ policy.open-cluster-management.io/categories: CM Configuration Management
+ policy.open-cluster-management.io/controls: CM-2 Baseline Configuration
+spec:
+ remediationAction: enforce
+ disabled: false
+ policy-templates:
+ - objectDefinition:
+ apiVersion: policy.open-cluster-management.io/v1
+ kind: ConfigurationPolicy
+ metadata:
+ name: policy-namespace-example
+ spec:
+ remediationAction: inform
+ severity: low
+ object-templates:
+ - complianceType: MustHave
+ objectDefinition:
+ kind: Namespace # must have namespace 'prod'
+ apiVersion: v1
+ metadata:
+ name: prod
diff --git a/vscode-extension/templates/ObjectBucket/subscription.yaml b/vscode-extension/templates/ObjectBucket/subscription.yaml
new file mode 100644
index 00000000..32adf457
--- /dev/null
+++ b/vscode-extension/templates/ObjectBucket/subscription.yaml
@@ -0,0 +1,16 @@
+---
+apiVersion: apps.open-cluster-management.io/v1
+kind: Subscription
+metadata:
+ annotations:
+ apps.open-cluster-management.io/bucket-path: # optional
+ apps.open-cluster-management.io/reconcile-option: merge # optional can be replace/merge
+ apps.open-cluster-management.io/reconcile-rate: medium # optional, can be off/low/medium/high
+ name: helloworld-subscription
+ namespace: helloworld-app
+spec:
+ channel: helloworld-chn/helloworld-channel
+ placement:
+ placementRef:
+ kind: Placement
+ name: helloworld-placement
diff --git a/vscode-extension/test/run.ts b/vscode-extension/test/run.ts
new file mode 100644
index 00000000..7d7d3e7f
--- /dev/null
+++ b/vscode-extension/test/run.ts
@@ -0,0 +1,20 @@
+import * as path from 'path';
+import { runTests } from '@vscode/test-electron';
+
+async function main(): Promise {
+ try {
+ let extensionDevelopmentPath = path.resolve(__dirname, '../', '../');
+ let extensionTestsPath = path.join(__dirname, './suite');
+ let testWorkspace = path.join(extensionDevelopmentPath, "test", "test-workspace");
+ await runTests({
+ extensionDevelopmentPath,
+ extensionTestsPath,
+ launchArgs: [testWorkspace]
+ });
+ } catch (err) {
+ console.error('Failed to run tests');
+ process.exit(1);
+ }
+}
+
+main();
diff --git a/vscode-extension/test/suite.ts b/vscode-extension/test/suite.ts
new file mode 100644
index 00000000..df6a6f8e
--- /dev/null
+++ b/vscode-extension/test/suite.ts
@@ -0,0 +1,61 @@
+import * as Mocha from 'mocha';
+import * as glob from 'glob';
+import * as path from 'path';
+
+function wrapCoverage(): any {
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ let nyc = new (require('nyc'))({
+ all: true,
+ cwd: path.join(__dirname, '..', '..'),
+ exclude: [".vscode-test"],
+ hookRequire: true,
+ exitOnError: true,
+ });
+
+ nyc.reset();
+ nyc.wrap();
+}
+
+export function run(): Promise {
+ wrapCoverage();
+
+ let mochaOpts: Mocha.MochaOptions = {
+ ui: 'tdd',
+ color: true,
+ slow: 1500,
+ timeout: 5000,
+ bail: true,
+ fullTrace: true,
+ };
+
+ if ('true' === process?.env?.QUICK_TEST) {
+ mochaOpts.fgrep = '@slow';
+ mochaOpts.invert = true;
+ }
+
+ let mocha = new Mocha(mochaOpts);
+ let testsRoot = path.join(__dirname, 'test-files');
+
+ return new Promise((resolve, reject) => {
+ glob('**/**.test.js', { cwd: testsRoot }, (err: Error | null, files: string[]) => {
+ if (err) {
+ return reject(err);
+ }
+
+ files.forEach(f => mocha.addFile(path.resolve(testsRoot, f)));
+
+ try {
+ mocha.run(failures => {
+ if (failures > 0) {
+ reject(new Error(`${failures} tests failed.`));
+ } else {
+ resolve();
+ }
+ });
+ } catch (err) {
+ console.error(err);
+ reject(err);
+ }
+ });
+ });
+}
diff --git a/vscode-extension/test/test-files/commands/argo.project.test.ts b/vscode-extension/test/test-files/commands/argo.project.test.ts
new file mode 100644
index 00000000..90b875fe
--- /dev/null
+++ b/vscode-extension/test/test-files/commands/argo.project.test.ts
@@ -0,0 +1,122 @@
+import * as chaiAsPromised from 'chai-as-promised';
+import * as chaiThings from 'chai-things';
+import * as fse from 'fs-extra';
+import * as path from 'path';
+import * as sinon from 'sinon';
+import * as sinonChai from 'sinon-chai';
+import * as vscode from 'vscode';
+import * as yaml from 'js-yaml';
+import { use as chaiUse, expect } from 'chai';
+import { beforeEach } from 'mocha';
+
+chaiUse(chaiAsPromised);
+chaiUse(sinonChai);
+chaiUse(chaiThings);
+
+interface ExpectedTemplate {
+ channelType: string,
+ verifySubscription: CallableFunction
+}
+
+async function sleep(ms: number): Promise {
+ return new Promise((resolve, _reject) => setTimeout(() => resolve(), ms));
+}
+
+// Test cases for the the ocm-vscode-extension.ocmNewProject command
+suite('Create a new argo project command', () => {
+ let quickPickStub: sinon.SinonStub;
+ let projectCreationDelayMS = 500;
+
+ // expected template files
+ const expectedTemplateFiles = [
+ '00-namespaces.yaml',
+ 'README.md',
+ 'application-set.yaml',
+ 'argo-configmap.yaml',
+ 'role.yaml',
+ 'placement.yaml',
+ 'rolebinding.yaml'
+ ];
+
+
+ beforeEach(() => {
+ sinon.restore(); // unwrap previously wrapped sinon objects
+ quickPickStub = sinon.stub(vscode.window, 'showQuickPick'); // stub the show quick pick
+ });
+
+
+
+ test('Successfully create a project with the default name and type', async () => {
+ // wrap a spy around the information box
+ let infoBoxSpy = sinon.spy(vscode.window, 'showInformationMessage');
+ // given the default path
+ let projectFolder: string = path.resolve(__dirname, '../../../../test/test-workspace/ocm-argo-application');
+ // given the path doesn't already exists
+ await fse.remove(projectFolder);
+ // given the user will not input a project name (type enter)
+ sinon.stub(vscode.window, 'showInputBox').resolves('');
+ // when invoking the command
+ await vscode.commands.executeCommand('ocm-vscode-extension.argoProject');
+ await sleep(projectCreationDelayMS); // wait a sec
+ // the grab the application-set resource file
+ let applicationSet = yaml.load(await fse.readFile(`${projectFolder}/application-set.yaml`, 'utf-8'));
+ return Promise.all([
+ // then expect the following
+ expect(fse.pathExists(projectFolder)).to.eventually.be.true,
+ expect(fse.readdir(projectFolder)).to.eventually.have.members(expectedTemplateFiles),
+ expect(infoBoxSpy).to.have.been.calledOnceWith('OCM extension, project ocm-argo-application created'),
+ expect(applicationSet).to.have.property('kind').that.equals('ApplicationSet'),
+ expect(applicationSet).to.have.property('spec').that.has.a.property('template'),
+ expect(applicationSet).to.have.property('spec').that.has.a.property('generators')
+ ]);
+ });
+
+ test('Fail creating a new project when the folder already exists', async () => {
+ // wrap a spy over vscode's error message box
+ let errorBoxSpy = sinon.spy(vscode.window, 'showErrorMessage');
+ // given the following project name and path
+ let projectNameInput = 'existing-folder-name';
+ let projectFolder: string = path.resolve(__dirname, `../../../../test/test-workspace/${projectNameInput}`);
+ // given the folder already exists (with no files in it)
+ await fse.emptyDir(projectFolder);
+ // given the user will input the project name as the existing folder
+ sinon.stub(vscode.window, 'showInputBox').resolves(projectNameInput);
+ // when invoking the command
+ await vscode.commands.executeCommand('ocm-vscode-extension.argoProject');
+ await sleep(projectCreationDelayMS); // wait a sec
+ return Promise.all([
+ // then expect the following
+ expect(fse.pathExists(projectFolder)).to.eventually.be.true,
+ expect(fse.readdir(projectFolder)).to.eventually.be.empty,
+ expect(errorBoxSpy).to.have.been.calledWith(
+ `OCM extension, project folder ${projectNameInput} exists, please use another`
+ )
+ ]);
+ });
+
+ test('Fail creating a new project when not in a workspace', async () => {
+ // wrap a spy over vscode's warning message box
+ let warningBoxSpy = sinon.spy(vscode.window, 'showWarningMessage');
+ // given the following project name and path
+ let projectNameInput = 'non-existing-folder-name';
+ let projectFolder: string = path.resolve(__dirname, `../../../../test/test-workspace/${projectNameInput}`);
+ // given the path doesn't already exists
+ await fse.remove(projectFolder);
+ // given the user will input the project name as the existing folder
+ sinon.stub(vscode.window, 'showInputBox').resolves(projectNameInput);
+ // given the workspace api will return undefined workspaceFolders
+ sinon.stub(vscode.workspace, 'workspaceFolders').value(undefined);
+ // when invoking the command
+ await vscode.commands.executeCommand('ocm-vscode-extension.argoProject');
+ return Promise.all([
+ // then expect the following
+ expect(fse.pathExists(projectFolder)).to.eventually.be.false,
+ expect(warningBoxSpy).to.have.been.calledWith(
+ 'OCM extension, no workspace folder, please open a project or create a workspace')
+ ]);
+ });
+});
+
+
+
+
diff --git a/vscode-extension/test/test-files/commands/create.environment.test.ts b/vscode-extension/test/test-files/commands/create.environment.test.ts
new file mode 100644
index 00000000..b54b2dff
--- /dev/null
+++ b/vscode-extension/test/test-files/commands/create.environment.test.ts
@@ -0,0 +1,193 @@
+import * as buildTools from '../../../src/utils/build';
+import * as environmentTools from '../../../src/utils/environment';
+import * as sinon from 'sinon';
+import * as sinonChai from 'sinon-chai';
+import * as vscode from 'vscode';
+import { use as chaiUse, expect } from 'chai';
+import { YesNo } from '../../../src/commands/createEnvironment';
+import { beforeEach } from 'mocha';
+
+chaiUse(sinonChai);
+
+async function sleep(ms: number): Promise {
+ return new Promise((res, _rej) => setTimeout(() => res(), ms));
+}
+
+// Test cases for the the ocm-vscode-extension.createLocalEnvironment command
+suite('Create local environment command', () => {
+ const fakeBuildSuccessMsg = 'this is a fake message';
+ const buildEnvironmentDelayMS = 2000;
+
+ let quickPickStub: sinon.SinonStub;
+ let inputBoxStub: sinon.SinonStub;
+ let infoBoxSpy: sinon.SinonSpy;
+
+ const matchDefaultConfigQuickPick = sinon.match(
+ op => op['title'] === 'Use default configuration, 1 hub and 2 managed clusters?');
+
+ beforeEach(() => {
+ sinon.restore(); // unwrap previously wrapped sinon objects
+ quickPickStub = sinon.stub(vscode.window, 'showQuickPick'); // stub the show quick pick
+ inputBoxStub = sinon.stub(vscode.window, 'showInputBox'); // stub the input box
+ infoBoxSpy = sinon.spy(vscode.window, 'showInformationMessage'); // wrap a spy around the information box
+ });
+
+ suite('Using the default configuration @slow', () => {
+ let errorBoxSpy: sinon.SinonSpy;
+
+ beforeEach(() => {
+ // given the the user will choose to use the default configuration
+ quickPickStub.withArgs([YesNo.yes, YesNo.no], matchDefaultConfigQuickPick).resolves(YesNo.yes);
+ errorBoxSpy = sinon.spy(vscode.window, 'showErrorMessage'); // wrap a spy around the error box
+ });
+
+ test('Selecting to build with the default configuration, the command should be successful', async () => {
+ // given all the required tools are installed
+ sinon.stub(environmentTools, 'verifyTools').withArgs(...environmentTools.requiredTools).resolves();
+ // given the build tool utility function will be resolved with a fake message
+ let buildLocalEnvSpy = sinon.stub(buildTools, 'buildLocalEnv').resolves(fakeBuildSuccessMsg);
+ // when invoking the command
+ vscode.commands.executeCommand('ocm-vscode-extension.createLocalEnvironment');
+ await sleep(buildEnvironmentDelayMS); // wait a sec
+ // then expect info message to be called
+ expect(infoBoxSpy).to.be.calledOnceWith(fakeBuildSuccessMsg);
+ // then expect the build environment utility function will be invoked with default configuration
+ expect(buildLocalEnvSpy).to.be.calledOnceWith(
+ buildTools.defaultClusters,
+ sinon.match.func
+ );
+ });
+
+ test('When the required tools for building the environment are missing, the command should fail', async () => {
+ // given kind and clusteradm are missing
+ sinon.stub(environmentTools, 'verifyTools').withArgs(...environmentTools.requiredTools).rejects();
+ // when invoking the command
+ vscode.commands.executeCommand('ocm-vscode-extension.createLocalEnvironment');
+ await sleep(buildEnvironmentDelayMS); // wait a sec
+ // the expect a failure message
+ expect(errorBoxSpy).to.be.calledWith('OCM extension, unable to verify the existence of the required tools');
+ });
+
+ test('When the required tools are present but the build fails, the command should fail', async () => {
+ // given all the required tools are installed
+ sinon.stub(environmentTools, 'verifyTools').withArgs(...environmentTools.requiredTools).resolves();
+ // given the build tool utility function will be rejected with a fake message
+ sinon.stub(buildTools, 'buildLocalEnv').rejects('oops try again');
+ // when invoking the command
+ vscode.commands.executeCommand('ocm-vscode-extension.createLocalEnvironment');
+ await sleep(buildEnvironmentDelayMS); // wait a sec
+ //the expect a failure message
+ expect(errorBoxSpy).to.be.calledOnceWith('oops try again');
+ });
+ });
+
+ suite('Using a custom configuration @slow', () => {
+ const fakeHubName = 'my-fake-hub';
+ const numManagedClusters = 3;
+ const fakeManage1Name = `my-managed-cluster-1`;
+ const fakeManage2Name = `my-managed-cluster-2`;
+ const fakeManage3Name = `my-managed-cluster-3`;
+
+ let buildLocalEnvSpy: sinon.SinonSpy;
+
+ const matchHubNameInputBox = sinon.match(op =>op['title'] === 'Choose a name for the hub cluster');
+ const matchNumManagedInputBox = sinon.match(op => op['title'] === 'Choose the number of managed clusters to be created');
+ const matchUseDefaultNameForManagedInputBox = sinon.match(op => op['title'] === 'Name managed clusters by index (clusterX)?');
+ const matchCluster1NameInputBox = sinon.match(op => op['title'] === 'Choose a name for managed cluster number 1');
+ const matchCluster2NameInputBox = sinon.match(op => op['title'] === 'Choose a name for managed cluster number 2');
+ const matchCluster3NameInputBox = sinon.match(op => op['title'] === 'Choose a name for managed cluster number 3');
+
+ beforeEach(() => {
+ // given the the user will choose NOT to use the default configuration
+ quickPickStub.withArgs([YesNo.yes, YesNo.no], matchDefaultConfigQuickPick).resolves(YesNo.no);
+ // given all the required tools are installed
+ sinon.stub(environmentTools, 'verifyTools').withArgs(...environmentTools.requiredTools).resolves();
+ // given the build tool utility function will be resolved with a fake message
+ buildLocalEnvSpy = sinon.stub(buildTools, 'buildLocalEnv').resolves(fakeBuildSuccessMsg);
+ });
+
+ test('Create hub cluster with a custom name and 3 managed clusters using defaults names', async () => {
+ // given the user will input a custom hub name
+ inputBoxStub.withArgs(matchHubNameInputBox).resolves(fakeHubName);
+ // given the user will select the amount of desired managed clusters
+ inputBoxStub.withArgs(matchNumManagedInputBox).resolves(numManagedClusters);
+ // given the user will choose to use the default naming convention for the managed clusters
+ quickPickStub.withArgs([YesNo.yes, YesNo.no], matchUseDefaultNameForManagedInputBox).resolves(YesNo.yes);
+ // when invoking the command
+ vscode.commands.executeCommand('ocm-vscode-extension.createLocalEnvironment');
+ await sleep(buildEnvironmentDelayMS); // wait a sec
+ // then expect info message to be called
+ expect(infoBoxSpy).to.be.calledOnceWith(fakeBuildSuccessMsg);
+ // then expect the build environment utility function will be invoked with the expected configuration
+ expect(buildLocalEnvSpy).to.be.calledOnceWith(
+ [
+ {
+ name: fakeHubName,
+ context: `kind-${fakeHubName}`,
+ type: buildTools.ClusterType.hub
+ },
+ {
+ name: 'cluster1',
+ context: 'kind-cluster1',
+ type: buildTools.ClusterType.managed
+ },
+ {
+ name: 'cluster2',
+ context: 'kind-cluster2',
+ type: buildTools.ClusterType.managed
+ },
+ {
+ name: 'cluster3',
+ context: 'kind-cluster3',
+ type: buildTools.ClusterType.managed
+ },
+ ],
+ sinon.match.func
+ );
+ });
+
+ test('Create hub cluster with a custom name and 3 managed clusters using custom names', async () => {
+ // given the user will input a custom hub name
+ inputBoxStub.withArgs(matchHubNameInputBox).resolves(fakeHubName);
+ // given the user will select the amount of desired managed clusters
+ inputBoxStub.withArgs(matchNumManagedInputBox).resolves(numManagedClusters);
+ // given the user will choose NOT to use the default naming convention for the managed clusters
+ quickPickStub.withArgs([YesNo.yes, YesNo.no], matchUseDefaultNameForManagedInputBox).resolves(YesNo.no);
+ // given the user will input a custom name for each desired managed cluster
+ inputBoxStub.withArgs(matchCluster1NameInputBox).resolves(fakeManage1Name);
+ inputBoxStub.withArgs(matchCluster2NameInputBox).resolves(fakeManage2Name);
+ inputBoxStub.withArgs(matchCluster3NameInputBox).resolves(fakeManage3Name);
+ // when invoking the command
+ vscode.commands.executeCommand('ocm-vscode-extension.createLocalEnvironment');
+ await sleep(buildEnvironmentDelayMS); // wait a sec
+ // then expect info message to be called
+ expect(infoBoxSpy).to.be.calledOnceWith(fakeBuildSuccessMsg);
+ // then expect the build environment utility function will be invoked with the expected configuration
+ expect(buildLocalEnvSpy).to.be.calledOnceWith(
+ [
+ {
+ name: fakeHubName,
+ context: `kind-${fakeHubName}`,
+ type: buildTools.ClusterType.hub
+ },
+ {
+ name: fakeManage1Name,
+ context: `kind-${fakeManage1Name}`,
+ type: buildTools.ClusterType.managed
+ },
+ {
+ name: fakeManage2Name,
+ context: `kind-${fakeManage2Name}`,
+ type: buildTools.ClusterType.managed
+ },
+ {
+ name: fakeManage3Name,
+ context: `kind-${fakeManage3Name}`,
+ type: buildTools.ClusterType.managed
+ },
+ ],
+ sinon.match.func
+ );
+ });
+ });
+});
diff --git a/vscode-extension/test/test-files/commands/new.project.test.ts b/vscode-extension/test/test-files/commands/new.project.test.ts
new file mode 100644
index 00000000..828f2431
--- /dev/null
+++ b/vscode-extension/test/test-files/commands/new.project.test.ts
@@ -0,0 +1,204 @@
+import * as chaiAsPromised from 'chai-as-promised';
+import * as chaiThings from 'chai-things';
+import * as fse from 'fs-extra';
+import * as path from 'path';
+import * as sinon from 'sinon';
+import * as sinonChai from 'sinon-chai';
+import * as vscode from 'vscode';
+import * as yaml from 'js-yaml';
+import { use as chaiUse, expect } from 'chai';
+import { beforeEach } from 'mocha';
+
+chaiUse(chaiAsPromised);
+chaiUse(sinonChai);
+chaiUse(chaiThings);
+
+interface ExpectedTemplate {
+ channelType: string,
+ verifySubscription: CallableFunction
+}
+
+async function sleep(ms: number): Promise {
+ return new Promise((resolve, _reject) => setTimeout(() => resolve(), ms));
+}
+
+// Test cases for the the ocm-vscode-extension.ocmNewProject command
+suite('Create a new project command', () => {
+ let quickPickStub: sinon.SinonStub;
+ let projectCreationDelayMS = 500;
+
+ // expected template files
+ const expectedTemplateFiles = [
+ '00-namespaces.yaml',
+ 'README.md',
+ 'channel.yaml',
+ 'clusterset.yaml',
+ 'clustersetbinding.yaml',
+ 'placement.yaml',
+ 'policy.yaml',
+ 'subscription.yaml'
+ ];
+
+ // expected template types and verification functions
+ const expectedTemplates: ExpectedTemplate[] = [
+ {
+ channelType: 'Git',
+ verifySubscription: verifyGitSubscription
+ },
+ {
+ channelType: 'HelmRepo',
+ verifySubscription: verifyHelmRepoSubscription
+ },
+ {
+ channelType: 'ObjectBucket',
+ verifySubscription: verifyObjectBucketSubscription
+ }
+ ];
+
+ beforeEach(() => {
+ sinon.restore(); // unwrap previously wrapped sinon objects
+ quickPickStub = sinon.stub(vscode.window, 'showQuickPick'); // stub the show quick pick
+ });
+
+ expectedTemplates.forEach(sut => {
+ test(`Successfully create a project with a custom name for type ${sut.channelType}`, async () => {
+ // wrap a spy around the information box
+ let infoBoxSpy = sinon.spy(vscode.window, 'showInformationMessage');
+ // given the following project name and path
+ let projectNameInput = `dummy-project-name-${sut.channelType}`;
+ let projectFolder: string = path.resolve(__dirname, `../../../../test/test-workspace/${projectNameInput}`);
+ // given the path doesn't already exists
+ await fse.remove(projectFolder);
+ // given the user will select the sut type in the pick box
+ quickPickStub.resolves(sut.channelType);
+ // given the user will input the project name
+ sinon.stub(vscode.window, 'showInputBox').resolves(projectNameInput);
+ // when invoking the command
+ await vscode.commands.executeCommand('ocm-vscode-extension.ocmNewProject');
+ await sleep(projectCreationDelayMS); // wait a sec
+ // then grab the resource files
+ let channel = yaml.load(await fse.readFile(`${projectFolder}/channel.yaml`, 'utf-8'));
+ let subscription = yaml.load(await fse.readFile(`${projectFolder}/subscription.yaml`, 'utf-8'));
+ return Promise.all([
+ // then expect the following
+ expect(fse.pathExists(projectFolder)).to.eventually.be.true,
+ expect(fse.readdir(projectFolder)).to.eventually.have.members(expectedTemplateFiles),
+ expect(infoBoxSpy).to.have.been.calledOnceWith(`OCM extension, project ${projectNameInput} created`),
+ expect(channel).to.have.property('kind').that.equals('Channel'),
+ expect(channel).to.have.property('spec').that.has.a.property('type').that.equals(sut.channelType),
+ // then verify using the dynamic verification method
+ sut.verifySubscription(subscription)
+ ]);
+ });
+ });
+
+ test('Successfully create a project with the default name and type', async () => {
+ // wrap a spy around the information box
+ let infoBoxSpy = sinon.spy(vscode.window, 'showInformationMessage');
+ // given the default path
+ let projectFolder: string = path.resolve(__dirname, '../../../../test/test-workspace/ocm-application');
+ // given the path doesn't already exists
+ await fse.remove(projectFolder);
+ // given the user will not input a project name (type enter)
+ sinon.stub(vscode.window, 'showInputBox').resolves('');
+ // when invoking the command
+ await vscode.commands.executeCommand('ocm-vscode-extension.ocmNewProject');
+ await sleep(projectCreationDelayMS); // wait a sec
+ // the grab the channel resource file
+ let channel = yaml.load(await fse.readFile(`${projectFolder}/channel.yaml`, 'utf-8'));
+ return Promise.all([
+ // then expect the following
+ expect(fse.pathExists(projectFolder)).to.eventually.be.true,
+ expect(fse.readdir(projectFolder)).to.eventually.have.members(expectedTemplateFiles),
+ expect(infoBoxSpy).to.have.been.calledOnceWith('OCM extension, project ocm-application created'),
+ expect(channel).to.have.property('kind').that.equals('Channel'),
+ expect(channel).to.have.property('spec').that.has.a.property('type').that.equals('Git')
+ ]);
+ });
+
+ test('Fail creating a new project when the folder already exists', async () => {
+ // wrap a spy over vscode's error message box
+ let errorBoxSpy = sinon.spy(vscode.window, 'showErrorMessage');
+ // given the following project name and path
+ let projectNameInput = 'existing-folder-name';
+ let projectFolder: string = path.resolve(__dirname, `../../../../test/test-workspace/${projectNameInput}`);
+ // given the folder already exists (with no files in it)
+ await fse.emptyDir(projectFolder);
+ // given the user will input the project name as the existing folder
+ sinon.stub(vscode.window, 'showInputBox').resolves(projectNameInput);
+ // when invoking the command
+ await vscode.commands.executeCommand('ocm-vscode-extension.ocmNewProject');
+ await sleep(projectCreationDelayMS); // wait a sec
+ return Promise.all([
+ // then expect the following
+ expect(fse.pathExists(projectFolder)).to.eventually.be.true,
+ expect(fse.readdir(projectFolder)).to.eventually.be.empty,
+ expect(errorBoxSpy).to.have.been.calledWith(
+ `OCM extension, project folder ${projectNameInput} exists, please use another`
+ )
+ ]);
+ });
+
+ test('Fail creating a new project when not in a workspace', async () => {
+ // wrap a spy over vscode's warning message box
+ let warningBoxSpy = sinon.spy(vscode.window, 'showWarningMessage');
+ // given the following project name and path
+ let projectNameInput = 'non-existing-folder-name';
+ let projectFolder: string = path.resolve(__dirname, `../../../../test/test-workspace/${projectNameInput}`);
+ // given the path doesn't already exists
+ await fse.remove(projectFolder);
+ // given the user will input the project name as the existing folder
+ sinon.stub(vscode.window, 'showInputBox').resolves(projectNameInput);
+ // given the workspace api will return undefined workspaceFolders
+ sinon.stub(vscode.workspace, 'workspaceFolders').value(undefined);
+ // when invoking the command
+ await vscode.commands.executeCommand('ocm-vscode-extension.ocmNewProject');
+ return Promise.all([
+ // then expect the following
+ expect(fse.pathExists(projectFolder)).to.eventually.be.false,
+ expect(warningBoxSpy).to.have.been.calledWith(
+ 'OCM extension, no workspace folder, please open a project or create a workspace')
+ ]);
+ });
+});
+
+/* ############################### ##
+## Template Verification Functions ##
+## ############################# ##*/
+function verifyGitSubscription(subscription: any): void {
+ expect(subscription).to.have.property('kind').that.equals('Subscription');
+ expect(subscription).to.have.property('metadata').that.contain.keys(['name', 'namespace']);
+ expect(subscription).to.have.property('metadata').that.has.a.property('annotations')
+ .that.has.keys([
+ 'apps.open-cluster-management.io/git-branch',
+ 'apps.open-cluster-management.io/git-path',
+ 'apps.open-cluster-management.io/git-tag',
+ 'apps.open-cluster-management.io/git-desired-commit',
+ 'apps.open-cluster-management.io/git-clone-depth',
+ 'apps.open-cluster-management.io/reconcile-option',
+ 'apps.open-cluster-management.io/reconcile-rate'
+ ]);
+}
+
+function verifyHelmRepoSubscription(subscription: any): void {
+ expect(subscription).to.have.property('kind').that.equals('Subscription');
+ expect(subscription).to.have.property('metadata').that.contain.keys(['name', 'namespace']);
+ expect(subscription).to.have.property('metadata').that.has.a.property('annotations')
+ .that.has.keys([
+ 'apps.open-cluster-management.io/reconcile-option',
+ 'apps.open-cluster-management.io/reconcile-rate'
+ ]);
+ expect(subscription).to.have.property('spec').that.has.a.property('packageOverrides')
+ .that.include.something.that.contains.keys(['packageName', 'packageAlias']);
+}
+
+function verifyObjectBucketSubscription(subscription: any): void {
+ expect(subscription).to.have.property('kind').that.equals('Subscription');
+ expect(subscription).to.have.property('metadata').that.contain.keys(['name', 'namespace']);
+ expect(subscription).to.have.property('metadata').that.has.a.property('annotations')
+ .that.has.keys([
+ 'apps.open-cluster-management.io/bucket-path',
+ 'apps.open-cluster-management.io/reconcile-option',
+ 'apps.open-cluster-management.io/reconcile-rate'
+ ]);
+}
diff --git a/vscode-extension/test/test-files/commands/show.context.details.test.ts b/vscode-extension/test/test-files/commands/show.context.details.test.ts
new file mode 100644
index 00000000..9b32e7e7
--- /dev/null
+++ b/vscode-extension/test/test-files/commands/show.context.details.test.ts
@@ -0,0 +1,156 @@
+/* eslint-disable @typescript-eslint/unbound-method */
+import * as distributor from '../../../src/data/distributor';
+import * as fixtures from '../data/fixtures';
+import * as loader from '../../../src/data/loader';
+import * as sinon from 'sinon';
+import * as sinonChai from 'sinon-chai';
+import * as vscode from 'vscode';
+import { afterEach, beforeEach } from 'mocha';
+import { expect, use } from 'chai';
+import { ConnectedContextWebProvider } from '../../../src/providers/contextWebProvider';
+
+use(sinonChai);
+
+suite('Use the web provider to render the ui panel', () => {
+ let sandbox: sinon.SinonSandbox;
+
+ beforeEach(() => {
+ sinon.restore();
+ sandbox = sinon.createSandbox();
+ });
+
+ afterEach(() => {
+ // @ts-ignore
+ loader.Load.loader = undefined;
+ sandbox.restore();
+ });
+
+ test('Rendering a panel without selecting a context should not set the context', async () => {
+ // stub the show quick pick
+ let quickPickStub = sandbox.stub(vscode.window, 'showQuickPick');
+ // wrap a spy around the error box
+ let errorBoxSpy = sandbox.spy(vscode.window, 'showErrorMessage');
+ // mock a loader stubbing the required methods for the test run
+ let mockLoad = sandbox.createStubInstance(loader.Load, {
+ getContexts: [fixtures.connectedContext1, fixtures.connectedContext2]
+ });
+ // @ts-ignore inject a loader mock as the loader's singleton instance
+ loader.Load.loader = mockLoad;
+ // @ts-ignore given the user will not select a context from selection menu
+ quickPickStub.withArgs([fixtures.connectedContext1.name, fixtures.connectedContext2.name]).resolves(undefined);
+ // when
+ await vscode.commands.executeCommand('ocm-vscode-extension.showContextDetails');
+ // then
+ expect(mockLoad.setContext).to.have.not.been.called;
+ // cleanup
+ quickPickStub.restore();
+ errorBoxSpy.restore();
+ });
+
+ test('Rendering a selected panel for an unreachable cluster should display an error message', async () => {
+ // stub the show quick pick
+ let quickPickStub = sandbox.stub(vscode.window, 'showQuickPick');
+ // wrap a spy around the error box
+ let errorBoxSpy = sandbox.spy(vscode.window, 'showErrorMessage');
+ // mock a loader stubbing the required methods for the test run
+ let mockLoad = sandbox.createStubInstance(loader.Load, {
+ getContexts: [fixtures.connectedContext1, fixtures.connectedContext2],
+ verifyReachability: Promise.reject('fake rejection message')
+ });
+ // @ts-ignore inject a loader mock as the loader's singleton instance
+ loader.Load.loader = mockLoad;
+ // @ts-ignore stub the quick pick box to return our fake context name
+ quickPickStub.withArgs([fixtures.connectedContext1.name, fixtures.connectedContext2.name]).resolves(fixtures.connectedContext1.name);
+ // when
+ await vscode.commands.executeCommand('ocm-vscode-extension.showContextDetails');
+ // then
+ expect(mockLoad.setContext).to.have.been.calledOnceWith(fixtures.connectedContext1);
+ expect(errorBoxSpy).to.have.been.calledOnceWith('fake rejection message');
+ // cleanup
+ quickPickStub.restore();
+ errorBoxSpy.restore();
+ });
+
+ test('Rendering a pre-selected panel (tree view) for an unreachable cluster should display an error message', async () => {
+ // wrap a spy around the error box
+ let errorBoxSpy = sandbox.spy(vscode.window, 'showErrorMessage');
+ // mock a loader stubbing the required methods for the test run
+ let mockLoad = sandbox.createStubInstance(loader.Load, {
+ verifyReachability: Promise.reject('fake rejection message')
+ });
+ // @ts-ignore inject a loader mock as the loader's singleton instance
+ loader.Load.loader = mockLoad;
+ // when
+ await vscode.commands.executeCommand('ocm-vscode-extension.showContextDetails', fixtures.connectedContext2);
+ // then
+ expect(mockLoad.setContext).to.have.been.calledOnceWith(fixtures.connectedContext2);
+ expect(errorBoxSpy).to.have.been.calledOnceWith('fake rejection message');
+ // cleanup
+ errorBoxSpy.restore();
+ });
+
+ test('Successfully rendering a provider should dispose the existing panel, create a new one, and invoke the message distributor', async () => {
+ // @ts-ignore create a fake webview panel with a dispose method to act as the previously created panel
+ let previousPanel: vscode.WebviewPanel = sandbox.stub();
+ previousPanel.dispose = sandbox.fake();
+ // create a fake provider for encapsulating the previous panel
+ let fakeProvider = sandbox.createStubInstance(ConnectedContextWebProvider);
+ // @ts-ignore inject the previous panel to the encapsulating provider
+ fakeProvider.panel = previousPanel;
+ // @ts-ignore inject the fake provider as the current running provider
+ ConnectedContextWebProvider.currentPanel = fakeProvider;
+ // mock a loader
+ let mockLoad = sandbox.createStubInstance(loader.Load);
+ // @ts-ignore inject a loader mock as the loader's singleton instance
+ loader.Load.loader = mockLoad;
+ // mock the distributor message posting function
+ let distributorMock = sandbox.stub(distributor, 'distributeMessages');
+ // when
+ await vscode.commands.executeCommand('ocm-vscode-extension.showContextDetails', fixtures.connectedContext1);
+ // collect new panel
+ let newProvider = ConnectedContextWebProvider.currentPanel;
+ // then
+ expect(mockLoad.setContext).to.have.been.calledOnceWith(fixtures.connectedContext1);
+ expect(previousPanel.dispose).to.have.been.calledOnce;
+ // @ts-ignore
+ expect(newProvider.panel.title).to.equal('Context Details');
+ // @ts-ignore
+ expect(newProvider.panel.visible).to.be.true;
+ // @ts-ignore
+ expect(newProvider.panel.webview.html).to.exist;
+ // @ts-ignore
+ expect(newProvider.panel.options.retainContextWhenHidden).to.be.true;
+ // @ts-ignore
+ expect(newProvider.panel.options.enableScripts).to.be.true;
+ expect(distributorMock).to.have.been.calledOnceWith(fixtures.connectedContext1, sandbox.match.func);
+ });
+
+ test('Disposing the previous panel should dispose the encapsulated panel, dispose all disposables, and remove current provider', async () => {
+ // mock a loader
+ let mockLoad = sandbox.createStubInstance(loader.Load);
+ // @ts-ignore inject a loader mock as the loader's singleton instance
+ loader.Load.loader = mockLoad;
+ // execute the command for creating the new provider
+ await vscode.commands.executeCommand('ocm-vscode-extension.showContextDetails', fixtures.connectedContext1);
+ // collect new panel
+ let newProvider = ConnectedContextWebProvider.currentPanel;
+ // @ts-ignore create a fake webview panel with a dispose method
+ let fakePanel: vscode.WebviewPanel = sandbox.stub();
+ fakePanel.dispose = sandbox.fake();
+ // @ts-ignore inject the fake panel
+ newProvider.panel = fakePanel;
+ // @ts-ignore create a disposable with a dispose method
+ let fakeDisposable: vscode.Disposable = sandbox.stub();
+ fakeDisposable.dispose = sandbox.fake();
+ // @ts-ignore inject the fake disposable
+ newProvider.disposables.push(fakeDisposable);
+ // when
+ newProvider?.dispose();
+ // then
+ expect(fakePanel.dispose).to.have.been.calledOnce;
+ expect(fakeDisposable.dispose).to.have.been.calledOnce;
+ // @ts-ignore
+ expect(newProvider.disposables).to.be.empty;
+ expect(ConnectedContextWebProvider.currentPanel).to.be.undefined;
+ });
+});
diff --git a/vscode-extension/test/test-files/commands/verify.tools.test.ts b/vscode-extension/test/test-files/commands/verify.tools.test.ts
new file mode 100644
index 00000000..481a653e
--- /dev/null
+++ b/vscode-extension/test/test-files/commands/verify.tools.test.ts
@@ -0,0 +1,48 @@
+import * as environmentTools from '../../../src/utils/environment';
+import * as sinon from 'sinon';
+import * as sinonChai from 'sinon-chai';
+import * as vscode from 'vscode';
+import { use as chaiUse, expect } from 'chai';
+import { beforeEach } from 'mocha';
+
+chaiUse(sinonChai);
+
+// Test cases for the the ocm-vscode-extension.verifyTools command
+suite('Verify installed developer tools command', () => {
+ beforeEach(() => sinon.restore()); // unwrap previously wrapped sinon objects
+
+ test('When all the required tools are installed, the command should end successfully', async () => {
+ // wrap a spy around the information box
+ let infoBoxSpy = sinon.spy(vscode.window, 'showInformationMessage');
+ // given all the required tools are installed
+ sinon.stub(environmentTools, 'verifyTools')
+ .withArgs(...environmentTools.requiredTools)
+ .resolves('whoosh, found all tools');
+ // when invoking the command
+ await vscode.commands.executeCommand('ocm-vscode-extension.verifyTools');
+ // then expect info message to be called
+ expect(infoBoxSpy).to.be.calledOnceWith('whoosh, found all tools');
+ });
+
+ test('When a tool is missing, the command should fail while providing an installation instructions link', async () => {
+ let fakeToolMsg = 'fake-tool is missing, please install';
+ let fakeToolUrl = 'http://instructions.to.installation';
+ // @ts-ignore stub the error box and resolve to the Install Instructions action
+ let errorBoxStub = sinon.stub(vscode.window, 'showErrorMessage').resolves('Install Instructions');
+ // stub the open external function for verifying url call
+ let openExternalStub = sinon.stub(vscode.env, 'openExternal').resolves();
+ // given all the required tools are installed
+ sinon.stub(environmentTools, 'verifyTools')
+ .withArgs(...environmentTools.requiredTools)
+ .rejects([fakeToolMsg, fakeToolUrl]);
+ // when invoking the command
+ await vscode.commands.executeCommand('ocm-vscode-extension.verifyTools');
+ // then expect info message to be called
+ expect(errorBoxStub).to.be.calledOnceWith(fakeToolMsg, 'Install Instructions');
+ expect(openExternalStub).to.be.calledOnceWith(
+ sinon.match(arg =>
+ arg['scheme'] === 'http' &&
+ arg['authority'] === 'instructions.to.installation' &&
+ arg['path'] === '/'));
+ });
+});
diff --git a/vscode-extension/test/test-files/data/builder.test.ts b/vscode-extension/test/test-files/data/builder.test.ts
new file mode 100644
index 00000000..642f4b91
--- /dev/null
+++ b/vscode-extension/test/test-files/data/builder.test.ts
@@ -0,0 +1,95 @@
+import * as builder from '../../../src/data/builder';
+import * as fixtures from './fixtures';
+import * as k8s from '@kubernetes/client-node';
+import * as sinon from 'sinon';
+import { beforeEach } from 'mocha';
+import { expect } from 'chai';
+
+suite('Build using the data builder', () => {
+ let buildSut: builder.Build;
+
+ beforeEach(() => {
+ // stub and inject the various getX methods
+ let getUserStub = sinon.stub();
+ getUserStub.withArgs(fixtures.k8sUser1.name).returns(fixtures.k8sUser1);
+ let getClusterStub = sinon.stub();
+ getClusterStub.withArgs(fixtures.k8sCluster1.name).returns(fixtures.k8sCluster1);
+ let getContextStub = sinon.stub();
+ getContextStub.withArgs(fixtures.k8sContext1.name).returns(fixtures.k8sContext1);
+
+ let configMock = sinon.createStubInstance(k8s.KubeConfig, {
+ getUser: getUserStub,
+ // @ts-ignore
+ getCluster: getClusterStub,
+ // @ts-ignore
+ getContextObject: getContextStub
+ });
+ buildSut = new builder.Build(configMock);
+ });
+
+ [
+ {
+ title: 'Successfully build a user from a k8s user',
+ argument: fixtures.k8sUser1
+ },
+ {
+ title: 'Successfully build a user from a user name',
+ argument: fixtures.k8sUser1.name
+ }
+ ].forEach(value => {
+ test(value.title, () => {
+ expect(buildSut.user(value.argument)).to.deep.equal(fixtures.connectedUser1);
+ });
+ });
+
+ test('Build a user from an wrong k8s user name should return undefined', () => {
+ expect(buildSut.user('unknownUser')).to.be.undefined;
+ });
+
+ [
+ {
+ title: 'Successfully build a cluster from a k8s cluster',
+ argument: fixtures.k8sCluster1
+
+ },
+ {
+ title: 'Successfully build a cluster from a cluster name',
+ argument: fixtures.k8sCluster1.name
+ }
+ ].forEach(value => {
+ test(value.title, () => {
+ expect(buildSut.cluster(value.argument)).to.deep.equal(fixtures.connectedCluster1);
+ });
+ });
+
+ test('Build a cluster from an wrong k8s cluster name should return undefined', () => {
+ expect(buildSut.cluster('unknownCluster')).to.be.undefined;
+ });
+
+ [
+ {
+ title: 'Successfully build a context from a k8s context',
+ argument: fixtures.k8sContext1
+ },
+ {
+ title: 'Successfully build a context from a context name',
+ argument: fixtures.k8sContext1.name
+ }
+ ].forEach(value => {
+ test(value.title, () => {
+ expect(buildSut.context(value.argument)).to.deep.equal(fixtures.connectedContext1);
+ });
+ });
+
+ test('Build a context from an wrong k8s context name should return undefined', () => {
+ expect(buildSut.context('unknownContext')).to.be.undefined;
+ });
+
+ test('Build a context with a wrong cluster name should return undefined', () => {
+ expect(buildSut.context({ name: 'fakeContext', cluster: 'unknownCluster', user: 'fakeUser'})).to.be.undefined;
+ });
+
+ test('Build a context with a wrong user name should return undefined', () => {
+ expect(buildSut.context({ name: 'fakeContext', cluster: 'fakeCluster', user: 'unknownUser'})).to.be.undefined;
+ });
+});
diff --git a/vscode-extension/test/test-files/data/distributor.test.ts b/vscode-extension/test/test-files/data/distributor.test.ts
new file mode 100644
index 00000000..d1bf8e59
--- /dev/null
+++ b/vscode-extension/test/test-files/data/distributor.test.ts
@@ -0,0 +1,159 @@
+/* eslint-disable @typescript-eslint/unbound-method */
+import * as distributor from '../../../src/data/distributor';
+import * as fixtures from './fixtures';
+import * as loader from '../../../src/data/loader';
+import * as sinon from 'sinon';
+import * as sinonChai from 'sinon-chai';
+import { afterEach, beforeEach } from 'mocha';
+import { expect, use } from 'chai';
+
+use(sinonChai);
+
+suite('Distribute messages using the data distributor', () => {
+ let sandbox: sinon.SinonSandbox;
+ let mockConsumer: sinon.SinonStub;
+
+ beforeEach(() => {
+ sinon.restore();
+ sandbox = sinon.createSandbox();
+ mockConsumer = sandbox.stub();
+ });
+
+ afterEach(() => {
+ // @ts-ignore
+ loader.Load.loader = undefined;
+ sandbox.restore();
+ });
+
+ test('When the context is not a hub nor a spoke, should only distribute a context info message', async () => {
+ // mock a loader instance
+ let mockLoad = sandbox.createStubInstance(loader.Load);
+ // @ts-ignore inject a loader mock as the loader's singleton instance
+ loader.Load.loader = mockLoad;
+ // given the cluster in context is not a hub (no ManagedCluster crs)
+ mockLoad.getCrs.withArgs('ManagedCluster').resolves([]);
+ // given the cluster in context is not a spoke (no Klusterlet crs)
+ mockLoad.getCrs.withArgs('Klusterlet').resolves([]);
+ // when
+ await distributor.distributeMessages(fixtures.connectedContext1, mockConsumer);
+ // then
+ expect(mockConsumer).to.be.calledOnceWith({selectedContext: JSON.stringify(fixtures.connectedContext1)});
+ });
+
+ test('When the context is a hub but has no other resources, should distribute only context info and ManagedCluster messages', async () => {
+ // mock a loader instance
+ let mockLoad = sandbox.createStubInstance(loader.Load);
+ // @ts-ignore inject a loader mock as the loader's singleton instance
+ loader.Load.loader = mockLoad;
+ // given the cluster in context is a hub (ManagedCluster crs exist)
+ mockLoad.getCrs.withArgs('ManagedCluster').resolves([fixtures.ocmCr2Clustered]);
+ // given the cluster in context is not a spoke (no Klusterlet crs)
+ mockLoad.getCrs.withArgs('Klusterlet').resolves([]);
+ // when
+ await distributor.distributeMessages(fixtures.connectedContext1, mockConsumer);
+ // then
+ expect(mockConsumer).to.have.callCount(2);
+ expect(mockConsumer).to.be.calledWith({selectedContext: JSON.stringify(fixtures.connectedContext1)});
+ expect(mockConsumer).to.be.calledWith({ crsDistribution: { kind: 'ManagedCluster', crs: JSON.stringify([fixtures.ocmCr2Clustered])}});
+ });
+
+ ['ManifestWork', 'Placement', 'PlacementDecision', 'ManagedClusterSet', 'ManagedClusterAddOn', 'ClusterManager', 'SubscriptionReport']
+ .forEach(kind => {
+ test(`When the context is a hub with existing ${kind} crs, should distribute context info, ManagedCluster, and ${kind} messages`, async () => {
+ // mock a loader instance
+ let mockLoad = sandbox.createStubInstance(loader.Load);
+ // @ts-ignore inject a loader mock as the loader's singleton instance
+ loader.Load.loader = mockLoad;
+ // given the cluster in context is a hub (ManagedCluster crs exist)
+ mockLoad.getCrs.withArgs('ManagedCluster').resolves([fixtures.ocmCr2Clustered]);
+ // given the cluster in context is not a spoke (no Klusterlet crs)
+ mockLoad.getCrs.withArgs('Klusterlet').resolves([]);
+ // given the kind under test has resources
+ mockLoad.getCrs.withArgs(kind).resolves([fixtures.ocmCr1Namespaced]);
+ // when
+ await distributor.distributeMessages(fixtures.connectedContext1, mockConsumer);
+ // then
+ expect(mockConsumer).to.have.callCount(3);
+ expect(mockConsumer).to.be.calledWith({selectedContext: JSON.stringify(fixtures.connectedContext1)});
+ expect(mockConsumer).to.be.calledWith({ crsDistribution: { kind: 'ManagedCluster', crs: JSON.stringify([fixtures.ocmCr2Clustered])}});
+ expect(mockConsumer).to.be.calledWith({ crsDistribution: { kind: kind, crs: JSON.stringify([fixtures.ocmCr1Namespaced])}});
+ });
+ });
+
+ test('When the context is a spoke but has no other resources, should distribute only context info and klusterlet messages', async () => {
+ // mock a loader instance
+ let mockLoad = sandbox.createStubInstance(loader.Load);
+ // @ts-ignore inject a loader mock as the loader's singleton instance
+ loader.Load.loader = mockLoad;
+ // given the cluster in context is not a hub (no ManagedCluster crs)
+ mockLoad.getCrs.withArgs('ManagedCluster').resolves([]);
+ // given the cluster in context is a spoke (Klusterlet crs exists)
+ mockLoad.getCrs.withArgs('Klusterlet').resolves([fixtures.ocmCr2Clustered]);
+ // when
+ await distributor.distributeMessages(fixtures.connectedContext1, mockConsumer);
+ // then
+ expect(mockConsumer).to.have.callCount(2);
+ expect(mockConsumer).to.be.calledWith({selectedContext: JSON.stringify(fixtures.connectedContext1)});
+ expect(mockConsumer).to.be.calledWith({ crsDistribution: { kind: 'Klusterlet', crs: JSON.stringify([fixtures.ocmCr2Clustered])}});
+ });
+
+ ['AppliedManifestWork', 'SubscriptionStatus']
+ .forEach(kind => {
+ test(`When the context is a spoke with existing ${kind} crs, should distribute context info, Klusterlet, and ${kind} messages`, async () => {
+ // mock a loader instance
+ let mockLoad = sandbox.createStubInstance(loader.Load);
+ // @ts-ignore inject a loader mock as the loader's singleton instance
+ loader.Load.loader = mockLoad;
+ // given the cluster in context is not a hub (ManagedCluster crs exist)
+ mockLoad.getCrs.withArgs('ManagedCluster').resolves([]);
+ // given the cluster in context is a spoke (no Klusterlet crs)
+ mockLoad.getCrs.withArgs('Klusterlet').resolves([fixtures.ocmCr2Clustered]);
+ // given the kind under test has resources
+ mockLoad.getCrs.withArgs(kind).resolves([fixtures.ocmCr1Namespaced]);
+ // when
+ await distributor.distributeMessages(fixtures.connectedContext2, mockConsumer);
+ // then
+ expect(mockConsumer).to.have.callCount(3);
+ expect(mockConsumer).to.be.calledWith({selectedContext: JSON.stringify(fixtures.connectedContext2)});
+ expect(mockConsumer).to.be.calledWith({ crsDistribution: { kind: 'Klusterlet', crs: JSON.stringify([fixtures.ocmCr2Clustered])}});
+ expect(mockConsumer).to.be.calledWith({ crsDistribution: { kind: kind, crs: JSON.stringify([fixtures.ocmCr1Namespaced])}});
+ });
+ });
+
+ test('When the context is both a hub and a spoke, should distribute context info, ManagedCluster, and Klusterlet messages', async () => {
+ // mock a loader instance
+ let mockLoad = sandbox.createStubInstance(loader.Load);
+ // @ts-ignore inject a loader mock as the loader's singleton instance
+ loader.Load.loader = mockLoad;
+ // given the cluster in context is a hub (no ManagedCluster crs)
+ mockLoad.getCrs.withArgs('ManagedCluster').resolves([fixtures.ocmCr2Clustered]);
+ // given the cluster in context is a spoke (no Klusterlet crs)
+ mockLoad.getCrs.withArgs('Klusterlet').resolves([fixtures.ocmCr2Clustered]);
+ // when
+ await distributor.distributeMessages(fixtures.connectedContext1, mockConsumer);
+ // then
+ expect(mockConsumer).to.have.callCount(3);
+ expect(mockConsumer).to.be.calledWith({selectedContext: JSON.stringify(fixtures.connectedContext1)});
+ expect(mockConsumer).to.be.calledWith({ crsDistribution: { kind: 'ManagedCluster', crs: JSON.stringify([fixtures.ocmCr2Clustered])}});
+ expect(mockConsumer).to.be.calledWith({ crsDistribution: { kind: 'Klusterlet', crs: JSON.stringify([fixtures.ocmCr2Clustered])}});
+ });
+
+ test('When no crs found for kind, should not send message for kind', async () => {
+ // mock a loader instance
+ let mockLoad = sandbox.createStubInstance(loader.Load);
+ // @ts-ignore inject a loader mock as the loader's singleton instance
+ loader.Load.loader = mockLoad;
+ // given the cluster in context is not a hub (ManagedCluster crs exist)
+ mockLoad.getCrs.withArgs('ManagedCluster').resolves([]);
+ // given the cluster in context is a spoke (no Klusterlet crs)
+ mockLoad.getCrs.withArgs('Klusterlet').resolves([fixtures.ocmCr2Clustered]);
+ // given the AppliedManifestWork has no resources
+ mockLoad.getCrs.withArgs('AppliedManifestWork').resolves([]);
+ // when
+ await distributor.distributeMessages(fixtures.connectedContext2, mockConsumer);
+ // then
+ expect(mockConsumer).to.have.callCount(2);
+ expect(mockConsumer).to.be.calledWith({selectedContext: JSON.stringify(fixtures.connectedContext2)});
+ expect(mockConsumer).to.be.calledWith({ crsDistribution: { kind: 'Klusterlet', crs: JSON.stringify([fixtures.ocmCr2Clustered])}});
+ });
+});
diff --git a/vscode-extension/test/test-files/data/fixtures.ts b/vscode-extension/test/test-files/data/fixtures.ts
new file mode 100644
index 00000000..a8105db8
--- /dev/null
+++ b/vscode-extension/test/test-files/data/fixtures.ts
@@ -0,0 +1,219 @@
+import * as builder from '../../data/builder';
+import * as k8s from '@kubernetes/client-node';
+import * as loader from '../../data/loader';
+
+
+/* ################################
+########## Fake Clusters ##########
+################################ */
+export const k8sCluster1: k8s.Cluster = {
+ name: 'fakeCluster1',
+ server: 'https://my-fake-first-server:6443',
+ skipTLSVerify: false
+};
+
+export const k8sCluster2: k8s.Cluster = {
+ name: 'fakeCluster2',
+ server: 'https://my-fake-second-server:6443',
+ skipTLSVerify: false
+};
+
+export const connectedCluster1: builder.ConnectedCluster = {
+ kluster: k8sCluster1,
+ name: k8sCluster1.name,
+ server: k8sCluster1.server,
+};
+
+export const connectedCluster2: builder.ConnectedCluster = {
+ kluster: k8sCluster2,
+ name: k8sCluster2.name,
+ server: k8sCluster2.server,
+};
+
+/* ################################
+########### Fake Users ############
+################################ */
+export const k8sUser1: k8s.User = {
+ name: 'fakeUser1'
+};
+
+export const k8sUser2: k8s.User = {
+ name: 'fakeUser2'
+};
+
+export const connectedUser1: builder.ConnectedUser = {
+ kuser: k8sUser1,
+ name: k8sUser1.name,
+};
+
+export const connectedUser2: builder.ConnectedUser = {
+ kuser: k8sUser2,
+ name: k8sUser2.name,
+};
+
+/* ################################
+########## Fake Contexts ##########
+################################ */
+export const k8sContext1: k8s.Context = {
+ name: 'fakeContext1',
+ cluster: k8sCluster1.name,
+ user: k8sUser1.name
+};
+
+export const k8sContext2: k8s.Context = {
+ name: 'fakeContext2',
+ cluster: k8sCluster2.name,
+ user: k8sUser2.name
+};
+
+export const connectedContext1: builder.ConnectedContext = {
+ kontext: k8sContext1,
+ name: k8sContext1.name,
+ cluster: connectedCluster1,
+ user: connectedUser1
+};
+
+export const connectedContext2: builder.ConnectedContext = {
+ kontext: k8sContext2,
+ name: k8sContext2.name,
+ cluster: connectedCluster2,
+ user: connectedUser2
+};
+
+/* ################################
+############ Fake CRDS ############
+################################ */
+export const k8sCrd1Namespaced: k8s.V1CustomResourceDefinition = {
+ metadata: {
+ name: 'fakecrd1s.fake.group.open-cluster-management'
+ },
+ spec: {
+ names: {
+ kind: 'FakeCrd1',
+ listKind: 'FakeCrd1List',
+ plural: 'fakecrd1s',
+ singular: 'fakecrd1'
+ },
+ group: 'fake.group.open-cluster-management',
+ scope: 'Namespaced',
+ versions: [
+ {
+ name: 'v1beta1',
+ served: false,
+ storage: false,
+ schema: {}
+ },
+ {
+ name: 'v1beta2',
+ served: true,
+ storage: false,
+ deprecated: true,
+ deprecationWarning: 'please use v1',
+ schema: {}
+ },
+ {
+ name: 'v1',
+ served: true,
+ storage: true,
+ schema: {}
+ }
+ ]
+ }
+};
+
+export const k8sCrd2Clustered: k8s.V1CustomResourceDefinition = {
+ metadata: {
+ name: 'fakecrd2s.another.fake.group.open-cluster-management'
+ },
+ spec: {
+ names: {
+ kind: 'FakeCrd2',
+ listKind: 'FakeCrd2List',
+ plural: 'fakecrd2s',
+ singular: 'fakecrd2'
+ },
+ group: 'another.fake.group.open-cluster-management',
+ scope: 'Cluster',
+ versions: [
+ {
+ name: 'v1beta1',
+ served: true,
+ storage: false,
+ schema: {}
+ },
+ {
+ name: 'v1beta2',
+ served: true,
+ storage: true,
+ schema: {}
+ },
+ ]
+ }
+};
+
+export const ocmCrd1Namespaced: loader.OcmResourceDefinition = {
+ krd: k8sCrd1Namespaced,
+ // @ts-ignore
+ name: k8sCrd1Namespaced.metadata.name,
+ plural: k8sCrd1Namespaced.spec.names.plural,
+ namespaced: true,
+ kind: k8sCrd1Namespaced.spec.names.kind,
+ version: 'v1',
+ group: k8sCrd1Namespaced.spec.group
+};
+
+export const ocmCrd2Clustered: loader.OcmResourceDefinition = {
+ krd: k8sCrd2Clustered,
+ // @ts-ignore
+ name: k8sCrd2Clustered.metadata.name,
+ plural: k8sCrd2Clustered.spec.names.plural,
+ namespaced: false,
+ kind: k8sCrd2Clustered.spec.names.kind,
+ version: 'v1beta2',
+ group: k8sCrd2Clustered.spec.group
+};
+
+/* ################################
+############ Fake CRS #############
+################################ */
+export const k8sNamespace: k8s.V1Namespace = {
+ metadata: {
+ name: 'my-fake-app-namespace'
+ }
+};
+
+export const k8sCr1Namespaced = {
+ apiVersion: `${k8sCrd1Namespaced.spec.group}/v1`,
+ kind: k8sCrd1Namespaced.spec.names.kind,
+ metadata: {
+ name: 'my-fake-namespaced-resource',
+ namespace: k8sNamespace.metadata?.name
+ },
+ spec: {},
+ status: {}
+};
+
+
+export const k8sCr2Clustered = {
+ apiVersion: `${k8sCrd2Clustered.spec.group}/v1beta2`,
+ kind: k8sCrd2Clustered.spec.names.kind,
+ metadata: {
+ name: 'my-fake-clustered-resource'
+ },
+ spec: {},
+ status: {}
+};
+
+export const ocmCr1Namespaced: loader.OcmResource = {
+ kr: k8sCr1Namespaced,
+ crd: ocmCrd1Namespaced,
+ name: k8sCr1Namespaced.metadata.name,
+ namespace: k8sNamespace.metadata?.name
+};
+
+export const ocmCr2Clustered: loader.OcmResource = {
+ kr: k8sCr2Clustered,
+ crd: ocmCrd2Clustered,
+ name: k8sCr2Clustered.metadata.name,
+ namespace: undefined
+};
diff --git a/vscode-extension/test/test-files/data/loader.test.ts b/vscode-extension/test/test-files/data/loader.test.ts
new file mode 100644
index 00000000..c3b7f43b
--- /dev/null
+++ b/vscode-extension/test/test-files/data/loader.test.ts
@@ -0,0 +1,485 @@
+/* eslint-disable @typescript-eslint/unbound-method */
+import * as builder from '../../../src/data/builder';
+import * as chaiAsPromised from 'chai-as-promised';
+import * as fixtures from './fixtures';
+import * as k8s from '@kubernetes/client-node';
+import * as loader from '../../../src/data/loader';
+import * as sinon from 'sinon';
+import * as sinonChai from 'sinon-chai';
+import { expect, use } from 'chai';
+import { beforeEach } from 'mocha';
+
+use(chaiAsPromised);
+use(sinonChai);
+
+suite('Load data using the data loader', () => {
+ let configMock: k8s.KubeConfig;
+ let buildMock: builder.Build;
+ let loadSut: loader.Load;
+
+ beforeEach(() => {
+ // inject values to the stubbed k8s config
+ configMock = sinon.createStubInstance(k8s.KubeConfig, {
+ makeApiClient: sinon.stub()
+ });
+ configMock.currentContext = fixtures.connectedContext1.name;
+ configMock.contexts = [fixtures.connectedContext1.kontext];
+ configMock.clusters = [fixtures.connectedCluster1.kluster];
+ configMock.users = [fixtures.connectedUser1.kuser];
+ // mock our builder short-circuiting the building methods
+ buildMock = sinon.createStubInstance(builder.Build, {
+ context: fixtures.connectedContext1,
+ cluster: fixtures.connectedCluster1,
+ user: fixtures.connectedUser1,
+ });
+ // @ts-ignore
+ loadSut = new loader.Load(configMock, buildMock);
+ });
+
+ test('Instantiating the loader should load the default config and refresh the apis', () => {
+ verifyApiRefresh(true);
+ });
+
+ test('Hitting the refresh on the loader should load the default config and refresh the apis', () => {
+ // @ts-ignore
+ configMock.loadFromDefault.reset();
+ // @ts-ignore
+ configMock.makeApiClient.reset();
+ loadSut.refresh();
+ verifyApiRefresh(true);
+ });
+
+ [
+ {
+ title: 'Setting a connected context should invoke k8s config and refresh the apis',
+ argument: fixtures.connectedContext1
+ },
+ {
+ title: 'Setting a string context should invoke k8s config and refresh the apis',
+ argument: fixtures.connectedContext1.name
+ }
+ ].forEach(value => {
+ test(value.title, () => {
+ // @ts-ignore
+ configMock.loadFromDefault.reset();
+ // @ts-ignore
+ configMock.makeApiClient.reset();
+ loadSut.setContext(value.argument);
+ expect(configMock.setCurrentContext).to.have.been.calledOnceWith('fakeContext1');
+ // @ts-ignore
+ configMock.setCurrentContext.reset();
+ verifyApiRefresh();
+ });
+ });
+
+ test('Successfully retrieving the current connected context', () => {
+ expect(loadSut.getContext()).to.deep.equal(fixtures.connectedContext1);
+ });
+
+ test('Successfully retrieving all connected contexts', () => {
+ let contexts = loadSut.getContexts();
+ expect(contexts).to.have.lengthOf(1);
+ expect(contexts[0]).to.deep.equal(fixtures.connectedContext1);
+ });
+
+ test('Successfully retrieving a connected user', () => {
+ expect(loadSut.getUser(fixtures.connectedUser1.name)).to.deep.equal(fixtures.connectedUser1);
+ });
+
+ test('Successfully retrieving all connected users', () => {
+ let users = loadSut.getUsers();
+ expect(users).to.have.lengthOf(1);
+ expect(users[0]).to.deep.equal(fixtures.connectedUser1);
+ });
+
+ test('Successfully retrieving a connected cluster', () => {
+ expect(loadSut.getCluster(fixtures.connectedCluster1.name)).to.deep.equal(fixtures.connectedCluster1);
+ });
+
+ test('Successfully retrieving all connected clusters', () => {
+ let clusters = loadSut.getClusters();
+ expect(clusters).to.have.lengthOf(1);
+ expect(clusters[0]).to.deep.equal(fixtures.connectedCluster1);
+ });
+
+ test('Successfully verify cluster reachability', () => {
+ // @ts-ignore
+ loadSut.coreApi = sinon.createStubInstance(k8s.CoreV1Api, {
+ // @ts-ignore
+ listNode: Promise.resolve({
+ response: {
+ statusCode: 200
+ }
+ })
+ });
+ return expect(loadSut.verifyReachability()).to.eventually.be.fulfilled;
+ });
+
+ [
+ {
+ prefix: 'Failed response',
+ stub: Promise.resolve({
+ response: {
+ statusCode: 500
+ }
+ }),
+ message: 'Cluster is not accessible, 500'
+ },
+ {
+ prefix: 'No response',
+ stub: Promise.resolve(undefined),
+ message: 'Cluster is not accessible'
+ },
+ {
+ prefix: 'Error thrown',
+ stub: sinon.stub().throwsException('fake reason'),
+ message: 'Cluster is not accessible, fake reason'
+ }
+ ].forEach(testCase => {
+ test(`${testCase.prefix} from the api should fail reachability verification`, () => {
+ // @ts-ignore
+ loadSut.coreApi = sinon.createStubInstance(k8s.CoreV1Api, {
+ // @ts-ignore
+ listNode: testCase.stub
+ });
+ return expect(loadSut.verifyReachability()).to.eventually.be.rejectedWith(testCase.message);
+ });
+ });
+
+ function verifyApiRefresh(includeLoad = false): void {
+ if (includeLoad) {
+ expect(configMock.loadFromDefault).to.be.calledOnce;
+ }
+ // @ts-ignore
+ expect(configMock.makeApiClient.callCount).to.equal(3);
+ expect(configMock.makeApiClient).to.be.calledWith(k8s.ApiextensionsV1Api);
+ expect(configMock.makeApiClient).to.be.calledWith(k8s.CustomObjectsApi);
+ expect(configMock.makeApiClient).to.be.calledWith(k8s.CoreV1Api);
+ }
+
+ suite("Load crds", () => {
+ test('Successfully retrieving all existing ocm crds', async () => {
+ // @ts-ignore
+ loadSut.extApi = sinon.createStubInstance(k8s.ApiextensionsV1Api, {
+ listCustomResourceDefinition: Promise.resolve({
+ response: {
+ statusCode: 200
+ },
+ body: {
+ items: [
+ fixtures.k8sCrd1Namespaced,
+ fixtures.k8sCrd2Clustered
+ ]
+ }
+ })
+ });
+
+ let crds = await loadSut.getCrds();
+ expect(crds).to.have.lengthOf(2);
+ expect(crds[0]).to.deep.equal(fixtures.ocmCrd1Namespaced);
+ expect(crds[1]).to.deep.equal(fixtures.ocmCrd2Clustered);
+ });
+
+ test('When retrieving with no ocm crds available should return an empty array', async () => {
+ // @ts-ignore
+ loadSut.extApi = sinon.createStubInstance(k8s.ApiextensionsV1Api, {
+ // @ts-ignore
+ listCustomResourceDefinition: Promise.resolve({
+ response: {
+ statusCode: 200
+ },
+ body: {
+ items: [
+ {...fixtures.k8sCrd1Namespaced, spec: { group: 'non-ocm-group'}},
+ ]
+ }
+ })
+ });
+
+ expect(await loadSut.getCrds()).to.be.empty;
+ });
+
+ test('Failed cluster access when retrieving all crds should not fail, but return an empty array', async () => {
+ // @ts-ignore
+ loadSut.extApi = sinon.createStubInstance(k8s.ApiextensionsV1Api, {
+ // @ts-ignore
+ listCustomResourceDefinition: Promise.resolve({
+ response: {
+ statusCode: 500
+ }
+ })
+ });
+ expect(await loadSut.getCrds()).to.be.empty;
+ });
+
+ test('Successfully retrieving a single existing ocm crd', async () => {
+ // @ts-ignore
+ loadSut.extApi = sinon.createStubInstance(k8s.ApiextensionsV1Api, {
+ listCustomResourceDefinition: Promise.resolve({
+ response: {
+ statusCode: 200
+ },
+ body: {
+ items: [
+ fixtures.k8sCrd1Namespaced
+ ]
+ }
+ })
+ });
+ expect(await loadSut.getCrd(fixtures.k8sCrd1Namespaced.spec.names.kind)).to.be.deep.equal(fixtures.ocmCrd1Namespaced);
+ });
+
+ test('Retrieving a non-existing single ocm crd should return undefined', async () => {
+ // @ts-ignore
+ loadSut.extApi = sinon.createStubInstance(k8s.ApiextensionsV1Api, {
+ listCustomResourceDefinition: Promise.resolve({
+ response: {
+ statusCode: 200
+ },
+ body: {
+ items: []
+ }
+ })
+ });
+ expect(await loadSut.getCrd(fixtures.k8sCrd1Namespaced.spec.names.kind)).to.be.undefined;
+ });
+
+ test('Failed cluster access while retrieving a single crd should not fail, but return undefined', async () => {
+ // @ts-ignore
+ loadSut.extApi = sinon.createStubInstance(k8s.ApiextensionsV1Api, {
+ // @ts-ignore
+ listCustomResourceDefinition: Promise.resolve({
+ response: {
+ statusCode: 500
+ }
+ })
+ });
+ expect(await loadSut.getCrd(fixtures.k8sCrd1Namespaced.spec.names.kind)).to.be.undefined;
+ });
+ });
+
+ suite('Load crs', () => {
+ [
+ {
+ title: 'Successfully retrieving existing ocm crs for a clustered ocm crd',
+ argument: fixtures.ocmCrd2Clustered
+ },
+ {
+ title: 'Successfully retrieving existing ocm crs a clustered kind name',
+ argument: fixtures.ocmCrd2Clustered.kind
+ }
+ ].forEach(value => {
+ test(value.title, async () => {
+ // @ts-ignore
+ loadSut.extApi = sinon.createStubInstance(k8s.ApiextensionsV1Api, {
+ listCustomResourceDefinition: Promise.resolve({
+ response: {
+ statusCode: 200
+ },
+ body: {
+ items: [
+ fixtures.k8sCrd2Clustered
+ ]
+ }
+ })
+ });
+
+ // @ts-ignore
+ loadSut.objApi = sinon.createStubInstance(k8s.CustomObjectsApi, {
+ listClusterCustomObject: Promise.resolve({
+ response: {
+ statusCode: 200
+ },
+ body: {
+ items: [
+ fixtures.k8sCr2Clustered
+ ]
+ }
+ })
+ });
+
+ let crs = await loadSut.getCrs(value.argument);
+ expect(crs).to.be.lengthOf(1);
+ expect(crs[0]).to.deep.equal(fixtures.ocmCr2Clustered);
+ });
+ });
+
+ [
+ {
+ title: 'Successfully retrieving existing ocm crs for a namespaced ocm crd',
+ argument: fixtures.ocmCrd1Namespaced
+ },
+ {
+ title: 'Successfully retrieving existing ocm crs a namespaced kind name',
+ argument: fixtures.ocmCrd1Namespaced.kind
+ }
+ ].forEach(value => {
+ test(value.title, async () => {
+ // @ts-ignore
+ loadSut.extApi = sinon.createStubInstance(k8s.ApiextensionsV1Api, {
+ listCustomResourceDefinition: Promise.resolve({
+ response: {
+ statusCode: 200
+ },
+ body: {
+ items: [
+ fixtures.k8sCrd1Namespaced
+ ]
+ }
+ })
+ });
+
+ // @ts-ignore
+ loadSut.coreApi = sinon.createStubInstance(k8s.CoreV1Api, {
+ listNamespace: Promise.resolve({
+ response: {
+ statusCode: 200
+ },
+ body: {
+ items: [
+ fixtures.k8sNamespace
+ ]
+ }
+ })
+ });
+
+ // @ts-ignore
+ loadSut.objApi = sinon.createStubInstance(k8s.CustomObjectsApi, {
+ listNamespacedCustomObject: Promise.resolve({
+ response: {
+ statusCode: 200
+ },
+ body: {
+ items: [
+ fixtures.k8sCr1Namespaced
+ ]
+ }
+ })
+ });
+
+ let crs = await loadSut.getCrs(value.argument);
+ expect(crs).to.be.lengthOf(1);
+ expect(crs[0]).to.deep.equal(fixtures.ocmCr1Namespaced);
+ });
+ });
+
+ test('Retrieving crs for non-existing kind name should return an empty array', async () => {
+ // @ts-ignore
+ loadSut.extApi = sinon.createStubInstance(k8s.ApiextensionsV1Api, {
+ listCustomResourceDefinition: Promise.resolve({
+ response: {
+ statusCode: 200
+ },
+ body: {
+ items: []
+ }
+ })
+ });
+
+ expect(await loadSut.getCrs('non-existing-kind')).to.be.empty;
+ });
+
+ test('Retrieving crs for clustered crd when no crs available should return an empty array', async () => {
+ // @ts-ignore
+ loadSut.objApi = sinon.createStubInstance(k8s.CustomObjectsApi, {
+ listClusterCustomObject: Promise.resolve({
+ response: {
+ statusCode: 200
+ },
+ body: {
+ items: []
+ }
+ })
+ });
+
+ expect(await loadSut.getCrs(fixtures.ocmCrd2Clustered)).to.be.empty;
+ });
+
+ test('Failed cluster access while retrieving crs for clustered crd should not fail, but return an empty array', async () => {
+ // @ts-ignore
+ loadSut.objApi = sinon.createStubInstance(k8s.CustomObjectsApi, {
+ // @ts-ignore
+ listClusterCustomObject: Promise.resolve({
+ response: {
+ statusCode: 500
+ }
+ })
+ });
+
+ expect(await loadSut.getCrs(fixtures.ocmCrd2Clustered)).to.be.empty;
+ });
+
+ test('Retrieving crs for namespaced crd when no crs available should return an empty array', async () => {
+ // @ts-ignore
+ loadSut.coreApi = sinon.createStubInstance(k8s.CoreV1Api, {
+ listNamespace: Promise.resolve({
+ response: {
+ statusCode: 200
+ },
+ body: {
+ items: [
+ fixtures.k8sNamespace
+ ]
+ }
+ })
+ });
+
+ // @ts-ignore
+ loadSut.objApi = sinon.createStubInstance(k8s.CustomObjectsApi, {
+ listNamespacedCustomObject: Promise.resolve({
+ response: {
+ statusCode: 200
+ },
+ body: {
+ items: []
+ }
+ })
+ });
+
+ expect(await loadSut.getCrs(fixtures.ocmCrd1Namespaced)).to.be.empty;
+ });
+
+ test('Failed cluster access while fetching namespaces for retrieving crs for namespaced crd should not fail, but return an empty array', async () => {
+ // @ts-ignore
+ loadSut.coreApi = sinon.createStubInstance(k8s.CoreV1Api, {
+ // @ts-ignore
+ listNamespace: Promise.resolve({
+ response: {
+ statusCode: 500
+ }
+ })
+ });
+
+ expect(await loadSut.getCrs(fixtures.ocmCrd1Namespaced)).to.be.empty;
+ });
+
+ test('Failed cluster while retrieving crs for namespaced crd should not fail, but return an empty array', async () => {
+ // @ts-ignore
+ loadSut.coreApi = sinon.createStubInstance(k8s.CoreV1Api, {
+ listNamespace: Promise.resolve({
+ response: {
+ statusCode: 200
+ },
+ body: {
+ items: [
+ fixtures.k8sNamespace
+ ]
+ }
+ })
+ });
+
+ // @ts-ignore
+ loadSut.objApi = sinon.createStubInstance(k8s.CustomObjectsApi, {
+ // @ts-ignore
+ listNamespacedCustomObject: Promise.resolve({
+ response: {
+ statusCode: 500
+ }
+ })
+ });
+
+ expect(await loadSut.getCrs(fixtures.ocmCrd1Namespaced)).to.be.empty;
+ });
+
+ });
+});
diff --git a/vscode-extension/test/test-files/providers/contexts.tree.provider.test.ts b/vscode-extension/test/test-files/providers/contexts.tree.provider.test.ts
new file mode 100644
index 00000000..19f618f5
--- /dev/null
+++ b/vscode-extension/test/test-files/providers/contexts.tree.provider.test.ts
@@ -0,0 +1,106 @@
+/* eslint-disable @typescript-eslint/unbound-method */
+import * as contextsTreeProvider from '../../../src/providers/contextsTreeProvider';
+import * as fixtures from '../data/fixtures';
+import * as loader from '../../../src/data/loader';
+import * as sinon from 'sinon';
+import { expect, use } from 'chai';
+import { beforeEach } from 'mocha';
+import chaiExclude from 'chai-exclude';
+
+use(chaiExclude);
+
+suite('Load the tree provider', () => {
+ let loadStub: loader.Load;
+ let providerSut: contextsTreeProvider.ConnectedContextsTreeProvider;
+
+ beforeEach(() => {
+ // mock the loader and override the various methods with fakes and stubs
+ loadStub = sinon.createStubInstance(loader.Load, {
+ getContexts: [fixtures.connectedContext1, fixtures.connectedContext2],
+ getCrds: Promise.resolve([fixtures.ocmCrd1Namespaced, fixtures.ocmCrd2Clustered]),
+ getCrs: Promise.resolve([fixtures.ocmCr1Namespaced, fixtures.ocmCr2Clustered]) // irl two diff kinds of crs won't return together
+ });
+ // instantiate the provider subject under test
+ providerSut = new contextsTreeProvider.ConnectedContextsTreeProvider(loadStub);
+ });
+
+ test('Instantiation of the provider should refresh the load', () => {
+ expect(loadStub.refresh).to.have.been.calledOnce;
+ });
+
+ test('Hitting refresh on the provider should refresh the load', () => {
+ providerSut.refresh();
+ // twice bc the first call was for instantiation
+ expect(loadStub.refresh).to.have.been.calledTwice;
+ });
+
+ test('Retrieving a specific element should return itself unmodified', () => {
+ let fakeElement = sinon.fake();
+ // @ts-ignore
+ expect(providerSut.getTreeItem(fakeElement)).to.equal(fakeElement);
+ });
+
+ test('Retrieving top level children elements should return the contexts', async () => {
+ let treeContexts = await providerSut.getChildren();
+ expect(treeContexts).to.have.lengthOf(2);
+ expect(treeContexts[0]).excluding('iconPath').to.deep.equal({
+ collapsibleState: 1,
+ label: fixtures.connectedContext1.name,
+ context: fixtures.connectedContext1,
+ tooltip: fixtures.connectedContext1.cluster.name,
+ command: {
+ title: 'Context Info',
+ command: 'ocm-vscode-extension.showContextDetails',
+ arguments: [fixtures.connectedContext1]
+ }
+ });
+ expect(treeContexts[1]).excluding('iconPath').to.deep.equal({
+ collapsibleState: 1,
+ label: fixtures.connectedContext2.name,
+ context: fixtures.connectedContext2,
+ tooltip: fixtures.connectedContext2.cluster.name,
+ command: {
+ title: 'Context Info',
+ command: 'ocm-vscode-extension.showContextDetails',
+ arguments: [fixtures.connectedContext2]
+ }
+ });
+ });
+
+ test('Retrieving children for a context element should return the crds', async () => {
+ let treeCrds = await providerSut.getChildren(new contextsTreeProvider.TreeContext(fixtures.connectedContext1));
+ expect(loadStub.setContext).to.have.been.calledOnceWith(fixtures.connectedContext1);
+ expect(loadStub.getCrds).to.have.been.calledOnceWith();
+ expect(treeCrds).to.have.lengthOf(2);
+ expect(treeCrds[0]).excluding('iconPath').to.deep.equal({
+ collapsibleState: 1,
+ label: fixtures.ocmCrd1Namespaced.kind,
+ crd: fixtures.ocmCrd1Namespaced,
+ tooltip: fixtures.ocmCrd1Namespaced.group
+ });
+ expect(treeCrds[1]).excluding('iconPath').to.deep.equal({
+ collapsibleState: 1,
+ label: fixtures.ocmCrd2Clustered.kind,
+ crd: fixtures.ocmCrd2Clustered,
+ tooltip: fixtures.ocmCrd2Clustered.group
+ });
+ });
+
+ test('Retrieving children for a crd element should return the crs', async () => {
+ let treeCrs = await providerSut.getChildren(new contextsTreeProvider.TreeCrd(fixtures.ocmCrd1Namespaced));
+ expect(loadStub.getCrs).to.have.been.calledOnceWith(fixtures.ocmCrd1Namespaced);
+ expect(treeCrs).to.have.lengthOf(2);
+ expect(treeCrs[0]).to.deep.equal({
+ collapsibleState: 0,
+ label: fixtures.ocmCr1Namespaced.name,
+ cr: fixtures.ocmCr1Namespaced,
+ tooltip: fixtures.ocmCr1Namespaced.crd.version
+ });
+ expect(treeCrs[1]).to.deep.equal({
+ collapsibleState: 0,
+ label: fixtures.ocmCr2Clustered.name,
+ cr: fixtures.ocmCr2Clustered,
+ tooltip: fixtures.ocmCr2Clustered.crd.version
+ });
+ });
+});
diff --git a/vscode-extension/test/test-files/utils/build.test.ts b/vscode-extension/test/test-files/utils/build.test.ts
new file mode 100644
index 00000000..ca3e2378
--- /dev/null
+++ b/vscode-extension/test/test-files/utils/build.test.ts
@@ -0,0 +1,281 @@
+import * as chaiAsPromised from 'chai-as-promised';
+import * as shellTools from '../../../src/utils/shell';
+import * as sinon from 'sinon';
+import * as sinonChai from 'sinon-chai';
+import { Cluster, ClusterType, buildLocalEnv } from '../../../src/utils/build';
+import { use as chaiUse, expect } from 'chai';
+import { beforeEach } from 'mocha';
+
+chaiUse(chaiAsPromised);
+chaiUse(sinonChai);
+
+suite('Test cases for the build utility functions', () => {
+ let shellExecutionStub: sinon.SinonStub;
+ let fakeProgressReporter: sinon.SinonStub;
+
+ const dummyHubCluster1: Cluster = {
+ name: 'dummyHub1',
+ context: 'kind-dummyHub1',
+ type: ClusterType.hub
+ };
+
+ const dummyHubCluster2: Cluster = {
+ name: 'dummyHub2',
+ context: 'kind-dummyHub2',
+ type: ClusterType.hub
+ };
+
+ const dummyManagedCluster1: Cluster = {
+ name: 'dummyCluster1',
+ context: 'kind-dummyCluster1',
+ type: ClusterType.managed
+ };
+
+ const dummyManagedCluster2: Cluster = {
+ name: 'dummyCluster2',
+ context: 'kind-dummyCluster2',
+ type: ClusterType.managed
+ };
+
+ const fullClusterList = [dummyHubCluster1, dummyManagedCluster1, dummyManagedCluster2];
+
+ const expectedJoinCommand = 'clusteradm join --hub-token xyzxyz --hub-apiserver https://127.0.0.1:1234 --cluster-name';
+
+ const successfulHubInitialization = `The multicluster hub control plane has been initialized successfully!
+
+ You can now register cluster(s) to the hub control plane. Log onto those cluster(s) and run the following command:
+
+ ${expectedJoinCommand}
+
+ Replace with a cluster name of your choice. For example, cluster1.
+
+ `;
+
+ beforeEach(() => {
+ sinon.restore(); // unwrap previously wrapped sinon objects
+ shellExecutionStub = sinon.stub(shellTools, 'executeShellCommand'); // stub shell execution utility function
+ fakeProgressReporter = sinon.stub(); // mock a fake progress bar
+ sinon.stub(console, 'debug'); // silence debug logs
+ });
+
+ suite('Testing buildLocalEnv', () => {
+ test('When building with no hub cluster, the build should be rejected', async () => {
+ // given the requested cluster list contains two managed clusters and no hub cluster
+ let clusters = [dummyManagedCluster1, dummyManagedCluster2];
+ return Promise.all([
+ // then the build should be rejected
+ expect(buildLocalEnv(clusters, fakeProgressReporter))
+ .to.eventually.be.rejectedWith(
+ 'OCM extension, required 1 hub and at least 1 managed cluster, found 0 and 2'),
+ // then the progress reporter should be incremented fully
+ expect(fakeProgressReporter).to.have.been.calledOnceWith(
+ {increment: 100 , message: 'required 1 hub and at least 1 managed cluster, found 0 and 2'})
+ ]);
+ });
+
+ test('When building with multiple hub clusters, the build should be rejected', async () => {
+ // given the requested cluster list contains two hub clusters
+ let clusters = [dummyHubCluster1, dummyHubCluster2, dummyManagedCluster1];
+ return Promise.all([
+ // then the build should be rejected
+ expect(buildLocalEnv(clusters, fakeProgressReporter))
+ .to.eventually.be.rejectedWith(
+ 'OCM extension, required 1 hub and at least 1 managed cluster, found 2 and 1'),
+ // then the progress reporter should be incremented full
+ expect(fakeProgressReporter).to.have.been.calledOnceWith(
+ {increment: 100 , message: 'required 1 hub and at least 1 managed cluster, found 2 and 1'})
+ ]);
+ });
+
+ test('When building with no managed clusters, the build should be rejected', async () => {
+ // given the requested cluster list contains two hub clusters
+ let clusters = [dummyHubCluster1];
+ return Promise.all([
+ // then the build should be rejected
+ expect(buildLocalEnv(clusters, fakeProgressReporter))
+ .to.eventually.be.rejectedWith(
+ 'OCM extension, required 1 hub and at least 1 managed cluster, found 1 and 0'),
+ // then the progress reporter should be incremented full
+ expect(fakeProgressReporter).to.have.been.calledOnceWith(
+ {increment: 100 , message: 'required 1 hub and at least 1 managed cluster, found 1 and 0'})
+ ]);
+ });
+
+ test('When failed creating kind clusters, the build should be rejected', async () => {
+ // given kind will fail creating the clusters
+ shellExecutionStub
+ .withArgs(sinon.match((s: string) => s.startsWith('kind create cluster --name')))
+ .rejects('will-always-fail');
+ return Promise.all([
+ // then the build should be rejected
+ expect(buildLocalEnv(fullClusterList, fakeProgressReporter))
+ .to.eventually.be.rejectedWith('OCM extension, failed creating kind clusters')
+ .then(() => {
+ // then track the progress of the progress reporter
+ expect(fakeProgressReporter.firstCall.firstArg)
+ .to.contain({increment: 0 , message: 'creating 3 kind clusters'});
+ expect(fakeProgressReporter.secondCall.firstArg)
+ .to.contain({increment: 100 , message: 'failed creating kind clusters'});
+ }),
+ ]);
+ });
+
+ test('When failed initializing the hub cluster, the build should be rejected', async () => {
+ // given kind will successfully create the clusters
+ shellExecutionStub
+ .withArgs(sinon.match((s: string) => s.startsWith('kind create cluster --name')))
+ .resolves();
+ // given kubectl context switching will work
+ shellExecutionStub
+ .withArgs(sinon.match((s: string) => s.startsWith('kubectl config use')))
+ .resolves();
+ // given the hub initialization with clusteradm will fail
+ shellExecutionStub
+ .withArgs(sinon.match((s: string) => s.startsWith('clusteradm init --use-bootstrap-token')))
+ .rejects('will-always-fail');
+ return Promise.all([
+ // then the build should be rejected
+ expect(buildLocalEnv(fullClusterList, fakeProgressReporter))
+ .to.eventually.be.rejectedWith('OCM extension, failed initializing the hub cluster')
+ .then(() => {
+ // then track the progress of the progress reporter
+ expect(fakeProgressReporter.firstCall.firstArg)
+ .to.contain({increment: 0 , message: 'creating 3 kind clusters'});
+ expect(fakeProgressReporter.secondCall.firstArg)
+ .to.contain({increment: 20 , message: 'initializing the Hub cluster named dummyHub1'});
+ expect(fakeProgressReporter.thirdCall.firstArg)
+ .to.contain({increment: 100 , message: 'failed initializing the hub cluster'});
+ }),
+ ]);
+ });
+
+ test('When sending the join request fails, the build should be rejected', async () => {
+ // given kind will successfully create the clusters
+ shellExecutionStub
+ .withArgs(sinon.match((s: string) => s.startsWith('kind create cluster --name')))
+ .resolves();
+ // given kubectl context switching will work
+ shellExecutionStub
+ .withArgs(sinon.match((s: string) => s.startsWith('kubectl config use')))
+ .resolves();
+ // given the hub cluster will be successfully initialized
+ shellExecutionStub
+ .withArgs(sinon.match((s: string) => s.startsWith('clusteradm init --use-bootstrap-token')))
+ .resolves();
+ // given the hub cluster will be successfully initialized
+ shellExecutionStub
+ .withArgs(sinon.match((s: string) => s.startsWith('clusteradm init --use-bootstrap-token')))
+ .resolves(successfulHubInitialization);
+ // given the join request fails
+ shellExecutionStub
+ .withArgs(sinon.match((s: string) => s.startsWith(expectedJoinCommand)))
+ .rejects('will-always-fail');
+ return Promise.all([
+ // then the build should be rejected
+ expect(buildLocalEnv(fullClusterList, fakeProgressReporter))
+ .to.eventually.be.rejectedWith('OCM extension, failed to issue join requests')
+ .then(() => {
+ // then track the progress of the progress reporter
+ expect(fakeProgressReporter.getCall(0).firstArg)
+ .to.contain({increment: 0 , message: 'creating 3 kind clusters'});
+ expect(fakeProgressReporter.getCall(1).firstArg)
+ .to.contain({increment: 20 , message: 'initializing the Hub cluster named dummyHub1'});
+ expect(fakeProgressReporter.getCall(2).firstArg)
+ .to.contain({increment: 20 , message: 'issuing join requests for the managed clusters'});
+ expect(fakeProgressReporter.getCall(3).firstArg)
+ .to.contain({increment: 100 , message: 'failed to issue join requests'});
+ }),
+ ]);
+ });
+
+ test('When accepting the join requests fails, the build should be rejected', async () => {
+ // given kind will successfully create the clusters
+ shellExecutionStub
+ .withArgs(sinon.match((s: string) => s.startsWith('kind create cluster --name')))
+ .resolves();
+ // given kubectl context switching will work
+ shellExecutionStub
+ .withArgs(sinon.match((s: string) => s.startsWith('kubectl config use')))
+ .resolves();
+ // given the hub cluster will be successfully initialized
+ shellExecutionStub
+ .withArgs(sinon.match((s: string) => s.startsWith('clusteradm init --use-bootstrap-token')))
+ .resolves();
+ // given the hub cluster will be successfully initialized
+ shellExecutionStub
+ .withArgs(sinon.match((s: string) => s.startsWith('clusteradm init --use-bootstrap-token')))
+ .resolves(successfulHubInitialization);
+ // given the managed clusters has successfully issued a join request
+ shellExecutionStub
+ .withArgs(sinon.match((s: string) => s.startsWith(expectedJoinCommand)))
+ .resolves();
+ // given the clusteradm accept command fails
+ shellExecutionStub
+ .withArgs('clusteradm accept --clusters dummyCluster1,dummyCluster2 --wait')
+ .rejects('will-always-fail');
+ return Promise.all([
+ // then the build should be rejected
+ expect(buildLocalEnv(fullClusterList, fakeProgressReporter))
+ .to.eventually.be.rejectedWith('OCM extension, failed to accept join requests')
+ .then(() => {
+ // then track the progress of the progress reporter
+ expect(fakeProgressReporter.getCall(0).firstArg)
+ .to.contain({increment: 0 , message: 'creating 3 kind clusters'});
+ expect(fakeProgressReporter.getCall(1).firstArg)
+ .to.contain({increment: 20 , message: 'initializing the Hub cluster named dummyHub1'});
+ expect(fakeProgressReporter.getCall(2).firstArg)
+ .to.contain({increment: 20 , message: 'issuing join requests for the managed clusters'});
+ expect(fakeProgressReporter.getCall(3).firstArg)
+ .to.contain({increment: 20 , message: 'accepting the managed clusters join request from the hub cluster'});
+ expect(fakeProgressReporter.getCall(4).firstArg)
+ .to.contain({increment: 100 , message: 'failed to accept join requests'});
+ }),
+ ]);
+ });
+
+ test('When the hub successfully accepted the join requests, the build should be resolved', async () => {
+ // given kind will successfully create the clusters
+ shellExecutionStub
+ .withArgs(sinon.match((s: string) => s.startsWith('kind create cluster --name')))
+ .resolves();
+ // given kubectl context switching will work
+ shellExecutionStub
+ .withArgs(sinon.match((s: string) => s.startsWith('kubectl config use')))
+ .resolves();
+ // given the hub cluster will be successfully initialized
+ shellExecutionStub
+ .withArgs(sinon.match((s: string) => s.startsWith('clusteradm init --use-bootstrap-token')))
+ .resolves();
+ // given the hub cluster will be successfully initialized
+ shellExecutionStub
+ .withArgs(sinon.match((s: string) => s.startsWith('clusteradm init --use-bootstrap-token')))
+ .resolves(successfulHubInitialization);
+ // given the managed clusters has successfully issued a join request
+ shellExecutionStub
+ .withArgs(sinon.match((s: string) => s.startsWith(expectedJoinCommand)))
+ .resolves();
+ // given the clusteradm accept command fails
+ shellExecutionStub
+ .withArgs('clusteradm accept --clusters dummyCluster1,dummyCluster2 --wait')
+ .resolves();
+ return Promise.all([
+ // then the build should be rejected
+ expect(buildLocalEnv(fullClusterList, fakeProgressReporter))
+ .to.eventually.equal('OCM extension, successfully created your local environment, have fun')
+ .then(() => {
+ // then track the progress of the progress reporter
+ expect(fakeProgressReporter.getCall(0).firstArg)
+ .to.contain({increment: 0 , message: 'creating 3 kind clusters'});
+ expect(fakeProgressReporter.getCall(1).firstArg)
+ .to.contain({increment: 20 , message: 'initializing the Hub cluster named dummyHub1'});
+ expect(fakeProgressReporter.getCall(2).firstArg)
+ .to.contain({increment: 20 , message: 'issuing join requests for the managed clusters'});
+ expect(fakeProgressReporter.getCall(3).firstArg)
+ .to.contain({increment: 20 , message: 'accepting the managed clusters join request from the hub cluster'});
+ expect(fakeProgressReporter.getCall(4).firstArg)
+ .to.contain({increment: 100 , message: 'successfully created your local environment, have fun'});
+ }),
+ ]);
+ });
+ });
+});
diff --git a/vscode-extension/test/test-files/utils/environment.test.ts b/vscode-extension/test/test-files/utils/environment.test.ts
new file mode 100644
index 00000000..d0923917
--- /dev/null
+++ b/vscode-extension/test/test-files/utils/environment.test.ts
@@ -0,0 +1,67 @@
+import * as chaiAsPromised from 'chai-as-promised';
+import * as shellUtils from '../../../src/utils/shell';
+import * as sinon from 'sinon';
+import { RequiredTool, verifyTools } from '../../../src/utils/environment';
+import { use as chaiUse, expect } from 'chai';
+import { beforeEach } from 'mocha';
+
+chaiUse(chaiAsPromised);
+
+suite('Test cases for the environment utility functions', () => {
+ beforeEach(() => sinon.restore()); // unwrap previously wrapped sinon objects
+
+ suite('Testing verifyTools', () => {
+ // dummy tools used for testing purposes
+ const dummyTool1: RequiredTool = {
+ 'name': 'find-wally',
+ 'installUrl': 'https://you.can/find/install/instructions/#here'
+ };
+
+ const dummyTool2: RequiredTool = {
+ 'name': 'kill-wally',
+ 'installUrl': 'https://we.dont/have/install/instructions/#forthat'
+ };
+
+ test('When verifying with one existing tool, the function should be successful', async () => {
+ // given the shell utility will resolve the request, indicating the tool was found
+ sinon.stub(shellUtils, 'checkToolExists').withArgs(dummyTool1.name).resolves();
+ // then expect the promise to be resolved with the appropriate message
+ return expect(verifyTools(dummyTool1)).to.eventually.be.equal(
+ 'OCM extension, all tools are accessible, we\'re good to go'
+ );
+ });
+
+ test('When verifying with one non-existing tool, the function should fail', async () => {
+ // given the shell utility will reject the request, indicating the for tool was NOT found
+ sinon.stub(shellUtils, 'checkToolExists').withArgs(dummyTool2.name).rejects();
+ // @ts-ignore then expect the promise to be rejected with the missing tool info
+ return expect(verifyTools(dummyTool2)).to.eventually.be.rejectedWith([
+ `OCM extension, ${dummyTool2.name} is missing, please install it`,
+ dummyTool2.installUrl
+ ]);
+ });
+
+ test('When verifying with two existing tools, the function should be successful', async () => {
+ // given the shell utility will resolve for both requests, indicating the both tools were found
+ let checkToolExistsStub = sinon.stub(shellUtils, 'checkToolExists');
+ checkToolExistsStub.withArgs(dummyTool1.name).resolves();
+ checkToolExistsStub.withArgs(dummyTool2.name).resolves();
+ // then expect the promise to be resolved, the and the message consumers to be called accordingly
+ return expect(verifyTools(...[dummyTool1, dummyTool2])).to.eventually.be.equal(
+ 'OCM extension, all tools are accessible, we\'re good to go'
+ );
+ });
+
+ test('When verifying with two tools, but only one exists, the function should fail', async () => {
+ // given the shell utility will reject one request and resolve the other, indicating only one tool was found
+ let checkToolExistsStub = sinon.stub(shellUtils, 'checkToolExists');
+ checkToolExistsStub.withArgs(dummyTool1.name).rejects();
+ checkToolExistsStub.withArgs(dummyTool2.name).resolves();
+ // @ts-ignore then expect the promise to be rejected, the and the message consumers to be called accordingly
+ return expect(verifyTools(...[dummyTool1, dummyTool2])).to.eventually.be.rejectedWith([
+ `OCM extension, ${dummyTool1.name} is missing, please install it`,
+ dummyTool2.installUrl
+ ]);
+ });
+ });
+});
diff --git a/vscode-extension/test/test-files/utils/filesystem.test.ts b/vscode-extension/test/test-files/utils/filesystem.test.ts
new file mode 100644
index 00000000..473f6898
--- /dev/null
+++ b/vscode-extension/test/test-files/utils/filesystem.test.ts
@@ -0,0 +1,74 @@
+// @ts-nocheck
+import * as chaiAsPromised from 'chai-as-promised';
+import * as fse from 'fs-extra';
+import * as sinon from 'sinon';
+import { use as chaiUse, expect } from 'chai';
+import { beforeEach } from 'mocha';
+import { createProjectFromTemplate } from '../../../src/utils/filesystem';
+
+chaiUse(chaiAsPromised);
+
+const normalizePath = (path: string): string =>
+ path.replace(/[\\/]+/g, '/').replace(/^([a-zA-Z]+:|\.\/)/, '');
+
+function pathMatches(expected: string): sinon.SinonMatch {
+ return sinon.match((arg: string) => normalizePath(arg).endsWith(expected));
+}
+
+suite('Test cases for the filesystem utility functions', () => {
+ suite('Testing createProjectFromTemplate', () => {
+ let dummyWorkspaceFolder = '/path/to/workspace/folder';
+ let dummyProjectName = 'create-template-unit-test1';
+ let dummyProjectPath = `${dummyWorkspaceFolder}/${dummyProjectName}`;
+
+ beforeEach(() => sinon.restore());
+
+ test('When the requested project folder already exists, the function should fail', async () => {
+ // given the project path already exists
+ sinon.stub(fse, 'pathExists').withArgs(pathMatches(dummyProjectPath)).resolves(true);
+ // then the promise should be rejected
+ return expect(createProjectFromTemplate(dummyWorkspaceFolder, dummyProjectName, 'Git'))
+ .to.be.eventually.rejectedWith(
+ `OCM extension, project folder ${dummyProjectName} exists, please use another`
+ );
+ });
+
+ test('When failed to create the project folder, the function should fail', async () => {
+ // given the project path doesn't already exists
+ sinon.stub(fse, 'pathExists').withArgs(pathMatches(dummyProjectPath)).resolves(false);
+ // given the path creation will be rejected with an error
+ sinon.stub(fse, 'ensureDir').withArgs(pathMatches(dummyProjectPath)).rejects('the answer is not 41');
+ // then the promise should be rejected
+ return expect(createProjectFromTemplate(dummyWorkspaceFolder, dummyProjectName, 'Git'))
+ .to.be.eventually.rejectedWith(
+ `OCM extension, failed to create project folder ${dummyProjectName}, the answer is not 41`
+ );
+ });
+
+ test('When failed to copy the template files to the project folder, the function should fail', async () => {
+ // given the project path doesn't already exists
+ sinon.stub(fse, 'pathExists').withArgs(pathMatches(dummyProjectPath)).resolves(false);
+ // given the path creation will be resolved
+ sinon.stub(fse, 'ensureDir').withArgs(pathMatches(dummyProjectPath)).resolves();
+ // given the copy process will be rejected with an error
+ sinon.stub(fse, 'copy').withArgs(sinon.match.string ,pathMatches(dummyProjectPath)).rejects('it is also not 43');
+ // then the promise should be rejected
+ return expect(createProjectFromTemplate(dummyWorkspaceFolder, dummyProjectName, 'Git'))
+ .to.be.eventually.rejectedWith(
+ `OCM extension, failed creating project ${dummyProjectName}, it is also not 43`
+ );
+ });
+
+ test('When the template files are copied to the project folder successfully, the function should be successful', async () => {
+ // given the project path doesn't already exists
+ sinon.stub(fse, 'pathExists').withArgs(pathMatches(dummyProjectPath)).resolves(false);
+ // given the path creation will be resolved
+ sinon.stub(fse, 'ensureDir').withArgs(pathMatches(dummyProjectPath)).resolves();
+ // given the copy process will be resolved
+ sinon.stub(fse, 'copy').withArgs(sinon.match.string ,pathMatches(dummyProjectPath)).resolves();
+ // then the promise should be rejected
+ return expect(createProjectFromTemplate(dummyWorkspaceFolder, dummyProjectName, 'Git'))
+ .to.eventually.be.equal(`OCM extension, project ${dummyProjectName} created`);
+ });
+ });
+});
diff --git a/vscode-extension/test/test-files/utils/shell.test.ts b/vscode-extension/test/test-files/utils/shell.test.ts
new file mode 100644
index 00000000..2e1b3112
--- /dev/null
+++ b/vscode-extension/test/test-files/utils/shell.test.ts
@@ -0,0 +1,48 @@
+import * as chaiAsPromised from 'chai-as-promised';
+import * as shell from 'shelljs';
+import * as sinon from 'sinon';
+import { use as chaiUse, expect } from 'chai';
+import { checkToolExists, executeShellCommand } from '../../../src/utils/shell';
+import { beforeEach } from 'mocha';
+
+chaiUse(chaiAsPromised);
+
+suite('Test cases for the shell utility functions', () => {
+ beforeEach(() => sinon.restore()); // unwrap any previously wrapped sinon objects
+
+ suite('Testing checkToolExists', () => {
+ test('When checking with an existing tool, the function should be successful', async () => {
+ // @ts-ignore given the command in question exists
+ sinon.stub(shell, 'which').withArgs('existing-tool').returns({code: 0});
+ // then expect the promise to be resolved
+ return expect(checkToolExists('existing-tool')).to.eventually.be.fulfilled;
+ });
+
+ test('When checking with a non-existing tool, the function should fail', async () => {
+ // given the command in question doesn't exist
+ sinon.stub(shell, 'which').withArgs('non-existing-tool').returns(null);
+ // then expect the promise to be rejected
+ return expect(checkToolExists('non-existing-tool')).to.eventually.be.rejected;
+ });
+ });
+
+ suite('Testing executeShellCommand', () => {
+ test('When executing a successful command, the function should be successful', async () => {
+ let dummyCommand = 'dummy-successful-command';
+ let dummyStdout = 'all is good in the hood';
+ // given the dummy success execution will return for the dummy command
+ sinon.stub(shell, 'exec').withArgs(dummyCommand, sinon.match.func).yields(0, dummyStdout, null);
+ // then expect the promise to be resolved with the standard output from the succeeded execution
+ return expect(executeShellCommand(dummyCommand)).to.eventually.be.equal(dummyStdout);
+ });
+
+ test('When executing a failed command, the function should fail', async () => {
+ let dummyCommand = 'dummy-failed-command';
+ let dummyStderr = 'oh my god they killed kenny';
+ // given the dummy failed execution will return for the dummy command
+ sinon.stub(shell, 'exec').withArgs(dummyCommand, sinon.match.func).yields(999, null, dummyStderr);
+ // then expect the promise to be rejected with the error output from the failed execution
+ return expect(executeShellCommand(dummyCommand)).to.eventually.be.rejectedWith(dummyStderr);
+ });
+ });
+});
diff --git a/vscode-extension/test/test-workspace/.gitkeep b/vscode-extension/test/test-workspace/.gitkeep
new file mode 100644
index 00000000..e69de29b
diff --git a/vscode-extension/tsconfig.json b/vscode-extension/tsconfig.json
new file mode 100644
index 00000000..53fd2dae
--- /dev/null
+++ b/vscode-extension/tsconfig.json
@@ -0,0 +1,22 @@
+{
+ "compilerOptions": {
+ "module": "commonjs",
+ "target": "ES2020",
+ "outDir": "out",
+ "lib": [
+ "ES2020","dom"
+ ],
+ "sourceMap": true,
+ "rootDirs": [
+ "src",
+ "test"
+ ],
+ "skipLibCheck": true,
+ "strict": true /* enable all strict type-checking options */
+ /* Additional Checks */
+ // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
+ // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
+ // "noUnusedParameters": true, /* Report errors on unused parameters. */
+ },
+ "exclude": ["*/node_modules", ".vscode-test", "webview-ui"]
+}
diff --git a/vscode-extension/webview-ui/.eslintrc.json b/vscode-extension/webview-ui/.eslintrc.json
new file mode 100644
index 00000000..6d652325
--- /dev/null
+++ b/vscode-extension/webview-ui/.eslintrc.json
@@ -0,0 +1,13 @@
+{
+ "root": true,
+ "extends": ["react-app", "react-app/jest"],
+ "rules": {
+ "@typescript-eslint/unbound-method": "off",
+ "no-unused-expressions": "off"
+ },
+ "ignorePatterns": [
+ "build",
+ "dist",
+ "**/*.d.ts"
+ ]
+}
diff --git a/vscode-extension/webview-ui/README.md b/vscode-extension/webview-ui/README.md
new file mode 100644
index 00000000..6639692b
--- /dev/null
+++ b/vscode-extension/webview-ui/README.md
@@ -0,0 +1,46 @@
+# `webview-ui` Directory
+
+This directory contains all of the code that will be executed within the webview context. It can be thought of as the place where all the "frontend" code of a webview is contained.
+
+Types of content that can be contained here:
+
+- Frontend framework code (i.e. React, Svelte, Vue, etc.)
+- JavaScript files
+- CSS files
+- Assets / resources (i.e. images, illustrations, etc.)
+
+
+It is important to understand how the webview interacts with the extension.
+the webview is an iframe with limited access to the backend it cannot trigger queries on the host machine,
+since we need to interact with "kubectl" lib we need to transfer data between webview & extension
+
+bellow you can find a Sequence Diagram discarding the GetClusterDetails scenario
+
+```mermaid
+sequenceDiagram
+
+ participant User
+ participant ClusterDetailsPanel (extension)
+ participant ClusterDropDown (webview)
+ participant React App (webview)
+
+ User->>ClusterDetailsPanel (extension): getClusterDetails using k8s lib
+ ClusterDetailsPanel (extension)->>ClusterDropDown (webview): post message(Cluster List)
+ ClusterDropDown (webview)->>React App (webview): render cluster list()
+
+```
+
+When the user chooses a cluster the webview need to request more details from the extension
+
+
+```mermaid
+sequenceDiagram
+
+
+ ClusterDropDown (webview)->>ClusterDetailsPanel (extension): post message(selected cluster)
+ ClusterDetailsPanel (extension)->>k8s: getClusterDeatils(selectCluster)
+ k8s->>ClusterDetailsPanel (extension): ClusterDeatils: object
+ ClusterDetailsPanel (extension)->>ClusterDetails (webview): post message(clusterDeatils)
+ ClusterDetails (webview)->>React App (webview): render cluster list()
+
+```
\ No newline at end of file
diff --git a/vscode-extension/webview-ui/package.json b/vscode-extension/webview-ui/package.json
new file mode 100644
index 00000000..8ed05e0b
--- /dev/null
+++ b/vscode-extension/webview-ui/package.json
@@ -0,0 +1,40 @@
+{
+ "name": "ocm-webview",
+ "version": "0.0.1",
+ "private": true,
+ "scripts": {
+ "start": "EXTEND_ESLINT=true react-scripts start",
+ "build": "node ./scripts/build-react-no-split.js",
+ "test": "EXTEND_ESLINT=true react-scripts test",
+ "eject": "react-scripts eject"
+ },
+ "dependencies": {
+ "@types/react": "^18.0.20",
+ "@types/react-dom": "^18.0.6",
+ "@vscode/webview-ui-toolkit": "^1.0.1",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0"
+ },
+ "devDependencies": {
+ "@kubernetes/client-node": "^0.17.1",
+ "@types/node": "^18.7.18",
+ "@types/vscode-webview": "^1.57.0",
+ "eslint": "^8.23.1",
+ "eslint-config-react-app": "^7.0.1",
+ "react-scripts": "^5.0.1",
+ "rewire": "^6.0.0",
+ "typescript": "^4.8.3"
+ },
+ "browserslist": {
+ "production": [
+ ">0.2%",
+ "not dead",
+ "not op_mini all"
+ ],
+ "development": [
+ "last 1 chrome version",
+ "last 1 firefox version",
+ "last 1 safari version"
+ ]
+ }
+}
diff --git a/vscode-extension/webview-ui/public/favicon.ico b/vscode-extension/webview-ui/public/favicon.ico
new file mode 100644
index 0000000000000000000000000000000000000000..a11777cc471a4344702741ab1c8a588998b1311a
GIT binary patch
literal 3870
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
baj7?x%VD!5Cxk2YbYLNVeiXvvpMCWYo=by@
literal 0
HcmV?d00001
diff --git a/vscode-extension/webview-ui/public/index.html b/vscode-extension/webview-ui/public/index.html
new file mode 100644
index 00000000..05de62f8
--- /dev/null
+++ b/vscode-extension/webview-ui/public/index.html
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+ Hello World
+
+
+
+
+
+
diff --git a/vscode-extension/webview-ui/public/logo192.png b/vscode-extension/webview-ui/public/logo192.png
new file mode 100644
index 0000000000000000000000000000000000000000..fc44b0a3796c0e0a64c3d858ca038bd4570465d9
GIT binary patch
literal 5347
zcmZWtbyO6NvR-oO24RV%BvuJ&=?+<7=`LvyB&A_#M7mSDYw1v6DJkiYl9XjT!%$dLEBTQ8R9|wd3008in6lFF3GV-6mLi?MoP_y~}QUnaDCHI#t
z7w^m$@6DI)|C8_jrT?q=f8D?0AM?L)Z}xAo^e^W>t$*Y0KlT5=@bBjT9kxb%-KNdk
zeOS1tKO#ChhG7%{ApNBzE2ZVNcxbrin#E1TiAw#BlUhXllzhN$qWez5l;h+t^q#Eav8PhR2|T}y5kkflaK`ba-eoE+Z2q@o6P$)=&`
z+(8}+-McnNO>e#$Rr{32ngsZIAX>GH??tqgwUuUz6kjns|LjsB37zUEWd|(&O!)DY
zQLrq%Y>)Y8G`yYbYCx&aVHi@-vZ3|ebG!f$sTQqMgi0hWRJ^Wc+Ibv!udh_r%2|U)
zPi|E^PK?UE!>_4`f`1k4hqqj_$+d!EB_#IYt;f9)fBOumGNyglU(ofY`yHq4Y?B%-
zp&G!MRY<~ajTgIHErMe(Z8JG*;D-PJhd@RX@QatggM7+G(Lz8eZ;73)72Hfx5KDOE
zkT(m}i2;@X2AT5fW?qVp?@WgN$aT+f_6eo?IsLh;jscNRp|8H}Z9p_UBO^SJXpZew
zEK8fz|0Th%(Wr|KZBGTM4yxkA5CFdAj8=QSrT$fKW#tweUFqr0TZ9D~a5lF{)%-tTGMK^2tz(y2v$i%V8XAxIywrZCp=)83p(zIk6@S5AWl|Oa2hF`~~^W
zI;KeOSkw1O#TiQ8;U7OPXjZM|KrnN}9arP)m0v$c|L)lF`j_rpG(zW1Qjv$=^|p*f
z>)Na{D&>n`jOWMwB^TM}slgTEcjxTlUby89j1)|6ydRfWERn3|7Zd2&e7?!K&5G$x
z`5U3uFtn4~SZq|LjFVrz$3iln-+ucY4q$BC{CSm7Xe5c1J<=%Oagztj{ifpaZk_bQ
z9Sb-LaQMKp-qJA*bP6DzgE3`}*i1o3GKmo2pn@dj0;He}F=BgINo};6gQF8!n0ULZ
zL>kC0nPSFzlcB7p41doao2F7%6IUTi_+!L`MM4o*#Y#0v~WiO8uSeAUNp=vA2KaR&=jNR2iVwG>7t%sG2x_~yXzY)7K&
zk3p+O0AFZ1eu^T3s};B%6TpJ6h-Y%B^*zT&SN7C=N;g|#dGIVMSOru3iv^SvO>h4M=t-N1GSLLDqVTcgurco6)3&XpU!FP6Hlrmj}f$
zp95;b)>M~`kxuZF3r~a!rMf4|&1=uMG$;h^g=Kl;H&Np-(pFT9FF@++MMEx3RBsK?AU0fPk-#mdR)Wdkj)`>ZMl#^<80kM87VvsI3r_c@_vX=fdQ`_9-d(xiI
z4K;1y1TiPj_RPh*SpDI7U~^QQ?%0&!$Sh#?x_@;ag)P}ZkAik{_WPB4rHyW#%>|Gs
zdbhyt=qQPA7`?h2_8T;-E6HI#im9K>au*(j4;kzwMSLgo6u*}-K`$_Gzgu&XE)udQ
zmQ72^eZd|vzI)~!20JV-v-T|<4@7ruqrj|o4=JJPlybwMg;M$Ud7>h6g()CT@wXm`
zbq=A(t;RJ^{Xxi*Ff~!|3!-l_PS{AyNAU~t{h;(N(PXMEf^R(B+ZVX3
z8y0;0A8hJYp@g+c*`>eTA|3Tgv9U8#BDTO9@a@gVMDxr(fVaEqL1tl?md{v^j8aUv
zm&%PX4^|rX|?E4^CkplWWNv*OKM>DxPa
z!RJ)U^0-WJMi)Ksc!^ixOtw^egoAZZ2Cg;X7(5xZG7yL_;UJ#yp*ZD-;I^Z9qkP`}
zwCTs0*%rIVF1sgLervtnUo&brwz?6?PXRuOCS*JI-WL6GKy7-~yi0giTEMmDs_-UX
zo=+nFrW_EfTg>oY72_4Z0*uG>MnXP=c0VpT&*|rvv1iStW;*^={rP1y?Hv+6R6bxFMkxpWkJ>m7Ba{>zc_q
zEefC3jsXdyS5??Mz7IET$Kft|EMNJIv7Ny8ZOcKnzf`K5Cd)&`-fTY#W&jnV0l2vt
z?Gqhic}l}mCv1yUEy$%DP}4AN;36$=7aNI^*AzV(eYGeJ(Px-j<^gSDp5dBAv2#?;
zcMXv#aj>%;MiG^q^$0MSg-(uTl!xm49dH!{X0){Ew7ThWV~Gtj7h%ZD
zVN-R-^7Cf0VH!8O)uUHPL2mO2tmE*cecwQv_5CzWeh)ykX8r5Hi`ehYo)d{Jnh&3p
z9ndXT$OW51#H5cFKa76c<%nNkP~FU93b5h-|Cb}ScHs@4Q#|}byWg;KDMJ#|l
zE=MKD0c>*F@HDBcX@~QJH%56eh~jfPO-uKm}~t7VkHxHT;)4sd+?Wc4*
z>CyR*{w@4(gnYRdFq=^(#-ytb^5ESD?x<0Skhb%Pt?npNW1m+Nv`tr9+qN<3H1f<%
zZvNEqyK5FgPsQ`QIu9P0x_}wJR~^CotL|n
zk?dn;tLRw9jJTur4uWoX6iMm914f0AJfB@C74a;_qRrAP4E7l890P&{v<}>_&GLrW
z)klculcg`?zJO~4;BBAa=POU%aN|pmZJn2{hA!d!*lwO%YSIzv8bTJ}=nhC^n}g(ld^rn#kq9Z3)z`k9lvV>y#!F4e{5c$tnr9M{V)0m(Z<
z#88vX6-AW7T2UUwW`g<;8I$Jb!R%z@rCcGT)-2k7&x9kZZT66}Ztid~6t0jKb&9mm
zpa}LCb`bz`{MzpZR#E*QuBiZXI#<`5qxx=&LMr-UUf~@dRk}YI2hbMsAMWOmDzYtm
zjof16D=mc`^B$+_bCG$$@R0t;e?~UkF?7<(vkb70*EQB1rfUWXh$j)R2)+dNAH5%R
zEBs^?N;UMdy}V};59Gu#0$q53$}|+q7CIGg_w_WlvE}AdqoS<7DY1LWS9?TrfmcvT
zaypmplwn=P4;a8-%l^e?f`OpGb}%(_mFsL&GywhyN(-VROj`4~V~9bGv%UhcA|YW%
zs{;nh@aDX11y^HOFXB$a7#Sr3cEtNd4eLm@Y#fc&j)TGvbbMwze
zXtekX_wJqxe4NhuW$r}cNy|L{V=t#$%SuWEW)YZTH|!iT79k#?632OFse{+BT_gau
zJwQcbH{b}dzKO?^dV&3nTILYlGw{27UJ72ZN){BILd_HV_s$WfI2DC<9LIHFmtyw?
zQ;?MuK7g%Ym+4e^W#5}WDLpko%jPOC=aN)3!=8)s#Rnercak&b3ESRX3z{xfKBF8L
z5%CGkFmGO@x?_mPGlpEej!3!AMddChabyf~nJNZxx!D&{@xEb!TDyvqSj%Y5@A{}9
zRzoBn0?x}=krh{ok3Nn%e)#~uh;6jpezhA)ySb^b#E>73e*frBFu6IZ^D7Ii&rsiU
z%jzygxT-n*joJpY4o&8UXr2s%j^Q{?e-voloX`4DQyEK+DmrZh8A$)iWL#NO9+Y@!sO2f@rI!@jN@>HOA<
z?q2l{^%mY*PNx2FoX+A7X3N}(RV$B`g&N=e0uvAvEN1W^{*W?zT1i#fxuw10%~))J
zjx#gxoVlXREWZf4hRkgdHx5V_S*;p-y%JtGgQ4}lnA~MBz-AFdxUxU1RIT$`sal|X
zPB6sEVRjGbXIP0U+?rT|y5+ev&OMX*5C$n2SBPZr`jqzrmpVrNciR0e*Wm?fK6DY&
zl(XQZ60yWXV-|Ps!A{EF;=_z(YAF=T(-MkJXUoX
zI{UMQDAV2}Ya?EisdEW;@pE6dt;j0fg5oT2dxCi{wqWJ<)|SR6fxX~5CzblPGr8cb
zUBVJ2CQd~3L?7yfTpLNbt)He1D>*KXI^GK%<`bq^cUq$Q@uJifG>p3LU(!H=C)aEL
zenk7pVg}0{dKU}&l)Y2Y2eFMdS(JS0}oZUuVaf2+K*YFNGHB`^YGcIpnBlMhO7d4@vV
zv(@N}(k#REdul8~fP+^F@ky*wt@~&|(&&meNO>rKDEnB{ykAZ}k>e@lad7to>Ao$B
zz<1(L=#J*u4_LB=8w+*{KFK^u00NAmeNN7pr+Pf+N*Zl^dO{LM-hMHyP6N!~`24jd
zXYP|Ze;dRXKdF2iJG$U{k=S86l@pytLx}$JFFs8e)*Vi?aVBtGJ3JZUj!~c{(rw5>vuRF$`^p!P8w1B=O!skwkO5yd4_XuG^QVF
z`-r5K7(IPSiKQ2|U9+`@Js!g6sfJwAHVd|s?|mnC*q
zp|B|z)(8+mxXyxQ{8Pg3F4|tdpgZZSoU4P&9I8)nHo1@)9_9u&NcT^FI)6|hsAZFk
zZ+arl&@*>RXBf-OZxhZerOr&dN5LW9@gV=oGFbK*J+m#R-|e6(Loz(;g@T^*oO)0R
zN`N=X46b{7yk5FZGr#5&n1!-@j@g02g|X>MOpF3#IjZ_4wg{dX+G9eqS+Es9@6nC7
zD9$NuVJI}6ZlwtUm5cCAiYv0(Yi{%eH+}t)!E^>^KxB5^L~a`4%1~5q6h>d;paC9c
zTj0wTCKrhWf+F#5>EgX`sl%POl?oyCq0(w0xoL?L%)|Q7d|Hl92rUYAU#lc**I&^6p=4lNQPa0
znQ|A~i0ip@`B=FW-Q;zh?-wF;Wl5!+q3GXDu-x&}$gUO)NoO7^$BeEIrd~1Dh{Tr`
z8s<(Bn@gZ(mkIGnmYh_ehXnq78QL$pNDi)|QcT*|GtS%nz1uKE+E{7jdEBp%h0}%r
zD2|KmYGiPa4;md-t_m5YDz#c*oV_FqXd85d@eub?9N61QuYcb3CnVWpM(D-^|CmkL
z(F}L&N7qhL2PCq)fRh}XO@U`Yn<?TNGR4L(mF7#4u29{i~@k;pLsgl({YW5`Mo+p=zZn3L*4{JU;++dG9
X@eDJUQo;Ye2mwlRs?y0|+_a0zY+Zo%Dkae}+MySoIppb75o?vUW_?)>@g{U2`ERQIXV
zeY$JrWnMZ$QC<=ii4X|@0H8`si75jB(ElJb00HAB%>SlLR{!zO|C9P3zxw_U8?1d8uRZ=({Ga4shyN}3
zAK}WA(ds|``G4jA)9}Bt2Hy0+f3rV1E6b|@?hpGA=PI&r8)ah|)I2s(P5Ic*Ndhn^
z*T&j@gbCTv7+8rpYbR^Ty}1AY)YH;p!m948r#%7x^Z@_-w{pDl|1S4`EM3n_PaXvK
z1JF)E3qy$qTj5Xs{jU9k=y%SQ0>8E$;x?p9ayU0bZZeo{5Z@&FKX>}s!0+^>C^D#z
z>xsCPvxD3Z=dP}TTOSJhNTPyVt14VCQ9MQFN`rn!c&_p?&4<5_PGm4a;WS&1(!qKE
z_H$;dDdiPQ!F_gsN`2>`X}$I=B;={R8%L~`>RyKcS$72ai$!2>d(YkciA^J0@X%G4
z4cu!%Ps~2JuJ8ex`&;Fa0NQOq_nDZ&X;^A=oc1&f#3P1(!5il>6?uK4QpEG8z0Rhu
zvBJ+A9RV?z%v?!$=(vcH?*;vRs*+PPbOQ3cdPr5=tOcLqmfx@#hOqX0iN)wTTO21jH<>jpmwRIAGw7`a|sl?9y9zRBh>(_%|
zF?h|P7}~RKj?HR+q|4U`CjRmV-$mLW>MScKnNXiv{vD3&2@*u)-6P@h0A`eeZ7}71
zK(w%@R<4lLt`O7fs1E)$5iGb~fPfJ?WxhY7c3Q>T-w#wT&zW522pH-B%r5v#5y^CF
zcC30Se|`D2mY$hAlIULL%-PNXgbbpRHgn<&X3N9W!@BUk@9g*P5mz-YnZBb*-$zMM
z7Qq}ic0mR8n{^L|=+diODdV}Q!gwr?y+2m=3HWwMq4z)DqYVg0J~^}-%7rMR@S1;9
z7GFj6K}i32X;3*$SmzB&HW{PJ55kT+EI#SsZf}bD7nW^Haf}_gXciYKX{QBxIPSx2Ma?
zHQqgzZq!_{&zg{yxqv3xq8YV+`S}F6A>Gtl39_m;K4dA{pP$BW0oIXJ>jEQ!2V3A2
zdpoTxG&V=(?^q?ZTj2ZUpDUdMb)T?E$}CI>r@}PFPWD9@*%V6;4Ag>D#h>!s)=$0R
zRXvdkZ%|c}ubej`jl?cS$onl9Tw52rBKT)kgyw~Xy%z62Lr%V6Y=f?2)J|bZJ5(Wx
zmji`O;_B+*X@qe-#~`HFP<{8$w@z4@&`q^Q-Zk8JG3>WalhnW1cvnoVw>*R@c&|o8
zZ%w!{Z+MHeZ*OE4v*otkZqz11*s!#s^Gq>+o`8Z5
z^i-qzJLJh9!W-;SmFkR8HEZJWiXk$40i6)7
zZpr=k2lp}SasbM*Nbn3j$sn0;rUI;%EDbi7T1ZI4qL6PNNM2Y%6{LMIKW+FY_yF3)
zSKQ2QSujzNMSL2r&bYs`|i2Dnn
z=>}c0>a}>|uT!IiMOA~pVT~R@bGlm}Edf}Kq0?*Af6#mW9f9!}RjW7om0c9Qlp;yK
z)=XQs(|6GCadQbWIhYF=rf{Y)sj%^Id-ARO0=O^Ad;Ph+
z0?$eE1xhH?{T$QI>0JP75`r)U_$#%K1^BQ8z#uciKf(C701&RyLQWBUp*Q7eyn76}
z6JHpC9}R$J#(R0cDCkXoFSp;j6{x{b&0yE@P7{;pCEpKjS(+1RQy38`=&Yxo%F=3y
zCPeefABp34U-s?WmU#JJw23dcC{sPPFc2#J$ZgEN%zod}J~8dLm*fx9f6SpO
zn^Ww3bt9-r0XaT2a@Wpw;C23XM}7_14#%QpubrIw5aZtP+CqIFmsG4`Cm6rfxl9n5
z7=r2C-+lM2AB9X0T_`?EW&Byv&K?HS4QLoylJ|OAF
z`8atBNTzJ&AQ!>sOo$?^0xj~D(;kS$`9zbEGd>f6r`NC3X`tX)sWgWUUOQ7w=$TO&*j;=u%25ay-%>3@81tGe^_z*C7pb9y*Ed^H3t$BIKH2o+olp#$q;)_
zfpjCb_^VFg5fU~K)nf*d*r@BCC>UZ!0&b?AGk_jTPXaSnCuW110wjHPPe^9R^;jo3
zwvzTl)C`Zl5}O2}3lec=hZ*$JnkW#7enKKc)(pM${_$9Hc=Sr_A9Biwe*Y=T?~1CK
z6eZ9uPICjy-sMGbZl$yQmpB&`ouS8v{58__t0$JP%i3R&%QR3ianbZqDs<2#5FdN@n5bCn^ZtH992~5k(eA|8|@G9u`wdn7bnpg|@{m
z^d6Y`*$Zf2Xr&|g%sai#5}Syvv(>Jnx&EM7-|Jr7!M~zdAyjt*xl;OLhvW-a%H1m0
z*x5*nb=R5u><7lyVpNAR?q@1U59
zO+)QWwL8t
zyip?u_nI+K$uh{y)~}qj?(w0&=SE^8`_WMM
zTybjG=999h38Yes7}-4*LJ7H)UE8{mE(6;8voE+TYY%33A>S6`G_95^5QHNTo_;Ao
ztIQIZ_}49%{8|=O;isBZ?=7kfdF8_@azfoTd+hEJKWE!)$)N%HIe2cplaK`ry#=pV
z0q{9w-`i0h@!R8K3GC{ivt{70IWG`EP|(1g7i_Q<>aEAT{5(yD
z=!O?kq61VegV+st@XCw475j6vS)_z@efuqQgHQR1T4;|-#OLZNQJPV4k$AX1Uk8Lm
z{N*b*ia=I+MB}kWpupJ~>!C@xEN#Wa7V+7{m4j8c?)ChV=D?o~sjT?0C_AQ7B-vxqX30s0I_`2$in86#`mAsT-w?j{&AL@B3$;P
z31G4(lV|b}uSDCIrjk+M1R!X7s4Aabn<)zpgT}#gE|mIvV38^ODy@<&yflpCwS#fRf9ZX3lPV_?8@C5)A;T
zqmouFLFk;qIs4rA=hh=GL~sCFsXHsqO6_y~*AFt939UYVBSx1s(=Kb&5;j7cSowdE;7()CC2|-i9Zz+_BIw8#ll~-tyH?F3{%`QCsYa*b#s*9iCc`1P1oC26?`g<9))EJ3%xz+O!B3
zZ7$j~To)C@PquR>a1+Dh>-a%IvH_Y7^ys|4o?E%3`I&ADXfC8++hAdZfzIT#%C+Jz
z1lU~K_vAm0m8Qk}K$F>|>RPK%<1SI0(G+8q~H
zAsjezyP+u!Se4q3GW)`h`NPSRlMoBjCzNPesWJwVTY!o@G8=(6I%4XHGaSiS3MEBK
zhgGFv6Jc>L$4jVE!I?TQuwvz_%CyO!bLh94nqK11C2W$*aa2ueGopG8DnBICVUORP
zgytv#)49fVXDaR$SukloYC3u7#5H)}1K21=?DKj^U)8G;MS)&Op)g^zR2($<>C*zW
z;X7`hLxiIO#J`ANdyAOJle4V%ppa*(+0i3w;8i*BA_;u8gOO6)MY`ueq7stBMJTB;
z-a0R>hT*}>z|Gg}@^zDL1MrH+2hsR8
zHc}*9IvuQC^Ju)^#Y{fOr(96rQNPNhxc;mH@W*m206>Lo<*SaaH?~8zg&f&%YiOEG
zGiz?*CP>Bci}!WiS=zj#K5I}>DtpregpP_tfZtPa(N<%vo^#WCQ5BTv0vr%Z{)0q+
z)RbfHktUm|lg&U3YM%lMUM(fu}i#kjX9h>GYctkx9Mt_8{@s%!K_EI
zScgwy6%_fR?CGJQtmgNAj^h9B#zmaMDWgH55pGuY1Gv7D
z;8Psm(vEPiwn#MgJYu4Ty9D|h!?Rj0ddE|&L3S{IP%H4^N!m`60ZwZw^;eg4sk6K{
ziA^`Sbl_4~f&Oo%n;8Ye(tiAdlZKI!Z=|j$5hS|D$bDJ}p{gh$KN&JZYLUjv4h{NY
zBJ>X9z!xfDGY
z+oh_Z&_e#Q(-}>ssZfm=j$D&4W4FNy&-kAO1~#3Im;F)Nwe{(*75(p=P^VI?X0GFakfh+X-px4a%Uw@fSbmp9hM1_~R>?Z8+
ziy|e9>8V*`OP}4x5JjdWp}7eX;lVxp5qS}0YZek;SNmm7tEeSF*-dI)6U-A%m6YvCgM(}_=k#a6o^%-K4{`B1+}O4x
zztDT%hVb;v#?j`lTvlFQ3aV#zkX=7;YFLS$uIzb0E3lozs5`Xy
zi~vF+%{z9uLjKvKPhP%x5f~7-Gj+%5N`%^=yk*Qn{`>
z;xj&ROY6g`iy2a@{O)V(jk&8#hHACVDXey5a+KDod_Z&}kHM}xt7}Md@pil{2x7E~
zL$k^d2@Ec2XskjrN+IILw;#7((abu;OJii&v3?60x>d_Ma(onIPtcVnX@ELF0aL?T
zSmWiL3(dOFkt!x=1O!_0n(cAzZW+3nHJ{2S>tgSK?~cFha^y(l@-Mr2W$%MN{#af8J;V*>hdq!gx=d0h$T7l}>91Wh07)9CTX
zh2_ZdQCyFOQ)l(}gft0UZG`Sh2`x-w`5vC2UD}lZs*5
zG76$akzn}Xi))L3oGJ75#pcN=cX3!=57$Ha=hQ2^lwdyU#a}4JJOz6ddR%zae%#4&
za)bFj)z=YQela(F#Y|Q#dp}PJghITwXouVaMq$BM?K%cXn9^Y@g43$=O)F&ZlOUom
zJiad#dea;-eywBA@e&D6Pdso1?2^(pXiN91?jvcaUyYoKUmvl5G9e$W!okWe*@a<^
z8cQQ6cNSf+UPDx%?_G4aIiybZHHagF{;IcD(dPO!#=u
zWfqLcPc^+7Uu#l(Bpxft{*4lv#*u7X9AOzDO
z1D9?^jIo}?%iz(_dwLa{ex#T}76ZfN_Z-hwpus9y+4xaUu9cX}&P{XrZVWE{1^0yw
zO;YhLEW!pJcbCt3L8~a7>jsaN{V3>tz6_7`&pi%GxZ=V3?3K^U+*ryLSb)8^IblJ0
zSRLNDvIxt)S}g30?s_3NX>F?NKIGrG_zB9@Z>uSW3k2es_H2kU;Rnn%j5qP)!XHKE
zPB2mHP~tLCg4K_vH$xv`HbRsJwbZMUV(t=ez;Ec(vyHH)FbfLg`c61I$W_uBB>i^r
z&{_P;369-&>23R%qNIULe=1~T$(DA`ev*EWZ6j(B$(te}x1WvmIll21zvygkS%vwG
zzkR6Z#RKA2!z!C%M!O>!=Gr0(J0FP=-MN=5t-Ir)of50y10W}j`GtRCsXBakrKtG&
zazmITDJMA0C51&BnLY)SY9r)NVTMs);1<=oosS9g31l{4ztjD3#+2H7u_|66b|_*O
z;Qk6nalpqdHOjx|K&vUS_6ITgGll;TdaN*ta=M_YtyC)I9Tmr~VaPrH2qb6sd~=AcIxV+%z{E&0@y=DPArw
zdV7z(G1hBx7hd{>(cr43^WF%4Y@PXZ?wPpj{OQ#tvc$pABJbvPGvdR`cAtHn)cSEV
zrpu}1tJwQ3y!mSmH*uz*x0o|CS<^w%&KJzsj~DU0cLQUxk5B!hWE>aBkjJle8z~;s
z-!A=($+}Jq_BTK5^B!`R>!MulZN)F=iXXeUd0w5lUsE5VP*H*oCy(;?S$p*TVvTxwAeWFB$jHyb0593)$zqalVlDX=GcCN1gU0
zlgU)I$LcXZ8Oyc2TZYTPu@-;7<4YYB-``Qa;IDcvydIA$%kHhJKV^m*-zxcvU4viy&Kr5GVM{IT>WRywKQ9;>SEiQD*NqplK-KK4YR`p0@JW)n_{TU3bt0
zim%;(m1=#v2}zTps=?fU5w^(*y)xT%1vtQH&}50ZF!9YxW=&7*W($2kgKyz1mUgfs
zfV<*XVVIFnohW=|j+@Kfo!#liQR^x>2yQdrG;2o8WZR+XzU_nG=Ed2rK?ntA;K5B{
z>M8+*A4!Jm^Bg}aW?R?6;@QG@uQ8&oJ{hFixcfEnJ4QH?A4>P=q29oDGW;L;=
z9-a0;g%c`C+Ai!UmK$NC*4#;Jp<1=TioL=t^YM)<<%u#hnnfSS`nq63QKGO1L8RzX
z@MFDqs1z
ztYmxDl@LU)5acvHk)~Z`RW7=aJ_nGD!mOSYD>5Odjn@TK#LY{jf?+piB5AM-CAoT_
z?S-*q7}wyLJzK>N%eMPuFgN)Q_otKP;aqy=D5f!7<=n(lNkYRXVpkB{TAYLYg{|(jtRqYmg$xH
zjmq?B(RE4
zQx^~Pt}gxC2~l=K$$-sYy_r$CO(d=+b3H1MB*y_5g6WLaWTXn+TKQ|hNY^>Mp6k*$
zwkovomhu776vQATqT4blf~g;TY(MWCrf^^yfWJvSAB$p5l;jm@o#=!lqw+Lqfq>X=
z$6~kxfm7`3q4zUEB;u4qa#BdJxO!;xGm)wwuisj{0y2x{R(IGMrsIzDY9LW>m!Y`=
z04sx3IjnYvL<4JqxQ8f7qYd0s2Ig%`ytYPEMKI)s(LD}D@EY>x`VFtqvnADNBdeao
zC96X+MxnwKmjpg{U&gP3HE}1=s!lv&D{6(g_lzyF3A`7Jn*&d_kL<;dAFx!UZ>hB8
z5A*%LsAn;VLp>3${0>M?PSQ)9s3}|h2e?TG4_F{}{Cs>#3Q*t$(CUc}M)I}8cPF6%
z=+h(Kh^8)}gj(0}#e7O^FQ6`~fd1#8#!}LMuo3A0bN`o}PYsm!Y}sdOz$+Tegc=qT
z8x`PH$7lvnhJp{kHWb22l;@7B7|4yL4UOOVM0MP_>P%S1Lnid)+k9{+3D+JFa#Pyf
zhVc#&df87APl4W9X)F3pGS>@etfl=_E5tBcVoOfrD4hmVeTY-cj((pkn%n@EgN{0f
zwb_^Rk0I#iZuHK!l*lN`ceJn(sI{$Fq6nN&
zE<-=0_2WN}m+*ivmIOxB@#~Q-cZ>l136w{#TIJe478`KE7@=a{>SzPHsKLzYAyBQO
zAtuuF$-JSDy_S@6GW0MOE~R)b;+0f%_NMrW(+V#c_d&U8Z9+ec4=HmOHw?gdjF(Lu
zzra83M_BoO-1b3;9`%&DHfuUY)6YDV21P$C!Rc?mv&{lx#f8oc6?0?x
zK08{WP65?#>(vPfA-c=MCY|%*1_<3D4NX
zeVTi-JGl2uP_2@0F{G({pxQOXt_d{g_CV6b?jNpfUG9;8yle-^4KHRvZs-_2siata
zt+d_T@U$&t*xaD22(fH(W1r$Mo?3dc%Tncm=C6{V9y{v&VT#^1L04vDrLM9qBoZ4@
z6DBN#m57hX7$C(=#$Y5$bJmwA$T8jKD8+6A!-IJwA{WOfs%s}yxUw^?MRZjF$n_KN
z6`_bGXcmE#5e4Ym)aQJ)xg3Pg0@k`iGuHe?f(5LtuzSq=nS^5z>vqU0EuZ&75V%Z{
zYyhRLN^)$c6Ds{f7*FBpE;n5iglx5PkHfWrj3`x^j^t
z7ntuV`g!9Xg#^3!x)l*}IW=(Tz3>Y5l4uGaB&lz{GDjm2D5S$CExLT`I1#n^lBH7Y
zDgpMag@`iETKAI=p<5E#LTkwzVR@=yY|uBVI1HG|8h+d;G-qfuj}-ZR6fN>EfCCW
z9~wRQoAPEa#aO?3h?x{YvV*d+NtPkf&4V0k4|L=uj!U{L+oLa(z#&iuhJr3-PjO3R
z5s?=nn_5^*^Rawr>>Nr@K(jwkB#JK-=+HqwfdO<+P5byeim)wvqGlP-P|~Nse8=XF
zz`?RYB|D6SwS}C+YQv+;}k6$-%D(@+t14BL@vM
z2q%q?f6D-A5s$_WY3{^G0F131bbh|g!}#BKw=HQ7mx;Dzg4Z*bTLQSfo{ed{4}NZW
zfrRm^Ca$rlE{Ue~uYv>R9{3smwATcdM_6+yWIO
z*ZRH~uXE@#p$XTbCt5j7j2=86e{9>HIB6xDzV+vAo&B?KUiMP|ttOElepnl%|DPqL
b{|{}U^kRn2wo}j7|0ATu<;8xA7zX}7|B6mN
literal 0
HcmV?d00001
diff --git a/vscode-extension/webview-ui/public/manifest.json b/vscode-extension/webview-ui/public/manifest.json
new file mode 100644
index 00000000..080d6c77
--- /dev/null
+++ b/vscode-extension/webview-ui/public/manifest.json
@@ -0,0 +1,25 @@
+{
+ "short_name": "React App",
+ "name": "Create React App Sample",
+ "icons": [
+ {
+ "src": "favicon.ico",
+ "sizes": "64x64 32x32 24x24 16x16",
+ "type": "image/x-icon"
+ },
+ {
+ "src": "logo192.png",
+ "type": "image/png",
+ "sizes": "192x192"
+ },
+ {
+ "src": "logo512.png",
+ "type": "image/png",
+ "sizes": "512x512"
+ }
+ ],
+ "start_url": ".",
+ "display": "standalone",
+ "theme_color": "#000000",
+ "background_color": "#ffffff"
+}
diff --git a/vscode-extension/webview-ui/public/robots.txt b/vscode-extension/webview-ui/public/robots.txt
new file mode 100644
index 00000000..e9e57dc4
--- /dev/null
+++ b/vscode-extension/webview-ui/public/robots.txt
@@ -0,0 +1,3 @@
+# https://www.robotstxt.org/robotstxt.html
+User-agent: *
+Disallow:
diff --git a/vscode-extension/webview-ui/scripts/build-react-no-split.js b/vscode-extension/webview-ui/scripts/build-react-no-split.js
new file mode 100644
index 00000000..25d064be
--- /dev/null
+++ b/vscode-extension/webview-ui/scripts/build-react-no-split.js
@@ -0,0 +1,31 @@
+#!/usr/bin/env node
+
+/**
+ * A script that overrides some of the create-react-app build script configurations
+ * in order to disable code splitting/chunking and rename the output build files so
+ * they have no hash. (Reference: https://mtm.dev/disable-code-splitting-create-react-app).
+ *
+ * This is crucial for getting React webview code to run because VS Code expects a
+ * single (consistently named) JavaScript and CSS file when configuring webviews.
+ */
+
+const rewire = require("rewire");
+const defaults = rewire("react-scripts/scripts/build.js");
+const config = defaults.__get__("config");
+
+// Disable code splitting
+config.optimization.splitChunks = {
+ cacheGroups: {
+ default: false,
+ },
+};
+
+// Disable code chunks
+config.optimization.runtimeChunk = false;
+
+// Rename main.{hash}.js to main.js
+config.output.filename = "static/js/[name].js";
+
+// Rename main.{hash}.css to main.css
+config.plugins[5].options.filename = "static/css/[name].css";
+config.plugins[5].options.moduleFilename = () => "static/css/main.css";
diff --git a/vscode-extension/webview-ui/src/App.tsx b/vscode-extension/webview-ui/src/App.tsx
new file mode 100644
index 00000000..9694881f
--- /dev/null
+++ b/vscode-extension/webview-ui/src/App.tsx
@@ -0,0 +1,45 @@
+import React from "react";
+import ShowSelectedContext from "./comp/SelectedContext";
+import ShowManagedClusters from "./comp/ManagedClusters";
+import ShowManifestWorks from "./comp/ManifestWorks";
+import ShowPlacements from "./comp/Placements";
+import ShowPlacementDecisions from "./comp/PlacementDecisions";
+import ShowManagedClusterSets from "./comp/ManagedClusterSets";
+import ShowManagedClusterAddons from "./comp/ManagedClusterAddons";
+import ShowClusterManagers from "./comp/ClusterManagers";
+import ShowSubscriptionReports from "./comp/SubscriptionReports";
+import ShowKlusterlets from "./comp/Klusterlets";
+import ShowAppliedManifestWorks from "./comp/AppliedManifestWorks";
+import ShowSubscriptionStatuses from "./comp/SubscriptionStatuses";
+export default class App extends React.Component {
+ render(): JSX.Element {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+ }
+}
diff --git a/vscode-extension/webview-ui/src/comp/AppliedManifestWorks.tsx b/vscode-extension/webview-ui/src/comp/AppliedManifestWorks.tsx
new file mode 100644
index 00000000..d8d426a0
--- /dev/null
+++ b/vscode-extension/webview-ui/src/comp/AppliedManifestWorks.tsx
@@ -0,0 +1,42 @@
+import { VSCodeDataGrid, VSCodeDataGridCell, VSCodeDataGridRow, } from '@vscode/webview-ui-toolkit/react';
+import { useState, useEffect } from 'react';
+import { OcmResource } from '../../../src/data/loader'
+
+export default function ShowAppliedManifestWorks() {
+ let [appliedManifestWorks, setAppliedManifestWorks] = useState([]);
+
+ useEffect(() => {
+ window.addEventListener("message", event => {
+ if ('crsDistribution' in event.data && 'AppliedManifestWork' === event.data.crsDistribution.kind) {
+ setAppliedManifestWorks(JSON.parse(event.data.crsDistribution.crs));
+ }
+ });
+ });
+
+ return (
+
+ { appliedManifestWorks.length > 0 &&
+ <>
+
})}
+
+ } )
+ }
+
+
+ >
+ }
+
+ );
+}
diff --git a/vscode-extension/webview-ui/src/index.tsx b/vscode-extension/webview-ui/src/index.tsx
new file mode 100644
index 00000000..b1ef1c04
--- /dev/null
+++ b/vscode-extension/webview-ui/src/index.tsx
@@ -0,0 +1,10 @@
+import React from "react";
+import ReactDOM from "react-dom";
+import App from "./App";
+
+ReactDOM.render(
+
+
+ ,
+ document.getElementById("root")
+);
diff --git a/vscode-extension/webview-ui/src/react-app-env.d.ts b/vscode-extension/webview-ui/src/react-app-env.d.ts
new file mode 100644
index 00000000..6431bc5f
--- /dev/null
+++ b/vscode-extension/webview-ui/src/react-app-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/vscode-extension/webview-ui/src/utilities/vscode.ts b/vscode-extension/webview-ui/src/utilities/vscode.ts
new file mode 100644
index 00000000..4101b889
--- /dev/null
+++ b/vscode-extension/webview-ui/src/utilities/vscode.ts
@@ -0,0 +1,83 @@
+import type { WebviewApi } from "vscode-webview";
+
+/**
+ * A utility wrapper around the acquireVsCodeApi() function, which enables
+ * message passing and state management between the webview and extension
+ * contexts.
+ *
+ * This utility also enables webview code to be run in a web browser-based
+ * dev server by using native web browser features that mock the functionality
+ * enabled by acquireVsCodeApi.
+ */
+class VSCodeAPIWrapper {
+ private readonly vsCodeApi: WebviewApi | undefined;
+
+ constructor() {
+ // Check if the acquireVsCodeApi function exists in the current development
+ // context (i.e. VS Code development window or web browser)
+ if (typeof acquireVsCodeApi === "function") {
+ this.vsCodeApi = acquireVsCodeApi();
+ }
+ }
+
+ /**
+ * Post a message (i.e. send arbitrary data) to the owner of the webview.
+ *
+ * @remarks When running webview code inside a web browser, postMessage will instead
+ * log the given message to the console.
+ *
+ * @param message Abitrary data (must be JSON serializable) to send to the extension context.
+ */
+ public postMessage(message: unknown) {
+ if (this.vsCodeApi) {
+ this.vsCodeApi.postMessage(message);
+ } else {
+ console.log(message);
+ }
+ }
+
+ public eventListener(message: unknown) {
+
+
+ }
+
+ /**
+ * Get the persistent state stored for this webview.
+ *
+ * @remarks When running webview source code inside a web browser, getState will retrieve state
+ * from local storage (https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage).
+ *
+ * @return The current state or `undefined` if no state has been set.
+ */
+ public getState(): unknown | undefined {
+ if (this.vsCodeApi) {
+ return this.vsCodeApi.getState();
+ } else {
+ const state = localStorage.getItem("vscodeState");
+ return state ? JSON.parse(state) : undefined;
+ }
+ }
+
+ /**
+ * Set the persistent state stored for this webview.
+ *
+ * @remarks When running webview source code inside a web browser, setState will set the given
+ * state using local storage (https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage).
+ *
+ * @param newState New persisted state. This must be a JSON serializable object. Can be retrieved
+ * using {@link getState}.
+ *
+ * @return The new state.
+ */
+ public setState(newState: T): T {
+ if (this.vsCodeApi) {
+ return this.vsCodeApi.setState(newState);
+ } else {
+ localStorage.setItem("vscodeState", JSON.stringify(newState));
+ return newState;
+ }
+ }
+}
+
+// Exports class singleton to prevent multiple invocations of acquireVsCodeApi.
+export const vscode = new VSCodeAPIWrapper();
diff --git a/vscode-extension/webview-ui/tsconfig.json b/vscode-extension/webview-ui/tsconfig.json
new file mode 100644
index 00000000..b0941958
--- /dev/null
+++ b/vscode-extension/webview-ui/tsconfig.json
@@ -0,0 +1,30 @@
+{
+ "compilerOptions": {
+ "target": "es5",
+ "lib": [
+ "dom",
+ "dom.iterable",
+ "esnext"
+ ],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "forceConsistentCasingInFileNames": true,
+ "noFallthroughCasesInSwitch": true,
+ "module": "esnext",
+ "moduleResolution": "node",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strictPropertyInitialization": false
+ },
+ "exclude": [
+ "*/node_modules"
+ ],
+ "include": [
+ "src"
+ ]
+}