diff --git a/.editorconfig b/.editorconfig
index e89330a61..a64129e51 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -5,6 +5,8 @@ root = true
charset = utf-8
indent_style = space
indent_size = 2
+tab_width = 2
+max_line_length = 150
insert_final_newline = true
trim_trailing_whitespace = true
diff --git a/.eslintrc.json b/.eslintrc.json
index 6454811c1..359933ab1 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -1,71 +1,91 @@
{
"root": true,
"ignorePatterns": [
- "projects/**/*",
- "electron/**/*"
+ "cli/gushio",
+ "cli/coverage/**/*",
+ "cli/dist/**/*",
+ "cli/tmp/**/*",
+ "core/coverage/**/*",
+ "core/dist/**/*",
+ "core/gushio",
+ "desktop-app/coverage/**/*",
+ "desktop-app/dist/**/*",
+ "desktop-app/electron/**/*",
+ "desktop-app/e2e/**/*",
+ "desktop-app/gushio",
+ "desktop-app/scripts/notarize.js",
+ "gushio/**/*"
],
"overrides": [
{
"files": [
"*.ts"
],
- "parserOptions": {
- "project": [
- "tsconfig.app.json",
- "e2e/tsconfig.json"
- ],
- "createDefaultProgram": true
- },
+ "parser": "@typescript-eslint/parser",
+ "plugins": ["prettier"],
"extends": [
+ "eslint:recommended",
+ "plugin:@typescript-eslint/recommended",
"plugin:@angular-eslint/ng-cli-compat",
"plugin:@angular-eslint/ng-cli-compat--formatting-add-on",
- "plugin:@angular-eslint/template/process-inline-templates"
+ "plugin:@angular-eslint/template/process-inline-templates",
+ "prettier"
],
"rules": {
- "@angular-eslint/component-selector": [
+ "prettier/prettier": "error",
+ "brace-style": ["error", "1tbs"],
+ "id-blacklist": ["error"],
+ "no-underscore-dangle": "off",
+ "no-case-declarations": "off",
+ "no-dupe-else-if": "warn",
+ "no-unused-vars": [
"error",
{
- "type": "element",
- "prefix": "app",
- "style": "kebab-case"
+ "argsIgnorePattern": "^_",
+ "varsIgnorePattern": "^_",
+ "caughtErrorsIgnorePattern": "^_"
}
],
- "@angular-eslint/directive-selector": [
- "error",
+ "@typescript-eslint/explicit-module-boundary-types": [
+ "warn",
{
- "type": "attribute",
- "prefix": "app",
- "style": "camelCase"
+ "allowArgumentsExplicitlyTypedAsAny": true
}
],
+ "@typescript-eslint/no-unused-vars": "off",
+ "@typescript-eslint/prefer-for-of": "off",
"@typescript-eslint/consistent-type-definitions": "error",
"@typescript-eslint/dot-notation": "off",
"@typescript-eslint/explicit-member-accessibility": [
- "off",
+ "warn",
{
- "accessibility": "explicit"
+ "accessibility": "explicit",
+ "overrides": {
+ "accessors": "off",
+ "constructors": "no-public",
+ "methods": "no-public",
+ "properties": "off",
+ "parameterProperties": "off"
+ }
}
],
"@typescript-eslint/no-use-before-define": "error",
- "@typescript-eslint/prefer-for-of": "off",
- "arrow-parens": [
- "off",
- "always"
- ],
- "brace-style": [
+ "@angular-eslint/component-selector": [
"error",
- "1tbs"
+ {
+ "type": "element",
+ "prefix": "app",
+ "style": "kebab-case"
+ }
],
- "id-blacklist": "error",
- "import/order": "off",
- "max-len": [
+ "@angular-eslint/directive-selector": [
"error",
{
- "code": 1024
+ "type": "attribute",
+ "prefix": "app",
+ "style": "camelCase"
}
- ],
- "no-trailing-spaces": "off",
- "no-underscore-dangle": "off"
+ ]
}
},
{
diff --git a/.gitignore b/.gitignore
index e8cbc92ed..6fd311728 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,30 +1,30 @@
# See http://help.github.com/ignore-files/ for more about ignoring files.
# compiled output
-/dist
-/tmp
-/out-tsc
-/release
+desktop-app/dist
+desktop-app/tmp
+desktop-app/out-tsc
+desktop-app/release
# Only exists if Bazel was run
-/bazel-out
+desktop-app/bazel-out
+desktop-app/.angular
# dependencies
-/node_modules
+**/node_modules
# profiling files
-chrome-profiler-events.json
-speed-measure-plugin.json
+**/chrome-profiler-events.json
+**/speed-measure-plugin.json
# IDEs and editors
/.idea
.project
.classpath
.c9/
-.cache/
-.python-version
*.launch
.settings/
*.sublime-workspace
+release/
# IDE - VSCode
.vscode/*
@@ -35,26 +35,25 @@ speed-measure-plugin.json
.history/*
# misc
-/.angular/cache
-/.sass-cache
-/connect.lock
-/coverage
-/libpeerconnection.log
-npm-debug.log
-yarn-error.log
-testem.log
-/typings
+**/.sass-cache
+**/connect.lock
+**/coverage
+**/libpeerconnection.log
+**/npm-debug.log
+**/yarn-error.log
+**/testem.log
+**/typings
# System Files
.DS_Store
Thumbs.db
# Windows certificate
-windows.pfx
+desktop-app/windows.pfx
-package-lock.json
+**/package-lock.json
-/electron/dist
+desktop-app/electron/dist
start.sh
buildAll.sh
@@ -62,3 +61,19 @@ buildAll.sh
#Mkdocs
site/
temp/
+
+core/package-lock.json
+core/node_modules
+core/dist
+
+cli/*-debug.log
+cli/*-error.log
+cli/.nyc_output
+cli/dist
+cli/lib
+cli/package-lock.json
+cli/tmp
+cli/yarn.lock
+cli/node_modules
+cli/oclif.manifest.json
+/release/
diff --git a/.husky/commit-msg b/.husky/commit-msg
index 617efbdae..7cd8dd9a4 100755
--- a/.husky/commit-msg
+++ b/.husky/commit-msg
@@ -1,4 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
-npx --no-install commitlint --edit
+npx --no-install commitlint --edit
diff --git a/.husky/pre-commit b/.husky/pre-commit
new file mode 100755
index 000000000..36af21989
--- /dev/null
+++ b/.husky/pre-commit
@@ -0,0 +1,4 @@
+#!/bin/sh
+. "$(dirname "$0")/_/husky.sh"
+
+npx lint-staged
diff --git a/.nvmrc b/.nvmrc
index d5d3b29a4..832d38506 100644
--- a/.nvmrc
+++ b/.nvmrc
@@ -1 +1 @@
-16.14.1
+16.14.0
diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 000000000..c48887db6
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1,4 @@
+{
+ "arrow-parens": ["error", "always"],
+ "trailing-spaces": "off"
+}
diff --git a/.python-version b/.python-version
new file mode 100644
index 000000000..7b59a5caa
--- /dev/null
+++ b/.python-version
@@ -0,0 +1 @@
+3.10.2
diff --git a/.run/Buil Core.run.xml b/.run/Buil Core.run.xml
new file mode 100644
index 000000000..f84d0cb35
--- /dev/null
+++ b/.run/Buil Core.run.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.run/Build Core.run.xml b/.run/Build Core.run.xml
new file mode 100644
index 000000000..97f5b0007
--- /dev/null
+++ b/.run/Build Core.run.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.run/Build-Run Desktop App.run.xml b/.run/Build-Run Desktop App.run.xml
new file mode 100644
index 000000000..436586ada
--- /dev/null
+++ b/.run/Build-Run Desktop App.run.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.run/CLI Tests.run.xml b/.run/CLI Tests.run.xml
new file mode 100644
index 000000000..099392dfb
--- /dev/null
+++ b/.run/CLI Tests.run.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.run/Compile CLI.run.xml b/.run/Compile CLI.run.xml
new file mode 100644
index 000000000..0491d889b
--- /dev/null
+++ b/.run/Compile CLI.run.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.run/Core Tests.run.xml b/.run/Core Tests.run.xml
new file mode 100644
index 000000000..016661de6
--- /dev/null
+++ b/.run/Core Tests.run.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md
index 4c763d51b..f0136e06f 100644
--- a/DEVELOPMENT.md
+++ b/DEVELOPMENT.md
@@ -1,17 +1,24 @@
# What should I know before I get started?
-
If you want to start a code contribution to Leapp, whether it is a bug fix or a new feature, it is important for you to understand Leapp concepts and way to work.
-In the documentation site you’ll find all the information you need. [Leapp documentation](http://docs.leapp.cloud) covers the following concepts and topics:
+Inside Leapp's documentation site you can find following concepts and topics:
- Sessions
- Integrations
- Security
- - Short-term credentials generation
- - System Vault for storing sensitive information (e.g. AWS IAM User access keys)
+ - Short-term credentials generation
+ - System Vault for storing sensitive information (e.g. AWS IAM User access keys)
- Built-in features
- - EC2 connect through AWS SSM
- - AWS Multi-profile management
+ - EC2 connect through AWS SSM
+ - AWS Multi-profile management
+
+These concepts are implemented in Leapp Core. Leapp Core is a library that decouples Leapp's domain logic from the Client that is going to use it.
+Leapp Core is delivered as [NPM package](https://www.npmjs.com/package/@noovolari/leapp-core), and each client depends on it.
+
+A Client can be rather a GUI or a TUI represented, respectively, by Leapp Desktop App and Leapp CLI.
+
+Leapp CLI requires the Desktop app to be installed and running.
+The Leapp CLI is delivered as an NPM package and can be installed globally, using the *npm* developer tool.
# Development environment setup
@@ -19,7 +26,7 @@ In the documentation site you’ll find all the information you need. [Leapp doc
Follow [this](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm) official guide to install both Node.js and NPM.
-The latest build was released using Node.js version 14.17.0 - as specified in the .nvmrc - and NPM version 8.3.0.
+The latest build was released using Node.js version 16.14.0 - as specified in the .nvmrc - and NPM version 8.5.5.
## NVM
@@ -55,23 +62,48 @@ If it is the first time you fork a repository from the GitHub console, please re
## Install dependencies
-Inside the project root folder, run
+At a first glance, you can see that Leapp consists of a monorepo structure that contains **Leapp Core**, **Leapp Desktop App**,
+and **Leapp CLI**.
+Each of these packages contain its _package.json_ and _tsconfig.json_ file. We will deepen how the project is structured in the
+_Project Structure_ section.
+
+Inside the project root folder, run
```bash
nvm use
```
-to set the Node.js version to the one specified in the .nvmrc; then, run
+to set the Node.js version to the one specified in the .nvmrc; then, from the root folder, run
```bash
npm install
```
-to install all the dependencies specified in the package.json file.
+to install all the dependencies specified in the root package.json file.
+
+At this point, you can setup the entire project running a [Gushio](https://github.com/Forge-Srl/gushio) script called _setup_ from the root package.json.
+
+> Gushio* is built on top of battle-tested libraries like commander and shelljs and allows you to write a multiplatform shell script in a single JavaScript file without having to worry about package.json and dependencies installation.
+
+Run the setup script using the following command:
+
+```bash
+npm run setup
+```
+
+The _setup_ script installs node_modules dependencies for each package, and builds Leapp Core and Leapp CLI.
+To build and run Leapp Desktop App in the development environment, there is a specific script - called _build-and-run-dev_ -
+available in Leapp Desktop App's package.json.
+
+To run the _build-and-run-dev_ script, use the following command:
+
+```bash
+npm run build-and-run-dev
+```
## System Vault
-Skip this section if you are not using a Linux system.
+Skip this section if you are not using a Linux system.
Leapp relies on the System Vault to save sensitive information. In Linux systems it relies on libsecret and gnome-keyring dependencies. To install them, follow [this](https://docs.leapp.cloud/latest/installation/requirements/) documentation page.
@@ -89,53 +121,68 @@ To install the AWS SSM agent locally, follow [this](https://docs.leapp.cloud/lat
# Project Structure
-Leapp is an application built using Electron and Angular. The first is used in order to generate executables for different OSs: macOS, Windows, and Linux distros. It serves as a wrapper for the Angular site which hosts the application logic, by serving it through a combination of [Chromium](https://www.chromium.org/Home/) and Node.js.
-
-If you are new to Electron, please refer to the official [documentation](https://www.electronjs.org/docs/latest).
-
-Angular is a front-end web development framework for creating efficient and sophisticated single-page apps via HTML, Typescript, and modern SCSS.
-
-If you are new to Angular, why not try the excellent **tour of heroes** [sample project](https://angular.io/tutorial) to get you started?
-
-After you got yourself acquainted with our development tools, let’s dig into our code structure.
-
-## Monorepo
-
-To facilitate and keep track of contributions, we will approach a monorepo architecture; it allows maintaining different projects under the same repository. The current repository is not yet organized in a monorepo fashion. It will be introduced with Leapp Core and CLI. The Core will contain the application logic; basically, it will act as a library on top of which clients, like the Desktop Application and the CLI, will run. In the monorepo scenario, Desktop Application, CLI, and Core will be three different projects under the same repository.
-
-## Electron project elements
-
-There is an **electron** folder generated by Electron at the root of the repository. It contains the **main.ts** file which drives the application setup and starts the executable by injecting the Angular application into the main BrowserWindow. This is created after the Angular project has been set up, cleaned, compressed, and distributed as a minimized site.
+Leapp project is structured as a monorepo and these are its packages:
-## Angular project elements
+| package | folder |
+|---------------|--------------|
+| Leapp Core | /core |
+| Leapp CLI | /cli |
+| Leapp Desktop App | /desktop-app |
-The Angular project is wrapped in the Electron one and implements the logic behind each Leapp concept. Let’s dive into the Angular project, from the UX/UI elements to the low level ones, i.e. Models and Services.
-### Modules
+To facilitate and keep track of contributions, we approached a monorepo architecture; it allows maintaining different projects under the same repository.
+The Core contains the application logic.
-Modules are elements in an Angular project that allows using different components that are defined in the same functional scope. In Leapp we have **3 modules**.
+It acts as a library on top of which clients will run.
+In the monorepo scenario, Desktop Application, CLI, and Core are three different projects under the same repository.
-- **app.module.ts**: contains all the **global libraries ad components.** Here you can put all the external libraries that you need.
-- **layout.module.ts**: is specific for the layout component, and contains only information that is used in the layout.component.ts file. It is called inside the app module.
-- **components.module.ts**: is the module responsible for holding all the components of the application. It is called inside the app module.
+## Core
-There is also one super simple **app.routing.module**, which contains only one route pointing to the layout which contains our **3 main components**: **sidebar**, **command-bar**, **and sessions**.
+As described in the introduction of this document, Leapp Core is a library that decouples Leapp's domain logic from the Client that is going to use it.
-### Components
+The core package consists of four main folders: _errors_, _interfaces_, _models_, _services_.
-Inside the Component folder, there are all the different components of the applications, which are composed of a UI file in the form of an HTML template, a SCSS file, that contains the style, and finally, 2 TypeScript files: .ts for the logic, and .spec.ts for the unit tests.
+### Errors
-Components represent core UI/UX functionalities. If you intend to define a new functionality that must have its UI counterpart, please insert the new component here.
+This folder contains errors that belong to the domain of Leapp.
+Here you can find a base implementation, i.e. the _LeappBaseError_ class; all the other errors are an implementation of
+_LeappBaseError_.
+
+```typescript
+export class LeappBaseError extends Error {
+ private readonly _context: any;
+ private readonly _severity: LoggerLevel;
+
+ constructor(name: string, context: any, severity: LoggerLevel, message?: string) {
+ super(message);
+ this.name = name;
+ this._context = context;
+ this._severity = severity;
+ Object.setPrototypeOf(this, new.target.prototype);
+ }
+
+ public get severity(): LoggerLevel {
+ return this._severity;
+ }
+
+ public get context(): any {
+ return this._context;
+ }
+}
+```
-There is also a dialogs folder that contains, for easiness, all the dialog components of Leapp.
+As you can see from the code quote above, the _LeappBaseError_ class extends the TypeScript _Error_ class and provides
+additional information: _name_, _context_, and _severity_.
-For us, it is best to create a new component every time we need a new dialog in the interface, just to keep things well separated and DRY.
+If you consider it useful, you can implement a new error that extends _LeappBaseError_ one.
### Models
-The Models folder contains TypeScript interfaces that represents the state of Leapp, that is persisted in Leapp’s configuration file, and other interfaces that needs to be centralized and used across different logic inside the Angular project.
+The Models folder contains TypeScript interfaces that represents the state of Leapp, that is persisted in Leapp’s configuration file,
+and other interfaces that needs to be centralized and used across different logic inside the Leapp Core package.
-For what concerns the state of the application, you’ll find a definition of all the supported Sessions and a Workspace object which represents the template of the configuration file.
+For what concerns the state of the application, you’ll find a definition of all the supported Sessions and a Workspace object
+which represents the template of the configuration file.
The Workspace includes:
@@ -199,33 +246,161 @@ There is a **three-level abstraction** implementation for this kind of service:
**Integrations**
-To understand this concept, let’s dive into what the AWS SSO feature does.
+To understand this concept, let’s dive into what the AWS SSO feature does.
-In Leapp you can work with Sessions that corresponds to AWS accounts that belong to one or more AWS Organizations. By configuring AWS SSO in the root account (or another dedicated account), you're able to manage access to all of the AWS Organization’s accounts.
+In Leapp you can work with Sessions that corresponds to AWS accounts that belong to one or more AWS Organizations. By configuring AWS SSO in the root account (or another dedicated account), you're able to manage access to all of the AWS Organization’s accounts.
AWS SSO configuration is bound to a specific region (e.g. eu-west-1, etc.) and portal URL. The last one corresponds to the endpoint used to log into AWS SSO. By logging into AWS SSO through the AWS SDK, you have access to a token that can be used to list all the accounts and roles that can be accessed by the user. AWS SSO API allows you to automatically generate temporary credentials to access accounts with a specific role. Once you’re done, you can log out from AWS SSO.
From this behaviour we extrapulated the concept of Integration that can be applied to other third-party services like - for example - Okta and OneLogin.
-The concept of Integration encapsulates the following behaviours:
+The concept of Integration encapsulates the behaviours described below.
-- login - logging into the Integration and get an access token to exploit its APIs;
-- sync - automatically provision all the accounts and roles that can be access by the user through the access token;
-- logout - logging out from the Integration.
+- syncSessions
+ - logs into the Integration and gets an access token to exploit its APIs
+ - automatically provisions all the accounts and roles that can be accessed by the user through the access token
+- logout
+ - logs out from the Integration
-### Errors
+## Desktop App
+
+Leapp Desktop App is an application built using Electron and Angular. The first is used in order to generate executables for different OSs: macOS, Windows, and Linux distros.
+It serves as a wrapper for the Angular site which hosts the application logic, by serving it through a combination of [Chromium](https://www.chromium.org/Home/) and Node.js.
+
+If you are new to Electron, please refer to the official [documentation](https://www.electronjs.org/docs/latest).
+
+Angular is a front-end web development framework for creating efficient and sophisticated single-page apps via HTML, Typescript, and modern SCSS.
+
+If you are new to Angular, why not try the excellent **tour of heroes** [sample project](https://angular.io/tutorial) to get you started?
+
+After you got yourself acquainted with our development tools, let’s dig into our code structure.
-These folder contains Leapp standard errors, from the less specific LeappBaseError to the more specific ones. The specific errors extend LeappBaseError, inheriting attributes like name, context, and severity.
+### Electron project elements
-### Environments
+There is an **electron** folder generated by Electron at the root of the repository. It contains the **main.ts** file which drives the application setup and starts the executable by injecting the Angular application into the main BrowserWindow. This is created after the Angular project has been set up, cleaned, compressed, and distributed as a minimized site.
+
+### Angular project elements
+
+The Angular project is wrapped in the Electron one and implements the logic behind each Leapp concept. Let’s dive into the Angular project, from the UX/UI elements to the low level ones, i.e. Models and Services.
-Here you’ll find project’s constants that will be merged soon into the Constants model to avoid misunderstandings. We’ll leave here only information related to the environment the application is running in, e.g. development or production.
+### Angular project elements: Modules
+
+Modules are elements in an Angular project that allows using different components that are defined in the same functional scope. In Leapp we have **3 modules**.
+
+- **app.module.ts**: contains all the **global libraries ad components.** Here you can put all the external libraries that you need.
+- **layout.module.ts**: is specific for the layout component, and contains only information that is used in the layout.component.ts file. It is called inside the app module.
+- **components.module.ts**: is the module responsible for holding all the components of the application. It is called inside the app module.
+
+There is also one super simple **app.routing.module**, which contains only one route pointing to the layout which contains our **3 main components**: **sidebar**, **command-bar**, **and sessions**.
+
+### Angular project elements: Components
+
+Inside the Component folder, there are all the different components of the applications, which are composed of a UI file in the form of an HTML template, a SCSS file, that contains the style, and finally, 2 TypeScript files: .ts for the logic, and .spec.ts for the unit tests.
+
+Components represent core UI/UX functionalities. If you intend to define a new functionality that must have its UI counterpart, please insert the new component here.
+
+There is also a dialogs folder that contains, for easiness, all the dialog components of Leapp.
+
+For us, it is best to create a new component every time we need a new dialog in the interface, just to keep things well separated and DRY.
+
+## CLI
+
+This package consists of a CLI based on Oclif, an open CLI framework.
+Please, refer to the [official Oclif docs](https://oclif.io/docs/introduction) to know how it works.
+
+We organized the CLI's _/src_ folder in **/commands** and **/services** sub-folders.
+
+### Commands folder
+
+Commands folder contains Leapp CLI's commands implementation. Each command takes part of a **scope**. As far as now, there are
+five scopes available:
+
+- ipd-url;
+- integration;
+- profile;
+- region;
+- session.
+
+Each command extends the **LeappCommand** class, that is an implementation of @oclif/core's **Command** class.
+
+We built the LeappCommand class to introduce some logic before the actual command is executed.
+For example, we added a logic that block the command execution if the Desktop App is not installed and running.
+
+To write a new command, from scratch, use the following command template and position it in the proper scope folder (or create a new one).
+
+```typescript
+import { LeappCommand } from "../../leapp-command";
+
+export default class HelloWorld extends LeappCommand {
+ static description = "hello world";
+ static examples = ["$leapp scope hello-world"];
+
+ constructor(argv: string[], config: Config) {
+ super(argv, config);
+ }
+
+ async run(): Promise {
+ // write here the command logic
+ }
+}
+```
+
+### Services folder
+
+This folder contains an implementation for each of the following Leapp Core interfaces:
+
+- INativeService;
+- IMfaCodePrompter;
+- IAwsSamlAuthenticationService;
+- IAwsSsoOidcVerificationWindowService;
+- IOpenExternalUrlService.
+
+Moreover, you can find the CliProviderService, i.e. a class that is responsible for caching and providing instances used
+by Leapp CLI's commands. For example, it caches and provides all the Leapp Core's services instances that are needed by
+Leapp CLI's commands.
# Build
-The package.json file contains all the build scripts, in addition to the dependencies and other project’s metadata. You can find this file in the root of the project.
+This section addresses local development, not releases.
+
+Remember that the root folder's package.json contains the _setup_ script, that can be used to setup all the packages,
+i.e. Leapp Core, Leapp CLI and Leapp Desktop App. This script does not build the Desktop App; you've to do it using
+the `npm run build-and-run-dev` command.
+
+When developing locally, remember to make the two Clients - Desktop App and CLI - depend on the local Leapp Core build.
+
+To do that, please run the following command from the root of the project:
+
+```bash
+npm run set-core-dependency-to-local
+```
+
+## /core
+
+In /core/package.json you can find the _build_ script that you can use to build Leapp Core. The output folder is
+placed under /core/dist.
+
+You can run it using the following command from the /core folder:
+
+```bash
+npm run build
+```
+
+## /cli
+
+In /core/package.json you can find the _prepack_ script that you can use to build Leapp CLI and generate the
+oclif.manifest.json file, which is needed to make Oclif aware of the commands available.
+
+You can run it using the following command from the /cli folder:
+
+```bash
+npm run prepack
+```
+
+## /desktop-app
-All the scripts are grouped under the “scripts” key. There you can find the “build-and-run-dev” script that you can use to build and run the Electron application locally.
+In /desktop-app/package.json you can find the _build-and-run-dev_ script that you can use to build and run the Electron
+application locally.
If Electron is failing building the native Library `Keytar` just run the following command, before `npm run build-and-run-dev`:
diff --git a/README.md b/README.md
index 4b3db0c3b..f5a627245 100644
--- a/README.md
+++ b/README.md
@@ -41,6 +41,7 @@ For more information about features go to [our documentation](https://docs.leapp
- **Automatic [short-lived credentials rotation](https://docs.leapp.cloud/latest/security/credentials-generation/aws/)**
- **Automatic provisioning of [Sessions](https://docs.leapp.cloud/latest/sessions/) from [AWS Single Sign-on](https://docs.leapp.cloud/latest/configuring-integration/configure-aws-single-sign-on-integration/)**
- **Connect to EC2 instances straight away**
+- **Managing Leapp with its [CLI](https://docs.leapp.cloud/latest/cli/)**
All the covered access methods can be found [here](https://docs.leapp.cloud/latest/configuration/).
@@ -59,6 +60,8 @@ Thank you for thinking about contributing to Leapp!
Read through our [contributing guidelines](CONTRIBUTING.md)
to learn how you can bring your value to our project by submitting your first contribution.
+Want to start developing with Leapp? [Check out our developing guidelines!](DEVELOPMENT.md)
+
You can report bugs or suggest features using the GitHub issues channel; moreover, you can pick
[a good first issue](https://github.com/noovolari/leapp/contribute) and make your first code contribution.
diff --git a/cli/README.md b/cli/README.md
new file mode 100644
index 000000000..0c8b40459
--- /dev/null
+++ b/cli/README.md
@@ -0,0 +1,566 @@
+Leapp CLI
+=================
+
+Leapp's Command Line Interface.
+
+It relies on Leapp Core, which encapsulates the domain logic.
+
+For more information about the project visit the [site](www.leapp.cloud).
+
+[![oclif](https://img.shields.io/badge/cli-oclif-brightgreen.svg)](https://oclif.io)
+[![Version](https://img.shields.io/npm/v/@noovolari/leapp-core.svg)](https://npmjs.org/package/@noovolari/leapp-cli)
+[![Downloads/week](https://img.shields.io/npm/dw/@noovolari/leapp-core.svg)](https://npmjs.org/package/@noovolari/leapp-cli)
+[![License](https://img.shields.io/npm/l/@noovolari/leapp-core.svg)](https://github.com/Noovolari/leapp/package.json)
+
+
+
+* [Usage](#usage)
+* [Commands](#commands)
+
+# Usage
+
+```sh-session
+$ npm install -g @noovolari/leapp-cli
+$ leapp COMMAND
+running command...
+$ leapp (--version)
+@noovolari/leapp-cli/0.1.3 darwin-x64 node-v16.14.0
+$ leapp --help [COMMAND]
+USAGE
+ $ leapp COMMAND
+...
+```
+
+# Commands
+
+* [`leapp help [COMMAND]`](#leapp-help-command)
+* [`leapp idp-url create`](#leapp-idp-url-create)
+* [`leapp idp-url delete`](#leapp-idp-url-delete)
+* [`leapp idp-url edit`](#leapp-idp-url-edit)
+* [`leapp idp-url list`](#leapp-idp-url-list)
+* [`leapp integration create`](#leapp-integration-create)
+* [`leapp integration delete`](#leapp-integration-delete)
+* [`leapp integration list`](#leapp-integration-list)
+* [`leapp integration login`](#leapp-integration-login)
+* [`leapp integration logout`](#leapp-integration-logout)
+* [`leapp integration sync`](#leapp-integration-sync)
+* [`leapp profile create`](#leapp-profile-create)
+* [`leapp profile delete`](#leapp-profile-delete)
+* [`leapp profile edit`](#leapp-profile-edit)
+* [`leapp profile list`](#leapp-profile-list)
+* [`leapp region get-default`](#leapp-region-get-default)
+* [`leapp region set-default`](#leapp-region-set-default)
+* [`leapp session add`](#leapp-session-add)
+* [`leapp session change-profile`](#leapp-session-change-profile)
+* [`leapp session change-region`](#leapp-session-change-region)
+* [`leapp session current`](#leapp-session-current)
+* [`leapp session delete`](#leapp-session-delete)
+* [`leapp session generate SESSIONID`](#leapp-session-generate-sessionid)
+* [`leapp session get-id`](#leapp-session-get-id)
+* [`leapp session list`](#leapp-session-list)
+* [`leapp session open-web-console`](#leapp-session-open-web-console)
+* [`leapp session start`](#leapp-session-start)
+* [`leapp session start-ssm-session`](#leapp-session-start-ssm-session)
+* [`leapp session stop`](#leapp-session-stop)
+
+## `leapp help [COMMAND]`
+
+Display help for leapp.
+
+```
+USAGE
+ $ leapp help [COMMAND] [-n]
+
+ARGUMENTS
+ COMMAND Command to show help for.
+
+FLAGS
+ -n, --nested-commands Include all nested commands in the output.
+
+DESCRIPTION
+ Display help for leapp.
+```
+
+_See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/v5.1.12/src/commands/help.ts)_
+
+## `leapp idp-url create`
+
+Create a new identity provider URL
+
+```
+USAGE
+ $ leapp idp-url create
+
+DESCRIPTION
+ Create a new identity provider URL
+
+EXAMPLES
+ $leapp idp-url create
+```
+
+## `leapp idp-url delete`
+
+Delete an identity provider URL
+
+```
+USAGE
+ $ leapp idp-url delete
+
+DESCRIPTION
+ Delete an identity provider URL
+
+EXAMPLES
+ $leapp idp-url delete
+```
+
+## `leapp idp-url edit`
+
+Edit an identity provider URL
+
+```
+USAGE
+ $ leapp idp-url edit
+
+DESCRIPTION
+ Edit an identity provider URL
+
+EXAMPLES
+ $leapp idp-url edit
+```
+
+## `leapp idp-url list`
+
+Show identity providers list
+
+```
+USAGE
+ $ leapp idp-url list [--columns | -x] [--sort ] [--filter ] [--output csv|json|yaml | |
+ [--csv | --no-truncate]] [--no-header | ]
+
+FLAGS
+ -x, --extended show extra columns
+ --columns= only show provided columns (comma-separated)
+ --csv output is csv format [alias: --output=csv]
+ --filter= filter property by partial string matching, ex: name=foo
+ --no-header hide table header from output
+ --no-truncate do not truncate output to fit screen
+ --output= output in a more machine friendly format
+
+ --sort= property to sort by (prepend '-' for descending)
+
+DESCRIPTION
+ Show identity providers list
+
+EXAMPLES
+ $leapp idp-url list
+```
+
+## `leapp integration create`
+
+Create a new AWS SSO integration
+
+```
+USAGE
+ $ leapp integration create
+
+DESCRIPTION
+ Create a new AWS SSO integration
+
+EXAMPLES
+ $leapp integration create
+```
+
+## `leapp integration delete`
+
+Delete an integration
+
+```
+USAGE
+ $ leapp integration delete
+
+DESCRIPTION
+ Delete an integration
+
+EXAMPLES
+ $leapp integration delete
+```
+
+## `leapp integration list`
+
+Show integrations list
+
+```
+USAGE
+ $ leapp integration list [--columns | -x] [--sort ] [--filter ] [--output csv|json|yaml | |
+ [--csv | --no-truncate]] [--no-header | ]
+
+FLAGS
+ -x, --extended show extra columns
+ --columns= only show provided columns (comma-separated)
+ --csv output is csv format [alias: --output=csv]
+ --filter= filter property by partial string matching, ex: name=foo
+ --no-header hide table header from output
+ --no-truncate do not truncate output to fit screen
+ --output= output in a more machine friendly format
+
+ --sort= property to sort by (prepend '-' for descending)
+
+DESCRIPTION
+ Show integrations list
+
+EXAMPLES
+ $leapp integration list
+```
+
+## `leapp integration login`
+
+Login to synchronize integration sessions
+
+```
+USAGE
+ $ leapp integration login
+
+DESCRIPTION
+ Login to synchronize integration sessions
+
+EXAMPLES
+ $leapp integration login
+```
+
+## `leapp integration logout`
+
+Logout from integration
+
+```
+USAGE
+ $ leapp integration logout
+
+DESCRIPTION
+ Logout from integration
+
+EXAMPLES
+ $leapp integration logout
+```
+
+## `leapp integration sync`
+
+Synchronize integration sessions
+
+```
+USAGE
+ $ leapp integration sync
+
+DESCRIPTION
+ Synchronize integration sessions
+
+EXAMPLES
+ $leapp integration sync
+```
+
+## `leapp profile create`
+
+Create a new AWS named profile
+
+```
+USAGE
+ $ leapp profile create
+
+DESCRIPTION
+ Create a new AWS named profile
+
+EXAMPLES
+ $leapp profile create
+```
+
+## `leapp profile delete`
+
+Delete an AWS named profile
+
+```
+USAGE
+ $ leapp profile delete
+
+DESCRIPTION
+ Delete an AWS named profile
+
+EXAMPLES
+ $leapp profile delete
+```
+
+## `leapp profile edit`
+
+Rename an AWS named profile
+
+```
+USAGE
+ $ leapp profile edit
+
+DESCRIPTION
+ Rename an AWS named profile
+
+EXAMPLES
+ $leapp profile edit
+```
+
+## `leapp profile list`
+
+Show profile list
+
+```
+USAGE
+ $ leapp profile list [--columns | -x] [--sort ] [--filter ] [--output csv|json|yaml | |
+ [--csv | --no-truncate]] [--no-header | ]
+
+FLAGS
+ -x, --extended show extra columns
+ --columns= only show provided columns (comma-separated)
+ --csv output is csv format [alias: --output=csv]
+ --filter= filter property by partial string matching, ex: name=foo
+ --no-header hide table header from output
+ --no-truncate do not truncate output to fit screen
+ --output= output in a more machine friendly format
+
+ --sort= property to sort by (prepend '-' for descending)
+
+DESCRIPTION
+ Show profile list
+
+EXAMPLES
+ $leapp profile list
+```
+
+## `leapp region get-default`
+
+Displays the default region
+
+```
+USAGE
+ $ leapp region get-default
+
+DESCRIPTION
+ Displays the default region
+
+EXAMPLES
+ $leapp region get-default
+```
+
+## `leapp region set-default`
+
+Change the default region
+
+```
+USAGE
+ $ leapp region set-default
+
+DESCRIPTION
+ Change the default region
+
+EXAMPLES
+ $leapp region set-default
+```
+
+## `leapp session add`
+
+Add a new session
+
+```
+USAGE
+ $ leapp session add
+
+DESCRIPTION
+ Add a new session
+
+EXAMPLES
+ $leapp session add
+```
+
+## `leapp session change-profile`
+
+Change a session named-profile
+
+```
+USAGE
+ $ leapp session change-profile
+
+DESCRIPTION
+ Change a session named-profile
+
+EXAMPLES
+ $leapp session change-profile
+```
+
+## `leapp session change-region`
+
+Change a session region
+
+```
+USAGE
+ $ leapp session change-region
+
+DESCRIPTION
+ Change a session region
+
+EXAMPLES
+ $leapp session change-region
+```
+
+## `leapp session current`
+
+Provides info about the current active session for a selected profile (if no profile is provided it uses default profile)
+
+```
+USAGE
+ $ leapp session current [-i] [-p ] [-r aws|azure] [-f ]
+
+FLAGS
+ -f, --format= allows filtering data to show
+ - aws -> alias, accountNumber, roleArn
+ - azure -> tenantId, subscriptionId
+ -i, --inline
+ -p, --profile= [default: default] aws named profile of which gets info
+ -r, --provider= filters sessions by the cloud provider service
+
+
+DESCRIPTION
+ Provides info about the current active session for a selected profile (if no profile is provided it uses default
+ profile)
+
+EXAMPLES
+ $leapp session current --format "alias accountNumber" --inline --provider aws
+```
+
+## `leapp session delete`
+
+Delete a session
+
+```
+USAGE
+ $ leapp session delete
+
+DESCRIPTION
+ Delete a session
+
+EXAMPLES
+ $leapp session delete
+```
+
+## `leapp session generate SESSIONID`
+
+Generate temporary credentials for the given AWS session id
+
+```
+USAGE
+ $ leapp session generate [SESSIONID]
+
+ARGUMENTS
+ SESSIONID id of the session
+
+DESCRIPTION
+ Generate temporary credentials for the given AWS session id
+
+EXAMPLES
+ $leapp session generate 0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d
+```
+
+## `leapp session get-id`
+
+Get session id
+
+```
+USAGE
+ $ leapp session get-id
+
+DESCRIPTION
+ Get session id
+
+EXAMPLES
+ $leapp session get_id
+```
+
+## `leapp session list`
+
+Show sessions list
+
+```
+USAGE
+ $ leapp session list [--columns | -x] [--sort ] [--filter ] [--output csv|json|yaml | |
+ [--csv | --no-truncate]] [--no-header | ]
+
+FLAGS
+ -x, --extended show extra columns
+ --columns= only show provided columns (comma-separated)
+ --csv output is csv format [alias: --output=csv]
+ --filter= filter property by partial string matching, ex: name=foo
+ --no-header hide table header from output
+ --no-truncate do not truncate output to fit screen
+ --output= output in a more machine friendly format
+
+ --sort= property to sort by (prepend '-' for descending)
+
+DESCRIPTION
+ Show sessions list
+
+EXAMPLES
+ $leapp session list
+```
+
+## `leapp session open-web-console`
+
+Open an AWS Web Console
+
+```
+USAGE
+ $ leapp session open-web-console
+
+DESCRIPTION
+ Open an AWS Web Console
+
+EXAMPLES
+ $leapp session open-web-console
+```
+
+## `leapp session start`
+
+Start a session
+
+```
+USAGE
+ $ leapp session start
+
+DESCRIPTION
+ Start a session
+
+EXAMPLES
+ $leapp session start
+```
+
+## `leapp session start-ssm-session`
+
+Start an AWS SSM session
+
+```
+USAGE
+ $ leapp session start-ssm-session
+
+DESCRIPTION
+ Start an AWS SSM session
+
+EXAMPLES
+ $leapp session start-ssm-session
+```
+
+## `leapp session stop`
+
+Stop a session
+
+```
+USAGE
+ $ leapp session stop
+
+DESCRIPTION
+ Stop a session
+
+EXAMPLES
+ $leapp session stop
+```
+
diff --git a/cli/babel.config.js b/cli/babel.config.js
new file mode 100644
index 000000000..1e62346b3
--- /dev/null
+++ b/cli/babel.config.js
@@ -0,0 +1,11 @@
+module.exports = {
+ presets: [
+ ['@babel/preset-env', {targets: {node: 'current'}}],
+ '@babel/preset-typescript',
+
+ ],
+ plugins: [
+ ["@babel/plugin-proposal-decorators", { "legacy": true }]
+ ]
+}
+
diff --git a/cli/bin/dev b/cli/bin/dev
new file mode 100755
index 000000000..bbc3f51d5
--- /dev/null
+++ b/cli/bin/dev
@@ -0,0 +1,17 @@
+#!/usr/bin/env node
+
+const oclif = require('@oclif/core')
+
+const path = require('path')
+const project = path.join(__dirname, '..', 'tsconfig.json')
+
+// In dev mode -> use ts-node and dev plugins
+process.env.NODE_ENV = 'development'
+
+require('ts-node').register({project})
+
+// In dev mode, always show stack traces
+oclif.settings.debug = true;
+
+// Start the CLI
+oclif.run().then(oclif.flush).catch(oclif.Errors.handle)
diff --git a/cli/bin/dev.cmd b/cli/bin/dev.cmd
new file mode 100644
index 000000000..077b57ae7
--- /dev/null
+++ b/cli/bin/dev.cmd
@@ -0,0 +1,3 @@
+@echo off
+
+node "%~dp0\dev" %*
\ No newline at end of file
diff --git a/cli/bin/run b/cli/bin/run
new file mode 100755
index 000000000..a7635de86
--- /dev/null
+++ b/cli/bin/run
@@ -0,0 +1,5 @@
+#!/usr/bin/env node
+
+const oclif = require('@oclif/core')
+
+oclif.run().then(require('@oclif/core/flush')).catch(require('@oclif/core/handle'))
diff --git a/cli/bin/run.cmd b/cli/bin/run.cmd
new file mode 100644
index 000000000..968fc3075
--- /dev/null
+++ b/cli/bin/run.cmd
@@ -0,0 +1,3 @@
+@echo off
+
+node "%~dp0\run" %*
diff --git a/cli/gushio/compile-func.js b/cli/gushio/compile-func.js
new file mode 100644
index 000000000..e40c342dd
--- /dev/null
+++ b/cli/gushio/compile-func.js
@@ -0,0 +1,7 @@
+module.exports = async function compileFunction(path, shellJs) {
+ shellJs.cd(path.join(__dirname, '..'))
+ const result = shellJs.exec('npx tsc')
+ if (result.code !== 0) {
+ throw new Error(result.stderr)
+ }
+}
diff --git a/cli/gushio/delete-func.js b/cli/gushio/delete-func.js
new file mode 100644
index 000000000..6bbc8199b
--- /dev/null
+++ b/cli/gushio/delete-func.js
@@ -0,0 +1,4 @@
+module.exports = async function deleteFunction(path, relativePath) {
+ let dirPath = path.join(__dirname, relativePath)
+ await fs.remove(dirPath)
+}
diff --git a/cli/gushio/target-build.js b/cli/gushio/target-build.js
new file mode 100644
index 000000000..9da96e2a8
--- /dev/null
+++ b/cli/gushio/target-build.js
@@ -0,0 +1,23 @@
+module.exports = {
+ cli: {
+ name: 'build',
+ description: 'Build the leapp CLI',
+ version: '0.1',
+ },
+ run: async () => {
+ const path = require('path')
+ const shellJs = require('shelljs')
+ const compileFunction = require('./compile-func')
+
+ try {
+ await gushio.run(path.join(__dirname, './target-clean.js'))
+
+ console.log('Building leapp CLI... ')
+ await compileFunction(path, shellJs)
+ console.log('Build completed successfully')
+ } catch (e) {
+ e.message = e.message.red
+ throw e
+ }
+ },
+}
diff --git a/scripts/gushio/target-clean.js b/cli/gushio/target-clean.js
similarity index 67%
rename from scripts/gushio/target-clean.js
rename to cli/gushio/target-clean.js
index 8a1701900..4c9e0d1d9 100644
--- a/scripts/gushio/target-clean.js
+++ b/cli/gushio/target-clean.js
@@ -2,7 +2,7 @@ module.exports = {
cli: {
name: 'clean',
description: 'Cleanup all files built previously',
- version: '0.1',
+ version: '0.1'
},
run: async () => {
const path = require('path')
@@ -10,11 +10,12 @@ module.exports = {
try {
console.log('Performing cleanup... ')
- await deleteFunction(path, '../../electron/dist')
- await deleteFunction(path, '../../dist')
+ await deleteFunction(path, '../dist')
+ await deleteFunction(path, '../coverage')
console.log('Cleanup completed successfully')
} catch (e) {
- console.error(e.message.red)
+ e.message = e.message.red
+ throw e
}
- },
+ }
}
diff --git a/cli/gushio/target-post-pack.js b/cli/gushio/target-post-pack.js
new file mode 100644
index 000000000..d2b847a02
--- /dev/null
+++ b/cli/gushio/target-post-pack.js
@@ -0,0 +1,20 @@
+module.exports = {
+ cli: {
+ name: 'clean',
+ description: 'Post pack actions',
+ version: '0.1'
+ },
+ run: async () => {
+ const path = require('path')
+ const deleteFunction = require('./delete-func')
+
+ try {
+ console.log('Performing post-pack actions... ')
+ await deleteFunction(path, '../oclif.manifest.json')
+ console.log('post-pack actions completed successfully')
+ } catch (e) {
+ e.message = e.message.red
+ throw e
+ }
+ }
+}
diff --git a/cli/gushio/target-release.js b/cli/gushio/target-release.js
new file mode 100644
index 000000000..2eb061a34
--- /dev/null
+++ b/cli/gushio/target-release.js
@@ -0,0 +1,26 @@
+module.exports = {
+ cli: {
+ name: 'release',
+ description: 'Release the leapp-cli tool on NPM',
+ version: '0.1',
+ },
+ run: async () => {
+ const path = require('path')
+ const shellJs = require('shelljs')
+
+ try {
+ console.log('Publishing leapp-cli tool... ')
+ await gushio.run(path.join(__dirname, './target-build.js'))
+
+ shellJs.cd(path.join(__dirname, '..'))
+ const result = shellJs.exec('npm publish --access public')
+ if (result.code !== 0) {
+ throw new Error(result.stderr)
+ }
+ console.log('leapp-cli published on npm successfully')
+ } catch (e) {
+ e.message = e.message.red
+ throw e
+ }
+ }
+}
diff --git a/cli/package.json b/cli/package.json
new file mode 100644
index 000000000..cdb67693a
--- /dev/null
+++ b/cli/package.json
@@ -0,0 +1,135 @@
+{
+ "name": "@noovolari/leapp-cli",
+ "description": "Leapp's Command Line Interface\n\nIt relies on Leapp Core, which encapsulates the domain logic.\n\nFor more information about the project visit the [site](www.leapp.cloud).",
+ "version": "0.1.3",
+ "author": "besharp ",
+ "bin": {
+ "leapp": "./bin/run"
+ },
+ "bugs": "https://github.com/Noovolari/leapp/issues",
+ "dependencies": {
+ "@babel/plugin-proposal-decorators": "^7.16.5",
+ "@noovolari/leapp-core": "file:../core/dist",
+ "@oclif/core": "1.6.0",
+ "@oclif/plugin-help": "^5.1.12",
+ "@types/inquirer": "^8.2.0",
+ "@types/node": "^16.9.4",
+ "@types/node-ipc": "9.2.0",
+ "@types/uuid": "^8.3.0",
+ "assert": "2.0.0",
+ "aws-sdk": "2.928.0",
+ "aws-sdk-mock": "5.3.0",
+ "chdir": "0.0.0",
+ "class-transformer": "^0.4.0",
+ "compare-versions": "^3.6.0",
+ "copy-dir": "~1.3.0",
+ "crypto-js": "~4.0.0",
+ "es6-shim": "^0.35.6",
+ "extract-zip": "~2.0.1",
+ "fix-path": "~3.0.0",
+ "follow-redirects": "^1.14.9",
+ "fs-extra": "~9.1.0",
+ "fs-web": "1.0.1",
+ "http-proxy-agent": "4.0.1",
+ "https-proxy-agent": "5.0.0",
+ "ini": "~2.0.0",
+ "inquirer": "^8.2.0",
+ "is-url": "^1.2.4",
+ "jwt-decode": "~3.1.2",
+ "keytar": "7.7.0",
+ "ms": "^2.1.3",
+ "node-fetch": "^2.6.7",
+ "node-ipc": "9.2.1",
+ "node-log-rotate": "~0.1.5",
+ "node-machine-id": "~1.1.12",
+ "oclif": "^2.6.0",
+ "open": "^8.4.0",
+ "ps-list": "^8.1.0",
+ "ps-node": "^0.1.6",
+ "reflect-metadata": "^0.1.13",
+ "rimraf": "~3.0.2",
+ "rxjs": "~6.6.7",
+ "saml-encoder-decoder-js": "~1.0.1",
+ "semver": "~7.3.5",
+ "standard-version": "^9.3.0",
+ "sudo-prompt": "~9.2.1",
+ "tslib": "^2.3.1",
+ "uuid": "~8.3.2",
+ "wait-on": "^6.0.0",
+ "zlib": "~1.0.5"
+ },
+ "devDependencies": {
+ "@babel/preset-env": "^7.16.5",
+ "@babel/preset-typescript": "^7.16.5",
+ "@commitlint/cli": "16.2.1",
+ "@commitlint/config-conventional": "16.2.1",
+ "@types/jest": "^27.4.0",
+ "eslint": "^7.32.0",
+ "eslint-config-oclif": "^4",
+ "eslint-config-oclif-typescript": "^1.0.2",
+ "globby": "^11",
+ "gushio": "~0.5.0",
+ "jest": "^27.4.5",
+ "puppeteer": "~13.2.0",
+ "ts-node": "^10.2.1",
+ "tslib": "^2.3.1",
+ "typescript": "4.5.5"
+ },
+ "jest": {
+ "testTimeout": 10000,
+ "collectCoverageFrom": [
+ "src/**/{!(cli-native-service),}.ts"
+ ]
+ },
+ "dirname": "oex",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "files": [
+ "/bin",
+ "/dist",
+ "/npm-shrinkwrap.json",
+ "/oclif.manifest.json"
+ ],
+ "homepage": "https://github.com/noovolari/leapp",
+ "keywords": [
+ "oclif"
+ ],
+ "main": "dist/index.js",
+ "oclif": {
+ "bin": "leapp",
+ "commands": "./dist/commands",
+ "plugins": [
+ "@oclif/plugin-help"
+ ],
+ "topicSeparator": " ",
+ "topics": {
+ "idp-url": {
+ "description": "SAML 2.0 Identity providers URL management"
+ },
+ "integration": {
+ "description": "Leapp Integrations management"
+ },
+ "profile": {
+ "description": "Leapp AWS Multi-profile management"
+ },
+ "region": {
+ "description": "Leapp regions management"
+ },
+ "session": {
+ "description": "Sessions management"
+ }
+ },
+ "macos": {
+ "identifier": "cloud.leapp"
+ }
+ },
+ "repository": "noovolari/leapp",
+ "scripts": {
+ "clean": "gushio gushio/target-clean.js",
+ "test": "jest",
+ "prepack": "gushio gushio/target-build.js && oclif manifest && oclif readme",
+ "release": "gushio gushio/target-release.js"
+ },
+ "types": "dist/index.d.ts"
+}
\ No newline at end of file
diff --git a/cli/src/commands/idp-url/create.spec.ts b/cli/src/commands/idp-url/create.spec.ts
new file mode 100644
index 000000000..790fdb1e8
--- /dev/null
+++ b/cli/src/commands/idp-url/create.spec.ts
@@ -0,0 +1,98 @@
+import { jest, describe, test, expect } from "@jest/globals";
+import CreateIdpUrl from "./create";
+
+describe("CreateIdpUrl", () => {
+ const getTestCommand = (cliProviderService: any = null, argv: string[] = []): CreateIdpUrl => {
+ const command = new CreateIdpUrl(argv, {} as any);
+ (command as any).cliProviderService = cliProviderService;
+ return command;
+ };
+
+ test("promptAndCreateIdpUrl", async () => {
+ const command = getTestCommand();
+ command.getIdpUrl = jest.fn(async () => "idpUrl");
+ command.createIdpUrl = jest.fn((): any => "newIdpUrl");
+
+ const newIdpUrl = await command.promptAndCreateIdpUrl();
+
+ expect(command.createIdpUrl).toHaveBeenCalledWith("idpUrl");
+ expect(newIdpUrl).toBe("newIdpUrl");
+ });
+
+ test("getIdpUrl", async () => {
+ const cliProviderService: any = {
+ inquirer: {
+ prompt: async (params: any) => {
+ expect(params).toMatchObject([
+ {
+ name: "idpUrl",
+ message: `enter the identity provider URL`,
+ type: "input",
+ },
+ ]);
+ expect(params[0].validate("url")).toBe("validationResult");
+ return { idpUrl: "idpUrl" };
+ },
+ },
+ idpUrlsService: {
+ validateIdpUrl: jest.fn(() => "validationResult"),
+ },
+ };
+
+ const command = getTestCommand(cliProviderService);
+ const idpUrl = await command.getIdpUrl();
+ expect(idpUrl).toBe("idpUrl");
+ expect(cliProviderService.idpUrlsService.validateIdpUrl).toHaveBeenCalledWith("url");
+ });
+
+ test("createIdpUrl", async () => {
+ const cliProviderService: any = {
+ idpUrlsService: {
+ createIdpUrl: jest.fn(() => "newIdpUrl"),
+ },
+ remoteProceduresClient: { refreshSessions: jest.fn() },
+ };
+
+ const command = getTestCommand(cliProviderService);
+ command.log = jest.fn();
+ const newIdpUrl = await command.createIdpUrl("idpUrl");
+
+ expect(cliProviderService.idpUrlsService.createIdpUrl).toHaveBeenCalledWith("idpUrl");
+ expect(command.log).toHaveBeenCalledWith("identity provider URL created");
+ expect(newIdpUrl).toBe("newIdpUrl");
+ expect(cliProviderService.remoteProceduresClient.refreshSessions).toHaveBeenCalled();
+ });
+
+ const runCommand = async (errorToThrow: any, expectedErrorMessage: string) => {
+ const command = getTestCommand();
+ command.promptAndCreateIdpUrl = jest.fn((): any => {
+ if (errorToThrow) {
+ throw errorToThrow;
+ }
+ });
+
+ let occurredError;
+ try {
+ await command.run();
+ } catch (error) {
+ occurredError = error;
+ }
+
+ expect(command.promptAndCreateIdpUrl).toHaveBeenCalled();
+ if (errorToThrow) {
+ expect(occurredError).toEqual(new Error(expectedErrorMessage));
+ }
+ };
+
+ test("run", async () => {
+ await runCommand(undefined, "");
+ });
+
+ test("run - promptAndCreateIdpUrl throws exception", async () => {
+ await runCommand(new Error("errorMessage"), "errorMessage");
+ });
+
+ test("run - promptAndCreateIdpUrl throws undefined object", async () => {
+ await runCommand({ hello: "randomObj" }, "Unknown error: [object Object]");
+ });
+});
diff --git a/cli/src/commands/idp-url/create.ts b/cli/src/commands/idp-url/create.ts
new file mode 100644
index 000000000..5c42f4fff
--- /dev/null
+++ b/cli/src/commands/idp-url/create.ts
@@ -0,0 +1,45 @@
+import { LeappCommand } from "../../leapp-command";
+import { Config } from "@oclif/core/lib/config/config";
+import { IdpUrl } from "@noovolari/leapp-core/models/idp-url";
+
+export default class CreateIdpUrl extends LeappCommand {
+ static description = "Create a new identity provider URL";
+
+ static examples = [`$leapp idp-url create`];
+
+ constructor(argv: string[], config: Config) {
+ super(argv, config);
+ }
+
+ async run(): Promise {
+ try {
+ await this.promptAndCreateIdpUrl();
+ } catch (error) {
+ this.error(error instanceof Error ? error.message : `Unknown error: ${error}`);
+ }
+ }
+
+ async promptAndCreateIdpUrl(): Promise {
+ const idpUrl = await this.getIdpUrl();
+ return await this.createIdpUrl(idpUrl);
+ }
+
+ async getIdpUrl(): Promise {
+ const answer: any = await this.cliProviderService.inquirer.prompt([
+ {
+ name: "idpUrl",
+ message: `enter the identity provider URL`,
+ validate: (url) => this.cliProviderService.idpUrlsService.validateIdpUrl(url),
+ type: "input",
+ },
+ ]);
+ return answer.idpUrl;
+ }
+
+ async createIdpUrl(idpUrl: string): Promise {
+ const newIdpUrl = this.cliProviderService.idpUrlsService.createIdpUrl(idpUrl);
+ await this.cliProviderService.remoteProceduresClient.refreshSessions();
+ this.log("identity provider URL created");
+ return newIdpUrl;
+ }
+}
diff --git a/cli/src/commands/idp-url/delete.spec.ts b/cli/src/commands/idp-url/delete.spec.ts
new file mode 100644
index 000000000..e76f6047f
--- /dev/null
+++ b/cli/src/commands/idp-url/delete.spec.ts
@@ -0,0 +1,152 @@
+import { jest, describe, test, expect } from "@jest/globals";
+import DeleteIdpUrl from "./delete";
+
+describe("DeleteIdpUrl", () => {
+ const getTestCommand = (cliProviderService: any = null, argv: string[] = []): DeleteIdpUrl => {
+ const command = new DeleteIdpUrl(argv, {} as any);
+ (command as any).cliProviderService = cliProviderService;
+ return command;
+ };
+
+ test("selectIdpUrl", async () => {
+ const ipdUrl = { url: "url1" };
+ const cliProviderService: any = {
+ idpUrlsService: {
+ getIdpUrls: jest.fn(() => [ipdUrl]),
+ },
+ inquirer: {
+ prompt: async (params: any) => {
+ expect(params).toEqual([
+ {
+ name: "selectedIdUrl",
+ message: "select an identity provider URL to delete",
+ type: "list",
+ choices: [{ name: ipdUrl.url, value: ipdUrl }],
+ },
+ ]);
+ return { selectedIdUrl: ipdUrl };
+ },
+ },
+ };
+
+ const command = getTestCommand(cliProviderService);
+ const selectedIdpUrl = await command.selectIdpUrl();
+
+ expect(cliProviderService.idpUrlsService.getIdpUrls).toHaveBeenCalled();
+ expect(selectedIdpUrl).toBe(ipdUrl);
+ });
+
+ test("selectIdpUrl, no idp urls", async () => {
+ const cliProviderService: any = {
+ idpUrlsService: {
+ getIdpUrls: jest.fn(() => []),
+ },
+ };
+ const command = getTestCommand(cliProviderService);
+
+ await expect(command.selectIdpUrl()).rejects.toThrow(new Error("no identity provider URLs available"));
+ });
+
+ test("getAffectedSessions", async () => {
+ const cliProviderService: any = {
+ idpUrlsService: {
+ getDependantSessions: jest.fn(() => "sessions"),
+ },
+ };
+ const command = getTestCommand(cliProviderService);
+
+ const sessions = command.getAffectedSessions("idpUrlId");
+ expect(sessions).toBe("sessions");
+ expect(cliProviderService.idpUrlsService.getDependantSessions).toHaveBeenCalledWith("idpUrlId");
+ });
+
+ test("askForConfirmation", async () => {
+ const cliProviderService: any = {
+ inquirer: {
+ prompt: async (params: any) => {
+ expect(params).toEqual([
+ {
+ name: "confirmation",
+ message:
+ "deleting this identity provider URL will delete also these sessions\n" + "- sess1\n" + "- sess2\n" + "Do you want to continue?",
+ type: "confirm",
+ },
+ ]);
+ return { confirmation: true };
+ },
+ },
+ };
+ const command = getTestCommand(cliProviderService);
+
+ const affectedSessions = [{ sessionName: "sess1" }, { sessionName: "sess2" }] as any;
+ const confirmation = await command.askForConfirmation(affectedSessions);
+
+ expect(confirmation).toBe(true);
+ });
+
+ test("askForConfirmation, no affected sessions", async () => {
+ const command = getTestCommand();
+
+ const confirmation = await command.askForConfirmation([]);
+ expect(confirmation).toBe(true);
+ });
+
+ test("deleteIdpUrl", async () => {
+ const cliProviderService: any = {
+ idpUrlsService: {
+ deleteIdpUrl: jest.fn(),
+ },
+ remoteProceduresClient: { refreshSessions: jest.fn() },
+ };
+
+ const command = getTestCommand(cliProviderService);
+ command.log = jest.fn();
+ await command.deleteIdpUrl("idpUrl");
+
+ expect(cliProviderService.idpUrlsService.deleteIdpUrl).toHaveBeenCalledWith("idpUrl");
+ expect(cliProviderService.remoteProceduresClient.refreshSessions).toHaveBeenCalled();
+ expect(command.log).toHaveBeenCalledWith("identity provider URL deleted");
+ });
+
+ const runCommand = async (errorToThrow: any, expectedErrorMessage: string) => {
+ const idpUrl = { id: "1" };
+ const affectedSessions = [{ sessionId: "2" }] as any;
+
+ const command = getTestCommand();
+ command.selectIdpUrl = jest.fn(async (): Promise => idpUrl);
+ command.getAffectedSessions = jest.fn(() => affectedSessions);
+ command.askForConfirmation = jest.fn(async (): Promise => true);
+ command.deleteIdpUrl = jest.fn(async () => {
+ if (errorToThrow) {
+ throw errorToThrow;
+ }
+ });
+
+ let occurredError;
+ try {
+ await command.run();
+ } catch (error) {
+ occurredError = error;
+ }
+
+ expect(command.selectIdpUrl).toHaveBeenCalled();
+ expect(command.getAffectedSessions).toHaveBeenCalledWith(idpUrl.id);
+ expect(command.askForConfirmation).toHaveBeenCalledWith(affectedSessions);
+ expect(command.deleteIdpUrl).toHaveBeenCalledWith(idpUrl.id);
+ if (errorToThrow) {
+ expect(occurredError).toEqual(new Error(expectedErrorMessage));
+ }
+ };
+
+ test("run", async () => {
+ await runCommand(undefined, "");
+ });
+
+ test("run - deleteIdpUrl throws exception", async () => {
+ await runCommand(new Error("errorMessage"), "errorMessage");
+ });
+
+ test("run - deleteIdpUrl throws undefined object", async () => {
+ await runCommand({ hello: "randomObj" }, "Unknown error: [object Object]");
+ });
+});
diff --git a/cli/src/commands/idp-url/delete.ts b/cli/src/commands/idp-url/delete.ts
new file mode 100644
index 000000000..856725866
--- /dev/null
+++ b/cli/src/commands/idp-url/delete.ts
@@ -0,0 +1,67 @@
+import { LeappCommand } from "../../leapp-command";
+import { Config } from "@oclif/core/lib/config/config";
+import { Session } from "@noovolari/leapp-core/models/session";
+import { IdpUrl } from "@noovolari/leapp-core/models/idp-url";
+
+export default class DeleteIdpUrl extends LeappCommand {
+ static description = "Delete an identity provider URL";
+
+ static examples = [`$leapp idp-url delete`];
+
+ constructor(argv: string[], config: Config) {
+ super(argv, config);
+ }
+
+ async run(): Promise {
+ try {
+ const selectedIdpUrl = await this.selectIdpUrl();
+ const affectedSessions = this.getAffectedSessions(selectedIdpUrl.id);
+ if (await this.askForConfirmation(affectedSessions)) {
+ await this.deleteIdpUrl(selectedIdpUrl.id);
+ }
+ } catch (error) {
+ this.error(error instanceof Error ? error.message : `Unknown error: ${error}`);
+ }
+ }
+
+ async selectIdpUrl(): Promise {
+ const idpUrls = this.cliProviderService.idpUrlsService.getIdpUrls();
+ if (idpUrls.length === 0) {
+ throw new Error("no identity provider URLs available");
+ }
+ const answer: any = await this.cliProviderService.inquirer.prompt([
+ {
+ name: "selectedIdUrl",
+ message: "select an identity provider URL to delete",
+ type: "list",
+ choices: idpUrls.map((idpUrl) => ({ name: idpUrl.url, value: idpUrl })),
+ },
+ ]);
+ return answer.selectedIdUrl;
+ }
+
+ getAffectedSessions(idpUrlId: string): Session[] {
+ return this.cliProviderService.idpUrlsService.getDependantSessions(idpUrlId);
+ }
+
+ async askForConfirmation(affectedSessions: Session[]): Promise {
+ if (affectedSessions.length === 0) {
+ return true;
+ }
+ const sessionsList = affectedSessions.map((session) => `- ${session.sessionName}`).join("\n");
+ const answer: any = await this.cliProviderService.inquirer.prompt([
+ {
+ name: "confirmation",
+ message: `deleting this identity provider URL will delete also these sessions\n${sessionsList}\nDo you want to continue?`,
+ type: "confirm",
+ },
+ ]);
+ return answer.confirmation;
+ }
+
+ async deleteIdpUrl(id: string): Promise {
+ await this.cliProviderService.idpUrlsService.deleteIdpUrl(id);
+ await this.cliProviderService.remoteProceduresClient.refreshSessions();
+ this.log("identity provider URL deleted");
+ }
+}
diff --git a/cli/src/commands/idp-url/edit.spec.ts b/cli/src/commands/idp-url/edit.spec.ts
new file mode 100644
index 000000000..9212c6325
--- /dev/null
+++ b/cli/src/commands/idp-url/edit.spec.ts
@@ -0,0 +1,132 @@
+import { jest, describe, test, expect } from "@jest/globals";
+import EditIdpUrl from "./edit";
+
+describe("EditIdpUrl", () => {
+ const getTestCommand = (cliProviderService: any = null, argv: string[] = []): EditIdpUrl => {
+ const command = new EditIdpUrl(argv, {} as any);
+ (command as any).cliProviderService = cliProviderService;
+ return command;
+ };
+
+ test("selectIdpUrl", async () => {
+ const idpUrl = { url: "url1" };
+ const cliProviderService: any = {
+ idpUrlsService: {
+ getIdpUrls: jest.fn(() => [idpUrl]),
+ },
+ inquirer: {
+ prompt: async (params: any) => {
+ expect(params).toEqual([
+ {
+ name: "selectedIdpUrl",
+ message: "select an identity provider URL",
+ type: "list",
+ choices: [{ name: idpUrl.url, value: idpUrl }],
+ },
+ ]);
+ return { selectedIdpUrl: idpUrl };
+ },
+ },
+ };
+
+ const command = getTestCommand(cliProviderService);
+ const selectedIdpUrl = await command.selectIdpUrl();
+
+ expect(cliProviderService.idpUrlsService.getIdpUrls).toHaveBeenCalled();
+ expect(selectedIdpUrl).toBe(idpUrl);
+ });
+
+ test("selectIdpUrl, no idp urls", async () => {
+ const cliProviderService: any = {
+ idpUrlsService: {
+ getIdpUrls: jest.fn(() => []),
+ },
+ };
+
+ const command = getTestCommand(cliProviderService);
+ await expect(command.selectIdpUrl()).rejects.toThrow(new Error("no identity provider URLs available"));
+ });
+
+ test("getNewIdpUrl", async () => {
+ const cliProviderService: any = {
+ inquirer: {
+ prompt: async (params: any) => {
+ expect(params).toMatchObject([
+ {
+ name: "newIdpUrl",
+ message: "choose a new URL",
+ type: "input",
+ },
+ ]);
+ expect(params[0].validate("url")).toBe("validationResult");
+ return { newIdpUrl: "idpUrl" };
+ },
+ },
+ idpUrlsService: {
+ validateIdpUrl: jest.fn(() => "validationResult"),
+ },
+ };
+
+ const command = getTestCommand(cliProviderService);
+ const idpUrl = await command.getNewIdpUrl();
+ expect(idpUrl).toBe("idpUrl");
+ expect(cliProviderService.idpUrlsService.validateIdpUrl).toHaveBeenCalledWith("url");
+ });
+
+ test("editIdpUrl", async () => {
+ const cliProviderService: any = {
+ idpUrlsService: {
+ editIdpUrl: jest.fn(),
+ },
+ remoteProceduresClient: { refreshSessions: jest.fn() },
+ };
+
+ const command = getTestCommand(cliProviderService);
+ command.log = jest.fn();
+ await command.editIdpUrl("idpUrlId", "url");
+
+ expect(cliProviderService.idpUrlsService.editIdpUrl).toHaveBeenCalledWith("idpUrlId", "url");
+ expect(command.log).toHaveBeenCalledWith("IdP URL edited");
+ expect(cliProviderService.remoteProceduresClient.refreshSessions).toHaveBeenCalled();
+ });
+
+ const runCommand = async (errorToThrow: any, expectedErrorMessage: string) => {
+ const idpUrl = { id: "1" };
+ const newUrl = "newName";
+
+ const command = getTestCommand();
+ command.selectIdpUrl = jest.fn(async (): Promise => idpUrl);
+ command.getNewIdpUrl = jest.fn(async (): Promise => newUrl);
+ command.editIdpUrl = jest.fn(async () => {
+ if (errorToThrow) {
+ throw errorToThrow;
+ }
+ });
+
+ let occurredError;
+ try {
+ await command.run();
+ } catch (error) {
+ occurredError = error;
+ }
+
+ expect(command.selectIdpUrl).toHaveBeenCalled();
+ expect(command.getNewIdpUrl).toHaveBeenCalled();
+ expect(command.editIdpUrl).toHaveBeenCalledWith(idpUrl.id, newUrl);
+ if (errorToThrow) {
+ expect(occurredError).toEqual(new Error(expectedErrorMessage));
+ }
+ };
+
+ test("run", async () => {
+ await runCommand(undefined, "");
+ });
+
+ test("run - editIdpUrl throws exception", async () => {
+ await runCommand(new Error("errorMessage"), "errorMessage");
+ });
+
+ test("run - editIdpUrl throws undefined object", async () => {
+ await runCommand({ hello: "randomObj" }, "Unknown error: [object Object]");
+ });
+});
diff --git a/cli/src/commands/idp-url/edit.ts b/cli/src/commands/idp-url/edit.ts
new file mode 100644
index 000000000..2425c5424
--- /dev/null
+++ b/cli/src/commands/idp-url/edit.ts
@@ -0,0 +1,57 @@
+import { LeappCommand } from "../../leapp-command";
+import { Config } from "@oclif/core/lib/config/config";
+import { IdpUrl } from "@noovolari/leapp-core/models/idp-url";
+
+export default class EditIdpUrl extends LeappCommand {
+ static description = "Edit an identity provider URL";
+
+ static examples = [`$leapp idp-url edit`];
+
+ constructor(argv: string[], config: Config) {
+ super(argv, config);
+ }
+
+ async run(): Promise {
+ try {
+ const selectedIdpUrl = await this.selectIdpUrl();
+ const newIdpUrl = await this.getNewIdpUrl();
+ await this.editIdpUrl(selectedIdpUrl.id, newIdpUrl);
+ } catch (error) {
+ this.error(error instanceof Error ? error.message : `Unknown error: ${error}`);
+ }
+ }
+
+ async selectIdpUrl(): Promise {
+ const idpUrls = this.cliProviderService.idpUrlsService.getIdpUrls();
+ if (idpUrls.length === 0) {
+ throw new Error("no identity provider URLs available");
+ }
+ const answer: any = await this.cliProviderService.inquirer.prompt([
+ {
+ name: "selectedIdpUrl",
+ message: "select an identity provider URL",
+ type: "list",
+ choices: idpUrls.map((idpUrl) => ({ name: idpUrl.url, value: idpUrl })),
+ },
+ ]);
+ return answer.selectedIdpUrl;
+ }
+
+ async getNewIdpUrl(): Promise {
+ const answer: any = await this.cliProviderService.inquirer.prompt([
+ {
+ name: "newIdpUrl",
+ message: "choose a new URL",
+ validate: (url) => this.cliProviderService.idpUrlsService.validateIdpUrl(url),
+ type: "input",
+ },
+ ]);
+ return answer.newIdpUrl;
+ }
+
+ async editIdpUrl(id: string, newIdpUrl: string): Promise {
+ await this.cliProviderService.idpUrlsService.editIdpUrl(id, newIdpUrl);
+ await this.cliProviderService.remoteProceduresClient.refreshSessions();
+ this.log("IdP URL edited");
+ }
+}
diff --git a/cli/src/commands/idp-url/list.spec.ts b/cli/src/commands/idp-url/list.spec.ts
new file mode 100644
index 000000000..11b778f26
--- /dev/null
+++ b/cli/src/commands/idp-url/list.spec.ts
@@ -0,0 +1,75 @@
+import { CliUx } from "@oclif/core";
+import { describe, expect, jest, test } from "@jest/globals";
+import ListIdpUrls from "./list";
+
+describe("ListIdpUrls", () => {
+ const getTestCommand = (cliProviderService: any = null, argv: string[] = []): ListIdpUrls => {
+ const command = new ListIdpUrls(argv, {} as any);
+ (command as any).cliProviderService = cliProviderService;
+ return command;
+ };
+
+ test("run", async () => {
+ const command = getTestCommand();
+ command.showIdpUrls = jest.fn();
+ await command.run();
+
+ expect(command.showIdpUrls).toHaveBeenCalled();
+ });
+
+ test("run - showIdpUrls throw an error", async () => {
+ const command = getTestCommand();
+ command.showIdpUrls = jest.fn(async () => {
+ throw new Error("error");
+ });
+ try {
+ await command.run();
+ } catch (error) {
+ expect(error).toEqual(new Error("error"));
+ }
+ });
+
+ test("run - showIdpUrls throw an object", async () => {
+ const command = getTestCommand();
+ const errorToThrow = "string";
+ command.showIdpUrls = jest.fn(async () => {
+ throw errorToThrow;
+ });
+ try {
+ await command.run();
+ } catch (error) {
+ expect(error).toEqual(new Error("Unknown error: string"));
+ }
+ });
+
+ test("showIdpUrls", async () => {
+ const idpUrls = [
+ {
+ url: "idpUrlsName",
+ },
+ ];
+ const cliProviderService = {
+ idpUrlsService: {
+ getIdpUrls: () => idpUrls,
+ },
+ };
+
+ const command = getTestCommand(cliProviderService);
+ const tableSpy = jest.spyOn(CliUx.ux, "table").mockImplementation(() => null);
+
+ await command.showIdpUrls();
+
+ const expectedData = [
+ {
+ url: "idpUrlsName",
+ },
+ ];
+
+ expect(tableSpy.mock.calls[0][0]).toEqual(expectedData);
+
+ const expectedColumns = {
+ url: { header: "Identity Provider URL" },
+ };
+ expect(tableSpy.mock.calls[0][1]).toEqual(expectedColumns);
+ });
+});
diff --git a/cli/src/commands/idp-url/list.ts b/cli/src/commands/idp-url/list.ts
new file mode 100644
index 000000000..ac06d347f
--- /dev/null
+++ b/cli/src/commands/idp-url/list.ts
@@ -0,0 +1,37 @@
+import { CliUx } from "@oclif/core";
+import { Config } from "@oclif/core/lib/config/config";
+import { LeappCommand } from "../../leapp-command";
+
+export default class ListIdpUrls extends LeappCommand {
+ static description = "Show identity providers list";
+ static examples = ["$leapp idp-url list"];
+
+ static flags = {
+ ...CliUx.ux.table.flags(),
+ };
+
+ constructor(argv: string[], config: Config) {
+ super(argv, config);
+ }
+
+ async run(): Promise {
+ try {
+ await this.showIdpUrls();
+ } catch (error) {
+ this.error(error instanceof Error ? error.message : `Unknown error: ${error}`);
+ }
+ }
+
+ async showIdpUrls(): Promise {
+ const { flags } = await this.parse(ListIdpUrls);
+ const data = this.cliProviderService.idpUrlsService.getIdpUrls().map((idpUrl: any) => ({
+ url: idpUrl.url,
+ })) as any as Record[];
+
+ const columns = {
+ url: { header: "Identity Provider URL" },
+ };
+
+ CliUx.ux.table(data, columns, { ...flags });
+ }
+}
diff --git a/cli/src/commands/integration/create.spec.ts b/cli/src/commands/integration/create.spec.ts
new file mode 100644
index 000000000..a11821967
--- /dev/null
+++ b/cli/src/commands/integration/create.spec.ts
@@ -0,0 +1,142 @@
+import { jest, describe, test, expect } from "@jest/globals";
+import CreateSsoIntegration from "./create";
+import { AwsSsoIntegrationService, IntegrationCreationParams } from "@noovolari/leapp-core/services/aws-sso-integration-service";
+import { constants } from "@noovolari/leapp-core/models/constants";
+import { SessionType } from "@noovolari/leapp-core/models/session-type";
+
+describe("CreateSsoIntegration", () => {
+ const getTestCommand = (cliProviderService: any = null, argv: string[] = []): CreateSsoIntegration => {
+ const command = new CreateSsoIntegration(argv, {} as any);
+ (command as any).cliProviderService = cliProviderService;
+ return command;
+ };
+
+ test("askConfigurationParameters", async () => {
+ const cliProviderService = {
+ inquirer: {
+ prompt: jest.fn(async (questions) => {
+ if (questions[0].name === "selectedAlias") {
+ return { selectedAlias: "alias" };
+ } else if (questions[0].name === "selectedPortalUrl") {
+ return { selectedPortalUrl: "portalUrl" };
+ } else if (questions[0].name === "selectedRegion") {
+ return { selectedRegion: "region" };
+ }
+ }),
+ },
+ cloudProviderService: {
+ availableRegions: jest.fn(() => [
+ {
+ fieldName: "nameRegion1",
+ fieldValue: "valueRegion1",
+ },
+ ]),
+ },
+ } as any;
+ const command = getTestCommand(cliProviderService);
+ const actualCreationParams = await command.askConfigurationParameters();
+
+ expect(cliProviderService.inquirer.prompt).toHaveBeenNthCalledWith(1, [
+ {
+ name: "selectedAlias",
+ message: "Insert an alias",
+ validate: AwsSsoIntegrationService.validateAlias,
+ type: "input",
+ },
+ ]);
+
+ expect(cliProviderService.inquirer.prompt).toHaveBeenNthCalledWith(2, [
+ {
+ name: "selectedPortalUrl",
+ message: "Insert a portal URL",
+ validate: AwsSsoIntegrationService.validatePortalUrl,
+ type: "input",
+ },
+ ]);
+
+ expect(cliProviderService.inquirer.prompt).toHaveBeenNthCalledWith(3, [
+ {
+ name: "selectedRegion",
+ message: "Select a region",
+ type: "list",
+ choices: [
+ {
+ name: "nameRegion1",
+ value: "valueRegion1",
+ },
+ ],
+ },
+ ]);
+
+ expect(cliProviderService.inquirer.prompt).toHaveBeenCalledTimes(3);
+ expect(actualCreationParams).toEqual({
+ browserOpening: constants.inBrowser,
+ alias: "alias",
+ portalUrl: "portalUrl",
+ region: "region",
+ });
+ expect(cliProviderService.cloudProviderService.availableRegions).toHaveBeenCalledWith(SessionType.aws);
+ });
+
+ const runCommand = async (errorToThrow: any, expectedErrorMessage: string) => {
+ const configurationParams = { param1: "param1" };
+
+ const command = getTestCommand();
+ command.askConfigurationParameters = jest.fn(async (): Promise => configurationParams);
+ command.createIntegration = jest.fn(async () => {
+ if (errorToThrow) {
+ throw errorToThrow;
+ }
+ });
+
+ let occurredError;
+ try {
+ await command.run();
+ } catch (error) {
+ occurredError = error;
+ }
+
+ expect(command.askConfigurationParameters).toHaveBeenCalled();
+ expect(command.createIntegration).toHaveBeenCalledWith(configurationParams);
+ if (errorToThrow) {
+ expect(occurredError).toEqual(new Error(expectedErrorMessage));
+ }
+ };
+
+ test("run", async () => {
+ await runCommand(undefined, "");
+ });
+
+ test("run - createIntegration throws exception", async () => {
+ await runCommand(new Error("errorMessage"), "errorMessage");
+ });
+
+ test("run - createIntegration throws undefined object", async () => {
+ await runCommand({ hello: "randomObj" }, "Unknown error: [object Object]");
+ });
+
+ test("createIntegration", async () => {
+ const cliProviderService = {
+ awsSsoIntegrationService: {
+ createIntegration: jest.fn(),
+ },
+ remoteProceduresClient: {
+ refreshIntegrations: jest.fn(),
+ },
+ } as any;
+
+ const command = getTestCommand(cliProviderService);
+ command.log = jest.fn();
+ const creationParam: IntegrationCreationParams = {
+ alias: "alias",
+ portalUrl: "portalUrl",
+ region: "region",
+ browserOpening: "browserOpening",
+ };
+ await command.createIntegration(creationParam);
+
+ expect(cliProviderService.awsSsoIntegrationService.createIntegration).toBeCalledWith(creationParam);
+ expect(command.log).toHaveBeenCalledWith("aws sso integration created");
+ expect(cliProviderService.remoteProceduresClient.refreshIntegrations).toHaveBeenCalled();
+ });
+});
diff --git a/cli/src/commands/integration/create.ts b/cli/src/commands/integration/create.ts
new file mode 100644
index 000000000..5de46c617
--- /dev/null
+++ b/cli/src/commands/integration/create.ts
@@ -0,0 +1,65 @@
+import { LeappCommand } from "../../leapp-command";
+import { Config } from "@oclif/core/lib/config/config";
+import { SessionType } from "@noovolari/leapp-core/models/session-type";
+import { constants } from "@noovolari/leapp-core/models/constants";
+import { AwsSsoIntegrationService, IntegrationCreationParams } from "@noovolari/leapp-core/services/aws-sso-integration-service";
+
+export default class CreateSsoIntegration extends LeappCommand {
+ static description = "Create a new AWS SSO integration";
+ static examples = ["$leapp integration create"];
+
+ constructor(argv: string[], config: Config) {
+ super(argv, config);
+ }
+
+ async run(): Promise {
+ try {
+ const creationParams = await this.askConfigurationParameters();
+ await this.createIntegration(creationParams);
+ } catch (error) {
+ this.error(error instanceof Error ? error.message : `Unknown error: ${error}`);
+ }
+ }
+
+ async askConfigurationParameters(): Promise {
+ const creationParams = { browserOpening: constants.inBrowser } as IntegrationCreationParams;
+ const aliasAnswer: any = await this.cliProviderService.inquirer.prompt([
+ {
+ name: "selectedAlias",
+ message: "Insert an alias",
+ validate: AwsSsoIntegrationService.validateAlias,
+ type: "input",
+ },
+ ]);
+ creationParams.alias = aliasAnswer.selectedAlias;
+
+ const portalUrlAnswer: any = await this.cliProviderService.inquirer.prompt([
+ {
+ name: "selectedPortalUrl",
+ message: "Insert a portal URL",
+ validate: AwsSsoIntegrationService.validatePortalUrl,
+ type: "input",
+ },
+ ]);
+ creationParams.portalUrl = portalUrlAnswer.selectedPortalUrl;
+
+ const awsRegions = this.cliProviderService.cloudProviderService.availableRegions(SessionType.aws);
+ const regionAnswer = await this.cliProviderService.inquirer.prompt([
+ {
+ name: "selectedRegion",
+ message: "Select a region",
+ type: "list",
+ choices: awsRegions.map((region) => ({ name: region.fieldName, value: region.fieldValue })),
+ },
+ ]);
+ creationParams.region = regionAnswer.selectedRegion;
+
+ return creationParams;
+ }
+
+ async createIntegration(creationParams: IntegrationCreationParams): Promise {
+ await this.cliProviderService.awsSsoIntegrationService.createIntegration(creationParams);
+ await this.cliProviderService.remoteProceduresClient.refreshIntegrations();
+ this.log("aws sso integration created");
+ }
+}
diff --git a/cli/src/commands/integration/delete.spec.ts b/cli/src/commands/integration/delete.spec.ts
new file mode 100644
index 000000000..3309e0568
--- /dev/null
+++ b/cli/src/commands/integration/delete.spec.ts
@@ -0,0 +1,109 @@
+import { jest, describe, test, expect } from "@jest/globals";
+import DeleteIntegration from "./delete";
+
+describe("DeleteIntegration", () => {
+ const getTestCommand = (cliProviderService: any = null, argv: string[] = []): DeleteIntegration => {
+ const command = new DeleteIntegration(argv, {} as any);
+ (command as any).cliProviderService = cliProviderService;
+ return command;
+ };
+
+ test("selectIntegration", async () => {
+ const integration = { alias: "integration1" };
+ const cliProviderService: any = {
+ awsSsoIntegrationService: {
+ getIntegrations: jest.fn(() => [integration]),
+ },
+ inquirer: {
+ prompt: async (params: any) => {
+ expect(params).toEqual([
+ {
+ name: "selectedIntegration",
+ message: "select an integration to delete",
+ type: "list",
+ choices: [{ name: integration.alias, value: integration }],
+ },
+ ]);
+ return { selectedIntegration: integration };
+ },
+ },
+ };
+
+ const command = getTestCommand(cliProviderService);
+ const selectedIntegration = await command.selectIntegration();
+
+ expect(cliProviderService.awsSsoIntegrationService.getIntegrations).toHaveBeenCalled();
+ expect(selectedIntegration).toBe(integration);
+ });
+
+ test("selectIntegration, no integrations", async () => {
+ const cliProviderService: any = {
+ awsSsoIntegrationService: {
+ getIntegrations: jest.fn(() => []),
+ },
+ };
+
+ const command = getTestCommand(cliProviderService);
+ await expect(command.selectIntegration()).rejects.toThrow(new Error("no integrations available"));
+ });
+
+ test("delete", async () => {
+ const cliProviderService: any = {
+ awsSsoIntegrationService: {
+ deleteIntegration: jest.fn(),
+ },
+ remoteProceduresClient: {
+ refreshIntegrations: jest.fn(),
+ refreshSessions: jest.fn(),
+ },
+ };
+
+ const command = getTestCommand(cliProviderService);
+ command.log = jest.fn();
+
+ const integration = { id: "integration1" } as any;
+ await command.delete(integration);
+
+ expect(cliProviderService.awsSsoIntegrationService.deleteIntegration).toHaveBeenCalledWith(integration.id);
+ expect(command.log).toHaveBeenLastCalledWith("integration deleted");
+ expect(cliProviderService.remoteProceduresClient.refreshIntegrations).toHaveBeenCalled();
+ expect(cliProviderService.remoteProceduresClient.refreshSessions).toHaveBeenCalled();
+ });
+
+ const runCommand = async (errorToThrow: any, expectedErrorMessage: string) => {
+ const selectedIntegration = { id: "1" };
+
+ const command = getTestCommand();
+ command.selectIntegration = jest.fn(async (): Promise => selectedIntegration);
+ command.delete = jest.fn(async () => {
+ if (errorToThrow) {
+ throw errorToThrow;
+ }
+ });
+
+ let occurredError;
+ try {
+ await command.run();
+ } catch (error) {
+ occurredError = error;
+ }
+
+ expect(command.selectIntegration).toHaveBeenCalled();
+ expect(command.delete).toHaveBeenCalledWith(selectedIntegration);
+ if (errorToThrow) {
+ expect(occurredError).toEqual(new Error(expectedErrorMessage));
+ }
+ };
+
+ test("run", async () => {
+ await runCommand(undefined, "");
+ });
+
+ test("run - delete throws exception", async () => {
+ await runCommand(new Error("errorMessage"), "errorMessage");
+ });
+
+ test("run - delete throws undefined object", async () => {
+ await runCommand({ hello: "randomObj" }, "Unknown error: [object Object]");
+ });
+});
diff --git a/cli/src/commands/integration/delete.ts b/cli/src/commands/integration/delete.ts
new file mode 100644
index 000000000..a216253b7
--- /dev/null
+++ b/cli/src/commands/integration/delete.ts
@@ -0,0 +1,49 @@
+import { LeappCommand } from "../../leapp-command";
+import { Config } from "@oclif/core/lib/config/config";
+import { AwsSsoIntegration } from "@noovolari/leapp-core/models/aws-sso-integration";
+
+export default class DeleteIntegration extends LeappCommand {
+ static description = "Delete an integration";
+
+ static examples = ["$leapp integration delete"];
+
+ constructor(argv: string[], config: Config) {
+ super(argv, config);
+ }
+
+ async run(): Promise {
+ try {
+ const selectedIntegration = await this.selectIntegration();
+ await this.delete(selectedIntegration);
+ } catch (error) {
+ this.error(error instanceof Error ? error.message : `Unknown error: ${error}`);
+ }
+ }
+
+ async delete(integration: AwsSsoIntegration): Promise {
+ try {
+ await this.cliProviderService.awsSsoIntegrationService.deleteIntegration(integration.id);
+ this.log(`integration deleted`);
+ } finally {
+ await this.cliProviderService.remoteProceduresClient.refreshIntegrations();
+ await this.cliProviderService.remoteProceduresClient.refreshSessions();
+ }
+ }
+
+ async selectIntegration(): Promise {
+ const integrations = this.cliProviderService.awsSsoIntegrationService.getIntegrations();
+ if (integrations.length === 0) {
+ throw new Error("no integrations available");
+ }
+
+ const answer: any = await this.cliProviderService.inquirer.prompt([
+ {
+ name: "selectedIntegration",
+ message: "select an integration to delete",
+ type: "list",
+ choices: integrations.map((integration: any) => ({ name: integration.alias, value: integration })),
+ },
+ ]);
+ return answer.selectedIntegration;
+ }
+}
diff --git a/cli/src/commands/integration/list.spec.ts b/cli/src/commands/integration/list.spec.ts
new file mode 100644
index 000000000..a32614d4a
--- /dev/null
+++ b/cli/src/commands/integration/list.spec.ts
@@ -0,0 +1,91 @@
+import ListIntegrations from "./list";
+import { CliUx } from "@oclif/core";
+import { describe, expect, jest, test } from "@jest/globals";
+
+describe("ListIntegrations", () => {
+ const getTestCommand = (cliProviderService: any = null, argv: string[] = []): ListIntegrations => {
+ const command = new ListIntegrations(argv, {} as any);
+ (command as any).cliProviderService = cliProviderService;
+ return command;
+ };
+
+ test("run", async () => {
+ const command = getTestCommand();
+ command.showIntegrations = jest.fn();
+ await command.run();
+
+ expect(command.showIntegrations).toHaveBeenCalled();
+ });
+
+ test("run - showIntegrations throw an error", async () => {
+ const command = getTestCommand();
+ command.showIntegrations = jest.fn(async () => {
+ throw new Error("error");
+ });
+ try {
+ await command.run();
+ } catch (error) {
+ expect(error).toEqual(new Error("error"));
+ }
+ });
+
+ test("run - showIntegrations throw an object", async () => {
+ const command = getTestCommand();
+ const errorToThrow = "string";
+ command.showIntegrations = jest.fn(async () => {
+ throw errorToThrow;
+ });
+ try {
+ await command.run();
+ } catch (error) {
+ expect(error).toEqual(new Error("Unknown error: string"));
+ }
+ });
+
+ test("showIntegrations", async () => {
+ const integrations = [
+ {
+ alias: "integrationName",
+ portalUrl: "portalUrl",
+ region: "region",
+ accessTokenExpiration: "expiration",
+ },
+ ];
+ const cliProviderService = {
+ awsSsoIntegrationService: {
+ getIntegrations: () => integrations,
+ isOnline: jest.fn(() => true),
+ remainingHours: jest.fn(() => "remainingHours"),
+ },
+ };
+
+ const command = getTestCommand(cliProviderService);
+ const tableSpy = jest.spyOn(CliUx.ux, "table").mockImplementation(() => null);
+
+ await command.showIntegrations();
+
+ const expectedData = [
+ {
+ integrationName: "integrationName",
+ portalUrl: "portalUrl",
+ region: "region",
+ status: "Online",
+ expirationInHours: "Expiring remainingHours",
+ },
+ ];
+
+ expect(tableSpy.mock.calls[0][0]).toEqual(expectedData);
+
+ expect(cliProviderService.awsSsoIntegrationService.isOnline).toHaveBeenCalledWith(integrations[0]);
+ expect(cliProviderService.awsSsoIntegrationService.remainingHours).toHaveBeenCalledWith(integrations[0]);
+
+ const expectedColumns = {
+ integrationName: { header: "Integration Name" },
+ portalUrl: { header: "Portal URL" },
+ region: { header: "Region" },
+ status: { header: "Status" },
+ expirationInHours: { header: "Expiration" },
+ };
+ expect(tableSpy.mock.calls[0][1]).toEqual(expectedColumns);
+ });
+});
diff --git a/cli/src/commands/integration/list.ts b/cli/src/commands/integration/list.ts
new file mode 100644
index 000000000..3be69f24f
--- /dev/null
+++ b/cli/src/commands/integration/list.ts
@@ -0,0 +1,48 @@
+import { CliUx } from "@oclif/core";
+import { Config } from "@oclif/core/lib/config/config";
+import { LeappCommand } from "../../leapp-command";
+
+export default class ListIntegrations extends LeappCommand {
+ static description = "Show integrations list";
+ static examples = ["$leapp integration list"];
+
+ static flags = {
+ ...CliUx.ux.table.flags(),
+ };
+
+ constructor(argv: string[], config: Config) {
+ super(argv, config);
+ }
+
+ async run(): Promise {
+ try {
+ await this.showIntegrations();
+ } catch (error) {
+ this.error(error instanceof Error ? error.message : `Unknown error: ${error}`);
+ }
+ }
+
+ async showIntegrations(): Promise {
+ const { flags } = await this.parse(ListIntegrations);
+ const data = this.cliProviderService.awsSsoIntegrationService.getIntegrations().map((integration: any) => {
+ const isOnline = this.cliProviderService.awsSsoIntegrationService.isOnline(integration);
+ return {
+ integrationName: integration.alias,
+ portalUrl: integration.portalUrl,
+ region: integration.region,
+ status: isOnline ? "Online" : "Offline",
+ expirationInHours: isOnline ? `Expiring ${this.cliProviderService.awsSsoIntegrationService.remainingHours(integration)}` : "-",
+ };
+ }) as any as Record[];
+
+ const columns = {
+ integrationName: { header: "Integration Name" },
+ portalUrl: { header: "Portal URL" },
+ region: { header: "Region" },
+ status: { header: "Status" },
+ expirationInHours: { header: "Expiration" },
+ };
+
+ CliUx.ux.table(data, columns, { ...flags });
+ }
+}
diff --git a/cli/src/commands/integration/login.spec.ts b/cli/src/commands/integration/login.spec.ts
new file mode 100644
index 000000000..eda3bf176
--- /dev/null
+++ b/cli/src/commands/integration/login.spec.ts
@@ -0,0 +1,116 @@
+import { jest, describe, test, expect } from "@jest/globals";
+import LoginIntegration from "./login";
+
+describe("LoginIntegration", () => {
+ const getTestCommand = (cliProviderService: any = null, argv: string[] = []): LoginIntegration => {
+ const command = new LoginIntegration(argv, {} as any);
+ (command as any).cliProviderService = cliProviderService;
+ return command;
+ };
+
+ test("selectIntegration", async () => {
+ const integration = { alias: "integration1" };
+ const cliProviderService: any = {
+ awsSsoIntegrationService: {
+ getOfflineIntegrations: jest.fn(() => [integration]),
+ },
+ inquirer: {
+ prompt: async (params: any) => {
+ expect(params).toEqual([
+ {
+ name: "selectedIntegration",
+ message: "select an integration",
+ type: "list",
+ choices: [{ name: integration.alias, value: integration }],
+ },
+ ]);
+ return { selectedIntegration: integration };
+ },
+ },
+ };
+
+ const command = getTestCommand(cliProviderService);
+ const selectedIntegration = await command.selectIntegration();
+
+ expect(cliProviderService.awsSsoIntegrationService.getOfflineIntegrations).toHaveBeenCalled();
+ expect(selectedIntegration).toBe(integration);
+ });
+
+ test("selectIntegration, no integrations", async () => {
+ const cliProviderService: any = {
+ awsSsoIntegrationService: {
+ getOfflineIntegrations: jest.fn(() => []),
+ },
+ };
+
+ const command = getTestCommand(cliProviderService);
+ await expect(command.selectIntegration()).rejects.toThrow(new Error("no offline integrations available"));
+ });
+
+ test("login", async () => {
+ const sessionsDiff = { sessionsToAdd: ["session1", "session2"], sessionsToDelete: ["session3"] };
+ const cliProviderService: any = {
+ awsSsoIntegrationService: {
+ syncSessions: jest.fn(async () => sessionsDiff),
+ },
+ cliVerificationWindowService: {
+ closeBrowser: jest.fn(),
+ },
+ remoteProceduresClient: {
+ refreshIntegrations: jest.fn(),
+ refreshSessions: jest.fn(),
+ },
+ };
+
+ const command = getTestCommand(cliProviderService);
+ command.log = jest.fn();
+
+ const integration = { id: "id1" } as any;
+ await command.login(integration);
+
+ expect(command.log).toHaveBeenNthCalledWith(1, "waiting for browser authorization using your AWS sign-in...");
+ expect(cliProviderService.awsSsoIntegrationService.syncSessions).toHaveBeenCalledWith(integration.id);
+ expect(command.log).toHaveBeenNthCalledWith(2, `${sessionsDiff.sessionsToAdd.length} sessions added`);
+ expect(command.log).toHaveBeenNthCalledWith(3, `${sessionsDiff.sessionsToDelete.length} sessions removed`);
+ expect(cliProviderService.remoteProceduresClient.refreshIntegrations).toHaveBeenCalled();
+ expect(cliProviderService.remoteProceduresClient.refreshSessions).toHaveBeenCalled();
+ //expect(cliProviderService.cliVerificationWindowService.closeBrowser).toHaveBeenCalled();
+ });
+
+ const runCommand = async (errorToThrow: any, expectedErrorMessage: string) => {
+ const selectedIntegration = { id: "1" };
+
+ const command = getTestCommand();
+ command.selectIntegration = jest.fn(async (): Promise => selectedIntegration);
+ command.login = jest.fn(async () => {
+ if (errorToThrow) {
+ throw errorToThrow;
+ }
+ });
+
+ let occurredError;
+ try {
+ await command.run();
+ } catch (error) {
+ occurredError = error;
+ }
+
+ expect(command.selectIntegration).toHaveBeenCalled();
+ expect(command.login).toHaveBeenCalledWith(selectedIntegration);
+ if (errorToThrow) {
+ expect(occurredError).toEqual(new Error(expectedErrorMessage));
+ }
+ };
+
+ test("run", async () => {
+ await runCommand(undefined, "");
+ });
+
+ test("run - login throws exception", async () => {
+ await runCommand(new Error("errorMessage"), "errorMessage");
+ });
+
+ test("run - login throws undefined object", async () => {
+ await runCommand({ hello: "randomObj" }, "Unknown error: [object Object]");
+ });
+});
diff --git a/cli/src/commands/integration/login.ts b/cli/src/commands/integration/login.ts
new file mode 100644
index 000000000..404666a1a
--- /dev/null
+++ b/cli/src/commands/integration/login.ts
@@ -0,0 +1,51 @@
+import { LeappCommand } from "../../leapp-command";
+import { Config } from "@oclif/core/lib/config/config";
+import { AwsSsoIntegration } from "@noovolari/leapp-core/models/aws-sso-integration";
+
+export default class LoginIntegration extends LeappCommand {
+ static description = "Login to synchronize integration sessions";
+
+ static examples = ["$leapp integration login"];
+
+ constructor(argv: string[], config: Config) {
+ super(argv, config);
+ }
+
+ async run(): Promise {
+ try {
+ const selectedIntegration = await this.selectIntegration();
+ await this.login(selectedIntegration);
+ } catch (error) {
+ this.error(error instanceof Error ? error.message : `Unknown error: ${error}`);
+ }
+ }
+
+ async login(integration: AwsSsoIntegration): Promise {
+ this.log("waiting for browser authorization using your AWS sign-in...");
+ try {
+ const sessionsDiff = await this.cliProviderService.awsSsoIntegrationService.syncSessions(integration.id);
+ this.log(`${sessionsDiff.sessionsToAdd.length} sessions added`);
+ this.log(`${sessionsDiff.sessionsToDelete.length} sessions removed`);
+ } finally {
+ await this.cliProviderService.remoteProceduresClient.refreshIntegrations();
+ await this.cliProviderService.remoteProceduresClient.refreshSessions();
+ }
+ }
+
+ async selectIntegration(): Promise {
+ const offlineIntegrations = this.cliProviderService.awsSsoIntegrationService.getOfflineIntegrations();
+ if (offlineIntegrations.length === 0) {
+ throw new Error("no offline integrations available");
+ }
+
+ const answer: any = await this.cliProviderService.inquirer.prompt([
+ {
+ name: "selectedIntegration",
+ message: "select an integration",
+ type: "list",
+ choices: offlineIntegrations.map((integration: any) => ({ name: integration.alias, value: integration })),
+ },
+ ]);
+ return answer.selectedIntegration;
+ }
+}
diff --git a/cli/src/commands/integration/logout.spec.ts b/cli/src/commands/integration/logout.spec.ts
new file mode 100644
index 000000000..be72c0a55
--- /dev/null
+++ b/cli/src/commands/integration/logout.spec.ts
@@ -0,0 +1,106 @@
+import { jest, describe, test, expect } from "@jest/globals";
+import LogoutIntegration from "./logout";
+
+describe("LogoutIntegration", () => {
+ const getTestCommand = (cliProviderService: any = null, argv: string[] = []): LogoutIntegration => {
+ const command = new LogoutIntegration(argv, {} as any);
+ (command as any).cliProviderService = cliProviderService;
+ return command;
+ };
+
+ test("selectIntegration", async () => {
+ const integration = { alias: "integration1" };
+ const cliProviderService: any = {
+ awsSsoIntegrationService: {
+ getOnlineIntegrations: jest.fn(() => [integration]),
+ },
+ inquirer: {
+ prompt: async (params: any) => {
+ expect(params).toEqual([
+ {
+ name: "selectedIntegration",
+ message: "select an integration",
+ type: "list",
+ choices: [{ name: integration.alias, value: integration }],
+ },
+ ]);
+ return { selectedIntegration: integration };
+ },
+ },
+ };
+
+ const command = getTestCommand(cliProviderService);
+ const selectedIntegration = await command.selectIntegration();
+
+ expect(cliProviderService.awsSsoIntegrationService.getOnlineIntegrations).toHaveBeenCalled();
+ expect(selectedIntegration).toBe(integration);
+ });
+
+ test("selectIntegration, no integrations", async () => {
+ const cliProviderService: any = {
+ awsSsoIntegrationService: {
+ getOnlineIntegrations: jest.fn(() => []),
+ },
+ };
+
+ const command = getTestCommand(cliProviderService);
+ await expect(command.selectIntegration()).rejects.toThrow(new Error("no online integrations available"));
+ });
+
+ test("logout", async () => {
+ const cliProviderService: any = {
+ awsSsoIntegrationService: {
+ logout: jest.fn(),
+ },
+ remoteProceduresClient: { refreshIntegrations: jest.fn(), refreshSessions: jest.fn() },
+ };
+
+ const command = getTestCommand(cliProviderService);
+ command.log = jest.fn();
+
+ const integration = { id: "id1" } as any;
+ await command.logout(integration);
+
+ expect(cliProviderService.awsSsoIntegrationService.logout).toHaveBeenCalledWith(integration.id);
+ expect(command.log).toHaveBeenLastCalledWith("logout successful");
+ expect(cliProviderService.remoteProceduresClient.refreshSessions).toHaveBeenCalled();
+ expect(cliProviderService.remoteProceduresClient.refreshSessions).toHaveBeenCalled();
+ });
+
+ const runCommand = async (errorToThrow: any, expectedErrorMessage: string) => {
+ const selectedIntegration = { id: "1" };
+
+ const command = getTestCommand();
+ command.selectIntegration = jest.fn(async (): Promise => selectedIntegration);
+ command.logout = jest.fn(async () => {
+ if (errorToThrow) {
+ throw errorToThrow;
+ }
+ });
+
+ let occurredError;
+ try {
+ await command.run();
+ } catch (error) {
+ occurredError = error;
+ }
+
+ expect(command.selectIntegration).toHaveBeenCalled();
+ expect(command.logout).toHaveBeenCalledWith(selectedIntegration);
+ if (errorToThrow) {
+ expect(occurredError).toEqual(new Error(expectedErrorMessage));
+ }
+ };
+
+ test("run", async () => {
+ await runCommand(undefined, "");
+ });
+
+ test("run - logout throws exception", async () => {
+ await runCommand(new Error("errorMessage"), "errorMessage");
+ });
+
+ test("run - logout throws undefined object", async () => {
+ await runCommand({ hello: "randomObj" }, "Unknown error: [object Object]");
+ });
+});
diff --git a/cli/src/commands/integration/logout.ts b/cli/src/commands/integration/logout.ts
new file mode 100644
index 000000000..e9c92d3b1
--- /dev/null
+++ b/cli/src/commands/integration/logout.ts
@@ -0,0 +1,49 @@
+import { LeappCommand } from "../../leapp-command";
+import { Config } from "@oclif/core/lib/config/config";
+import { AwsSsoIntegration } from "@noovolari/leapp-core/models/aws-sso-integration";
+
+export default class LogoutIntegration extends LeappCommand {
+ static description = "Logout from integration";
+
+ static examples = ["$leapp integration logout"];
+
+ constructor(argv: string[], config: Config) {
+ super(argv, config);
+ }
+
+ async run(): Promise {
+ try {
+ const selectedIntegration = await this.selectIntegration();
+ await this.logout(selectedIntegration);
+ } catch (error) {
+ this.error(error instanceof Error ? error.message : `Unknown error: ${error}`);
+ }
+ }
+
+ async logout(integration: AwsSsoIntegration): Promise {
+ try {
+ await this.cliProviderService.awsSsoIntegrationService.logout(integration.id);
+ this.log("logout successful");
+ } finally {
+ await this.cliProviderService.remoteProceduresClient.refreshIntegrations();
+ await this.cliProviderService.remoteProceduresClient.refreshSessions();
+ }
+ }
+
+ async selectIntegration(): Promise {
+ const onlineIntegrations = this.cliProviderService.awsSsoIntegrationService.getOnlineIntegrations();
+ if (onlineIntegrations.length === 0) {
+ throw new Error("no online integrations available");
+ }
+
+ const answer: any = await this.cliProviderService.inquirer.prompt([
+ {
+ name: "selectedIntegration",
+ message: "select an integration",
+ type: "list",
+ choices: onlineIntegrations.map((integration: any) => ({ name: integration.alias, value: integration })),
+ },
+ ]);
+ return answer.selectedIntegration;
+ }
+}
diff --git a/cli/src/commands/integration/sync.spec.ts b/cli/src/commands/integration/sync.spec.ts
new file mode 100644
index 000000000..d1fb88a96
--- /dev/null
+++ b/cli/src/commands/integration/sync.spec.ts
@@ -0,0 +1,109 @@
+import { jest, describe, test, expect } from "@jest/globals";
+import SyncIntegration from "./sync";
+
+describe("SyncIntegration", () => {
+ const getTestCommand = (cliProviderService: any = null, argv: string[] = []): SyncIntegration => {
+ const command = new SyncIntegration(argv, {} as any);
+ (command as any).cliProviderService = cliProviderService;
+ return command;
+ };
+
+ test("selectIntegration", async () => {
+ const integration = { alias: "integration1" };
+ const cliProviderService: any = {
+ awsSsoIntegrationService: {
+ getOnlineIntegrations: jest.fn(() => [integration]),
+ },
+ inquirer: {
+ prompt: async (params: any) => {
+ expect(params).toEqual([
+ {
+ name: "selectedIntegration",
+ message: "select an integration",
+ type: "list",
+ choices: [{ name: integration.alias, value: integration }],
+ },
+ ]);
+ return { selectedIntegration: integration };
+ },
+ },
+ };
+
+ const command = getTestCommand(cliProviderService);
+ const selectedIntegration = await command.selectIntegration();
+
+ expect(cliProviderService.awsSsoIntegrationService.getOnlineIntegrations).toHaveBeenCalled();
+ expect(selectedIntegration).toBe(integration);
+ });
+
+ test("selectIntegration, no integrations", async () => {
+ const cliProviderService: any = {
+ awsSsoIntegrationService: {
+ getOnlineIntegrations: jest.fn(() => []),
+ },
+ };
+
+ const command = getTestCommand(cliProviderService);
+ await expect(command.selectIntegration()).rejects.toThrow(new Error("no online integrations available"));
+ });
+
+ test("sync", async () => {
+ const sessionsDiff = { sessionsToAdd: ["session1", "session2"], sessionsToDelete: ["session3"] };
+ const cliProviderService: any = {
+ awsSsoIntegrationService: {
+ syncSessions: jest.fn(async () => sessionsDiff),
+ },
+ remoteProceduresClient: {
+ refreshSessions: jest.fn(),
+ },
+ };
+
+ const command = getTestCommand(cliProviderService);
+ command.log = jest.fn();
+
+ const integration = { id: "id1" } as any;
+ await command.sync(integration);
+
+ expect(cliProviderService.awsSsoIntegrationService.syncSessions).toHaveBeenCalledWith(integration.id);
+ expect(command.log).toHaveBeenNthCalledWith(1, `${sessionsDiff.sessionsToAdd.length} sessions added`);
+ expect(command.log).toHaveBeenNthCalledWith(2, `${sessionsDiff.sessionsToDelete.length} sessions removed`);
+ expect(cliProviderService.remoteProceduresClient.refreshSessions).toHaveBeenCalled();
+ });
+
+ const runCommand = async (errorToThrow: any, expectedErrorMessage: string) => {
+ const selectedIntegration = { id: "1" };
+
+ const command = getTestCommand();
+ command.selectIntegration = jest.fn(async (): Promise => selectedIntegration);
+ command.sync = jest.fn(async () => {
+ if (errorToThrow) {
+ throw errorToThrow;
+ }
+ });
+
+ let occurredError;
+ try {
+ await command.run();
+ } catch (error) {
+ occurredError = error;
+ }
+
+ expect(command.selectIntegration).toHaveBeenCalled();
+ expect(command.sync).toHaveBeenCalledWith(selectedIntegration);
+ if (errorToThrow) {
+ expect(occurredError).toEqual(new Error(expectedErrorMessage));
+ }
+ };
+
+ test("run", async () => {
+ await runCommand(undefined, "");
+ });
+
+ test("run - sync throws exception", async () => {
+ await runCommand(new Error("errorMessage"), "errorMessage");
+ });
+
+ test("run - sync throws undefined object", async () => {
+ await runCommand({ hello: "randomObj" }, "Unknown error: [object Object]");
+ });
+});
diff --git a/cli/src/commands/integration/sync.ts b/cli/src/commands/integration/sync.ts
new file mode 100644
index 000000000..9ce4b5537
--- /dev/null
+++ b/cli/src/commands/integration/sync.ts
@@ -0,0 +1,49 @@
+import { LeappCommand } from "../../leapp-command";
+import { Config } from "@oclif/core/lib/config/config";
+import { AwsSsoIntegration } from "@noovolari/leapp-core/models/aws-sso-integration";
+
+export default class SyncIntegration extends LeappCommand {
+ static description = "Synchronize integration sessions";
+
+ static examples = ["$leapp integration sync"];
+
+ constructor(argv: string[], config: Config) {
+ super(argv, config);
+ }
+
+ async run(): Promise {
+ try {
+ const selectedIntegration = await this.selectIntegration();
+ await this.sync(selectedIntegration);
+ } catch (error) {
+ this.error(error instanceof Error ? error.message : `Unknown error: ${error}`);
+ }
+ }
+
+ async sync(integration: AwsSsoIntegration): Promise {
+ try {
+ const sessionsDiff = await this.cliProviderService.awsSsoIntegrationService.syncSessions(integration.id);
+ this.log(`${sessionsDiff.sessionsToAdd.length} sessions added`);
+ this.log(`${sessionsDiff.sessionsToDelete.length} sessions removed`);
+ } finally {
+ await this.cliProviderService.remoteProceduresClient.refreshSessions();
+ }
+ }
+
+ async selectIntegration(): Promise {
+ const onlineIntegrations = this.cliProviderService.awsSsoIntegrationService.getOnlineIntegrations();
+ if (onlineIntegrations.length === 0) {
+ throw new Error("no online integrations available");
+ }
+
+ const answer: any = await this.cliProviderService.inquirer.prompt([
+ {
+ name: "selectedIntegration",
+ message: "select an integration",
+ type: "list",
+ choices: onlineIntegrations.map((integration: any) => ({ name: integration.alias, value: integration })),
+ },
+ ]);
+ return answer.selectedIntegration;
+ }
+}
diff --git a/cli/src/commands/profile/create.spec.ts b/cli/src/commands/profile/create.spec.ts
new file mode 100644
index 000000000..8f4584382
--- /dev/null
+++ b/cli/src/commands/profile/create.spec.ts
@@ -0,0 +1,89 @@
+import { jest, describe, test, expect } from "@jest/globals";
+import CreateNamedProfile from "./create";
+
+describe("CreateNamedProfile", () => {
+ const getTestCommand = (cliProviderService: any = null, argv: string[] = []): CreateNamedProfile => {
+ const command = new CreateNamedProfile(argv, {} as any);
+ (command as any).cliProviderService = cliProviderService;
+ return command;
+ };
+
+ test("getProfileName", async () => {
+ const cliProviderService: any = {
+ inquirer: {
+ prompt: async (params: any) => {
+ expect(params).toMatchObject([
+ {
+ name: "namedProfileName",
+ message: `choose a name for the profile`,
+ type: "input",
+ },
+ ]);
+ expect(params[0].validate("profileName")).toBe("validationResult");
+ return { namedProfileName: "profileName" };
+ },
+ },
+ namedProfilesService: {
+ validateNewProfileName: jest.fn(() => "validationResult"),
+ },
+ };
+
+ const command = getTestCommand(cliProviderService);
+ const profileName = await command.getProfileName();
+ expect(profileName).toBe("profileName");
+ expect(cliProviderService.namedProfilesService.validateNewProfileName).toHaveBeenCalledWith("profileName");
+ });
+
+ test("createNamedProfile", async () => {
+ const cliProviderService: any = {
+ namedProfilesService: {
+ createNamedProfile: jest.fn(),
+ },
+ remoteProceduresClient: { refreshSessions: jest.fn() },
+ };
+
+ const command = getTestCommand(cliProviderService);
+ command.log = jest.fn();
+ await command.createNamedProfile("profileName");
+
+ expect(cliProviderService.namedProfilesService.createNamedProfile).toHaveBeenCalledWith("profileName");
+ expect(command.log).toHaveBeenCalledWith("profile created");
+ expect(cliProviderService.remoteProceduresClient.refreshSessions).toHaveBeenCalled();
+ });
+
+ const runCommand = async (errorToThrow: any, expectedErrorMessage: string) => {
+ const profileName = "profile1";
+ const command = getTestCommand();
+ command.getProfileName = jest.fn(async (): Promise => profileName);
+ command.createNamedProfile = jest.fn(async () => {
+ if (errorToThrow) {
+ throw errorToThrow;
+ }
+ });
+
+ let occurredError;
+ try {
+ await command.run();
+ } catch (error) {
+ occurredError = error;
+ }
+
+ expect(command.getProfileName).toHaveBeenCalled();
+ expect(command.createNamedProfile).toHaveBeenCalledWith(profileName);
+ if (errorToThrow) {
+ expect(occurredError).toEqual(new Error(expectedErrorMessage));
+ }
+ };
+
+ test("run", async () => {
+ await runCommand(undefined, "");
+ });
+
+ test("run - createNamedProfile throws exception", async () => {
+ await runCommand(new Error("errorMessage"), "errorMessage");
+ });
+
+ test("run - createNamedProfile throws undefined object", async () => {
+ await runCommand({ hello: "randomObj" }, "Unknown error: [object Object]");
+ });
+});
diff --git a/cli/src/commands/profile/create.ts b/cli/src/commands/profile/create.ts
new file mode 100644
index 000000000..bdcc9e931
--- /dev/null
+++ b/cli/src/commands/profile/create.ts
@@ -0,0 +1,39 @@
+import { LeappCommand } from "../../leapp-command";
+import { Config } from "@oclif/core/lib/config/config";
+
+export default class CreateNamedProfile extends LeappCommand {
+ static description = "Create a new AWS named profile";
+
+ static examples = [`$leapp profile create`];
+
+ constructor(argv: string[], config: Config) {
+ super(argv, config);
+ }
+
+ async run(): Promise {
+ try {
+ const profileName = await this.getProfileName();
+ await this.createNamedProfile(profileName);
+ } catch (error) {
+ this.error(error instanceof Error ? error.message : `Unknown error: ${error}`);
+ }
+ }
+
+ async getProfileName(): Promise {
+ const answer: any = await this.cliProviderService.inquirer.prompt([
+ {
+ name: "namedProfileName",
+ message: `choose a name for the profile`,
+ validate: (profileName) => this.cliProviderService.namedProfilesService.validateNewProfileName(profileName),
+ type: "input",
+ },
+ ]);
+ return answer.namedProfileName;
+ }
+
+ async createNamedProfile(profileName: string): Promise {
+ this.cliProviderService.namedProfilesService.createNamedProfile(profileName);
+ await this.cliProviderService.remoteProceduresClient.refreshSessions();
+ this.log("profile created");
+ }
+}
diff --git a/cli/src/commands/profile/delete.spec.ts b/cli/src/commands/profile/delete.spec.ts
new file mode 100644
index 000000000..ac17ebcd5
--- /dev/null
+++ b/cli/src/commands/profile/delete.spec.ts
@@ -0,0 +1,151 @@
+import { jest, describe, test, expect } from "@jest/globals";
+import DeleteNamedProfile from "./delete";
+
+describe("DeleteNamedProfile", () => {
+ const getTestCommand = (cliProviderService: any = null, argv: string[] = []): DeleteNamedProfile => {
+ const command = new DeleteNamedProfile(argv, {} as any);
+ (command as any).cliProviderService = cliProviderService;
+ return command;
+ };
+
+ test("selectNamedProfile", async () => {
+ const namedProfile = { name: "profileName" };
+ const cliProviderService: any = {
+ namedProfilesService: {
+ getNamedProfiles: jest.fn(() => [namedProfile]),
+ },
+ inquirer: {
+ prompt: async (params: any) => {
+ expect(params).toEqual([
+ {
+ name: "selectedNamedProfile",
+ message: `select a profile to delete`,
+ type: "list",
+ choices: [{ name: namedProfile.name, value: namedProfile }],
+ },
+ ]);
+ return { selectedNamedProfile: namedProfile };
+ },
+ },
+ };
+
+ const command = getTestCommand(cliProviderService);
+ const selectedProfile = await command.selectNamedProfile();
+
+ expect(cliProviderService.namedProfilesService.getNamedProfiles).toHaveBeenCalledWith(true);
+ expect(selectedProfile).toBe(namedProfile);
+ });
+
+ test("selectNamedProfile, no named profiles", async () => {
+ const cliProviderService: any = {
+ namedProfilesService: {
+ getNamedProfiles: jest.fn(() => []),
+ },
+ };
+ const command = getTestCommand(cliProviderService);
+
+ await expect(command.selectNamedProfile()).rejects.toThrow(new Error("no profiles available"));
+ });
+
+ test("getAffectedSessions", async () => {
+ const cliProviderService: any = {
+ namedProfilesService: {
+ getSessionsWithNamedProfile: jest.fn(() => "sessions"),
+ },
+ };
+ const command = getTestCommand(cliProviderService);
+
+ const sessions = command.getAffectedSessions("profileId");
+ expect(sessions).toBe("sessions");
+ expect(cliProviderService.namedProfilesService.getSessionsWithNamedProfile).toHaveBeenCalledWith("profileId");
+ });
+
+ test("askForConfirmation", async () => {
+ const cliProviderService: any = {
+ inquirer: {
+ prompt: async (params: any) => {
+ expect(params).toEqual([
+ {
+ name: "confirmation",
+ message: "Deleting this profile will set default to these sessions\n" + "- sess1\n" + "- sess2\n" + "Do you want to continue?",
+ type: "confirm",
+ },
+ ]);
+ return { confirmation: true };
+ },
+ },
+ };
+ const command = getTestCommand(cliProviderService);
+
+ const affectedSessions = [{ sessionName: "sess1" }, { sessionName: "sess2" }] as any;
+ const confirmation = await command.askForConfirmation(affectedSessions);
+
+ expect(confirmation).toBe(true);
+ });
+
+ test("askForConfirmation, no affected sessions", async () => {
+ const command = getTestCommand();
+
+ const confirmation = await command.askForConfirmation([]);
+ expect(confirmation).toBe(true);
+ });
+
+ test("deleteNamedProfile", async () => {
+ const cliProviderService: any = {
+ namedProfilesService: {
+ deleteNamedProfile: jest.fn(),
+ },
+ remoteProceduresClient: { refreshSessions: jest.fn() },
+ };
+
+ const command = getTestCommand(cliProviderService);
+ command.log = jest.fn();
+ await command.deleteNamedProfile("profileId");
+
+ expect(cliProviderService.namedProfilesService.deleteNamedProfile).toHaveBeenCalledWith("profileId");
+ expect(command.log).toHaveBeenCalledWith("profile deleted");
+ expect(cliProviderService.remoteProceduresClient.refreshSessions).toHaveBeenCalled();
+ });
+
+ const runCommand = async (errorToThrow: any, expectedErrorMessage: string) => {
+ const namedProfile = { id: "1" };
+ const affectedSessions = [{ sessionId: "2" }] as any;
+
+ const command = getTestCommand();
+ command.selectNamedProfile = jest.fn(async (): Promise => namedProfile);
+ command.getAffectedSessions = jest.fn(() => affectedSessions);
+ command.askForConfirmation = jest.fn(async (): Promise => true);
+ command.deleteNamedProfile = jest.fn(async () => {
+ if (errorToThrow) {
+ throw errorToThrow;
+ }
+ });
+
+ let occurredError;
+ try {
+ await command.run();
+ } catch (error) {
+ occurredError = error;
+ }
+
+ expect(command.selectNamedProfile).toHaveBeenCalled();
+ expect(command.getAffectedSessions).toHaveBeenCalledWith(namedProfile.id);
+ expect(command.askForConfirmation).toHaveBeenCalledWith(affectedSessions);
+ expect(command.deleteNamedProfile).toHaveBeenCalledWith(namedProfile.id);
+ if (errorToThrow) {
+ expect(occurredError).toEqual(new Error(expectedErrorMessage));
+ }
+ };
+
+ test("run", async () => {
+ await runCommand(undefined, "");
+ });
+
+ test("run - deleteNamedProfile throws exception", async () => {
+ await runCommand(new Error("errorMessage"), "errorMessage");
+ });
+
+ test("run - deleteNamedProfile throws undefined object", async () => {
+ await runCommand({ hello: "randomObj" }, "Unknown error: [object Object]");
+ });
+});
diff --git a/cli/src/commands/profile/delete.ts b/cli/src/commands/profile/delete.ts
new file mode 100644
index 000000000..acd69944d
--- /dev/null
+++ b/cli/src/commands/profile/delete.ts
@@ -0,0 +1,70 @@
+import { LeappCommand } from "../../leapp-command";
+import { Config } from "@oclif/core/lib/config/config";
+import { AwsNamedProfile } from "@noovolari/leapp-core/models/aws-named-profile";
+import { Session } from "@noovolari/leapp-core/models/session";
+
+export default class DeleteNamedProfile extends LeappCommand {
+ static description = "Delete an AWS named profile";
+
+ static examples = [`$leapp profile delete`];
+
+ constructor(argv: string[], config: Config) {
+ super(argv, config);
+ }
+
+ async run(): Promise {
+ try {
+ const selectedNamedProfile = await this.selectNamedProfile();
+ const affectedSessions = this.getAffectedSessions(selectedNamedProfile.id);
+ if (await this.askForConfirmation(affectedSessions)) {
+ await this.deleteNamedProfile(selectedNamedProfile.id);
+ }
+ } catch (error) {
+ this.error(error instanceof Error ? error.message : `Unknown error: ${error}`);
+ }
+ }
+
+ async selectNamedProfile(): Promise {
+ const namedProfiles = this.cliProviderService.namedProfilesService.getNamedProfiles(true);
+ if (namedProfiles.length === 0) {
+ throw new Error("no profiles available");
+ }
+ const answer: any = await this.cliProviderService.inquirer.prompt([
+ {
+ name: "selectedNamedProfile",
+ message: `select a profile to delete`,
+ type: "list",
+ choices: namedProfiles.map((profile) => ({ name: profile.name, value: profile })),
+ },
+ ]);
+ return answer.selectedNamedProfile;
+ }
+
+ getAffectedSessions(namedProfileId: string): Session[] {
+ return this.cliProviderService.namedProfilesService.getSessionsWithNamedProfile(namedProfileId);
+ }
+
+ async askForConfirmation(affectedSessions: Session[]): Promise {
+ if (affectedSessions.length === 0) {
+ return true;
+ }
+ const sessionsList = affectedSessions.map((session) => `- ${session.sessionName}`).join("\n");
+ const answer: any = await this.cliProviderService.inquirer.prompt([
+ {
+ name: "confirmation",
+ message: `Deleting this profile will set default to these sessions\n${sessionsList}\nDo you want to continue?`,
+ type: "confirm",
+ },
+ ]);
+ return answer.confirmation;
+ }
+
+ async deleteNamedProfile(id: string): Promise {
+ try {
+ await this.cliProviderService.namedProfilesService.deleteNamedProfile(id);
+ this.log("profile deleted");
+ } finally {
+ await this.cliProviderService.remoteProceduresClient.refreshSessions();
+ }
+ }
+}
diff --git a/cli/src/commands/profile/edit.spec.ts b/cli/src/commands/profile/edit.spec.ts
new file mode 100644
index 000000000..8eeeabe02
--- /dev/null
+++ b/cli/src/commands/profile/edit.spec.ts
@@ -0,0 +1,132 @@
+import { jest, describe, test, expect } from "@jest/globals";
+import EditNamedProfile from "./edit";
+
+describe("EditNamedProfile", () => {
+ const getTestCommand = (cliProviderService: any = null, argv: string[] = []): EditNamedProfile => {
+ const command = new EditNamedProfile(argv, {} as any);
+ (command as any).cliProviderService = cliProviderService;
+ return command;
+ };
+
+ test("selectNamedProfile", async () => {
+ const namedProfile = { name: "profileName" };
+ const cliProviderService: any = {
+ namedProfilesService: {
+ getNamedProfiles: jest.fn(() => [namedProfile]),
+ },
+ inquirer: {
+ prompt: async (params: any) => {
+ expect(params).toEqual([
+ {
+ name: "selectedNamedProfile",
+ message: `select a profile`,
+ type: "list",
+ choices: [{ name: namedProfile.name, value: namedProfile }],
+ },
+ ]);
+ return { selectedNamedProfile: namedProfile };
+ },
+ },
+ };
+
+ const command = getTestCommand(cliProviderService);
+ const selectedProfile = await command.selectNamedProfile();
+
+ expect(cliProviderService.namedProfilesService.getNamedProfiles).toHaveBeenCalledWith(true);
+ expect(selectedProfile).toBe(namedProfile);
+ });
+
+ test("selectNamedProfile, no named profiles", async () => {
+ const cliProviderService: any = {
+ namedProfilesService: {
+ getNamedProfiles: jest.fn(() => []),
+ },
+ };
+
+ const command = getTestCommand(cliProviderService);
+ await expect(command.selectNamedProfile()).rejects.toThrow(new Error("no profiles available"));
+ });
+
+ test("getProfileName", async () => {
+ const cliProviderService: any = {
+ inquirer: {
+ prompt: async (params: any) => {
+ expect(params).toMatchObject([
+ {
+ name: "namedProfileName",
+ message: `choose a new name for the profile`,
+ type: "input",
+ },
+ ]);
+ expect(params[0].validate("profileName")).toBe("validationResult");
+ return { namedProfileName: "profileName" };
+ },
+ },
+ namedProfilesService: {
+ validateNewProfileName: jest.fn(() => "validationResult"),
+ },
+ };
+
+ const command = getTestCommand(cliProviderService);
+ const profileName = await command.getProfileName();
+ expect(profileName).toBe("profileName");
+ expect(cliProviderService.namedProfilesService.validateNewProfileName).toHaveBeenCalledWith("profileName");
+ });
+
+ test("editNamedProfile", async () => {
+ const cliProviderService: any = {
+ namedProfilesService: {
+ editNamedProfile: jest.fn(),
+ },
+ remoteProceduresClient: { refreshSessions: jest.fn() },
+ };
+
+ const command = getTestCommand(cliProviderService);
+ command.log = jest.fn();
+ await command.editNamedProfile("profileId", "profileName");
+
+ expect(cliProviderService.namedProfilesService.editNamedProfile).toHaveBeenCalledWith("profileId", "profileName");
+ expect(command.log).toHaveBeenCalledWith("profile edited");
+ expect(cliProviderService.remoteProceduresClient.refreshSessions).toHaveBeenCalled();
+ });
+
+ const runCommand = async (errorToThrow: any, expectedErrorMessage: string) => {
+ const namedProfile = { id: "1" };
+ const profileName = "newName";
+
+ const command = getTestCommand();
+ command.selectNamedProfile = jest.fn(async (): Promise => namedProfile);
+ command.getProfileName = jest.fn(async (): Promise => profileName);
+ command.editNamedProfile = jest.fn(async () => {
+ if (errorToThrow) {
+ throw errorToThrow;
+ }
+ });
+
+ let occurredError;
+ try {
+ await command.run();
+ } catch (error) {
+ occurredError = error;
+ }
+
+ expect(command.selectNamedProfile).toHaveBeenCalled();
+ expect(command.getProfileName).toHaveBeenCalled();
+ expect(command.editNamedProfile).toHaveBeenCalledWith(namedProfile.id, profileName);
+ if (errorToThrow) {
+ expect(occurredError).toEqual(new Error(expectedErrorMessage));
+ }
+ };
+
+ test("run", async () => {
+ await runCommand(undefined, "");
+ });
+
+ test("run - editNamedProfile throws exception", async () => {
+ await runCommand(new Error("errorMessage"), "errorMessage");
+ });
+
+ test("run - editNamedProfile throws undefined object", async () => {
+ await runCommand({ hello: "randomObj" }, "Unknown error: [object Object]");
+ });
+});
diff --git a/cli/src/commands/profile/edit.ts b/cli/src/commands/profile/edit.ts
new file mode 100644
index 000000000..d11c4f9f7
--- /dev/null
+++ b/cli/src/commands/profile/edit.ts
@@ -0,0 +1,60 @@
+import { LeappCommand } from "../../leapp-command";
+import { Config } from "@oclif/core/lib/config/config";
+import { AwsNamedProfile } from "@noovolari/leapp-core/models/aws-named-profile";
+
+export default class EditNamedProfile extends LeappCommand {
+ static description = "Rename an AWS named profile";
+
+ static examples = [`$leapp profile edit`];
+
+ constructor(argv: string[], config: Config) {
+ super(argv, config);
+ }
+
+ async run(): Promise {
+ try {
+ const selectedNamedProfile = await this.selectNamedProfile();
+ const newProfileName = await this.getProfileName();
+ await this.editNamedProfile(selectedNamedProfile.id, newProfileName);
+ } catch (error) {
+ this.error(error instanceof Error ? error.message : `Unknown error: ${error}`);
+ }
+ }
+
+ async selectNamedProfile(): Promise {
+ const namedProfiles = this.cliProviderService.namedProfilesService.getNamedProfiles(true);
+ if (namedProfiles.length === 0) {
+ throw new Error("no profiles available");
+ }
+ const answer: any = await this.cliProviderService.inquirer.prompt([
+ {
+ name: "selectedNamedProfile",
+ message: `select a profile`,
+ type: "list",
+ choices: namedProfiles.map((profile) => ({ name: profile.name, value: profile })),
+ },
+ ]);
+ return answer.selectedNamedProfile;
+ }
+
+ async getProfileName(): Promise {
+ const answer: any = await this.cliProviderService.inquirer.prompt([
+ {
+ name: "namedProfileName",
+ message: `choose a new name for the profile`,
+ validate: (profileName) => this.cliProviderService.namedProfilesService.validateNewProfileName(profileName),
+ type: "input",
+ },
+ ]);
+ return answer.namedProfileName;
+ }
+
+ async editNamedProfile(id: string, newName: string): Promise {
+ try {
+ await this.cliProviderService.namedProfilesService.editNamedProfile(id, newName);
+ this.log("profile edited");
+ } finally {
+ await this.cliProviderService.remoteProceduresClient.refreshSessions();
+ }
+ }
+}
diff --git a/cli/src/commands/profile/list.spec.ts b/cli/src/commands/profile/list.spec.ts
new file mode 100644
index 000000000..7161ae001
--- /dev/null
+++ b/cli/src/commands/profile/list.spec.ts
@@ -0,0 +1,75 @@
+import { CliUx } from "@oclif/core";
+import { describe, expect, jest, test } from "@jest/globals";
+import ListProfiles from "./list";
+
+describe("ListProfiles", () => {
+ const getTestCommand = (cliProviderService: any = null, argv: string[] = []): ListProfiles => {
+ const command = new ListProfiles(argv, {} as any);
+ (command as any).cliProviderService = cliProviderService;
+ return command;
+ };
+
+ test("run", async () => {
+ const command = getTestCommand();
+ command.showProfiles = jest.fn();
+ await command.run();
+
+ expect(command.showProfiles).toHaveBeenCalled();
+ });
+
+ test("run - showProfiles throw an error", async () => {
+ const command = getTestCommand();
+ command.showProfiles = jest.fn(async () => {
+ throw new Error("error");
+ });
+ try {
+ await command.run();
+ } catch (error) {
+ expect(error).toEqual(new Error("error"));
+ }
+ });
+
+ test("run - showProfiles throw an object", async () => {
+ const command = getTestCommand();
+ const errorToThrow = "string";
+ command.showProfiles = jest.fn(async () => {
+ throw errorToThrow;
+ });
+ try {
+ await command.run();
+ } catch (error) {
+ expect(error).toEqual(new Error("Unknown error: string"));
+ }
+ });
+
+ test("showProfiles", async () => {
+ const profiles = [
+ {
+ name: "profileName",
+ },
+ ];
+ const cliProviderService = {
+ namedProfilesService: {
+ getNamedProfiles: () => profiles,
+ },
+ };
+
+ const command = getTestCommand(cliProviderService);
+ const tableSpy = jest.spyOn(CliUx.ux, "table").mockImplementation(() => null);
+
+ await command.showProfiles();
+
+ const expectedData = [
+ {
+ name: "profileName",
+ },
+ ];
+
+ expect(tableSpy.mock.calls[0][0]).toEqual(expectedData);
+
+ const expectedColumns = {
+ name: { header: "Profile Name" },
+ };
+ expect(tableSpy.mock.calls[0][1]).toEqual(expectedColumns);
+ });
+});
diff --git a/cli/src/commands/profile/list.ts b/cli/src/commands/profile/list.ts
new file mode 100644
index 000000000..a86bd6dc1
--- /dev/null
+++ b/cli/src/commands/profile/list.ts
@@ -0,0 +1,37 @@
+import { CliUx } from "@oclif/core";
+import { Config } from "@oclif/core/lib/config/config";
+import { LeappCommand } from "../../leapp-command";
+
+export default class ListProfiles extends LeappCommand {
+ static description = "Show profile list";
+ static examples = ["$leapp profile list"];
+
+ static flags = {
+ ...CliUx.ux.table.flags(),
+ };
+
+ constructor(argv: string[], config: Config) {
+ super(argv, config);
+ }
+
+ async run(): Promise {
+ try {
+ await this.showProfiles();
+ } catch (error) {
+ this.error(error instanceof Error ? error.message : `Unknown error: ${error}`);
+ }
+ }
+
+ async showProfiles(): Promise {
+ const { flags } = await this.parse(ListProfiles);
+ const data = this.cliProviderService.namedProfilesService.getNamedProfiles().map((profile: any) => ({
+ name: profile.name,
+ })) as any as Record[];
+
+ const columns = {
+ name: { header: "Profile Name" },
+ };
+
+ CliUx.ux.table(data, columns, { ...flags });
+ }
+}
diff --git a/cli/src/commands/region/get-default.spec.ts b/cli/src/commands/region/get-default.spec.ts
new file mode 100644
index 000000000..613dd35eb
--- /dev/null
+++ b/cli/src/commands/region/get-default.spec.ts
@@ -0,0 +1,16 @@
+import { describe, expect, jest, test } from "@jest/globals";
+import GetDefaultRegion from "./get-default";
+
+describe("GetDefaultRegion", () => {
+ test("run", async () => {
+ const cliProviderService = { regionsService: { getDefaultAwsRegion: () => "defaultRegion" } } as any;
+
+ const command = new GetDefaultRegion([], {} as any);
+ (command as any).cliProviderService = cliProviderService;
+ command.log = jest.fn();
+
+ await command.run();
+
+ expect(command.log).toHaveBeenCalledWith("defaultRegion");
+ });
+});
diff --git a/cli/src/commands/region/get-default.ts b/cli/src/commands/region/get-default.ts
new file mode 100644
index 000000000..6bb7b3b30
--- /dev/null
+++ b/cli/src/commands/region/get-default.ts
@@ -0,0 +1,17 @@
+import { LeappCommand } from "../../leapp-command";
+import { Config } from "@oclif/core/lib/config/config";
+
+export default class GetDefaultRegion extends LeappCommand {
+ static description = "Displays the default region";
+
+ static examples = [`$leapp region get-default`];
+
+ constructor(argv: string[], config: Config) {
+ super(argv, config);
+ }
+
+ async run(): Promise {
+ const defaultAwsRegion = this.cliProviderService.regionsService.getDefaultAwsRegion();
+ this.log(defaultAwsRegion);
+ }
+}
diff --git a/cli/src/commands/region/set-default.spec.ts b/cli/src/commands/region/set-default.spec.ts
new file mode 100644
index 000000000..3e0469c7f
--- /dev/null
+++ b/cli/src/commands/region/set-default.spec.ts
@@ -0,0 +1,98 @@
+import { describe, expect, jest, test } from "@jest/globals";
+import ChangeDefaultRegion from "./set-default";
+import { SessionType } from "@noovolari/leapp-core/models/session-type";
+
+describe("ChangeDefaultRegion", () => {
+ const getTestCommand = (cliProviderService: any = null, argv: string[] = []): ChangeDefaultRegion => {
+ const command = new ChangeDefaultRegion(argv, {} as any);
+ (command as any).cliProviderService = cliProviderService;
+ return command;
+ };
+
+ test("selectDefaultRegion", async () => {
+ const cliProviderService: any = {
+ regionsService: {
+ getDefaultAwsRegion: () => "region1",
+ },
+ cloudProviderService: {
+ availableRegions: jest.fn(() => [{ fieldName: "region2", fieldValue: "region3" }]),
+ },
+ inquirer: {
+ prompt: async (params: any) => {
+ expect(params).toEqual([
+ {
+ name: "selectedDefaultRegion",
+ message: "current default region is region1, select a new default region",
+ type: "list",
+ choices: [{ name: "region2", value: "region3" }],
+ },
+ ]);
+ return { selectedDefaultRegion: "selectedRegion" };
+ },
+ },
+ };
+
+ const command = getTestCommand(cliProviderService);
+ const selectedRegion = await command.selectDefaultRegion();
+ expect(selectedRegion).toBe("selectedRegion");
+
+ expect(cliProviderService.cloudProviderService.availableRegions).toHaveBeenCalledWith(SessionType.aws);
+ });
+
+ test("changeDefaultRegion", async () => {
+ const newRegion = "newRegion";
+ const cliProviderService: any = {
+ regionsService: {
+ changeDefaultAwsRegion: jest.fn(),
+ },
+ remoteProceduresClient: {
+ refreshSessions: jest.fn(),
+ },
+ };
+
+ const command = getTestCommand(cliProviderService);
+ command.log = jest.fn();
+
+ await command.changeDefaultRegion(newRegion);
+ expect(cliProviderService.regionsService.changeDefaultAwsRegion).toHaveBeenCalledWith(newRegion);
+ expect(cliProviderService.remoteProceduresClient.refreshSessions).toHaveBeenCalled();
+ expect(command.log).toHaveBeenCalledWith("default region changed");
+ });
+
+ const runCommand = async (errorToThrow: any, expectedErrorMessage: string) => {
+ const region = "region";
+ const command = getTestCommand();
+
+ command.selectDefaultRegion = jest.fn(async (): Promise => region);
+ command.changeDefaultRegion = jest.fn(async (): Promise => {
+ if (errorToThrow) {
+ throw errorToThrow;
+ }
+ });
+
+ let occurredError;
+ try {
+ await command.run();
+ } catch (error) {
+ occurredError = error;
+ }
+
+ expect(command.selectDefaultRegion).toHaveBeenCalled();
+ expect(command.changeDefaultRegion).toHaveBeenCalledWith(region);
+ if (errorToThrow) {
+ expect(occurredError).toEqual(new Error(expectedErrorMessage));
+ }
+ };
+
+ test("run", async () => {
+ await runCommand(undefined, "");
+ });
+
+ test("run - changeDefaultRegion throws exception", async () => {
+ await runCommand(new Error("errorMessage"), "errorMessage");
+ });
+
+ test("run - changeDefaultRegion throws undefined object", async () => {
+ await runCommand({ hello: "randomObj" }, "Unknown error: [object Object]");
+ });
+});
diff --git a/cli/src/commands/region/set-default.ts b/cli/src/commands/region/set-default.ts
new file mode 100644
index 000000000..1d2b63d5d
--- /dev/null
+++ b/cli/src/commands/region/set-default.ts
@@ -0,0 +1,43 @@
+import { LeappCommand } from "../../leapp-command";
+import { Config } from "@oclif/core/lib/config/config";
+import { SessionType } from "@noovolari/leapp-core/models/session-type";
+
+export default class ChangeDefaultRegion extends LeappCommand {
+ static description = "Change the default region";
+
+ static examples = [`$leapp region set-default`];
+
+ constructor(argv: string[], config: Config) {
+ super(argv, config);
+ }
+
+ async run(): Promise {
+ const selectedDefaultRegion = await this.selectDefaultRegion();
+ try {
+ await this.changeDefaultRegion(selectedDefaultRegion);
+ } catch (error) {
+ this.error(error instanceof Error ? error.message : `Unknown error: ${error}`);
+ }
+ }
+
+ async selectDefaultRegion(): Promise {
+ const defaultRegion = this.cliProviderService.regionsService.getDefaultAwsRegion();
+ const availableRegions = this.cliProviderService.cloudProviderService.availableRegions(SessionType.aws);
+
+ const answer: any = await this.cliProviderService.inquirer.prompt([
+ {
+ name: "selectedDefaultRegion",
+ message: `current default region is ${defaultRegion}, select a new default region`,
+ type: "list",
+ choices: availableRegions.map((region) => ({ name: region.fieldName, value: region.fieldValue })),
+ },
+ ]);
+ return answer.selectedDefaultRegion;
+ }
+
+ async changeDefaultRegion(newDefaultRegion: string): Promise {
+ this.cliProviderService.regionsService.changeDefaultAwsRegion(newDefaultRegion);
+ await this.cliProviderService.remoteProceduresClient.refreshSessions();
+ this.log("default region changed");
+ }
+}
diff --git a/cli/src/commands/session/add.spec.ts b/cli/src/commands/session/add.spec.ts
new file mode 100644
index 000000000..803c38816
--- /dev/null
+++ b/cli/src/commands/session/add.spec.ts
@@ -0,0 +1,222 @@
+import { describe, expect, jest, test } from "@jest/globals";
+import { CloudProviderType } from "@noovolari/leapp-core/models/cloud-provider-type";
+import AddSession from "./add";
+import { IdpUrlAccessMethodField } from "@noovolari/leapp-core/models/idp-url-access-method-field";
+import { AccessMethodFieldType } from "@noovolari/leapp-core/models/access-method-field-type";
+
+describe("AddSession", () => {
+ const getTestCommand = (cliProviderService: any = null, createIdpUrlCommand: any = null): AddSession => {
+ const command = new AddSession([], {} as any, createIdpUrlCommand);
+ (command as any).cliProviderService = cliProviderService;
+ return command;
+ };
+
+ test("chooseCloudProvider", async () => {
+ const cliProviderService: any = {
+ cloudProviderService: {
+ availableCloudProviders: () => [CloudProviderType.aws],
+ },
+ inquirer: {
+ prompt: async (params: any) => {
+ expect(params).toEqual([
+ {
+ name: "selectedProvider",
+ message: "select a provider",
+ type: "list",
+ choices: [{ name: "aws" }],
+ },
+ ]);
+ return { selectedProvider: "aws" };
+ },
+ },
+ };
+
+ const command = getTestCommand(cliProviderService);
+ const selectedCloudProvider = await command.chooseCloudProvider();
+ expect(selectedCloudProvider).toBe("aws");
+ });
+
+ test("chooseAccessMethod", async () => {
+ const cliProviderService: any = {
+ cloudProviderService: {
+ creatableAccessMethods: () => [{ label: "IAmUser" }],
+ },
+ inquirer: {
+ prompt: (param: any) => {
+ expect(param).toEqual([
+ {
+ choices: [{ name: "IAmUser", value: { label: "IAmUser" } }],
+ message: "select an access method",
+ name: "selectedMethod",
+ type: "list",
+ },
+ ]);
+ return { selectedMethod: "Method" };
+ },
+ },
+ };
+
+ const command = getTestCommand(cliProviderService);
+ const accessMethod = await command.chooseAccessMethod(CloudProviderType.aws);
+ expect(accessMethod).toStrictEqual("Method");
+ });
+
+ test("chooseAccessMethodParams", async () => {
+ const expectedMap: any = new Map([["field", "choiceValue"]]);
+ const selectedAccessMethod: any = {
+ accessMethodFields: [
+ {
+ creationRequestField: "field",
+ message: "message",
+ type: "type",
+ choices: [{ fieldName: "choice", fieldValue: "choiceValue" }],
+ },
+ ],
+ };
+ const cliProviderService: any = {
+ inquirer: {
+ prompt: (params: any) => {
+ expect(params).toStrictEqual([
+ {
+ name: "field",
+ message: "message",
+ type: "type",
+ choices: [{ name: "choice", value: "choiceValue" }],
+ },
+ ]);
+ return { field: "choiceValue" };
+ },
+ },
+ };
+
+ const command = getTestCommand(cliProviderService);
+ const map = await command.chooseAccessMethodParams(selectedAccessMethod);
+ expect(map).toEqual(expectedMap);
+ });
+
+ test("chooseAccessMethodParams - IdpUrlAccessMethodField", async () => {
+ const expectedMap: any = new Map([["field", "choiceValue"]]);
+ const idpUrlAccessMethodField = new IdpUrlAccessMethodField("field", "message", AccessMethodFieldType.list, []);
+ idpUrlAccessMethodField.isIdpUrlToCreate = jest.fn(() => false);
+ const selectedAccessMethod: any = {
+ accessMethodFields: [idpUrlAccessMethodField],
+ };
+ const cliProviderService: any = {
+ inquirer: {
+ prompt: () => ({ field: "choiceValue" }),
+ },
+ };
+
+ const command = getTestCommand(cliProviderService);
+ const map = await command.chooseAccessMethodParams(selectedAccessMethod);
+ expect(map).toEqual(expectedMap);
+ });
+
+ test("chooseAccessMethodParams - IdpUrlAccessMethodField - idpUrl creation", async () => {
+ const expectedMap: any = new Map([["field", "newIdpUrlId"]]);
+ const idpUrlAccessMethodField = new IdpUrlAccessMethodField("field", "message", AccessMethodFieldType.list, []);
+ idpUrlAccessMethodField.isIdpUrlToCreate = jest.fn(() => true);
+ const selectedAccessMethod: any = {
+ accessMethodFields: [idpUrlAccessMethodField],
+ };
+ const cliProviderService: any = {
+ inquirer: {
+ prompt: () => ({ field: null }),
+ },
+ };
+ const createIdpUrlCommand = {
+ promptAndCreateIdpUrl: async () => ({ id: "newIdpUrlId" }),
+ };
+
+ const command = getTestCommand(cliProviderService, createIdpUrlCommand);
+ const map = await command.chooseAccessMethodParams(selectedAccessMethod);
+ expect(map).toEqual(expectedMap);
+ });
+
+ test("chooseAccessMethodParams - choices not present", async () => {
+ const selectedAccessMethod: any = {
+ accessMethodFields: [{ creationRequestField: "field", message: "message", type: "type", choices: undefined }],
+ };
+ const cliProviderService: any = {
+ inquirer: {
+ prompt: (params: any) => {
+ expect(params).toStrictEqual([
+ {
+ name: "field",
+ message: "message",
+ type: "type",
+ choices: undefined,
+ },
+ ]);
+ return { field: "inputValue" };
+ },
+ },
+ };
+
+ const command = getTestCommand(cliProviderService);
+ const map = await command.chooseAccessMethodParams(selectedAccessMethod);
+ expect(map).toEqual(new Map([["field", "inputValue"]]));
+ });
+
+ test("createSession", async () => {
+ const selectedParams = new Map([["name", "prova"]]);
+ const accessMethod: any = {
+ getSessionCreationRequest: (params: any) => {
+ expect(params).toEqual(selectedParams);
+ return "creationRequest";
+ },
+ sessionType: "sessionType",
+ };
+
+ const cliProviderService: any = { sessionFactory: { createSession: jest.fn() }, remoteProceduresClient: { refreshSessions: jest.fn() } };
+ const command = getTestCommand(cliProviderService);
+ command.log = jest.fn();
+
+ await command.createSession(accessMethod, selectedParams);
+ expect(cliProviderService.sessionFactory.createSession).toHaveBeenCalledWith("sessionType", "creationRequest");
+ expect(cliProviderService.remoteProceduresClient.refreshSessions).toHaveBeenCalled();
+ expect(command.log).toHaveBeenCalledWith("session added");
+ });
+
+ const runCommand = async (errorToThrow: any, expectedErrorMessage: string) => {
+ const cloudProvider = "cloudProvider";
+ const accessMethod = "accessMethod";
+ const params = "params";
+ const command = getTestCommand();
+ command.chooseCloudProvider = jest.fn(async (): Promise => cloudProvider);
+ command.chooseAccessMethod = jest.fn(async (): Promise => accessMethod);
+ command.chooseAccessMethodParams = jest.fn(async (): Promise => params);
+ command.createSession = jest.fn(async (): Promise => {
+ if (errorToThrow) {
+ throw errorToThrow;
+ }
+ });
+
+ let occurredError;
+ try {
+ await command.run();
+ } catch (error) {
+ occurredError = error;
+ }
+
+ expect(command.chooseCloudProvider).toHaveBeenCalled();
+ expect(command.chooseAccessMethod).toHaveBeenCalledWith(cloudProvider);
+ expect(command.chooseAccessMethodParams).toHaveBeenCalledWith(accessMethod);
+ expect(command.createSession).toHaveBeenCalledWith(accessMethod, params);
+ if (errorToThrow) {
+ expect(occurredError).toEqual(new Error(expectedErrorMessage));
+ }
+ };
+
+ test("run", async () => {
+ await runCommand(undefined, "");
+ });
+
+ test("run - createSession throws exception", async () => {
+ await runCommand(new Error("errorMessage"), "errorMessage");
+ });
+
+ test("run - createSession throws undefined object", async () => {
+ await runCommand({ hello: "randomObj" }, "Unknown error: [object Object]");
+ });
+});
diff --git a/cli/src/commands/session/add.ts b/cli/src/commands/session/add.ts
new file mode 100644
index 000000000..bd704c05f
--- /dev/null
+++ b/cli/src/commands/session/add.ts
@@ -0,0 +1,81 @@
+import { AccessMethod } from "@noovolari/leapp-core/models/access-method";
+import { CloudProviderType } from "@noovolari/leapp-core/models/cloud-provider-type";
+import { LeappCommand } from "../../leapp-command";
+import { Config } from "@oclif/core/lib/config/config";
+import { IdpUrlAccessMethodField } from "@noovolari/leapp-core/models/idp-url-access-method-field";
+import CreateIdpUrl from "../idp-url/create";
+
+export default class AddSession extends LeappCommand {
+ static description = "Add a new session";
+ static examples = ["$leapp session add"];
+
+ constructor(argv: string[], config: Config, private createIdpUrlCommand: CreateIdpUrl = new CreateIdpUrl(argv, config)) {
+ super(argv, config);
+ }
+
+ async run(): Promise {
+ try {
+ const selectedCloudProvider = await this.chooseCloudProvider();
+ const selectedAccessMethod = await this.chooseAccessMethod(selectedCloudProvider);
+ const selectedParams = await this.chooseAccessMethodParams(selectedAccessMethod);
+ await this.createSession(selectedAccessMethod, selectedParams);
+ } catch (error) {
+ this.error(error instanceof Error ? error.message : `Unknown error: ${error}`);
+ }
+ }
+
+ async createSession(accessMethod: AccessMethod, selectedParams: Map): Promise {
+ const creationRequest = accessMethod.getSessionCreationRequest(selectedParams);
+ await this.cliProviderService.sessionFactory.createSession(accessMethod.sessionType, creationRequest);
+ await this.cliProviderService.remoteProceduresClient.refreshSessions();
+ this.log("session added");
+ }
+
+ async chooseCloudProvider(): Promise {
+ const availableCloudProviders = this.cliProviderService.cloudProviderService.availableCloudProviders();
+ const cloudProviderAnswer: any = await this.cliProviderService.inquirer.prompt([
+ {
+ name: "selectedProvider",
+ message: "select a provider",
+ type: "list",
+ choices: availableCloudProviders.map((cloudProvider: any) => ({ name: cloudProvider })),
+ },
+ ]);
+ return cloudProviderAnswer.selectedProvider;
+ }
+
+ async chooseAccessMethod(cloudProviderType: CloudProviderType): Promise {
+ const accessMethods = this.cliProviderService.cloudProviderService.creatableAccessMethods(cloudProviderType);
+ const accessMethodAnswer: any = await this.cliProviderService.inquirer.prompt([
+ {
+ name: "selectedMethod",
+ message: "select an access method",
+ type: "list",
+ choices: accessMethods.map((accessMethod: any) => ({ name: accessMethod.label, value: accessMethod })),
+ },
+ ]);
+ return accessMethodAnswer.selectedMethod;
+ }
+
+ async chooseAccessMethodParams(selectedAccessMethod: AccessMethod): Promise> {
+ const fieldValuesMap = new Map();
+ for (const field of selectedAccessMethod.accessMethodFields) {
+ const fieldAnswer: any = await this.cliProviderService.inquirer.prompt([
+ {
+ name: field.creationRequestField,
+ message: field.message,
+ type: field.type,
+ choices: field.choices?.map((choice: any) => ({ name: choice.fieldName, value: choice.fieldValue })),
+ },
+ ]);
+ let fieldAnswerValue = fieldAnswer[field.creationRequestField];
+ if (field instanceof IdpUrlAccessMethodField && field.isIdpUrlToCreate(fieldAnswerValue)) {
+ const newIdpUrl = await this.createIdpUrlCommand.promptAndCreateIdpUrl();
+ fieldAnswerValue = newIdpUrl.id;
+ }
+ fieldValuesMap.set(field.creationRequestField, fieldAnswerValue);
+ }
+
+ return fieldValuesMap;
+ }
+}
diff --git a/cli/src/commands/session/change-profile.spec.ts b/cli/src/commands/session/change-profile.spec.ts
new file mode 100644
index 000000000..b20510ecc
--- /dev/null
+++ b/cli/src/commands/session/change-profile.spec.ts
@@ -0,0 +1,148 @@
+import { jest, describe, test, expect } from "@jest/globals";
+import { AwsIamUserService } from "@noovolari/leapp-core/services/session/aws/aws-iam-user-service";
+import ChangeSessionProfile from "./change-profile";
+
+describe("ChangeProfile", () => {
+ const getTestCommand = (cliProviderService: any = null): ChangeSessionProfile => {
+ const command = new ChangeSessionProfile([], {} as any);
+ (command as any).cliProviderService = cliProviderService;
+ return command;
+ };
+
+ test("selectSession", async () => {
+ const session1 = { sessionName: "sessionName" };
+ const cliProviderService: any = {
+ repository: {
+ getSessions: () => [session1],
+ },
+ sessionFactory: {
+ getSessionService: () => new AwsIamUserService(null, null, null, null, null, null),
+ },
+ inquirer: {
+ prompt: async (params: any) => {
+ expect(params).toEqual([
+ {
+ name: "selectedSession",
+ message: "select a session",
+ type: "list",
+ choices: [{ name: session1.sessionName, value: session1 }],
+ },
+ ]);
+ return { selectedSession: "selectedSession" };
+ },
+ },
+ };
+
+ const command = getTestCommand(cliProviderService);
+ const selectedSession = await command.selectSession();
+ expect(selectedSession).toBe("selectedSession");
+ });
+
+ test("selectProfile", async () => {
+ const profileFieldChoice = { name: "profileName1", id: "profileId1" };
+ const cliProviderService: any = {
+ repository: {
+ getProfiles: jest.fn(() => [profileFieldChoice]),
+ getProfileName: jest.fn(() => "profileName1"),
+ },
+ inquirer: {
+ prompt: async (params: any) => {
+ expect(params).toEqual([
+ {
+ name: "selectedProfile",
+ message: "current profile is profileName1, select a new profile",
+ type: "list",
+ choices: [{ name: profileFieldChoice.name, value: profileFieldChoice.id }],
+ },
+ ]);
+ return { selectedProfile: "selectedProfile" };
+ },
+ },
+ };
+
+ const command = getTestCommand(cliProviderService);
+
+ const session = { type: "type", profileId: "profileId2" } as any;
+ const selectedProfile = await command.selectProfile(session);
+
+ expect(selectedProfile).toBe("selectedProfile");
+ expect(cliProviderService.repository.getProfiles).toHaveBeenCalled();
+ expect(cliProviderService.repository.getProfileName).toHaveBeenCalledWith(session.profileId);
+ });
+
+ test("selectProfile - error: no profile available", async () => {
+ const cliProviderService: any = {
+ repository: {
+ getProfiles: jest.fn(() => []),
+ getProfileName: jest.fn(() => "profileName1"),
+ },
+ };
+
+ const command = getTestCommand(cliProviderService);
+
+ const session = { type: "type", profileId: "profileId2" } as any;
+
+ await expect(command.selectProfile(session)).rejects.toThrow(new Error("no profiles available"));
+ expect(cliProviderService.repository.getProfiles).toHaveBeenCalled();
+ expect(cliProviderService.repository.getProfileName).toHaveBeenCalledWith(session.profileId);
+ });
+
+ test("changeSessionProfile", async () => {
+ const session = {} as any;
+ const newProfile = {} as any;
+
+ const cliProviderService: any = {
+ namedProfilesService: {
+ changeNamedProfile: jest.fn(),
+ },
+ remoteProceduresClient: { refreshSessions: jest.fn() },
+ };
+
+ const command = getTestCommand(cliProviderService);
+ command.log = jest.fn();
+
+ await command.changeSessionProfile(session, newProfile);
+ expect(cliProviderService.namedProfilesService.changeNamedProfile).toHaveBeenCalledWith(session, newProfile);
+ expect(command.log).toHaveBeenCalledWith("session profile changed");
+ expect(cliProviderService.remoteProceduresClient.refreshSessions).toHaveBeenCalled();
+ });
+
+ const runCommand = async (errorToThrow: any, expectedErrorMessage: string) => {
+ const session = "session";
+ const region = "region";
+ const command = getTestCommand();
+ command.selectSession = jest.fn(async (): Promise => session);
+ command.selectProfile = jest.fn(async (): Promise => region);
+ command.changeSessionProfile = jest.fn(async (): Promise => {
+ if (errorToThrow) {
+ throw errorToThrow;
+ }
+ });
+
+ let occurredError;
+ try {
+ await command.run();
+ } catch (error) {
+ occurredError = error;
+ }
+
+ expect(command.selectSession).toHaveBeenCalled();
+ expect(command.selectProfile).toHaveBeenCalledWith(session);
+ expect(command.changeSessionProfile).toHaveBeenCalledWith(session, region);
+ if (errorToThrow) {
+ expect(occurredError).toEqual(new Error(expectedErrorMessage));
+ }
+ };
+
+ test("run", async () => {
+ await runCommand(undefined, "");
+ });
+
+ test("run - changeSessionRegion throws exception", async () => {
+ await runCommand(new Error("errorMessage"), "errorMessage");
+ });
+
+ test("run - changeSessionRegion throws undefined object", async () => {
+ await runCommand({ hello: "randomObj" }, "Unknown error: [object Object]");
+ });
+});
diff --git a/cli/src/commands/session/change-profile.ts b/cli/src/commands/session/change-profile.ts
new file mode 100644
index 000000000..53c7a7c59
--- /dev/null
+++ b/cli/src/commands/session/change-profile.ts
@@ -0,0 +1,68 @@
+import { AwsSessionService } from "@noovolari/leapp-core/services/session/aws/aws-session-service";
+import { LeappCommand } from "../../leapp-command";
+import { Config } from "@oclif/core/lib/config/config";
+import { Session } from "@noovolari/leapp-core/models/session";
+
+export default class ChangeSessionProfile extends LeappCommand {
+ static description = "Change a session named-profile";
+
+ static examples = [`$leapp session change-profile`];
+
+ constructor(argv: string[], config: Config) {
+ super(argv, config);
+ }
+
+ async run(): Promise {
+ try {
+ const selectedSession = await this.selectSession();
+ const selectedProfile = await this.selectProfile(selectedSession);
+ await this.changeSessionProfile(selectedSession, selectedProfile);
+ } catch (error) {
+ this.error(error instanceof Error ? error.message : `Unknown error: ${error}`);
+ }
+ }
+
+ async selectSession(): Promise {
+ const availableSessions = this.cliProviderService.repository
+ .getSessions()
+ .filter((session) => this.cliProviderService.sessionFactory.getSessionService(session.type) instanceof AwsSessionService);
+
+ const answer: any = await this.cliProviderService.inquirer.prompt([
+ {
+ name: "selectedSession",
+ message: "select a session",
+ type: "list",
+ choices: availableSessions.map((session) => ({ name: session.sessionName, value: session })),
+ },
+ ]);
+ return answer.selectedSession;
+ }
+
+ async selectProfile(session: Session): Promise {
+ const currentProfileName = this.cliProviderService.repository.getProfileName((session as any).profileId);
+ const availableProfiles = this.cliProviderService.repository.getProfiles().filter((profile) => profile.id !== (session as any).profileId);
+
+ if (availableProfiles.length === 0) {
+ throw new Error("no profiles available");
+ }
+
+ const answer: any = await this.cliProviderService.inquirer.prompt([
+ {
+ name: "selectedProfile",
+ message: `current profile is ${currentProfileName}, select a new profile`,
+ type: "list",
+ choices: availableProfiles.map((profile) => ({ name: profile.name, value: profile.id })),
+ },
+ ]);
+ return answer.selectedProfile;
+ }
+
+ async changeSessionProfile(session: Session, newProfileId: string): Promise {
+ try {
+ await this.cliProviderService.namedProfilesService.changeNamedProfile(session, newProfileId);
+ this.log("session profile changed");
+ } finally {
+ await this.cliProviderService.remoteProceduresClient.refreshSessions();
+ }
+ }
+}
diff --git a/cli/src/commands/session/change-region.spec.ts b/cli/src/commands/session/change-region.spec.ts
new file mode 100644
index 000000000..e6ffee3d3
--- /dev/null
+++ b/cli/src/commands/session/change-region.spec.ts
@@ -0,0 +1,125 @@
+import { jest, describe, test, expect } from "@jest/globals";
+import ChangeSessionRegion from "./change-region";
+
+describe("ChangeRegion", () => {
+ const getTestCommand = (cliProviderService: any = null): ChangeSessionRegion => {
+ const command = new ChangeSessionRegion([], {} as any);
+ (command as any).cliProviderService = cliProviderService;
+ return command;
+ };
+
+ test("selectSession", async () => {
+ const session1 = { sessionName: "sessionName" };
+ const cliProviderService: any = {
+ repository: {
+ getSessions: () => [session1],
+ },
+ inquirer: {
+ prompt: async (params: any) => {
+ expect(params).toEqual([
+ {
+ name: "selectedSession",
+ message: "select a session",
+ type: "list",
+ choices: [{ name: session1.sessionName, value: session1 }],
+ },
+ ]);
+ return { selectedSession: "selectedSession" };
+ },
+ },
+ };
+
+ const command = getTestCommand(cliProviderService);
+ const selectedSession = await command.selectSession();
+ expect(selectedSession).toBe("selectedSession");
+ });
+
+ test("selectRegion", async () => {
+ const regionFieldChoice = { fieldName: "regionName2", fieldValue: "regionName3" };
+ const cliProviderService: any = {
+ cloudProviderService: {
+ availableRegions: jest.fn(() => [regionFieldChoice]),
+ },
+ inquirer: {
+ prompt: async (params: any) => {
+ expect(params).toEqual([
+ {
+ name: "selectedRegion",
+ message: "current region is regionName1, select a new region",
+ type: "list",
+ choices: [{ name: regionFieldChoice.fieldName, value: regionFieldChoice.fieldValue }],
+ },
+ ]);
+ return { selectedRegion: "selectedRegion" };
+ },
+ },
+ };
+
+ const command = getTestCommand(cliProviderService);
+
+ const session = { type: "type", region: "regionName1" } as any;
+ const selectedRegion = await command.selectRegion(session);
+
+ expect(selectedRegion).toBe("selectedRegion");
+ expect(cliProviderService.cloudProviderService.availableRegions).toHaveBeenCalledWith(session.type);
+ });
+
+ test("changeSessionRegion", async () => {
+ const session = {} as any;
+ const newRegion = {} as any;
+
+ const cliProviderService: any = {
+ regionsService: {
+ changeRegion: jest.fn(),
+ },
+ remoteProceduresClient: { refreshSessions: jest.fn() },
+ };
+
+ const command = getTestCommand(cliProviderService);
+ command.log = jest.fn();
+
+ await command.changeSessionRegion(session, newRegion);
+ expect(cliProviderService.regionsService.changeRegion).toHaveBeenCalledWith(session, newRegion);
+ expect(command.log).toHaveBeenCalledWith("session region changed");
+ expect(cliProviderService.remoteProceduresClient.refreshSessions).toHaveBeenCalled();
+ });
+
+ const runCommand = async (errorToThrow: any, expectedErrorMessage: string) => {
+ const session = "session";
+ const region = "region";
+ const command = getTestCommand();
+ command.selectSession = jest.fn(async (): Promise => session);
+ command.selectRegion = jest.fn(async (): Promise => region);
+ command.changeSessionRegion = jest.fn(async (): Promise => {
+ if (errorToThrow) {
+ throw errorToThrow;
+ }
+ });
+
+ let occurredError;
+ try {
+ await command.run();
+ } catch (error) {
+ occurredError = error;
+ }
+
+ expect(command.selectSession).toHaveBeenCalled();
+ expect(command.selectRegion).toHaveBeenCalledWith(session);
+ expect(command.changeSessionRegion).toHaveBeenCalledWith(session, region);
+ if (errorToThrow) {
+ expect(occurredError).toEqual(new Error(expectedErrorMessage));
+ }
+ };
+
+ test("run", async () => {
+ await runCommand(undefined, "");
+ });
+
+ test("run - changeSessionRegion throws exception", async () => {
+ await runCommand(new Error("errorMessage"), "errorMessage");
+ });
+
+ test("run - changeSessionRegion throws undefined object", async () => {
+ await runCommand({ hello: "randomObj" }, "Unknown error: [object Object]");
+ });
+});
diff --git a/cli/src/commands/session/change-region.ts b/cli/src/commands/session/change-region.ts
new file mode 100644
index 000000000..8cac03229
--- /dev/null
+++ b/cli/src/commands/session/change-region.ts
@@ -0,0 +1,60 @@
+import { LeappCommand } from "../../leapp-command";
+import { Config } from "@oclif/core/lib/config/config";
+import { Session } from "@noovolari/leapp-core/models/session";
+
+export default class ChangeSessionRegion extends LeappCommand {
+ static description = "Change a session region";
+
+ static examples = [`$leapp session change-region`];
+
+ constructor(argv: string[], config: Config) {
+ super(argv, config);
+ }
+
+ async run(): Promise {
+ try {
+ const selectedSession = await this.selectSession();
+ const selectedRegion = await this.selectRegion(selectedSession);
+ await this.changeSessionRegion(selectedSession, selectedRegion);
+ } catch (error) {
+ this.error(error instanceof Error ? error.message : `Unknown error: ${error}`);
+ }
+ }
+
+ async selectSession(): Promise {
+ const availableSessions = this.cliProviderService.repository.getSessions();
+
+ const answer: any = await this.cliProviderService.inquirer.prompt([
+ {
+ name: "selectedSession",
+ message: "select a session",
+ type: "list",
+ choices: availableSessions.map((session) => ({ name: session.sessionName, value: session })),
+ },
+ ]);
+ return answer.selectedSession;
+ }
+
+ async selectRegion(session: Session): Promise {
+ const availableRegions = this.cliProviderService.cloudProviderService.availableRegions(session.type);
+
+ const answer: any = await this.cliProviderService.inquirer.prompt([
+ {
+ name: "selectedRegion",
+ message: `current region is ${session.region}, select a new region`,
+ type: "list",
+ choices: availableRegions.map((region) => ({ name: region.fieldName, value: region.fieldValue })),
+ },
+ ]);
+ return answer.selectedRegion;
+ }
+
+ async changeSessionRegion(session: Session, newRegion: string): Promise {
+ try {
+ await this.cliProviderService.regionsService.changeRegion(session, newRegion);
+ this.log("session region changed");
+ } finally {
+ await this.cliProviderService.remoteProceduresClient.refreshSessions();
+ }
+ }
+}
diff --git a/cli/src/commands/session/current.spec.ts b/cli/src/commands/session/current.spec.ts
new file mode 100644
index 000000000..850aa7ba3
--- /dev/null
+++ b/cli/src/commands/session/current.spec.ts
@@ -0,0 +1,448 @@
+import { describe, expect, jest, test } from "@jest/globals";
+import { SessionType } from "@noovolari/leapp-core/models/session-type";
+import { AwsIamUserService } from "@noovolari/leapp-core/services/session/aws/aws-iam-user-service";
+import CurrentSession from "./current";
+import { AzureService } from "@noovolari/leapp-core/services/session/azure/azure-service";
+
+const awsProvider = "aws";
+const azureProvider = "azure";
+
+describe("CurrentSession", () => {
+ const getTestCommand = (cliProviderService: any = null): CurrentSession => {
+ const command = new CurrentSession([], {} as any);
+ (command as any).cliProviderService = cliProviderService;
+ return command;
+ };
+
+ test("currentSession", async () => {
+ const command = getTestCommand();
+ const session = "session";
+ const dataFormat = "dataFormat";
+ const dataFilter = "dataFilter";
+ command.getSessionData = jest.fn(async () => "sessionData");
+ command.filterSessionData = jest.fn(() => "filterSessionData");
+ command.formatSessionData = jest.fn();
+ command.log = jest.fn();
+
+ const currentSessionData = await command.currentSession(session as any, dataFormat, dataFilter as any);
+
+ expect(command.getSessionData).toHaveBeenCalledWith(session);
+ expect(command.filterSessionData).toHaveBeenCalledWith("sessionData", dataFilter);
+ expect(command.formatSessionData).toHaveBeenCalledWith("filterSessionData", dataFormat);
+ expect(command.log).toHaveBeenCalledWith(currentSessionData);
+ });
+
+ test("getSessionFromProfile - default, with provider", () => {
+ const sessions = [
+ { profileId: "profileId1", type: "type1" },
+ { profileId: "profileId1", type: "type2" },
+ { profileId: "profileId2", type: "type2" },
+ ];
+ const cliProviderService: any = {
+ repository: {
+ getDefaultProfileId: jest.fn(() => "profileId1"),
+ listActive: jest.fn(() => sessions),
+ },
+ };
+ const command = getTestCommand(cliProviderService);
+
+ const profileName = "default";
+ const provider = "provider";
+
+ const sessionTypes = ["type1"];
+ (command.getProviderAssociatedSessionTypes as any) = jest.fn(() => sessionTypes);
+
+ const sessionFromProfile = command.getSessionFromProfile(profileName, provider);
+
+ expect(sessionFromProfile).toEqual({ profileId: "profileId1", type: "type1" });
+ expect(cliProviderService.repository.getDefaultProfileId).toHaveBeenCalled();
+ expect(cliProviderService.repository.listActive).toHaveBeenCalled();
+ expect(command.getProviderAssociatedSessionTypes).toHaveBeenCalledWith(provider);
+ });
+
+ test("getSessionFromProfile - no default, without provider", () => {
+ const sessions = [
+ { profileId: "profileId1", type: "type1" },
+ { profileId: "profileId2", type: "type2" },
+ ];
+ const cliProviderService: any = {
+ repository: {
+ listActive: jest.fn(() => sessions),
+ },
+ };
+ const command = getTestCommand(cliProviderService);
+ command.getProfileId = jest.fn(() => "profileId1");
+
+ const profileName = "profileName";
+ const provider = undefined;
+
+ const sessionFromProfile = command.getSessionFromProfile(profileName, provider);
+
+ expect(sessionFromProfile).toEqual({ profileId: "profileId1", type: "type1" });
+ expect(command.getProfileId).toHaveBeenCalledWith(profileName);
+ expect(cliProviderService.repository.listActive).toHaveBeenCalled();
+ });
+
+ test("getSessionFromProfile - error: no sessions", () => {
+ const sessions = [];
+ const cliProviderService: any = {
+ repository: {
+ getDefaultProfileId: jest.fn(() => "defaultProfileId"),
+ listActive: jest.fn(() => sessions),
+ },
+ };
+ const command = getTestCommand(cliProviderService);
+
+ const profileName = "default";
+ const provider = undefined;
+
+ expect(() => command.getSessionFromProfile(profileName, provider)).toThrow(new Error("no active sessions available for the specified criteria"));
+ expect(cliProviderService.repository.getDefaultProfileId).toHaveBeenCalled();
+ expect(cliProviderService.repository.listActive).toHaveBeenCalled();
+ });
+
+ test("getSessionFromProfile - error: selected profile has more than one active session related for the given provider", () => {
+ const sessions = [
+ { profileId: "profileId1", type: "type1" },
+ { profileId: "profileId1", type: "type1" },
+ ];
+ const cliProviderService: any = {
+ repository: {
+ getDefaultProfileId: jest.fn(() => "profileId1"),
+ listActive: jest.fn(() => sessions),
+ },
+ };
+ const command = getTestCommand(cliProviderService);
+ const sessionTypes = ["type1"];
+ (command.getProviderAssociatedSessionTypes as any) = jest.fn(() => sessionTypes);
+
+ const profileName = "default";
+ const provider = "provider";
+
+ expect(() => command.getSessionFromProfile(profileName, provider)).toThrow(
+ new Error("multiple active sessions found, please specify a provider with --provider")
+ );
+ expect(cliProviderService.repository.getDefaultProfileId).toHaveBeenCalled();
+ expect(cliProviderService.repository.listActive).toHaveBeenCalled();
+ expect(command.getProviderAssociatedSessionTypes).toHaveBeenCalledWith(provider);
+ });
+
+ test("getSessionFromProfile - error: more than one active session from different providers", () => {
+ const sessions = [{ profileId: "profileId1", type: "type1" }, { type: "type2" }];
+ const cliProviderService: any = {
+ repository: {
+ getDefaultProfileId: jest.fn(() => "profileId1"),
+ listActive: jest.fn(() => sessions),
+ },
+ };
+ const command = getTestCommand(cliProviderService);
+
+ const profileName = "default";
+ const provider = undefined;
+
+ expect(() => command.getSessionFromProfile(profileName, provider)).toThrow(
+ new Error("multiple active sessions found, please specify a provider with --provider")
+ );
+ expect(cliProviderService.repository.getDefaultProfileId).toHaveBeenCalled();
+ expect(cliProviderService.repository.listActive).toHaveBeenCalled();
+ });
+
+ test("getProfileId", () => {
+ const profiles = [
+ { name: "profileName1", id: "profileId1" },
+ { name: "profileName2", id: "profileId2" },
+ ];
+ const cliProviderService: any = {
+ repository: {
+ getProfiles: jest.fn(() => profiles),
+ },
+ };
+ const profileName = "profileName1";
+ const command = getTestCommand(cliProviderService);
+ const profileId = command.getProfileId(profileName);
+
+ expect(profileId).toBe("profileId1");
+ expect(cliProviderService.repository.getProfiles).toHaveBeenCalled();
+ });
+
+ test("getProfileId - error: no profiles available", () => {
+ const profiles = [
+ { name: "profileName1", id: "profileId1" },
+ { name: "profileName2", id: "profileId2" },
+ ];
+ const cliProviderService: any = {
+ repository: {
+ getProfiles: jest.fn(() => profiles),
+ },
+ };
+ const profileName = "profileName3";
+ const command = getTestCommand(cliProviderService);
+
+ expect(() => command.getProfileId(profileName)).toThrow(new Error(`AWS named profile "${profileName}" not found`));
+ expect(cliProviderService.repository.getProfiles).toHaveBeenCalled();
+ });
+
+ test("getProfileId - error: selected profile has more than one occurrence", () => {
+ const profiles = [
+ { name: "profileName1", id: "profileId1" },
+ { name: "profileName1", id: "profileId2" },
+ { name: "profileName2", id: "profileId3" },
+ ];
+ const cliProviderService: any = {
+ repository: {
+ getProfiles: jest.fn(() => profiles),
+ },
+ };
+ const profileName = "profileName1";
+ const command = getTestCommand(cliProviderService);
+
+ expect(() => command.getProfileId(profileName)).toThrow(new Error("selected profile has more than one occurrence"));
+ expect(cliProviderService.repository.getProfiles).toHaveBeenCalled();
+ });
+
+ test("getFieldRequired", () => {
+ const command = getTestCommand();
+ const fieldRequiredString = "field1 field2";
+ const fieldRequiredArray = command.getFieldsRequired(fieldRequiredString);
+
+ expect(fieldRequiredArray).toEqual(["field1", "field2"]);
+ });
+
+ test("getSessionData - aws iam user", async () => {
+ const sessionService = new AwsIamUserService(null, null, null, null, null, null);
+ sessionService.getAccountNumberFromCallerIdentity = jest.fn(async () => "000");
+
+ const cliProviderService: any = {
+ sessionFactory: {
+ getSessionService: jest.fn(() => sessionService),
+ },
+ };
+ const session = {
+ sessionName: "sessionName",
+ type: SessionType.awsIamUser,
+ };
+ const command = getTestCommand(cliProviderService);
+
+ const sessionData = await command.getSessionData(session as any);
+
+ expect(sessionData).toEqual({
+ alias: session.sessionName,
+ accountNumber: "000",
+ roleArn: "none",
+ });
+ expect(cliProviderService.sessionFactory.getSessionService).toHaveBeenCalledWith(session.type);
+ expect(sessionService.getAccountNumberFromCallerIdentity).toHaveBeenCalledWith(session);
+ });
+
+ test("getSessionData - aws role federated", async () => {
+ const sessionService = new AwsIamUserService(null, null, null, null, null, null);
+ sessionService.getAccountNumberFromCallerIdentity = jest.fn(async () => "000");
+
+ const cliProviderService: any = {
+ sessionFactory: {
+ getSessionService: jest.fn(() => sessionService),
+ },
+ };
+ const session = {
+ sessionName: "sessionName",
+ type: SessionType.awsIamRoleFederated,
+ roleArn: "role:arn",
+ };
+ const command = getTestCommand(cliProviderService);
+
+ const sessionData = await command.getSessionData(session as any);
+
+ expect(sessionData).toEqual({
+ alias: session.sessionName,
+ accountNumber: "000",
+ roleArn: "role:arn",
+ });
+ expect(cliProviderService.sessionFactory.getSessionService).toHaveBeenCalledWith(session.type);
+ expect(sessionService.getAccountNumberFromCallerIdentity).toHaveBeenCalledWith(session);
+ });
+
+ test("getSessionData - azure", async () => {
+ const cliProviderService: any = {
+ sessionFactory: {
+ getSessionService: jest.fn(() => new AzureService(null, null, null, null, null)),
+ },
+ };
+ const session = {
+ sessionName: "sessionName",
+ tenantId: "tenantId",
+ subscriptionId: "subscriptionId",
+ type: SessionType.azure,
+ };
+ const command = getTestCommand(cliProviderService);
+
+ const sessionData = await command.getSessionData(session as any);
+
+ expect(sessionData).toEqual({
+ alias: "sessionName",
+ tenantId: "tenantId",
+ subscriptionId: "subscriptionId",
+ });
+ expect(cliProviderService.sessionFactory.getSessionService).toHaveBeenCalledWith(session.type);
+ });
+
+ test("getSessionData - error: session type not supported", async () => {
+ const cliProviderService: any = {
+ sessionFactory: {
+ getSessionService: jest.fn(() => {}),
+ },
+ };
+ const session = { type: "sessionType" };
+ const command = getTestCommand(cliProviderService);
+
+ await expect(() => command.getSessionData(session as any)).rejects.toThrow(new Error(`session type not supported: ${session.type}`));
+ expect(cliProviderService.sessionFactory.getSessionService).toHaveBeenCalledWith(session.type);
+ });
+
+ test("filterSessionData - matching filters", () => {
+ const command = getTestCommand();
+ const sessionData = {
+ alias: "sessionAlias",
+ accountNumber: "sessionAccountNumber",
+ };
+ const filterArray = ["alias"];
+ const filteredSessionData = command.filterSessionData(sessionData, filterArray);
+
+ expect(filteredSessionData).toEqual({
+ alias: "sessionAlias",
+ });
+ });
+
+ test("filterSessionData - non matching filters", () => {
+ const command = getTestCommand();
+ const sessionData = {
+ alias: "sessionAlias",
+ accountNumber: "sessionAccountNumber",
+ };
+ const filterArray = ["name"];
+ const filteredSessionData = command.filterSessionData(sessionData, filterArray);
+
+ expect(filteredSessionData).toEqual({});
+ });
+
+ test("filterSessionData - no filter params (filerArray undefined)", () => {
+ const command = getTestCommand();
+ const sessionData = {
+ alias: "sessionAlias",
+ accountNumber: "sessionAccountNumber",
+ };
+ const filterArray = undefined;
+ const filteredSessionData = command.filterSessionData(sessionData, filterArray);
+
+ expect(filteredSessionData).toEqual({
+ alias: "sessionAlias",
+ accountNumber: "sessionAccountNumber",
+ });
+ });
+
+ test("formatSessionData - JSON", () => {
+ const sessionData = {
+ alias: "sessionAlias",
+ accountNumber: "sessionAccountNumber",
+ };
+ const dataFormat = "JSON";
+ const command = getTestCommand();
+
+ const formattedSessionData = command.formatSessionData(sessionData, dataFormat);
+
+ expect(formattedSessionData).toBe('{"alias":"sessionAlias","accountNumber":"sessionAccountNumber"}');
+ });
+
+ test("formatSessionData - inline", () => {
+ const sessionData = {
+ alias: "sessionAlias",
+ accountNumber: "sessionAccountNumber",
+ };
+ const dataFormat = "inline";
+ const command = getTestCommand();
+
+ const formattedSessionData = command.formatSessionData(sessionData, dataFormat);
+
+ expect(formattedSessionData).toBe("alias: sessionAlias, accountNumber: sessionAccountNumber");
+ });
+
+ test("formatSessionData - error: formatting style not allowed", () => {
+ const sessionData = {
+ alias: "sessionAlias",
+ accountNumber: "sessionAccountNumber",
+ };
+ const dataFormat = "notAllowedFormatStyle";
+ const command = getTestCommand();
+
+ expect(() => command.formatSessionData(sessionData, dataFormat)).toThrow(new Error(`formatting style not allowed "${dataFormat}"`));
+ });
+
+ test("getProviderAssociatedSessionTypes - awsProvider", () => {
+ const provider = awsProvider;
+ const command = getTestCommand();
+
+ const sessionTypes = command.getProviderAssociatedSessionTypes(provider);
+
+ expect(sessionTypes).toEqual([SessionType.awsIamUser, SessionType.awsIamRoleChained, SessionType.awsIamRoleFederated, SessionType.awsSsoRole]);
+ });
+
+ test("getProviderAssociatedSessionTypes - azureProvider", () => {
+ const provider = azureProvider;
+ const command = getTestCommand();
+
+ const sessionTypes = command.getProviderAssociatedSessionTypes(provider);
+
+ expect(sessionTypes).toEqual([SessionType.azure]);
+ });
+
+ const runCommand = async (
+ errorToThrow: any,
+ expectedErrorMessage: string,
+ expectedFormat: string = null,
+ expectedInline: boolean = false,
+ expectedDataFormat: string = "JSON"
+ ) => {
+ const command = getTestCommand(null);
+
+ const flags = { inline: expectedInline, profile: "profile", provider: "provider", format: expectedFormat };
+ (command as any).parse = jest.fn(() => ({ flags }));
+
+ (command.getSessionFromProfile as any) = jest.fn(async (): Promise => "session");
+ command.currentSession = jest.fn(async (): Promise => {
+ if (errorToThrow) {
+ throw errorToThrow;
+ }
+ });
+ (command as any).getFieldsRequired = jest.fn((): any => "dataFilter");
+
+ try {
+ await command.run();
+ } catch (error) {
+ expect(error).toEqual(new Error(expectedErrorMessage));
+ }
+ if (flags.format) {
+ expect(command.getFieldsRequired).toHaveBeenCalledWith(flags.format);
+ }
+ expect(command.getSessionFromProfile).toHaveBeenCalledWith("profile", "provider");
+ expect(command.currentSession).toHaveBeenCalledWith("session", expectedDataFormat, flags.format ? "dataFilter" : undefined);
+ };
+
+ test("run - all ok - json", async () => {
+ await runCommand(undefined, "");
+ });
+
+ test("run - all ok - inline", async () => {
+ await runCommand(undefined, "", null, true, "inline");
+ });
+
+ test("run - all ok - with format", async () => {
+ await runCommand(undefined, "", "format");
+ });
+
+ test("run - generateSession throws exception", async () => {
+ await runCommand(new Error("errorMessage"), "errorMessage");
+ });
+
+ test("run - generateSession throws undefined object", async () => {
+ await runCommand({ hello: "randomObj" }, "Unknown error: [object Object]");
+ });
+});
diff --git a/cli/src/commands/session/current.ts b/cli/src/commands/session/current.ts
new file mode 100644
index 000000000..34d7b1f7a
--- /dev/null
+++ b/cli/src/commands/session/current.ts
@@ -0,0 +1,157 @@
+import { AwsNamedProfile } from "@noovolari/leapp-core/models/aws-named-profile";
+import { AzureSession } from "@noovolari/leapp-core/models/azure-session";
+import { SessionType } from "@noovolari/leapp-core/models/session-type";
+import { AwsSessionService } from "@noovolari/leapp-core/services/session/aws/aws-session-service";
+import { Flags } from "@oclif/core";
+import { LeappCommand } from "../../leapp-command";
+import { Config } from "@oclif/core/lib/config/config";
+import { Session } from "@noovolari/leapp-core/models/session";
+import { constants } from "@noovolari/leapp-core/models/constants";
+import { AzureService } from "@noovolari/leapp-core/services/session/azure/azure-service";
+
+const awsProvider = "aws";
+const azureProvider = "azure";
+
+export default class CurrentSession extends LeappCommand {
+ static description = "Provides info about the current active session for a selected profile (if no profile is provided it uses default profile)";
+ static examples = ['$leapp session current --format "alias accountNumber" --inline --provider aws'];
+ static flags = {
+ inline: Flags.boolean({
+ char: "i",
+ default: false,
+ }),
+ profile: Flags.string({
+ char: "p",
+ description: "aws named profile of which gets info",
+ default: constants.defaultAwsProfileName,
+ required: false,
+ }),
+ provider: Flags.string({
+ char: "r",
+ description: "filters sessions by the cloud provider service",
+ default: undefined,
+ required: false,
+ options: [awsProvider, azureProvider],
+ }),
+ format: Flags.string({
+ char: "f",
+ description: "allows filtering data to show \n\t- aws -> alias, accountNumber, roleArn\n\t- azure -> tenantId, subscriptionId",
+ default: undefined,
+ required: false,
+ }),
+ };
+
+ constructor(argv: string[], config: Config) {
+ super(argv, config);
+ }
+
+ async run(): Promise {
+ try {
+ const { flags } = await this.parse(CurrentSession);
+ const dataFormat = flags.inline ? "inline" : "JSON";
+ const dataFilter = flags.format ? this.getFieldsRequired(flags.format) : undefined;
+ const selectedSession = await this.getSessionFromProfile(flags.profile, flags.provider);
+ await this.currentSession(selectedSession, dataFormat, dataFilter);
+ } catch (error) {
+ this.error(error instanceof Error ? error.message : `Unknown error: ${error}`);
+ }
+ }
+
+ async currentSession(session: Session, dataFormat: string, dataFilter?: string[]): Promise {
+ const currentSessionData = this.formatSessionData(this.filterSessionData(await this.getSessionData(session), dataFilter), dataFormat);
+ this.log(currentSessionData);
+ }
+
+ getSessionFromProfile(profileName: string, provider: string | undefined): Session {
+ const profileId =
+ profileName === constants.defaultAwsProfileName ? this.cliProviderService.repository.getDefaultProfileId() : this.getProfileId(profileName);
+ let sessions = this.cliProviderService.repository.listActive().filter((session: Session) => {
+ const anySession = session as any;
+ return anySession.profileId === undefined || anySession.profileId === profileId;
+ });
+ if (provider) {
+ sessions = sessions.filter((session: Session) => this.getProviderAssociatedSessionTypes(provider).includes(session.type));
+ }
+ if (sessions.length === 0) {
+ throw new Error("no active sessions available for the specified criteria");
+ } else if (sessions.length > 1) {
+ throw new Error("multiple active sessions found, please specify a provider with --provider");
+ }
+ return sessions[0];
+ }
+
+ getProfileId(profileName: string): string {
+ const profiles = this.cliProviderService.repository.getProfiles().filter((profile: AwsNamedProfile) => profile.name === profileName);
+ if (profiles.length === 0) {
+ throw new Error(`AWS named profile "${profileName}" not found`);
+ } else if (profiles.length > 1) {
+ throw new Error("selected profile has more than one occurrence");
+ }
+ return profiles[0].id;
+ }
+
+ getFieldsRequired(filters: string): string[] {
+ return filters.split(" ").filter((token) => token.trim() !== "");
+ }
+
+ async getSessionData(session: Session): Promise {
+ const sessionService = this.cliProviderService.sessionFactory.getSessionService(session.type);
+ if (sessionService instanceof AwsSessionService) {
+ return {
+ alias: session.sessionName,
+ accountNumber: await sessionService.getAccountNumberFromCallerIdentity(session),
+ roleArn: session.type === SessionType.awsIamUser ? "none" : (session as any).roleArn,
+ };
+ } else if (sessionService instanceof AzureService) {
+ const azureSession = session as AzureSession;
+ return {
+ alias: azureSession.sessionName,
+ tenantId: azureSession.tenantId,
+ subscriptionId: azureSession.subscriptionId,
+ };
+ } else {
+ throw new Error(`session type not supported: ${session.type}`);
+ }
+ }
+
+ filterSessionData(sessionData: any, filterArray?: string[]): any {
+ const filteredData = Object.assign({}, sessionData) as any;
+ if (filterArray) {
+ for (const key in sessionData) {
+ if (Object.prototype.hasOwnProperty.call(sessionData, key)) {
+ if (!filterArray.includes(key)) {
+ delete filteredData[key];
+ }
+ }
+ }
+ }
+
+ return filteredData;
+ }
+
+ formatSessionData(sessionData: any, dataFormat: string): any {
+ let dataFormatted = "";
+ const lastKey = Object.keys(sessionData)[Object.entries(sessionData).length - 1];
+ if (dataFormat === "inline") {
+ for (const key in sessionData) {
+ if (Object.prototype.hasOwnProperty.call(sessionData, key)) {
+ dataFormatted += `${key}: ${sessionData[key]}${key === lastKey ? "" : ", "}`;
+ }
+ }
+ return dataFormatted;
+ } else if (dataFormat === "JSON") {
+ return JSON.stringify(sessionData);
+ } else {
+ throw new Error(`formatting style not allowed "${dataFormat}"`);
+ }
+ }
+
+ getProviderAssociatedSessionTypes(provider: string): SessionType[] {
+ const providerSessions: any = {
+ [awsProvider]: [SessionType.awsIamUser, SessionType.awsIamRoleChained, SessionType.awsIamRoleFederated, SessionType.awsSsoRole],
+ [azureProvider]: [SessionType.azure],
+ };
+
+ return providerSessions[provider];
+ }
+}
diff --git a/cli/src/commands/session/delete.spec.ts b/cli/src/commands/session/delete.spec.ts
new file mode 100644
index 000000000..9ed45430d
--- /dev/null
+++ b/cli/src/commands/session/delete.spec.ts
@@ -0,0 +1,189 @@
+import { describe, expect, jest, test } from "@jest/globals";
+import DeleteSession from "./delete";
+import { SessionStatus } from "@noovolari/leapp-core/models/session-status";
+
+describe("DeleteSession", () => {
+ const getTestCommand = (cliProviderService: any = null): DeleteSession => {
+ const command = new DeleteSession([], {} as any);
+ (command as any).cliProviderService = cliProviderService;
+ return command;
+ };
+
+ test("deleteSession", async () => {
+ const sessionService: any = {
+ delete: jest.fn(async () => {}),
+ };
+ const sessionFactory: any = {
+ getSessionService: jest.fn(() => sessionService),
+ };
+ const remoteProceduresClient = { refreshSessions: jest.fn() };
+
+ const cliProviderService: any = {
+ sessionFactory,
+ remoteProceduresClient,
+ };
+
+ const session: any = { sessionId: "sessionId", type: "sessionType" };
+ const command = getTestCommand(cliProviderService);
+ command.log = jest.fn();
+ await command.deleteSession(session);
+
+ expect(sessionFactory.getSessionService).toHaveBeenCalledWith("sessionType");
+ expect(sessionService.delete).toHaveBeenCalledWith("sessionId");
+ expect(command.log).toHaveBeenCalledWith("session deleted");
+ expect(remoteProceduresClient.refreshSessions).toHaveBeenCalled();
+ });
+
+ test("selectSession", async () => {
+ const cliProviderService: any = {
+ repository: {
+ getSessions: jest.fn(() => [
+ { sessionName: "sessionActive", status: SessionStatus.active },
+ { sessionName: "sessionPending", status: SessionStatus.pending },
+ { sessionName: "sessionInactive", status: SessionStatus.inactive },
+ ]),
+ },
+ inquirer: {
+ prompt: jest.fn(() => ({ selectedSession: { name: "sessionName", value: "sessionValue" } })),
+ },
+ };
+
+ const command = getTestCommand(cliProviderService);
+ const selectedSession = await command.selectSession();
+ expect(cliProviderService.inquirer.prompt).toHaveBeenCalledWith([
+ {
+ choices: [
+ {
+ name: "sessionActive",
+ value: {
+ sessionName: "sessionActive",
+ status: SessionStatus.active,
+ },
+ },
+ {
+ name: "sessionPending",
+ value: {
+ sessionName: "sessionPending",
+ status: SessionStatus.pending,
+ },
+ },
+ {
+ name: "sessionInactive",
+ value: {
+ sessionName: "sessionInactive",
+ status: SessionStatus.inactive,
+ },
+ },
+ ],
+ message: "select a session",
+ name: "selectedSession",
+ type: "list",
+ },
+ ]);
+ expect(selectedSession).toEqual({ name: "sessionName", value: "sessionValue" });
+ });
+
+ test("selectSession, no session available", async () => {
+ const cliProviderService: any = {
+ repository: {
+ getSessions: jest.fn(() => []),
+ },
+ };
+
+ const command = getTestCommand(cliProviderService);
+ await expect(command.selectSession()).rejects.toThrow(new Error("no sessions available"));
+ });
+
+ test("getAffectedSessions", async () => {
+ const session = {
+ type: "sessionType",
+ sessionId: "sessionId",
+ } as any;
+ const sessionService = {
+ getDependantSessions: jest.fn(() => "sessions"),
+ };
+ const cliProviderService: any = {
+ sessionFactory: {
+ getSessionService: jest.fn(() => sessionService),
+ },
+ };
+ const command = getTestCommand(cliProviderService);
+
+ const sessions = command.getAffectedSessions(session);
+ expect(sessions).toBe("sessions");
+ expect(sessionService.getDependantSessions).toHaveBeenCalledWith(session.sessionId);
+ expect(cliProviderService.sessionFactory.getSessionService).toHaveBeenCalledWith(session.type);
+ });
+
+ test("askForConfirmation", async () => {
+ const cliProviderService: any = {
+ inquirer: {
+ prompt: async (params: any) => {
+ expect(params).toEqual([
+ {
+ name: "confirmation",
+ message: "deleting this session will delete also these chained sessions\n" + "- sess1\n" + "- sess2\n" + "Do you want to continue?",
+ type: "confirm",
+ },
+ ]);
+ return { confirmation: true };
+ },
+ },
+ };
+ const command = getTestCommand(cliProviderService);
+
+ const affectedSessions = [{ sessionName: "sess1" }, { sessionName: "sess2" }] as any;
+ const confirmation = await command.askForConfirmation(affectedSessions);
+
+ expect(confirmation).toBe(true);
+ });
+
+ test("run - without confirmation", async () => {
+ const command = getTestCommand();
+
+ command.selectSession = jest.fn(async (): Promise => "session");
+ command.getAffectedSessions = jest.fn((): any => ["session1"]);
+ command.askForConfirmation = jest.fn(async () => false);
+ command.deleteSession = jest.fn();
+
+ await command.run();
+
+ expect(command.getAffectedSessions).toHaveBeenCalledWith("session");
+ expect(command.askForConfirmation).toHaveBeenCalledWith(["session1"]);
+ expect(command.deleteSession).not.toHaveBeenCalled();
+ });
+
+ const runCommand = async (errorToThrow: any, expectedErrorMessage: string) => {
+ const command = getTestCommand();
+
+ command.selectSession = jest.fn(async (): Promise => "session");
+ command.getAffectedSessions = jest.fn((): any => ["session1"]);
+ command.askForConfirmation = jest.fn(async () => true);
+ command.deleteSession = jest.fn(async (): Promise => {
+ if (errorToThrow) {
+ throw errorToThrow;
+ }
+ });
+
+ try {
+ await command.run();
+ } catch (error) {
+ expect(error).toEqual(new Error(expectedErrorMessage));
+ }
+ expect(command.deleteSession).toHaveBeenCalledWith("session");
+ expect(command.getAffectedSessions).toHaveBeenCalledWith("session");
+ expect(command.askForConfirmation).toHaveBeenCalledWith(["session1"]);
+ };
+
+ test("run - all ok", async () => {
+ await runCommand(undefined, "");
+ });
+
+ test("run - deleteSession throws exception", async () => {
+ await runCommand(new Error("errorMessage"), "errorMessage");
+ });
+
+ test("run - deleteSession throws undefined object", async () => {
+ await runCommand({ hello: "randomObj" }, "Unknown error: [object Object]");
+ });
+});
diff --git a/cli/src/commands/session/delete.ts b/cli/src/commands/session/delete.ts
new file mode 100644
index 000000000..5a79deb72
--- /dev/null
+++ b/cli/src/commands/session/delete.ts
@@ -0,0 +1,71 @@
+import { Session } from "@noovolari/leapp-core/models/session";
+import { LeappCommand } from "../../leapp-command";
+import { Config } from "@oclif/core/lib/config/config";
+
+export default class DeleteSession extends LeappCommand {
+ static description = "Delete a session";
+
+ static examples = [`$leapp session delete`];
+
+ constructor(argv: string[], config: Config) {
+ super(argv, config);
+ }
+
+ async run(): Promise {
+ try {
+ const selectedSession = await this.selectSession();
+ const affectedSessions = this.getAffectedSessions(selectedSession);
+ if (await this.askForConfirmation(affectedSessions)) {
+ await this.deleteSession(selectedSession);
+ }
+ } catch (error) {
+ this.error(error instanceof Error ? error.message : `Unknown error: ${error}`);
+ }
+ }
+
+ async selectSession(): Promise {
+ const availableSessions = this.cliProviderService.repository.getSessions();
+ if (availableSessions.length === 0) {
+ throw new Error("no sessions available");
+ }
+ const answer: any = await this.cliProviderService.inquirer.prompt([
+ {
+ name: "selectedSession",
+ message: "select a session",
+ type: "list",
+ choices: availableSessions.map((session: any) => ({ name: session.sessionName, value: session })),
+ },
+ ]);
+ return answer.selectedSession;
+ }
+
+ async deleteSession(session: Session): Promise {
+ try {
+ const sessionService = this.cliProviderService.sessionFactory.getSessionService(session.type);
+ await sessionService.delete(session.sessionId);
+ this.log("session deleted");
+ } finally {
+ await this.cliProviderService.remoteProceduresClient.refreshSessions();
+ }
+ }
+
+ getAffectedSessions(session: Session): Session[] {
+ const sessionService = this.cliProviderService.sessionFactory.getSessionService(session.type);
+ return sessionService.getDependantSessions(session.sessionId);
+ }
+
+ async askForConfirmation(affectedSessions: Session[]): Promise {
+ if (affectedSessions.length === 0) {
+ return true;
+ }
+ const sessionsList = affectedSessions.map((session) => `- ${session.sessionName}`).join("\n");
+ const answer: any = await this.cliProviderService.inquirer.prompt([
+ {
+ name: "confirmation",
+ message: `deleting this session will delete also these chained sessions\n${sessionsList}\nDo you want to continue?`,
+ type: "confirm",
+ },
+ ]);
+ return answer.confirmation;
+ }
+}
diff --git a/cli/src/commands/session/generate.spec.ts b/cli/src/commands/session/generate.spec.ts
new file mode 100644
index 000000000..322d13920
--- /dev/null
+++ b/cli/src/commands/session/generate.spec.ts
@@ -0,0 +1,141 @@
+import { describe, expect, jest, test } from "@jest/globals";
+import { Session } from "@noovolari/leapp-core/models/session";
+import { AwsSessionService } from "@noovolari/leapp-core/services/session/aws/aws-session-service";
+import GenerateSession from "./generate";
+
+describe("GenerateSession", () => {
+ const getTestCommand = (cliProviderService: any = null, argv: string[] = []): GenerateSession => {
+ const command = new GenerateSession(argv, {} as any);
+ (command as any).cliProviderService = cliProviderService;
+ return command;
+ };
+
+ test("generateSession", async () => {
+ const sessionService = {
+ generateProcessCredentials: jest.fn(async () => ["credentials"]),
+ };
+ const sessionFactory: any = {
+ getSessionService: jest.fn(() => sessionService),
+ };
+
+ const cliProviderService: any = {
+ sessionFactory,
+ };
+
+ const session = { sessionId: "sessionId", type: "sessionType" } as unknown as AwsSessionService;
+ const command = getTestCommand(cliProviderService);
+
+ command.log = jest.fn();
+ command.isAwsSession = jest.fn(() => true);
+ await command.generateSession(session as unknown as Session);
+
+ expect(sessionFactory.getSessionService).toHaveBeenCalledWith("sessionType");
+ expect(sessionService.generateProcessCredentials).toHaveBeenCalledWith("sessionId");
+ expect(command.log).toHaveBeenCalledWith('["credentials"]');
+ });
+
+ test("generateSession - not AWS Session", async () => {
+ const sessionService = {
+ generateProcessCredentials: jest.fn(async () => ["credentials"]),
+ };
+ const sessionFactory: any = {
+ getSessionService: jest.fn(() => sessionService),
+ };
+
+ const cliProviderService: any = {
+ sessionFactory,
+ };
+
+ const session = { sessionId: "sessionId", type: "sessionType" };
+ const command = getTestCommand(cliProviderService);
+
+ command.log = jest.fn();
+ command.isAwsSession = jest.fn(() => false);
+
+ await expect(command.generateSession(session as Session)).rejects.toThrow(new Error("AWS session is required"));
+ });
+
+ test("getSession", async () => {
+ const cliProviderService: any = {
+ repository: {
+ getSessions: jest.fn(() => [{ sessionId: "sessionId1" }, { sessionId: "sessionId2" }, { sessionId: "sessionId3" }]),
+ },
+ };
+
+ const command = getTestCommand(cliProviderService);
+
+ const selectedSession = await command.getSession("sessionId1");
+ expect(selectedSession).toEqual({ sessionId: "sessionId1" });
+ });
+
+ test("getSession, no session available", async () => {
+ const cliProviderService: any = {
+ repository: {
+ getSessions: jest.fn(() => []),
+ },
+ };
+
+ const command = getTestCommand(cliProviderService);
+
+ await expect(command.getSession("sessionId1")).rejects.toThrow(new Error("no sessions available"));
+ });
+
+ test("getSession, id not unique", async () => {
+ const cliProviderService: any = {
+ repository: {
+ getSessions: jest.fn(() => [{ sessionId: "sessionId1" }, { sessionId: "sessionId1" }, { sessionId: "sessionId3" }]),
+ },
+ };
+
+ const command = getTestCommand(cliProviderService);
+
+ await expect(command.getSession("sessionId1")).rejects.toThrow(new Error("id must be unique"));
+ });
+
+ test("isAwsSession - true", () => {
+ const command = getTestCommand();
+ const session = new (AwsSessionService as any)();
+ const isAwsSession = command.isAwsSession(session);
+
+ expect(isAwsSession).toBe(true);
+ });
+
+ test("isAwsSession - false", () => {
+ const command = getTestCommand();
+ const session = {} as Session;
+ const isAwsSession = command.isAwsSession(session);
+
+ expect(isAwsSession).toBe(false);
+ });
+
+ const runCommand = async (errorToThrow: any, expectedErrorMessage: string) => {
+ const command = getTestCommand(null, ["sessionId"]);
+
+ command.getSession = jest.fn(async (): Promise => "session");
+ command.generateSession = jest.fn(async (): Promise => {
+ if (errorToThrow) {
+ throw errorToThrow;
+ }
+ });
+
+ try {
+ await command.run();
+ } catch (error) {
+ expect(error).toEqual(new Error(expectedErrorMessage));
+ }
+ expect(command.getSession).toHaveBeenCalledWith("sessionId");
+ expect(command.generateSession).toHaveBeenCalledWith("session");
+ };
+
+ test("run - all ok", async () => {
+ await runCommand(undefined, "");
+ });
+
+ test("run - generateSession throws exception", async () => {
+ await runCommand(new Error("errorMessage"), "errorMessage");
+ });
+
+ test("run - generateSession throws undefined object", async () => {
+ await runCommand({ hello: "randomObj" }, "Unknown error: [object Object]");
+ });
+});
diff --git a/cli/src/commands/session/generate.ts b/cli/src/commands/session/generate.ts
new file mode 100644
index 000000000..7da7bdaf7
--- /dev/null
+++ b/cli/src/commands/session/generate.ts
@@ -0,0 +1,49 @@
+import { AwsSessionService } from "@noovolari/leapp-core/services/session/aws/aws-session-service";
+import { LeappCommand } from "../../leapp-command";
+import { Config } from "@oclif/core/lib/config/config";
+import { Session } from "@noovolari/leapp-core/models/session";
+import { SessionService } from "@noovolari/leapp-core/services/session/session-service";
+
+export default class GenerateSession extends LeappCommand {
+ static description = "Generate temporary credentials for the given AWS session id";
+ static examples = [`$leapp session generate 0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d`];
+ static args = [{ name: "sessionId", description: "id of the session", required: true }];
+
+ constructor(argv: string[], config: Config) {
+ super(argv, config);
+ }
+
+ async run(): Promise {
+ try {
+ const { args } = await this.parse(GenerateSession);
+ const selectedSession = await this.getSession(args.sessionId);
+ await this.generateSession(selectedSession);
+ } catch (error) {
+ this.error(error instanceof Error ? error.message : `Unknown error: ${error}`);
+ }
+ }
+
+ async generateSession(session: Session): Promise {
+ const sessionService = this.cliProviderService.sessionFactory.getSessionService(session.type);
+ if (this.isAwsSession(sessionService)) {
+ const processCredential = await (sessionService as any).generateProcessCredentials(session.sessionId);
+ this.log(JSON.stringify(processCredential));
+ } else {
+ throw new Error("AWS session is required");
+ }
+ }
+
+ async getSession(sessionId: string): Promise {
+ const selectedSessions = this.cliProviderService.repository.getSessions().filter((session: Session) => session.sessionId === sessionId);
+ if (selectedSessions.length === 0) {
+ throw new Error("no sessions available");
+ } else if (selectedSessions.length > 1) {
+ throw new Error("id must be unique");
+ }
+ return selectedSessions[0];
+ }
+
+ isAwsSession(sessionService: SessionService): boolean {
+ return sessionService instanceof AwsSessionService;
+ }
+}
diff --git a/cli/src/commands/session/get-id.ts b/cli/src/commands/session/get-id.ts
new file mode 100644
index 000000000..3b5167473
--- /dev/null
+++ b/cli/src/commands/session/get-id.ts
@@ -0,0 +1,45 @@
+import { LeappCommand } from "../../leapp-command";
+import { Config } from "@oclif/core/lib/config/config";
+import { Session } from "@noovolari/leapp-core/models/session";
+import { SessionStatus } from "@noovolari/leapp-core/models/session-status";
+
+export default class GetIdSession extends LeappCommand {
+ static description = "Get session id";
+
+ static examples = [`$leapp session get_id`];
+
+ constructor(argv: string[], config: Config) {
+ super(argv, config);
+ }
+
+ async run(): Promise {
+ try {
+ const selectedSession = await this.selectSession();
+ this.logSessionId(selectedSession);
+ } catch (error) {
+ this.error(error instanceof Error ? error.message : `Unknown error: ${error}`);
+ }
+ }
+
+ logSessionId(session: Session): void {
+ this.log(session.sessionId);
+ }
+
+ async selectSession(): Promise {
+ const availableSessions = this.cliProviderService.repository
+ .getSessions()
+ .filter((session: Session) => session.status === SessionStatus.inactive);
+ if (availableSessions.length === 0) {
+ throw new Error("no sessions available");
+ }
+ const answer: any = await this.cliProviderService.inquirer.prompt([
+ {
+ name: "selectedSession",
+ message: "select a session",
+ type: "list",
+ choices: availableSessions.map((session) => ({ name: session.sessionName, value: session })),
+ },
+ ]);
+ return answer.selectedSession;
+ }
+}
diff --git a/cli/src/commands/session/list.spec.ts b/cli/src/commands/session/list.spec.ts
new file mode 100644
index 000000000..11c39b798
--- /dev/null
+++ b/cli/src/commands/session/list.spec.ts
@@ -0,0 +1,84 @@
+import ListSessions from "./list";
+import { CliUx } from "@oclif/core";
+import { describe, expect, jest, test } from "@jest/globals";
+import { AwsIamUserSession } from "@noovolari/leapp-core/models/aws-iam-user-session";
+import { SessionType } from "@noovolari/leapp-core/models/session-type";
+
+describe("ListSessions", () => {
+ const getTestCommand = (cliProviderService: any = null): ListSessions => {
+ const command = new ListSessions([], {} as any);
+ (command as any).cliProviderService = cliProviderService;
+ return command;
+ };
+
+ test("run", async () => {
+ const command = getTestCommand();
+ command.showSessions = jest.fn();
+ await command.run();
+
+ expect(command.showSessions).toHaveBeenCalled();
+ });
+
+ test("run - showSessions throw an error", async () => {
+ const command = getTestCommand();
+ command.showSessions = jest.fn(async () => {
+ throw Error("error");
+ });
+ try {
+ await command.run();
+ } catch (error) {
+ expect(error).toEqual(new Error("error"));
+ }
+ });
+
+ test("run - showSessions throw an object", async () => {
+ const command = getTestCommand();
+ const strError = "string";
+ command.showSessions = jest.fn(async () => {
+ throw strError;
+ });
+ try {
+ await command.run();
+ } catch (error) {
+ expect(error).toEqual(new Error("Unknown error: string"));
+ }
+ });
+
+ test("showSessions", async () => {
+ const sessions = [new AwsIamUserSession("sessionName", "region", "profileId")];
+ const namedProfileMap = new Map([["profileId", { id: "profileId", name: "profileName" }]]);
+ const sessionTypeMap = new Map([[SessionType.awsIamUser, "sessionTypeLabel"]]);
+ const cliProviderService = {
+ repository: {
+ getSessions: () => sessions,
+ },
+ cloudProviderService: {
+ getSessionTypeMap: () => sessionTypeMap,
+ },
+ namedProfilesService: {
+ getNamedProfilesMap: () => namedProfileMap,
+ },
+ };
+
+ const command = getTestCommand(cliProviderService);
+ const tableSpy = jest.spyOn(CliUx.ux, "table").mockImplementation(() => null);
+
+ await command.showSessions();
+ expect(tableSpy.mock.calls[0][0]).toEqual([
+ {
+ profileId: "profileName",
+ region: "region",
+ sessionName: "sessionName",
+ status: "inactive",
+ type: "sessionTypeLabel",
+ },
+ ]);
+ expect(tableSpy.mock.calls[0][1]).toEqual({
+ sessionName: { header: "Session Name" },
+ type: { header: "Type" },
+ profileId: { header: "Named Profile" },
+ region: { header: "Region/Location" },
+ status: { header: "Status" },
+ });
+ });
+});
diff --git a/cli/src/commands/session/list.ts b/cli/src/commands/session/list.ts
new file mode 100644
index 000000000..38871b046
--- /dev/null
+++ b/cli/src/commands/session/list.ts
@@ -0,0 +1,48 @@
+import { CliUx } from "@oclif/core";
+import { Config } from "@oclif/core/lib/config/config";
+import { SessionStatus } from "@noovolari/leapp-core/models/session-status";
+import { LeappCommand } from "../../leapp-command";
+
+export default class ListSessions extends LeappCommand {
+ static description = "Show sessions list";
+ static examples = [`$leapp session list`];
+
+ static flags = {
+ ...CliUx.ux.table.flags(),
+ };
+
+ constructor(argv: string[], config: Config) {
+ super(argv, config);
+ }
+
+ async run(): Promise {
+ try {
+ await this.showSessions();
+ } catch (error) {
+ this.error(error instanceof Error ? error.message : `Unknown error: ${error}`);
+ }
+ }
+
+ async showSessions(): Promise {
+ const { flags } = await this.parse(ListSessions);
+ const sessionTypeLabelMap = this.cliProviderService.cloudProviderService.getSessionTypeMap();
+ const namedProfilesMap = this.cliProviderService.namedProfilesService.getNamedProfilesMap();
+ const data = this.cliProviderService.repository.getSessions().map((session) => ({
+ sessionName: session.sessionName,
+ type: sessionTypeLabelMap.get(session.type),
+ profileId: "profileId" in session ? namedProfilesMap.get((session as any).profileId)?.name : "-",
+ region: session.region,
+ status: SessionStatus[session.status],
+ })) as any as Record[];
+
+ const columns = {
+ sessionName: { header: "Session Name" },
+ type: { header: "Type" },
+ profileId: { header: "Named Profile" },
+ region: { header: "Region/Location" },
+ status: { header: "Status" },
+ };
+
+ CliUx.ux.table(data, columns, { ...flags });
+ }
+}
diff --git a/cli/src/commands/session/open-web-console.spec.ts b/cli/src/commands/session/open-web-console.spec.ts
new file mode 100644
index 000000000..e833f000b
--- /dev/null
+++ b/cli/src/commands/session/open-web-console.spec.ts
@@ -0,0 +1,120 @@
+import { describe, expect, jest, test } from "@jest/globals";
+import { CredentialsInfo } from "@noovolari/leapp-core/models/credentials-info";
+import OpenWebConsole from "./open-web-console";
+import { SessionStatus } from "@noovolari/leapp-core/models/session-status";
+
+describe("OpenWebConsole", () => {
+ const getTestCommand = (cliProviderService: any = null): OpenWebConsole => {
+ const command = new OpenWebConsole([], {} as any);
+ (command as any).cliProviderService = cliProviderService;
+ return command;
+ };
+
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ const credentialInfo = { sessionToken: { aws_access_key_id: "123", aws_secret_access_key: "345", aws_session_token: "678" } };
+
+ test("openWebConsole", async () => {
+ const sessionService: any = {
+ generateCredentials: jest.fn(async () => credentialInfo),
+ };
+ const sessionFactory: any = {
+ getSessionService: jest.fn(() => sessionService),
+ };
+
+ const ssmService: any = {
+ startSession: jest.fn((_0: CredentialsInfo, _1: string, _2: string) => {}),
+ getSsmInstances: jest.fn(() => []),
+ };
+
+ const webConsoleService: any = {
+ openWebConsole: jest.fn(async () => {}),
+ };
+
+ const inquirer: any = {
+ prompt: jest.fn(() => ({ selectedInstance: {} })),
+ };
+
+ const cliProviderService: any = {
+ sessionFactory,
+ ssmService,
+ inquirer,
+ webConsoleService,
+ };
+
+ const session: any = { sessionId: "sessionId", type: "sessionType", region: "eu-west-1" };
+ const command = getTestCommand(cliProviderService);
+ command.log = jest.fn();
+
+ await command.openWebConsole(session);
+ expect(command.log).toHaveBeenCalledWith("opened AWS Web Console for this session");
+ expect(sessionFactory.getSessionService).toHaveBeenCalledWith("sessionType");
+ expect(sessionService.generateCredentials).toHaveBeenCalledWith("sessionId");
+
+ expect(webConsoleService.openWebConsole).toHaveBeenCalledWith(credentialInfo, "eu-west-1");
+ });
+
+ test("selectSession", async () => {
+ const sessionService: any = {
+ generateCredentials: jest.fn(async () => credentialInfo),
+ };
+ const sessionFactory: any = {
+ getSessionService: jest.fn(() => sessionService),
+ };
+
+ const ssmService: any = {
+ startSession: jest.fn((_0: CredentialsInfo, _1: string, _2: string) => {}),
+ getSsmInstances: jest.fn(() => []),
+ };
+
+ const webConsoleService: any = {
+ openWebConsole: jest.fn(async () => {}),
+ };
+
+ const session: any = { sessionName: "a", sessionId: "sessionId", type: "sessionType", region: "eu-west-1", status: 0 };
+ const session2: any = { sessionName: "b", sessionId: "sessionId2", type: "sessionType2", region: "eu-west-2", status: 1 };
+
+ const inquirer: any = {
+ prompt: jest.fn(() => ({ selectedSession: session })),
+ };
+
+ const repository: any = {
+ getSessions: jest.fn(() => [session, session2]),
+ };
+
+ const cliProviderService: any = {
+ sessionFactory,
+ ssmService,
+ inquirer,
+ webConsoleService,
+ repository,
+ };
+
+ let command = getTestCommand(cliProviderService);
+ command.log = jest.fn();
+
+ const result = await (command as any).selectSession();
+
+ expect(repository.getSessions).toHaveBeenCalledTimes(1);
+ expect(repository.getSessions).toReturnWith([session, session2]);
+ expect(repository.getSessions().filter((s) => s.status === SessionStatus.inactive)).toStrictEqual([session]);
+ expect(inquirer.prompt).toHaveBeenCalledTimes(1);
+ expect(inquirer.prompt).toHaveBeenCalledWith([
+ {
+ name: "selectedSession",
+ message: "select a session",
+ type: "list",
+ choices: [{ name: "a", value: session }],
+ },
+ ]);
+
+ expect(result).toBe(session);
+
+ cliProviderService.repository.getSessions = () => [];
+ command = getTestCommand(cliProviderService);
+ try {
+ await (command as any).selectSession();
+ } catch (err) {
+ expect(err.toString()).toBe("Error: no sessions available");
+ }
+ });
+});
diff --git a/cli/src/commands/session/open-web-console.ts b/cli/src/commands/session/open-web-console.ts
new file mode 100644
index 000000000..cfd3ae617
--- /dev/null
+++ b/cli/src/commands/session/open-web-console.ts
@@ -0,0 +1,55 @@
+import { LeappCommand } from "../../leapp-command";
+import { Config } from "@oclif/core/lib/config/config";
+import { Session } from "@noovolari/leapp-core/models/session";
+import { SessionStatus } from "@noovolari/leapp-core/models/session-status";
+import { AwsSessionService } from "@noovolari/leapp-core/services/session/aws/aws-session-service";
+import { SessionType } from "@noovolari/leapp-core/models/session-type";
+
+export default class OpenWebConsole extends LeappCommand {
+ static description = "Open an AWS Web Console";
+
+ static examples = [`$leapp session open-web-console`];
+
+ constructor(argv: string[], config: Config) {
+ super(argv, config);
+ }
+
+ async run(): Promise {
+ try {
+ const selectedSession = await this.selectSession();
+ await this.openWebConsole(selectedSession);
+ } catch (error) {
+ this.error(error instanceof Error ? error.message : `Unknown error: ${error}`);
+ }
+ }
+
+ async openWebConsole(session: Session): Promise {
+ const sessionService = this.cliProviderService.sessionFactory.getSessionService(session.type) as AwsSessionService;
+ const credentials = await sessionService.generateCredentials(session.sessionId);
+ try {
+ await this.cliProviderService.webConsoleService.openWebConsole(credentials, session.region);
+ } catch (e) {
+ console.log(e);
+ throw e;
+ }
+ this.log("opened AWS Web Console for this session");
+ }
+
+ private async selectSession(): Promise {
+ const availableSessions = this.cliProviderService.repository
+ .getSessions()
+ .filter((session: Session) => session.status === SessionStatus.inactive && session.type !== SessionType.azure);
+ if (availableSessions.length === 0) {
+ throw new Error("no sessions available");
+ }
+ const answer: any = await this.cliProviderService.inquirer.prompt([
+ {
+ name: "selectedSession",
+ message: "select a session",
+ type: "list",
+ choices: availableSessions.map((session: any) => ({ name: session.sessionName, value: session })),
+ },
+ ]);
+ return answer.selectedSession;
+ }
+}
diff --git a/cli/src/commands/session/start-ssm-session.spec.ts b/cli/src/commands/session/start-ssm-session.spec.ts
new file mode 100644
index 000000000..0612ca4f1
--- /dev/null
+++ b/cli/src/commands/session/start-ssm-session.spec.ts
@@ -0,0 +1,187 @@
+import { describe, expect, jest, test } from "@jest/globals";
+import StartSsmSession from "./start-ssm-session";
+import { AwsSessionService } from "@noovolari/leapp-core/services/session/aws/aws-session-service";
+import { constants } from "@noovolari/leapp-core/models/constants";
+
+describe("StartSsmSession", () => {
+ const getTestCommand = (cliProviderService: any = null): StartSsmSession => {
+ const command = new StartSsmSession([], {} as any);
+ (command as any).cliProviderService = cliProviderService;
+ return command;
+ };
+
+ const runCommand = async (errorToThrow: any, expectedErrorMessage: string) => {
+ const command = getTestCommand();
+
+ command.selectSession = jest.fn(async (): Promise => "session");
+ command.generateCredentials = jest.fn(async (): Promise => "credentials");
+ command.selectRegion = jest.fn(async (): Promise => "region");
+ command.selectSsmInstance = jest.fn(async (): Promise => "ssmInstance");
+ command.startSsmSession = jest.fn(async () => {
+ if (errorToThrow) {
+ throw errorToThrow;
+ }
+ });
+
+ try {
+ await command.run();
+ } catch (error) {
+ expect(error).toEqual(new Error(expectedErrorMessage));
+ }
+ expect(command.selectSession).toHaveBeenCalled();
+ expect(command.generateCredentials).toHaveBeenCalledWith("session");
+ expect(command.selectRegion).toHaveBeenCalledWith("session");
+ expect(command.selectSsmInstance).toHaveBeenCalledWith("credentials", "region");
+ expect(command.startSsmSession).toHaveBeenCalledWith("credentials", "ssmInstance", "region");
+ };
+
+ test("run - all ok", async () => {
+ await runCommand(undefined, "");
+ });
+
+ test("run - deleteSession throws exception", async () => {
+ await runCommand(new Error("errorMessage"), "errorMessage");
+ });
+
+ test("run - deleteSession throws undefined object", async () => {
+ await runCommand({ hello: "randomObj" }, "Unknown error: [object Object]");
+ });
+
+ test("selectSession", async () => {
+ const cliProviderService: any = {
+ repository: {
+ getSessions: jest.fn(() => [{ sessionName: "awsSession", type: "aws" }, { sessionName: "azureSession" }]),
+ },
+ sessionFactory: {
+ getSessionService: (sessionType) => (sessionType === "aws" ? new (AwsSessionService as any)() : "AzureSession"),
+ },
+ inquirer: {
+ prompt: jest.fn(() => ({ selectedSession: "awsSession" })),
+ },
+ };
+
+ const command = getTestCommand(cliProviderService);
+ const selectedSession = await command.selectSession();
+ expect(cliProviderService.inquirer.prompt).toHaveBeenCalledWith([
+ {
+ choices: [{ name: "awsSession", value: { sessionName: "awsSession", type: "aws" } }],
+ message: "select a session",
+ name: "selectedSession",
+ type: "list",
+ },
+ ]);
+ expect(selectedSession).toEqual("awsSession");
+ });
+
+ test("generateCredentials", async () => {
+ const awsSessionService = { generateCredentials: jest.fn(() => "credentials") };
+ const cliProviderService: any = {
+ sessionFactory: { getSessionService: jest.fn(() => awsSessionService) },
+ };
+
+ const command = getTestCommand(cliProviderService);
+
+ const session = { sessionId: "sessionId", type: "type" } as any;
+ const credentials = await command.generateCredentials(session);
+
+ expect(credentials).toBe("credentials");
+ expect(cliProviderService.sessionFactory.getSessionService).toHaveBeenCalledWith(session.type);
+ expect(awsSessionService.generateCredentials).toHaveBeenCalledWith(session.sessionId);
+ });
+
+ test("selectRegion", async () => {
+ const regionFieldChoice = { fieldName: "regionName2", fieldValue: "regionName3" };
+ const cliProviderService: any = {
+ cloudProviderService: {
+ availableRegions: jest.fn(() => [regionFieldChoice]),
+ },
+ inquirer: {
+ prompt: async (params: any) => {
+ expect(params).toEqual([
+ {
+ name: "selectedRegion",
+ message: "select region",
+ type: "list",
+ choices: [{ name: regionFieldChoice.fieldName, value: regionFieldChoice.fieldValue }],
+ },
+ ]);
+ return { selectedRegion: "selectedRegion" };
+ },
+ },
+ };
+
+ const command = getTestCommand(cliProviderService);
+
+ const session = { type: "type" } as any;
+ const selectedRegion = await command.selectRegion(session);
+
+ expect(selectedRegion).toBe("selectedRegion");
+ expect(cliProviderService.cloudProviderService.availableRegions).toHaveBeenCalledWith(session.type);
+ });
+
+ test("selectSsmInstance", async () => {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ const instances = { Name: "instanceName", InstanceId: "instanceId" };
+ const cliProviderService: any = {
+ ssmService: { getSsmInstances: jest.fn(() => [instances]) },
+ inquirer: {
+ prompt: async (params: any) => {
+ expect(params).toEqual([
+ {
+ name: "selectedInstance",
+ message: "select an instance",
+ type: "list",
+ choices: [{ name: instances.Name, value: instances.InstanceId }],
+ },
+ ]);
+ return { selectedInstance: "selectedInstance" };
+ },
+ },
+ };
+
+ const command = getTestCommand(cliProviderService);
+
+ const selectedInstance = await command.selectSsmInstance("credentials" as any, "region");
+
+ expect(selectedInstance).toBe("selectedInstance");
+ expect(cliProviderService.ssmService.getSsmInstances).toHaveBeenCalledWith("credentials", "region");
+ });
+
+ test("startSsmSession, macOS, iTerm", async () => {
+ const cliProviderService: any = {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ cliNativeService: { process: { platform: "darwin", env: { TERM_PROGRAM: "iTerm.app" } } },
+ ssmService: { startSession: jest.fn(() => null) },
+ };
+ const command = getTestCommand(cliProviderService);
+ command.log = jest.fn();
+
+ await command.startSsmSession("credentials" as any, "instanceId", "region");
+ expect(cliProviderService.ssmService.startSession).toHaveBeenCalledWith("credentials", "instanceId", "region", constants.macOsIterm2);
+ expect(command.log).toHaveBeenCalledWith("started AWS SSM session");
+ });
+
+ test("startSsmSession, macOS, other terminal", async () => {
+ const cliProviderService: any = {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ cliNativeService: { process: { platform: "darwin", env: { TERM_PROGRAM: "otherTerminal.app" } } },
+ ssmService: { startSession: jest.fn(() => null) },
+ };
+ const command = getTestCommand(cliProviderService);
+
+ await command.startSsmSession("credentials" as any, "instanceId", "region");
+ expect(cliProviderService.ssmService.startSession).toHaveBeenCalledWith("credentials", "instanceId", "region", constants.macOsTerminal);
+ });
+
+ test("startSsmSession, windows, iTerm", async () => {
+ const cliProviderService: any = {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ cliNativeService: { process: { platform: "win32", env: { TERM_PROGRAM: "iTerm.app" } } },
+ ssmService: { startSession: jest.fn(() => null) },
+ };
+ const command = getTestCommand(cliProviderService);
+
+ await command.startSsmSession("credentials" as any, "instanceId", "region");
+ expect(cliProviderService.ssmService.startSession).toHaveBeenCalledWith("credentials", "instanceId", "region", undefined);
+ });
+});
diff --git a/cli/src/commands/session/start-ssm-session.ts b/cli/src/commands/session/start-ssm-session.ts
new file mode 100644
index 000000000..e1697bcd3
--- /dev/null
+++ b/cli/src/commands/session/start-ssm-session.ts
@@ -0,0 +1,90 @@
+import { LeappCommand } from "../../leapp-command";
+import { Config } from "@oclif/core/lib/config/config";
+import { Session } from "@noovolari/leapp-core/models/session";
+import { AwsSessionService } from "@noovolari/leapp-core/services/session/aws/aws-session-service";
+import { CredentialsInfo } from "@noovolari/leapp-core/models/credentials-info";
+import { constants } from "@noovolari/leapp-core/models/constants";
+
+export default class StartSsmSession extends LeappCommand {
+ static description = "Start an AWS SSM session";
+
+ static examples = [`$leapp session start-ssm-session`];
+
+ constructor(argv: string[], config: Config) {
+ super(argv, config);
+ }
+
+ async run(): Promise {
+ try {
+ const selectedSession = await this.selectSession();
+ const credentials = await this.generateCredentials(selectedSession);
+ const selectedRegion = await this.selectRegion(selectedSession);
+ const selectedSsmInstanceId = await this.selectSsmInstance(credentials, selectedRegion);
+ await this.startSsmSession(credentials, selectedSsmInstanceId, selectedRegion);
+ } catch (error) {
+ this.error(error instanceof Error ? error.message : `Unknown error: ${error}`);
+ }
+ }
+
+ async selectSession(): Promise {
+ const availableSessions = this.cliProviderService.repository
+ .getSessions()
+ .filter((session: Session) => this.cliProviderService.sessionFactory.getSessionService(session.type) instanceof AwsSessionService);
+ if (availableSessions.length === 0) {
+ throw new Error("no sessions available");
+ }
+ const answer: any = await this.cliProviderService.inquirer.prompt([
+ {
+ name: "selectedSession",
+ message: "select a session",
+ type: "list",
+ choices: availableSessions.map((session: any) => ({ name: session.sessionName, value: session })),
+ },
+ ]);
+ return answer.selectedSession;
+ }
+
+ async generateCredentials(session: Session): Promise {
+ const sessionService = this.cliProviderService.sessionFactory.getSessionService(session.type) as AwsSessionService;
+ return await sessionService.generateCredentials(session.sessionId);
+ }
+
+ async selectRegion(session: Session): Promise {
+ // TODO: check if is possible to filter out unavailable regions
+ const availableRegions = this.cliProviderService.cloudProviderService.availableRegions(session.type);
+
+ const answer: any = await this.cliProviderService.inquirer.prompt([
+ {
+ name: "selectedRegion",
+ message: `select region`,
+ type: "list",
+ choices: availableRegions.map((region) => ({ name: region.fieldName, value: region.fieldValue })),
+ },
+ ]);
+ return answer.selectedRegion;
+ }
+
+ async selectSsmInstance(credentials: CredentialsInfo, region: string): Promise {
+ const availableInstances = await this.cliProviderService.ssmService.getSsmInstances(credentials, region);
+ const answer: any = await this.cliProviderService.inquirer.prompt([
+ {
+ name: "selectedInstance",
+ message: "select an instance",
+ type: "list",
+ choices: availableInstances.map((instance: any) => ({ name: instance.Name, value: instance.InstanceId })),
+ },
+ ]);
+ return answer.selectedInstance;
+ }
+
+ async startSsmSession(credentials: CredentialsInfo, ssmInstanceId: string, region: string): Promise {
+ let macOsTerminalType;
+ const process = this.cliProviderService.cliNativeService.process;
+ if (process.platform === "darwin") {
+ const terminalProgram = process.env["TERM_PROGRAM"];
+ macOsTerminalType = terminalProgram && terminalProgram.toLowerCase().includes("iterm") ? constants.macOsIterm2 : constants.macOsTerminal;
+ }
+ await this.cliProviderService.ssmService.startSession(credentials, ssmInstanceId, region, macOsTerminalType);
+ this.log("started AWS SSM session");
+ }
+}
diff --git a/cli/src/commands/session/start.spec.ts b/cli/src/commands/session/start.spec.ts
new file mode 100644
index 000000000..e76df364f
--- /dev/null
+++ b/cli/src/commands/session/start.spec.ts
@@ -0,0 +1,119 @@
+import { describe, expect, jest, test } from "@jest/globals";
+import StartSession from "./start";
+import { SessionStatus } from "@noovolari/leapp-core/models/session-status";
+
+describe("StartSession", () => {
+ const getTestCommand = (cliProviderService: any = null): StartSession => {
+ const command = new StartSession([], {} as any);
+ (command as any).cliProviderService = cliProviderService;
+ return command;
+ };
+
+ test("startSession", async () => {
+ const sessionService: any = {
+ start: jest.fn(async () => {}),
+ sessionDeactivated: jest.fn(async () => {}),
+ };
+ const sessionFactory: any = {
+ getSessionService: jest.fn(() => sessionService),
+ };
+ const remoteProceduresClient: any = { refreshSessions: jest.fn() };
+
+ const cliProviderService: any = {
+ sessionFactory,
+ remoteProceduresClient,
+ };
+
+ const session: any = { sessionId: "sessionId", type: "sessionType" };
+ const command = getTestCommand(cliProviderService);
+ command.log = jest.fn();
+ const processOn = jest.spyOn(process, "on").mockImplementation((event: any, callback: any): any => {
+ expect(event).toBe("SIGINT");
+ callback();
+ });
+ const processExit = jest.spyOn(process, "exit").mockImplementation((): any => {});
+ await command.startSession(session);
+
+ expect(sessionFactory.getSessionService).toHaveBeenCalledWith("sessionType");
+ expect(sessionService.start).toHaveBeenCalledWith("sessionId");
+ expect(command.log).toHaveBeenCalledWith("session started");
+ expect(processOn).toHaveBeenCalled();
+ expect(sessionService.sessionDeactivated).toHaveBeenCalledWith("sessionId");
+ expect(processExit).toHaveBeenCalledWith(0);
+ expect(remoteProceduresClient.refreshSessions).toHaveBeenCalled();
+ });
+
+ test("selectSession", async () => {
+ const cliProviderService: any = {
+ repository: {
+ getSessions: jest.fn(() => [
+ { sessionName: "sessionActive", status: SessionStatus.active },
+ { sessionName: "sessionPending", status: SessionStatus.pending },
+ { sessionName: "sessionInactive", status: SessionStatus.inactive },
+ ]),
+ },
+ inquirer: {
+ prompt: jest.fn(() => ({ selectedSession: { name: "sessionInactive", value: "InactiveSession" } })),
+ },
+ };
+
+ const command = getTestCommand(cliProviderService);
+ const selectedSession = await command.selectSession();
+ expect(cliProviderService.inquirer.prompt).toHaveBeenCalledWith([
+ {
+ choices: [
+ {
+ name: "sessionInactive",
+ value: { sessionName: "sessionInactive", status: SessionStatus.inactive },
+ },
+ ],
+ message: "select a session",
+ name: "selectedSession",
+ type: "list",
+ },
+ ]);
+ expect(selectedSession).toEqual({ name: "sessionInactive", value: "InactiveSession" });
+ });
+
+ test("selectSession, no session available", async () => {
+ const cliProviderService: any = {
+ repository: {
+ getSessions: jest.fn(() => []),
+ },
+ };
+
+ const command = getTestCommand(cliProviderService);
+ await expect(command.selectSession()).rejects.toThrow(new Error("no sessions available"));
+ });
+
+ const runCommand = async (errorToThrow: any, expectedErrorMessage: string) => {
+ const command = getTestCommand();
+
+ command.selectSession = jest.fn(async (): Promise => "session");
+
+ command.startSession = jest.fn(async (): Promise => {
+ if (errorToThrow) {
+ throw errorToThrow;
+ }
+ });
+
+ try {
+ await command.run();
+ } catch (error) {
+ expect(error).toEqual(new Error(expectedErrorMessage));
+ }
+ expect(command.startSession).toHaveBeenCalledWith("session");
+ };
+
+ test("run - all ok", async () => {
+ await runCommand(undefined, "");
+ });
+
+ test("run - createSession throws exception", async () => {
+ await runCommand(new Error("errorMessage"), "errorMessage");
+ });
+
+ test("run - createSession throws undefined object", async () => {
+ await runCommand({ hello: "randomObj" }, "Unknown error: [object Object]");
+ });
+});
diff --git a/cli/src/commands/session/start.ts b/cli/src/commands/session/start.ts
new file mode 100644
index 000000000..1f2958e73
--- /dev/null
+++ b/cli/src/commands/session/start.ts
@@ -0,0 +1,55 @@
+import { LeappCommand } from "../../leapp-command";
+import { Config } from "@oclif/core/lib/config/config";
+import { Session } from "@noovolari/leapp-core/models/session";
+import { SessionStatus } from "@noovolari/leapp-core/models/session-status";
+
+export default class StartSession extends LeappCommand {
+ static description = "Start a session";
+
+ static examples = [`$leapp session start`];
+
+ constructor(argv: string[], config: Config) {
+ super(argv, config);
+ }
+
+ async run(): Promise {
+ try {
+ const selectedSession = await this.selectSession();
+ await this.startSession(selectedSession);
+ } catch (error) {
+ this.error(error instanceof Error ? error.message : `Unknown error: ${error}`);
+ }
+ }
+
+ async startSession(session: Session): Promise {
+ const sessionService = this.cliProviderService.sessionFactory.getSessionService(session.type);
+ process.on("SIGINT", () => {
+ sessionService.sessionDeactivated(session.sessionId);
+ process.exit(0);
+ });
+ try {
+ await sessionService.start(session.sessionId);
+ this.log("session started");
+ } finally {
+ await this.cliProviderService.remoteProceduresClient.refreshSessions();
+ }
+ }
+
+ async selectSession(): Promise {
+ const availableSessions = this.cliProviderService.repository
+ .getSessions()
+ .filter((session: Session) => session.status === SessionStatus.inactive);
+ if (availableSessions.length === 0) {
+ throw new Error("no sessions available");
+ }
+ const answer: any = await this.cliProviderService.inquirer.prompt([
+ {
+ name: "selectedSession",
+ message: "select a session",
+ type: "list",
+ choices: availableSessions.map((session: any) => ({ name: session.sessionName, value: session })),
+ },
+ ]);
+ return answer.selectedSession;
+ }
+}
diff --git a/cli/src/commands/session/stop.spec.ts b/cli/src/commands/session/stop.spec.ts
new file mode 100644
index 000000000..71315b5be
--- /dev/null
+++ b/cli/src/commands/session/stop.spec.ts
@@ -0,0 +1,106 @@
+import { describe, expect, jest, test } from "@jest/globals";
+import StopSession from "./stop";
+import { SessionStatus } from "@noovolari/leapp-core/models/session-status";
+
+describe("StopSession", () => {
+ const getTestCommand = (cliProviderService: any = null): StopSession => {
+ const command = new StopSession([], {} as any);
+ (command as any).cliProviderService = cliProviderService;
+ return command;
+ };
+
+ test("stopSession", async () => {
+ const sessionService: any = {
+ stop: jest.fn(async () => {}),
+ };
+ const sessionFactory: any = {
+ getSessionService: jest.fn(() => sessionService),
+ };
+ const remoteProceduresClient = { refreshSessions: jest.fn() };
+
+ const cliProviderService: any = {
+ sessionFactory,
+ remoteProceduresClient,
+ };
+
+ const session: any = { sessionId: "sessionId", type: "sessionType" };
+ const command = getTestCommand(cliProviderService);
+ command.log = jest.fn();
+ await command.stopSession(session);
+
+ expect(sessionFactory.getSessionService).toHaveBeenCalledWith("sessionType");
+ expect(sessionService.stop).toHaveBeenCalledWith("sessionId");
+ expect(command.log).toHaveBeenCalledWith("session stopped");
+ expect(cliProviderService.remoteProceduresClient.refreshSessions).toHaveBeenCalled();
+ });
+
+ test("selectSession", async () => {
+ const cliProviderService: any = {
+ repository: {
+ getSessions: jest.fn(() => [
+ { sessionName: "sessionActive", status: SessionStatus.active },
+ { sessionName: "sessionPending", status: SessionStatus.pending },
+ { sessionName: "sessionInactive", status: SessionStatus.inactive },
+ ]),
+ },
+ inquirer: {
+ prompt: jest.fn(() => ({ selectedSession: { name: "sessionActive", value: "ActiveSession" } })),
+ },
+ };
+
+ const command = getTestCommand(cliProviderService);
+ const selectedSession = await command.selectSession();
+ expect(cliProviderService.inquirer.prompt).toHaveBeenCalledWith([
+ {
+ choices: [
+ { name: "sessionActive", value: { sessionName: "sessionActive", status: SessionStatus.active } },
+ { name: "sessionPending", value: { sessionName: "sessionPending", status: SessionStatus.pending } },
+ ],
+ message: "select a session",
+ name: "selectedSession",
+ type: "list",
+ },
+ ]);
+ expect(selectedSession).toEqual({ name: "sessionActive", value: "ActiveSession" });
+ });
+
+ test("selectSession, no session available", async () => {
+ const cliProviderService: any = {
+ repository: {
+ getSessions: jest.fn(() => []),
+ },
+ };
+
+ const command = getTestCommand(cliProviderService);
+ await expect(command.selectSession()).rejects.toThrow(new Error("no active sessions available"));
+ });
+
+ const runCommand = async (errorToThrow: any, expectedErrorMessage: string) => {
+ const command = getTestCommand();
+ command.selectSession = jest.fn(async (): Promise => "session");
+ command.stopSession = jest.fn(async (): Promise => {
+ if (errorToThrow) {
+ throw errorToThrow;
+ }
+ });
+
+ try {
+ await command.run();
+ } catch (error) {
+ expect(error).toEqual(new Error(expectedErrorMessage));
+ }
+ expect(command.stopSession).toHaveBeenCalledWith("session");
+ };
+
+ test("run - all ok", async () => {
+ await runCommand(undefined, "");
+ });
+
+ test("run - createSession throws exception", async () => {
+ await runCommand(new Error("errorMessage"), "errorMessage");
+ });
+
+ test("run - createSession throws undefined object", async () => {
+ await runCommand({ hello: "randomObj" }, "Unknown error: [object Object]");
+ });
+});
diff --git a/cli/src/commands/session/stop.ts b/cli/src/commands/session/stop.ts
new file mode 100644
index 000000000..9a6491141
--- /dev/null
+++ b/cli/src/commands/session/stop.ts
@@ -0,0 +1,51 @@
+import { LeappCommand } from "../../leapp-command";
+import { Config } from "@oclif/core/lib/config/config";
+import { Session } from "@noovolari/leapp-core/models/session";
+import { SessionStatus } from "@noovolari/leapp-core/models/session-status";
+
+export default class StopSession extends LeappCommand {
+ static description = "Stop a session";
+
+ static examples = [`$leapp session stop`];
+
+ constructor(argv: string[], config: Config) {
+ super(argv, config);
+ }
+
+ async run(): Promise {
+ try {
+ const selectedSession = await this.selectSession();
+ await this.stopSession(selectedSession);
+ } catch (error) {
+ this.error(error instanceof Error ? error.message : `Unknown error: ${error}`);
+ }
+ }
+
+ async stopSession(session: Session): Promise {
+ try {
+ const sessionService = this.cliProviderService.sessionFactory.getSessionService(session.type);
+ await sessionService.stop(session.sessionId);
+ this.log("session stopped");
+ } finally {
+ await this.cliProviderService.remoteProceduresClient.refreshSessions();
+ }
+ }
+
+ async selectSession(): Promise {
+ const availableSessions = this.cliProviderService.repository
+ .getSessions()
+ .filter((session: Session) => session.status === SessionStatus.active || session.status === SessionStatus.pending);
+ if (availableSessions.length === 0) {
+ throw new Error("no active sessions available");
+ }
+ const answer: any = await this.cliProviderService.inquirer.prompt([
+ {
+ name: "selectedSession",
+ message: "select a session",
+ type: "list",
+ choices: availableSessions.map((session: any) => ({ name: session.sessionName, value: session })),
+ },
+ ]);
+ return answer.selectedSession;
+ }
+}
diff --git a/cli/src/index.ts b/cli/src/index.ts
new file mode 100644
index 000000000..233785370
--- /dev/null
+++ b/cli/src/index.ts
@@ -0,0 +1 @@
+export { run } from "@oclif/core";
diff --git a/cli/src/leapp-command.spec.ts b/cli/src/leapp-command.spec.ts
new file mode 100644
index 000000000..8be6cbf30
--- /dev/null
+++ b/cli/src/leapp-command.spec.ts
@@ -0,0 +1,44 @@
+import { describe, test, expect, jest } from "@jest/globals";
+import { LeappCommand } from "./leapp-command";
+
+describe("LeappCommand", () => {
+ test("init", async () => {
+ const cliProviderService = {
+ awsSsoRoleService: {
+ setAwsIntegrationDelegate: jest.fn(),
+ },
+ awsSsoIntegrationService: "integrationService",
+ remoteProceduresClient: {
+ isDesktopAppRunning: jest.fn(async () => true),
+ },
+ };
+
+ const leappCommand = new (LeappCommand as any)(null, null, cliProviderService);
+ await leappCommand.init();
+
+ expect(cliProviderService.awsSsoRoleService.setAwsIntegrationDelegate).toHaveBeenCalledWith(cliProviderService.awsSsoIntegrationService);
+ expect(cliProviderService.remoteProceduresClient.isDesktopAppRunning).toHaveBeenCalled();
+ });
+
+ test("init - desktop app not running", async () => {
+ const cliProviderService = {
+ awsSsoRoleService: {
+ setAwsIntegrationDelegate: jest.fn(),
+ },
+ awsSsoIntegrationService: "integrationService",
+ remoteProceduresClient: {
+ isDesktopAppRunning: jest.fn(async () => false),
+ },
+ };
+
+ const leappCommand = new (LeappCommand as any)(null, null, cliProviderService);
+ leappCommand.error = jest.fn();
+ await leappCommand.init();
+
+ expect(cliProviderService.awsSsoRoleService.setAwsIntegrationDelegate).toHaveBeenCalledWith(cliProviderService.awsSsoIntegrationService);
+ expect(cliProviderService.remoteProceduresClient.isDesktopAppRunning).toHaveBeenCalled();
+ expect(leappCommand.error).toHaveBeenCalledWith(
+ "Leapp app must be running to use this CLI. You can download it here: https://www.leapp.cloud/releases"
+ );
+ });
+});
diff --git a/cli/src/leapp-command.ts b/cli/src/leapp-command.ts
new file mode 100644
index 000000000..6059da8f1
--- /dev/null
+++ b/cli/src/leapp-command.ts
@@ -0,0 +1,17 @@
+import { Command } from "@oclif/core";
+import { Config } from "@oclif/core/lib/config/config";
+import { CliProviderService } from "./service/cli-provider-service";
+
+export abstract class LeappCommand extends Command {
+ protected constructor(argv: string[], config: Config, protected cliProviderService = new CliProviderService()) {
+ super(argv, config);
+ }
+
+ async init(): Promise {
+ this.cliProviderService.awsSsoRoleService.setAwsIntegrationDelegate(this.cliProviderService.awsSsoIntegrationService);
+ const isDesktopAppRunning = await this.cliProviderService.remoteProceduresClient.isDesktopAppRunning();
+ if (!isDesktopAppRunning) {
+ this.error("Leapp app must be running to use this CLI. You can download it here: https://www.leapp.cloud/releases");
+ }
+ }
+}
diff --git a/cli/src/service/cli-aws-authentication-service.spec.ts b/cli/src/service/cli-aws-authentication-service.spec.ts
new file mode 100644
index 000000000..4a354debf
--- /dev/null
+++ b/cli/src/service/cli-aws-authentication-service.spec.ts
@@ -0,0 +1,154 @@
+import { jest, describe, expect, test } from "@jest/globals";
+import { CliAwsAuthenticationService } from "./cli-aws-authentication-service";
+import { of } from "rxjs";
+import { Page, HTTPRequest } from "puppeteer";
+import { CloudProviderType } from "@noovolari/leapp-core/models/cloud-provider-type";
+
+class PageStub {
+ public onPageCalled;
+ public gotoPageCalled;
+ public onEventCalledTimes;
+ private callback: Map Promise>;
+
+ constructor(public expectedIdpUrl: string, public requestStub: any) {
+ this.onPageCalled = false;
+ this.gotoPageCalled = false;
+ this.onEventCalledTimes = 0;
+ this.callback = new Map Promise>();
+ }
+
+ on(param: string, callback: any) {
+ this.onPageCalled = true;
+ if (this.onEventCalledTimes === 0) {
+ expect(param).toEqual("request");
+ } else {
+ expect(param).toEqual("close");
+ }
+
+ expect(callback).toBeDefined();
+ this.callback.set(param, callback);
+ this.onEventCalledTimes++;
+ }
+
+ async goto(url: string) {
+ this.gotoPageCalled = true;
+ expect(url).toEqual(this.expectedIdpUrl);
+ await this.callback.get("request")(this.requestStub);
+ return Promise.reject(new Error("errors in goto must be handled"));
+ }
+}
+
+describe("CliAwsAuthenticationService", () => {
+ const cases = [true, false];
+ test.each(cases)("needAuthentication: %p", async (needAuthentication) => {
+ const idpUrl = "https://idpUrl";
+ const page = new PageStub(idpUrl, {
+ url: () => idpUrl,
+ isInterceptResolutionHandled: () => false,
+ continue: async () => Promise.resolve(),
+ });
+ const authenticationService = {
+ isAuthenticationUrl: jest.fn(() => needAuthentication),
+ isSamlAssertionUrl: jest.fn(() => !needAuthentication),
+ };
+
+ const cliAwsAuthenticationService = new CliAwsAuthenticationService(authenticationService as any);
+ cliAwsAuthenticationService.getNavigationPage = async (headlessMode: boolean) => {
+ expect(headlessMode).toBeTruthy();
+ return of(page as unknown as Page).toPromise();
+ };
+
+ expect(await cliAwsAuthenticationService.needAuthentication(idpUrl)).toBe(needAuthentication);
+ expect(authenticationService.isAuthenticationUrl).toHaveBeenCalledWith(CloudProviderType.aws, idpUrl);
+ expect(authenticationService.isSamlAssertionUrl).toHaveBeenCalledWith(CloudProviderType.aws, idpUrl);
+ expect(page.onPageCalled).toBeTruthy();
+ expect(page.gotoPageCalled).toBeTruthy();
+ });
+
+ test("awsSignIn - saml assertion true", async () => {
+ const idpUrl = "https://idpUrl";
+ const needToAuthenticate = false;
+ const page = new PageStub(idpUrl, {
+ url: () => "samlUrl",
+ isInterceptResolutionHandled: () => false,
+ postData: () => "postData",
+ continue: async () => Promise.resolve(),
+ });
+
+ const authenticationService = {
+ isSamlAssertionUrl: jest.fn(() => true),
+ extractAwsSamlResponse: (responseHookDetails: any) => {
+ expect(responseHookDetails.uploadData[0].bytes.toString()).toBe("postData");
+
+ return "samlResponse";
+ },
+ };
+ const cliAwsAuthenticationService = new CliAwsAuthenticationService(authenticationService as any);
+ cliAwsAuthenticationService.getNavigationPage = async (headlessMode: boolean) => {
+ expect(headlessMode).toEqual(!needToAuthenticate);
+ return of(page as unknown as Page).toPromise();
+ };
+
+ const result = await cliAwsAuthenticationService.awsSignIn(idpUrl, needToAuthenticate);
+ expect(result).toBe("samlResponse");
+ expect(page.onPageCalled).toBeTruthy();
+ expect(page.gotoPageCalled).toBeTruthy();
+ expect(authenticationService.isSamlAssertionUrl).toHaveBeenCalledWith(CloudProviderType.aws, "samlUrl");
+ });
+
+ test("awsSignIn - saml assertion false", async () => {
+ const idpUrl = "https://idpUrl";
+ const needToAuthenticate = false;
+ const page = new PageStub(idpUrl, {
+ url: () => "wrongSamlUrl",
+ isInterceptResolutionHandled: () => false,
+ postData: () => "postData",
+ continue: async () => Promise.resolve(),
+ });
+
+ let requestCounter = 0;
+ const authenticationService = {
+ isSamlAssertionUrl: jest.fn(() => {
+ requestCounter++;
+ if (requestCounter === 1) {
+ page.goto(idpUrl).catch(() => null);
+ return false;
+ } else {
+ return true;
+ }
+ }),
+ extractAwsSamlResponse: (responseHookDetails: any) => {
+ expect(responseHookDetails.uploadData[0].bytes.toString()).toBe("postData");
+
+ return "samlResponse";
+ },
+ };
+
+ const cliAwsAuthenticationService = new CliAwsAuthenticationService(authenticationService as any);
+ cliAwsAuthenticationService.getNavigationPage = async (headlessMode: boolean) => {
+ expect(headlessMode).toEqual(!needToAuthenticate);
+ return of(page as unknown as Page).toPromise();
+ };
+
+ const result = await cliAwsAuthenticationService.awsSignIn(idpUrl, needToAuthenticate);
+ expect(result).toBe("samlResponse");
+ expect(page.onPageCalled).toBeTruthy();
+ expect(page.gotoPageCalled).toBeTruthy();
+ expect(authenticationService.isSamlAssertionUrl).toHaveBeenCalledWith(CloudProviderType.aws, "wrongSamlUrl");
+ expect(requestCounter).toBe(2);
+ });
+
+ test("getNavigationPage and closeAuthenticationWindow", async () => {
+ const cliAwsAuthenticationService = new CliAwsAuthenticationService(null);
+
+ const page = await cliAwsAuthenticationService.getNavigationPage(false);
+ const process = page.browser().process();
+ expect(process).toBeDefined();
+ expect(process?.killed).toBeFalsy();
+ expect(process?.signalCode).toBeNull();
+
+ await cliAwsAuthenticationService.closeAuthenticationWindow();
+ expect(process?.killed).toBeTruthy();
+ expect(process?.signalCode).toEqual("SIGKILL");
+ });
+});
diff --git a/cli/src/service/cli-aws-authentication-service.ts b/cli/src/service/cli-aws-authentication-service.ts
new file mode 100644
index 000000000..2faefaa6f
--- /dev/null
+++ b/cli/src/service/cli-aws-authentication-service.ts
@@ -0,0 +1,91 @@
+import puppeteer from "puppeteer";
+import { IAwsSamlAuthenticationService } from "@noovolari/leapp-core/interfaces/i-aws-saml-authentication-service";
+import { LeappModalClosedError } from "@noovolari/leapp-core/errors/leapp-modal-closed-error";
+import { AwsSamlAssertionExtractionService } from "@noovolari/leapp-core/services/aws-saml-assertion-extraction-service";
+import { CloudProviderType } from "@noovolari/leapp-core/models/cloud-provider-type";
+
+export class CliAwsAuthenticationService implements IAwsSamlAuthenticationService {
+ private browser: puppeteer.Browser;
+
+ constructor(private awsSamlAssertionExtractionService: AwsSamlAssertionExtractionService) {}
+
+ async needAuthentication(idpUrl: string): Promise {
+ // eslint-disable-next-line
+ return new Promise(async (resolve, reject) => {
+ const page = await this.getNavigationPage(true);
+
+ page.on("request", async (request) => {
+ if (request.isInterceptResolutionHandled()) {
+ reject("request unexpectedly already handled");
+ return;
+ }
+
+ const requestUrl = request.url().toString();
+ if (this.awsSamlAssertionExtractionService.isAuthenticationUrl(CloudProviderType.aws, requestUrl)) {
+ resolve(true);
+ }
+ if (this.awsSamlAssertionExtractionService.isSamlAssertionUrl(CloudProviderType.aws, requestUrl)) {
+ resolve(false);
+ }
+ await request.continue();
+ });
+
+ try {
+ await page.goto(idpUrl);
+ } catch (e) {}
+ });
+ }
+
+ async awsSignIn(idpUrl: string, needToAuthenticate: boolean): Promise {
+ // eslint-disable-next-line
+ return new Promise(async (resolve, reject) => {
+ const page = await this.getNavigationPage(!needToAuthenticate);
+
+ page.on("request", async (request) => {
+ if (request.isInterceptResolutionHandled()) {
+ reject("request unexpectedly already handled");
+ return;
+ }
+
+ const requestUrl = request.url().toString();
+ if (this.awsSamlAssertionExtractionService.isSamlAssertionUrl(CloudProviderType.aws, requestUrl)) {
+ const responseHookDetails = { uploadData: [{ bytes: { toString: () => request.postData() } } as any] };
+ resolve(this.awsSamlAssertionExtractionService.extractAwsSamlResponse(responseHookDetails));
+ return;
+ }
+
+ await request.continue();
+ });
+
+ page.on("close", () => {
+ reject(new LeappModalClosedError(this, "request window closed by user"));
+ });
+
+ try {
+ await page.goto(idpUrl);
+ } catch (e) {}
+ });
+ }
+
+ async closeAuthenticationWindow(): Promise {
+ if (this.browser) {
+ for (const page of await this.browser.pages()) {
+ page.removeAllListeners();
+ await page.close();
+ }
+
+ await this.browser.close();
+ }
+ }
+
+ async getNavigationPage(headlessMode: boolean): Promise {
+ this.browser = await puppeteer.launch({ headless: headlessMode, devtools: false });
+ const pages = await this.browser.pages();
+ const page = pages.length > 0 ? pages[0] : await this.browser.newPage();
+
+ await page.setDefaultNavigationTimeout(180000);
+ await page.setRequestInterception(true);
+
+ return page;
+ }
+}
diff --git a/cli/src/service/cli-aws-sso-oidc-verification-window-service.ts b/cli/src/service/cli-aws-sso-oidc-verification-window-service.ts
new file mode 100644
index 000000000..a3567e63a
--- /dev/null
+++ b/cli/src/service/cli-aws-sso-oidc-verification-window-service.ts
@@ -0,0 +1,47 @@
+import { IAwsSsoOidcVerificationWindowService } from "@noovolari/leapp-core/interfaces/i-aws-sso-oidc-verification-window-service";
+import {
+ RegisterClientResponse,
+ StartDeviceAuthorizationResponse,
+ VerificationResponse,
+} from "@noovolari/leapp-core/services/session/aws/aws-sso-role-service";
+import puppeteer from "puppeteer";
+
+export class CliAwsSsoOidcVerificationWindowService implements IAwsSsoOidcVerificationWindowService {
+ private browser: puppeteer.Browser;
+
+ async openVerificationWindow(
+ registerClientResponse: RegisterClientResponse,
+ startDeviceAuthorizationResponse: StartDeviceAuthorizationResponse
+ ): Promise {
+ const uriComplete = startDeviceAuthorizationResponse.verificationUriComplete;
+ const page = await this.getNavigationPage();
+ await page.goto(uriComplete as string);
+
+ return {
+ clientId: registerClientResponse.clientId,
+ clientSecret: registerClientResponse.clientSecret,
+ deviceCode: startDeviceAuthorizationResponse.deviceCode,
+ } as VerificationResponse;
+ }
+
+ async closeBrowser(): Promise {
+ if (!this.browser) {
+ return;
+ }
+
+ for (const page of await this.browser.pages()) {
+ page.removeAllListeners();
+ await page.close();
+ }
+
+ await this.browser.close();
+ }
+
+ private async getNavigationPage(): Promise {
+ this.browser = await puppeteer.launch({ headless: false, devtools: false });
+ const pages = await this.browser.pages();
+ const page = pages.length > 0 ? pages[0] : await this.browser.newPage();
+ await page.setDefaultNavigationTimeout(180000);
+ return page;
+ }
+}
diff --git a/cli/src/service/cli-mfa-code-prompt-service.spec.ts b/cli/src/service/cli-mfa-code-prompt-service.spec.ts
new file mode 100644
index 000000000..e9f405213
--- /dev/null
+++ b/cli/src/service/cli-mfa-code-prompt-service.spec.ts
@@ -0,0 +1,30 @@
+import { describe, test, expect, jest } from "@jest/globals";
+import { CliMfaCodePromptService } from "./cli-mfa-code-prompt-service";
+import { of } from "rxjs";
+
+describe("CliMfaCodePromptService", () => {
+ test("promptForMFACode", async () => {
+ const sessionName = "sessionName";
+ const selectedMfaCode = "selectedMfaCode";
+ const inquirer: any = {
+ prompt: (param: any) => {
+ expect(param).toEqual([
+ {
+ name: "mfaCode",
+ message: `Insert MFA code for session ${sessionName}`,
+ type: "input",
+ },
+ ]);
+ return of({ mfaCode: selectedMfaCode }).toPromise();
+ },
+ };
+ const callbackFunction = jest.fn((mfaCode: string) => {
+ expect(mfaCode).toEqual(selectedMfaCode);
+ });
+
+ const cliMfaCodePromptService = new CliMfaCodePromptService(inquirer);
+ cliMfaCodePromptService.promptForMFACode(sessionName, callbackFunction);
+ await Promise.all([inquirer.prompt]);
+ expect(callbackFunction).toHaveBeenCalled();
+ });
+});
diff --git a/cli/src/service/cli-mfa-code-prompt-service.ts b/cli/src/service/cli-mfa-code-prompt-service.ts
new file mode 100644
index 000000000..0839acebc
--- /dev/null
+++ b/cli/src/service/cli-mfa-code-prompt-service.ts
@@ -0,0 +1,18 @@
+import { IMfaCodePrompter } from "@noovolari/leapp-core/interfaces/i-mfa-code-prompter";
+import CliInquirer from "inquirer";
+
+export class CliMfaCodePromptService implements IMfaCodePrompter {
+ constructor(private inquirer: CliInquirer.Inquirer) {}
+
+ promptForMFACode(sessionName: string, callback: (code: string) => void): void {
+ this.inquirer
+ .prompt([
+ {
+ name: "mfaCode",
+ message: `Insert MFA code for session ${sessionName}`,
+ type: "input",
+ },
+ ])
+ .then((mfaResponse) => callback(mfaResponse.mfaCode));
+ }
+}
diff --git a/cli/src/service/cli-native-service.ts b/cli/src/service/cli-native-service.ts
new file mode 100644
index 000000000..c663c0170
--- /dev/null
+++ b/cli/src/service/cli-native-service.ts
@@ -0,0 +1,55 @@
+import { INativeService } from "@noovolari/leapp-core/interfaces/i-native-service";
+
+export class CliNativeService implements INativeService {
+ log: any;
+ url: any;
+ fs: any;
+ rimraf: any;
+ os: any;
+ ini: any;
+ exec: any;
+ unzip: any;
+ copydir: any;
+ sudo: any;
+ path: any;
+ semver: any;
+ machineId: any;
+ keytar: any;
+ followRedirects: any;
+ httpProxyAgent: any;
+ httpsProxyAgent: any;
+ process: any;
+ nodeIpc: any;
+
+ constructor() {
+ this.fs = require("fs-extra");
+ this.rimraf = require("rimraf");
+ this.os = require("os");
+ this.ini = require("ini");
+ this.path = require("path");
+ this.exec = require("child_process").exec;
+ this.process = global.process;
+ this.nodeIpc = require("node-ipc");
+ this.url = require("url");
+ this.unzip = require("extract-zip");
+ this.copydir = require("copy-dir");
+ this.sudo = require("sudo-prompt");
+ this.semver = require("semver");
+ this.machineId = require("node-machine-id").machineIdSync();
+ this.keytar = require("keytar");
+ this.followRedirects = require("follow-redirects");
+ this.httpProxyAgent = require("http-proxy-agent");
+ this.httpsProxyAgent = require("https-proxy-agent");
+ this.log = {
+ info: (msg: string) => {
+ global.console.info(msg);
+ },
+ warn: (msg: string) => {
+ global.console.warn(msg);
+ },
+ error: (msg: string) => {
+ global.console.error(msg);
+ },
+ };
+ }
+}
diff --git a/cli/src/service/cli-open-web-console-service.spec.ts b/cli/src/service/cli-open-web-console-service.spec.ts
new file mode 100644
index 000000000..06bc58d64
--- /dev/null
+++ b/cli/src/service/cli-open-web-console-service.spec.ts
@@ -0,0 +1,14 @@
+import { describe, test, jest, expect } from "@jest/globals";
+
+describe("CliOpenWebConsoleService", () => {
+ test("openExternalUrl", async () => {
+ const mockOpenFunction = jest.fn();
+ jest.mock("open", () => mockOpenFunction);
+ const { CliOpenWebConsoleService: serviceClass } = await import("./cli-open-web-console-service");
+
+ const cliOpenWebConsoleService = new serviceClass();
+ const url = "http://www.url.com";
+ await cliOpenWebConsoleService.openExternalUrl(url);
+ expect(mockOpenFunction).toHaveBeenCalledWith(url);
+ });
+});
diff --git a/cli/src/service/cli-open-web-console-service.ts b/cli/src/service/cli-open-web-console-service.ts
new file mode 100644
index 000000000..d06cc9817
--- /dev/null
+++ b/cli/src/service/cli-open-web-console-service.ts
@@ -0,0 +1,8 @@
+import { IOpenExternalUrlService } from "@noovolari/leapp-core/interfaces/i-open-external-url-service";
+import open from "open";
+
+export class CliOpenWebConsoleService implements IOpenExternalUrlService {
+ async openExternalUrl(url: string): Promise {
+ await open(url);
+ }
+}
diff --git a/cli/src/service/cli-provider-service.spec.ts b/cli/src/service/cli-provider-service.spec.ts
new file mode 100644
index 000000000..879c250ad
--- /dev/null
+++ b/cli/src/service/cli-provider-service.spec.ts
@@ -0,0 +1,29 @@
+import { describe, test, expect } from "@jest/globals";
+import { CliProviderService } from "./cli-provider-service";
+
+describe("CliProviderService", () => {
+ test("services", async () => {
+ for (const propertyName of Object.keys(Object.getOwnPropertyDescriptors(CliProviderService.prototype))) {
+ const cliProviderService = new CliProviderService();
+
+ let result;
+ try {
+ result = cliProviderService[propertyName];
+ } catch (e) {
+ throw new Error(`error getting: ${propertyName}`);
+ }
+
+ try {
+ expect(result).not.toBeFalsy();
+ } catch (e) {
+ throw new Error(`${propertyName} is falsy`);
+ }
+
+ try {
+ expect(cliProviderService[propertyName]).toBe(result);
+ } catch (error) {
+ throw new Error(`singleton not working for ${propertyName}`);
+ }
+ }
+ });
+});
diff --git a/cli/src/service/cli-provider-service.ts b/cli/src/service/cli-provider-service.ts
new file mode 100644
index 000000000..94c6357b8
--- /dev/null
+++ b/cli/src/service/cli-provider-service.ts
@@ -0,0 +1,319 @@
+import { CloudProviderService } from "@noovolari/leapp-core/services/cloud-provider-service";
+import { AwsIamUserService } from "@noovolari/leapp-core/services/session/aws/aws-iam-user-service";
+import { FileService } from "@noovolari/leapp-core/services/file-service";
+import { KeychainService } from "@noovolari/leapp-core/services/keychain-service";
+import { AwsCoreService } from "@noovolari/leapp-core/services/aws-core-service";
+import { LoggingService } from "@noovolari/leapp-core/services/logging-service";
+import { TimerService } from "@noovolari/leapp-core/services/timer-service";
+import { AwsIamRoleFederatedService } from "@noovolari/leapp-core/services/session/aws/aws-iam-role-federated-service";
+import { AzureService } from "@noovolari/leapp-core/services/session/azure/azure-service";
+import { ExecuteService } from "@noovolari/leapp-core/services/execute-service";
+import { RetroCompatibilityService } from "@noovolari/leapp-core/services/retro-compatibility-service";
+import { AwsParentSessionFactory } from "@noovolari/leapp-core/services/session/aws/aws-parent-session.factory";
+import { AwsIamRoleChainedService } from "@noovolari/leapp-core/services/session/aws/aws-iam-role-chained-service";
+import { Repository } from "@noovolari/leapp-core/services/repository";
+import { RegionsService } from "@noovolari/leapp-core/services/regions-service";
+import { AwsSsoRoleService } from "@noovolari/leapp-core/services/session/aws/aws-sso-role-service";
+import { WorkspaceService } from "@noovolari/leapp-core/services/workspace-service";
+import { SessionFactory } from "@noovolari/leapp-core/services/session-factory";
+import { RotationService } from "@noovolari/leapp-core/services/rotation-service";
+import { AzureCoreService } from "@noovolari/leapp-core/services/azure-core-service";
+import { CliMfaCodePromptService } from "./cli-mfa-code-prompt-service";
+import { CliNativeService } from "./cli-native-service";
+import { RemoteProceduresClient } from "@noovolari/leapp-core/services/remote-procedures-client";
+import { constants } from "@noovolari/leapp-core/models/constants";
+import { NamedProfilesService } from "@noovolari/leapp-core/services/named-profiles-service";
+import { IdpUrlsService } from "@noovolari/leapp-core/services/idp-urls-service";
+import { AwsSsoIntegrationService } from "@noovolari/leapp-core/services/aws-sso-integration-service";
+import CliInquirer from "inquirer";
+import { AwsSsoOidcService } from "@noovolari/leapp-core/services/aws-sso-oidc.service";
+import { CliOpenWebConsoleService } from "./cli-open-web-console-service";
+import { WebConsoleService } from "@noovolari/leapp-core/services/web-console-service";
+import fetch from "node-fetch";
+import { AwsSamlAssertionExtractionService } from "@noovolari/leapp-core/services/aws-saml-assertion-extraction-service";
+import { SsmService } from "@noovolari/leapp-core/services/ssm-service";
+import { CliRpcAwsSsoOidcVerificationWindowService } from "./cli-rpc-aws-sso-oidc-verification-window-service";
+import { IAwsSsoOidcVerificationWindowService } from "@noovolari/leapp-core/interfaces/i-aws-sso-oidc-verification-window-service";
+import { CliRpcAwsSamlAuthenticationService } from "./cli-rpc-aws-saml-authentication-service";
+
+/* eslint-disable */
+export class CliProviderService {
+ private cliNativeServiceInstance: CliNativeService;
+ private cliAwsSsoOidcVerificationWindowServiceInstance: IAwsSsoOidcVerificationWindowService;
+ private awsSamlAssertionExtractionServiceInstance: AwsSamlAssertionExtractionService;
+ private cliRpcAwsSamlAuthenticationServiceInstance: CliRpcAwsSamlAuthenticationService;
+ private remoteProceduresClientInstance: RemoteProceduresClient;
+ private cliMfaCodePromptServiceInstance: CliMfaCodePromptService;
+ private workspaceServiceInstance: WorkspaceService;
+ private awsIamUserServiceInstance: AwsIamUserService;
+ private awsIamRoleFederatedServiceInstance: AwsIamRoleFederatedService;
+ private awsIamRoleChainedServiceInstance: AwsIamRoleChainedService;
+ private awsSsoRoleServiceInstance: AwsSsoRoleService;
+ private awsSsoOidcServiceInstance: AwsSsoOidcService;
+ private azureServiceInstance: AzureService;
+ private sessionFactoryInstance: SessionFactory;
+ private awsParentSessionFactoryInstance: AwsParentSessionFactory;
+ private fileServiceInstance: FileService;
+ private repositoryInstance: Repository;
+ private regionsServiceInstance: RegionsService;
+ private namedProfilesServiceInstance: NamedProfilesService;
+ private idpUrlsServiceInstance: IdpUrlsService;
+ private awsSsoIntegrationServiceInstance: AwsSsoIntegrationService;
+ private keyChainServiceInstance: KeychainService;
+ private loggingServiceInstance: LoggingService;
+ private timerServiceInstance: TimerService;
+ private executeServiceInstance: ExecuteService;
+ private rotationServiceInstance: RotationService;
+ private retroCompatibilityServiceInstance: RetroCompatibilityService;
+ private cloudProviderServiceInstance: CloudProviderService;
+ private awsCoreServiceInstance: AwsCoreService;
+ private azureCoreServiceInstance: AzureCoreService;
+ private cliOpenWebConsoleServiceInstance: CliOpenWebConsoleService;
+ private webConsoleServiceInstance: WebConsoleService;
+ private ssmServiceInstance: SsmService;
+
+ public get cliNativeService(): CliNativeService {
+ if (!this.cliNativeServiceInstance) {
+ this.cliNativeServiceInstance = new CliNativeService();
+ }
+ return this.cliNativeServiceInstance;
+ }
+
+ public get cliAwsSsoOidcVerificationWindowService(): IAwsSsoOidcVerificationWindowService {
+ if (!this.cliAwsSsoOidcVerificationWindowServiceInstance) {
+ this.cliAwsSsoOidcVerificationWindowServiceInstance = new CliRpcAwsSsoOidcVerificationWindowService(this.remoteProceduresClient);
+ }
+ return this.cliAwsSsoOidcVerificationWindowServiceInstance;
+ }
+
+ public get awsSamlAssertionExtractionService(): AwsSamlAssertionExtractionService {
+ if (!this.awsSamlAssertionExtractionServiceInstance) {
+ this.awsSamlAssertionExtractionServiceInstance = new AwsSamlAssertionExtractionService();
+ }
+ return this.awsSamlAssertionExtractionServiceInstance;
+ }
+
+ public get cliRpcAwsSamlAuthenticationService(): CliRpcAwsSamlAuthenticationService {
+ if (!this.cliRpcAwsSamlAuthenticationServiceInstance) {
+ this.cliRpcAwsSamlAuthenticationServiceInstance = new CliRpcAwsSamlAuthenticationService(this.remoteProceduresClient);
+ }
+ return this.cliRpcAwsSamlAuthenticationServiceInstance;
+ }
+
+ public get remoteProceduresClient(): RemoteProceduresClient {
+ if (!this.remoteProceduresClientInstance) {
+ this.remoteProceduresClientInstance = new RemoteProceduresClient(this.cliNativeService);
+ }
+ return this.remoteProceduresClientInstance;
+ }
+
+ public get cliMfaCodePromptService(): CliMfaCodePromptService {
+ if (!this.cliMfaCodePromptServiceInstance) {
+ this.cliMfaCodePromptServiceInstance = new CliMfaCodePromptService(this.inquirer);
+ }
+ return this.cliMfaCodePromptServiceInstance;
+ }
+
+ public get workspaceService(): WorkspaceService {
+ if (!this.workspaceServiceInstance) {
+ this.workspaceServiceInstance = new WorkspaceService(this.repository);
+ }
+ return this.workspaceServiceInstance;
+ }
+
+ public get awsIamUserService(): AwsIamUserService {
+ if (!this.awsIamUserServiceInstance) {
+ this.awsIamUserServiceInstance = new AwsIamUserService(this.workspaceService, this.repository, this.cliMfaCodePromptService,
+ this.keyChainService, this.fileService, this.awsCoreService);
+ }
+ return this.awsIamUserServiceInstance;
+ }
+
+ get awsIamRoleFederatedService(): AwsIamRoleFederatedService {
+ if (!this.awsIamRoleFederatedServiceInstance) {
+ this.awsIamRoleFederatedServiceInstance = new AwsIamRoleFederatedService(this.workspaceService, this.repository,
+ this.fileService, this.awsCoreService, this.cliRpcAwsSamlAuthenticationService, constants.samlRoleSessionDuration);
+ }
+ return this.awsIamRoleFederatedServiceInstance;
+ }
+
+ get awsIamRoleChainedService(): AwsIamRoleChainedService {
+ if (!this.awsIamRoleChainedServiceInstance) {
+ this.awsIamRoleChainedServiceInstance = new AwsIamRoleChainedService(this.workspaceService, this.repository,
+ this.awsCoreService, this.fileService, this.awsIamUserService, this.awsParentSessionFactory);
+ }
+ return this.awsIamRoleChainedServiceInstance;
+ }
+
+ get awsSsoRoleService(): AwsSsoRoleService {
+ if (!this.awsSsoRoleServiceInstance) {
+ this.awsSsoRoleServiceInstance = new AwsSsoRoleService(this.workspaceService, this.repository, this.fileService,
+ this.keyChainService, this.awsCoreService, this.cliNativeService, this.awsSsoOidcService);
+ }
+ return this.awsSsoRoleServiceInstance;
+ }
+
+ get awsSsoOidcService(): AwsSsoOidcService {
+ if (!this.awsSsoOidcServiceInstance) {
+ this.awsSsoOidcServiceInstance = new AwsSsoOidcService(this.cliAwsSsoOidcVerificationWindowService, this.repository, true);
+ }
+ return this.awsSsoOidcServiceInstance;
+ }
+
+ get azureService(): AzureService {
+ if (!this.azureServiceInstance) {
+ this.azureServiceInstance = new AzureService(this.workspaceService, this.repository, this.fileService, this.executeService,
+ constants.azureAccessTokens);
+ }
+ return this.azureServiceInstance;
+ }
+
+ get sessionFactory(): SessionFactory {
+ if (!this.sessionFactoryInstance) {
+ this.sessionFactoryInstance = new SessionFactory(this.awsIamUserService, this.awsIamRoleFederatedService,
+ this.awsIamRoleChainedService, this.awsSsoRoleService, this.azureService);
+ }
+ return this.sessionFactoryInstance;
+ }
+
+ get awsParentSessionFactory(): AwsParentSessionFactory {
+ if (!this.awsParentSessionFactoryInstance) {
+ this.awsParentSessionFactoryInstance = new AwsParentSessionFactory(this.awsIamUserService, this.awsIamRoleFederatedService,
+ this.awsSsoRoleService);
+ }
+ return this.awsParentSessionFactoryInstance;
+ }
+
+ get fileService(): FileService {
+ if (!this.fileServiceInstance) {
+ this.fileServiceInstance = new FileService(this.cliNativeService);
+ }
+ return this.fileServiceInstance;
+ }
+
+ get repository(): Repository {
+ if (!this.repositoryInstance) {
+ this.repositoryInstance = new Repository(this.cliNativeService, this.fileService);
+ }
+ return this.repositoryInstance;
+ }
+
+ get regionsService(): RegionsService {
+ if (!this.regionsServiceInstance) {
+ this.regionsServiceInstance = new RegionsService(this.sessionFactory, this.repository, this.workspaceService);
+ }
+ return this.regionsServiceInstance;
+ }
+
+ get namedProfilesService(): NamedProfilesService {
+ if (!this.namedProfilesServiceInstance) {
+ this.namedProfilesServiceInstance = new NamedProfilesService(this.sessionFactory, this.repository, this.workspaceService);
+ }
+ return this.namedProfilesServiceInstance;
+ }
+
+ get idpUrlsService(): IdpUrlsService {
+ if (!this.idpUrlsServiceInstance) {
+ this.idpUrlsServiceInstance = new IdpUrlsService(this.sessionFactory, this.repository);
+ }
+ return this.idpUrlsServiceInstance;
+ }
+
+ get awsSsoIntegrationService(): AwsSsoIntegrationService {
+ if (!this.awsSsoIntegrationServiceInstance) {
+ this.awsSsoIntegrationServiceInstance = new AwsSsoIntegrationService(this.repository, this.awsSsoOidcService,
+ this.awsSsoRoleService, this.keyChainService, this.workspaceService, this.cliNativeService, this.sessionFactory);
+ }
+ return this.awsSsoIntegrationServiceInstance;
+ }
+
+ get keyChainService(): KeychainService {
+ if (!this.keyChainServiceInstance) {
+ this.keyChainServiceInstance = new KeychainService(this.cliNativeService);
+ }
+ return this.keyChainServiceInstance;
+ }
+
+ get loggingService(): LoggingService {
+ if (!this.loggingServiceInstance) {
+ this.loggingServiceInstance = new LoggingService(this.cliNativeService);
+ }
+ return this.loggingServiceInstance;
+ }
+
+ get timerService(): TimerService {
+ if (!this.timerServiceInstance) {
+ this.timerServiceInstance = new TimerService();
+ }
+ return this.timerServiceInstance;
+ }
+
+ get executeService(): ExecuteService {
+ if (!this.executeServiceInstance) {
+ this.executeServiceInstance = new ExecuteService(this.cliNativeService, this.repository);
+ }
+ return this.executeServiceInstance;
+ }
+
+ get rotationService(): RotationService {
+ if (!this.rotationServiceInstance) {
+ this.rotationServiceInstance = new RotationService(this.sessionFactory, this.repository);
+ }
+ return this.rotationServiceInstance;
+ }
+
+ get retroCompatibilityService(): RetroCompatibilityService {
+ if (!this.retroCompatibilityServiceInstance) {
+ this.retroCompatibilityServiceInstance = new RetroCompatibilityService(this.fileService, this.keyChainService,
+ this.repository, this.workspaceService, constants.appName, constants.lockFileDestination);
+ }
+ return this.retroCompatibilityServiceInstance;
+ }
+
+ get cloudProviderService(): CloudProviderService {
+ if (!this.cloudProviderServiceInstance) {
+ this.cloudProviderServiceInstance = new CloudProviderService(this.awsCoreService, this.azureCoreService,
+ this.namedProfilesService, this.idpUrlsService, this.repository);
+ }
+ return this.cloudProviderServiceInstance;
+ }
+
+ get awsCoreService(): AwsCoreService {
+ if (!this.awsCoreServiceInstance) {
+ this.awsCoreServiceInstance = new AwsCoreService(this.cliNativeService);
+ }
+ return this.awsCoreServiceInstance;
+ }
+
+ get azureCoreService(): AzureCoreService {
+ if (!this.azureCoreServiceInstance) {
+ this.azureCoreServiceInstance = new AzureCoreService();
+ }
+ return this.azureCoreServiceInstance;
+ }
+
+ get cliOpenWebConsoleService(): CliOpenWebConsoleService {
+ if (!this.cliOpenWebConsoleServiceInstance) {
+ this.cliOpenWebConsoleServiceInstance = new CliOpenWebConsoleService();
+ }
+ return this.cliOpenWebConsoleServiceInstance;
+ }
+
+ get webConsoleService(): WebConsoleService {
+ if (!this.webConsoleServiceInstance) {
+ this.webConsoleServiceInstance = new WebConsoleService(this.cliOpenWebConsoleService, this.loggingService, fetch);
+ }
+ return this.webConsoleServiceInstance;
+ }
+
+ get ssmService(): SsmService {
+ if (!this.ssmServiceInstance) {
+ this.ssmServiceInstance = new SsmService(this.loggingService, this.executeService);
+ }
+ return this.ssmServiceInstance;
+ }
+
+ get inquirer(): CliInquirer.Inquirer {
+ return CliInquirer;
+ }
+}
diff --git a/cli/src/service/cli-rpc-aws-saml-authentication-service.ts b/cli/src/service/cli-rpc-aws-saml-authentication-service.ts
new file mode 100644
index 000000000..c0573ef4b
--- /dev/null
+++ b/cli/src/service/cli-rpc-aws-saml-authentication-service.ts
@@ -0,0 +1,18 @@
+import { IAwsSamlAuthenticationService } from "@noovolari/leapp-core/interfaces/i-aws-saml-authentication-service";
+import { RemoteProceduresClient } from "@noovolari/leapp-core/services/remote-procedures-client";
+
+export class CliRpcAwsSamlAuthenticationService implements IAwsSamlAuthenticationService {
+ constructor(private remoteProceduresClient: RemoteProceduresClient) {}
+
+ async needAuthentication(idpUrl: string): Promise {
+ return this.remoteProceduresClient.needAuthentication(idpUrl);
+ }
+
+ async awsSignIn(idpUrl: string, needToAuthenticate: boolean): Promise {
+ return this.remoteProceduresClient.awsSignIn(idpUrl, needToAuthenticate);
+ }
+
+ async closeAuthenticationWindow(): Promise {
+ // TODO: not yet implemented in desktop app
+ }
+}
diff --git a/cli/src/service/cli-rpc-aws-sso-oidc-verification-window-service.ts b/cli/src/service/cli-rpc-aws-sso-oidc-verification-window-service.ts
new file mode 100644
index 000000000..f3b5f5f40
--- /dev/null
+++ b/cli/src/service/cli-rpc-aws-sso-oidc-verification-window-service.ts
@@ -0,0 +1,25 @@
+import { IAwsSsoOidcVerificationWindowService } from "@noovolari/leapp-core/interfaces/i-aws-sso-oidc-verification-window-service";
+import {
+ RegisterClientResponse,
+ StartDeviceAuthorizationResponse,
+ VerificationResponse,
+} from "@noovolari/leapp-core/services/session/aws/aws-sso-role-service";
+import { RemoteProceduresClient } from "@noovolari/leapp-core/services/remote-procedures-client";
+
+export class CliRpcAwsSsoOidcVerificationWindowService implements IAwsSsoOidcVerificationWindowService {
+ constructor(private remoteProceduresClient: RemoteProceduresClient) {}
+
+ async openVerificationWindow(
+ registerClientResponse: RegisterClientResponse,
+ startDeviceAuthorizationResponse: StartDeviceAuthorizationResponse,
+ windowModality: string,
+ onWindowClose: () => void
+ ): Promise {
+ return this.remoteProceduresClient.openVerificationWindow(
+ registerClientResponse,
+ startDeviceAuthorizationResponse,
+ windowModality,
+ onWindowClose
+ );
+ }
+}
diff --git a/cli/src/service/cli-verification-window-service.spec.ts b/cli/src/service/cli-verification-window-service.spec.ts
new file mode 100644
index 000000000..cc75b7fe5
--- /dev/null
+++ b/cli/src/service/cli-verification-window-service.spec.ts
@@ -0,0 +1,44 @@
+import { describe, jest, expect, test } from "@jest/globals";
+import { CliAwsSsoOidcVerificationWindowService } from "./cli-aws-sso-oidc-verification-window-service";
+
+describe("CliVerificationWindowService", () => {
+ test("openVerificationWindow", async () => {
+ const registerClientResponse = { clientId: "clientId", clientSecret: "clientSecret" } as any;
+ const startDeviceAuthorizationResponse = { verificationUriComplete: "verUri", deviceCode: "deviceCode" } as any;
+
+ const cliAwsSsoOidcVerificationWindowService = new CliAwsSsoOidcVerificationWindowService();
+ const page = { goto: jest.fn() };
+ (cliAwsSsoOidcVerificationWindowService as any).getNavigationPage = async () => page;
+
+ const verificationResponse = await cliAwsSsoOidcVerificationWindowService.openVerificationWindow(
+ registerClientResponse,
+ startDeviceAuthorizationResponse
+ );
+
+ expect(verificationResponse).toEqual({
+ clientId: "clientId",
+ clientSecret: "clientSecret",
+ deviceCode: "deviceCode",
+ });
+ expect(page.goto).toHaveBeenCalledWith("verUri");
+ });
+
+ test("getNavigationPage and closeBrowser", async () => {
+ const cliAwsSsoOidcVerificationWindowService = new CliAwsSsoOidcVerificationWindowService();
+ const page = await (cliAwsSsoOidcVerificationWindowService as any).getNavigationPage(false);
+
+ const process = page.browser().process();
+ expect(process).toBeDefined();
+ expect(process?.killed).toBeFalsy();
+ expect(process?.signalCode).toBeNull();
+
+ await cliAwsSsoOidcVerificationWindowService.closeBrowser();
+ expect(process?.killed).toBeTruthy();
+ expect(process?.signalCode).toEqual("SIGKILL");
+ });
+
+ test("closeBrowser, no opened browser", async () => {
+ const cliAwsSsoOidcVerificationWindowService = new CliAwsSsoOidcVerificationWindowService();
+ await cliAwsSsoOidcVerificationWindowService.closeBrowser();
+ });
+});
diff --git a/cli/tsconfig.json b/cli/tsconfig.json
new file mode 100644
index 000000000..8531945e8
--- /dev/null
+++ b/cli/tsconfig.json
@@ -0,0 +1,50 @@
+{
+ "extends": "../tsconfig.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "downlevelIteration": true,
+ "module": "CommonJS",
+ "moduleResolution": "Node",
+ "importHelpers": true,
+ "declaration": true,
+ "strict": true,
+ "noImplicitAny": true,
+ "strictNullChecks": true,
+ "noImplicitThis": true,
+ "alwaysStrict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noImplicitReturns": true,
+ "noFallthroughCasesInSwitch": true,
+ "inlineSourceMap": true,
+ "inlineSources": true,
+ "experimentalDecorators": true,
+ "strictPropertyInitialization": false,
+ "emitDecoratorMetadata": true,
+ "resolveJsonModule": true,
+ "esModuleInterop": true,
+ "target": "ES2019",
+ "types": ["node"],
+ "lib": ["ES2019", "DOM"],
+ "outDir": "dist",
+ "baseUrl": ".",
+ "skipLibCheck": true,
+ "paths": {
+ "*": [
+ "node_modules/*"
+ ]
+ }
+ },
+ "include": [
+ "src/**/*"
+ ],
+ "exclude": [
+ "**/*.spec.ts",
+ "coverage",
+ "node_modules",
+ "env",
+ "**/dist/**",
+ "dev",
+ "spec"
+ ]
+}
diff --git a/core/README.md b/core/README.md
new file mode 100644
index 000000000..5827afdcd
--- /dev/null
+++ b/core/README.md
@@ -0,0 +1,8 @@
+Leapp Core
+=================
+
+Leapp Core is a library that decouples Leapp's domain logic from the Client that is going to use it.
+
+There are two different Clients that rely on it: Leapp CLI and Leapp Desktop App.
+
+For more information about the project visit the [site](www.leapp.cloud).
diff --git a/core/babel.config.js b/core/babel.config.js
new file mode 100644
index 000000000..b7331ce79
--- /dev/null
+++ b/core/babel.config.js
@@ -0,0 +1,13 @@
+module.exports = {
+ presets: [
+ ['@babel/preset-env', {targets: {node: 'current'}}],
+ '@babel/preset-typescript',
+
+ ],
+ plugins: [
+ ["@babel/plugin-proposal-decorators", { "legacy": true }],
+ ["@babel/plugin-proposal-class-properties", { "loose": true }],
+ ["@babel/plugin-proposal-private-property-in-object", { "loose": true }]
+ ]
+}
+
diff --git a/core/errors/leapp-aws-sts-error.ts b/core/errors/leapp-aws-sts-error.ts
new file mode 100644
index 000000000..1c49add85
--- /dev/null
+++ b/core/errors/leapp-aws-sts-error.ts
@@ -0,0 +1,8 @@
+import { LeappBaseError } from "./leapp-base-error";
+import { LoggerLevel } from "../services/logging-service";
+
+export class LeappAwsStsError extends LeappBaseError {
+ constructor(context: any, message?: string) {
+ super("Leapp Aws Sts Error", context, LoggerLevel.warn, message);
+ }
+}
diff --git a/src/app/errors/leapp-base-error.ts b/core/errors/leapp-base-error.ts
similarity index 77%
rename from src/app/errors/leapp-base-error.ts
rename to core/errors/leapp-base-error.ts
index 9d3d4843c..a18ff300b 100644
--- a/src/app/errors/leapp-base-error.ts
+++ b/core/errors/leapp-base-error.ts
@@ -1,7 +1,6 @@
-import {LoggerLevel} from '../services/app.service';
+import { LoggerLevel } from "../services/logging-service";
export class LeappBaseError extends Error {
-
private readonly _context: any;
private readonly _severity: LoggerLevel;
@@ -13,11 +12,11 @@ export class LeappBaseError extends Error {
Object.setPrototypeOf(this, new.target.prototype);
}
- get severity(): LoggerLevel {
+ public get severity(): LoggerLevel {
return this._severity;
}
- get context(): any {
+ public get context(): any {
return this._context;
}
}
diff --git a/core/errors/leapp-execute-error.ts b/core/errors/leapp-execute-error.ts
new file mode 100644
index 000000000..ca5ce8baa
--- /dev/null
+++ b/core/errors/leapp-execute-error.ts
@@ -0,0 +1,8 @@
+import { LeappBaseError } from "./leapp-base-error";
+import { LoggerLevel } from "../services/logging-service";
+
+export class LeappExecuteError extends LeappBaseError {
+ constructor(context: any, message?: string) {
+ super("Leapp Execute Error", context, LoggerLevel.warn, message);
+ }
+}
diff --git a/core/errors/leapp-missing-mfa-token-error.ts b/core/errors/leapp-missing-mfa-token-error.ts
new file mode 100644
index 000000000..1b07d3018
--- /dev/null
+++ b/core/errors/leapp-missing-mfa-token-error.ts
@@ -0,0 +1,8 @@
+import { LeappBaseError } from "./leapp-base-error";
+import { LoggerLevel } from "../services/logging-service";
+
+export class LeappMissingMfaTokenError extends LeappBaseError {
+ constructor(context: any, message: string) {
+ super("Leapp Missing Mfa Token Error", context, LoggerLevel.warn, message);
+ }
+}
diff --git a/core/errors/leapp-modal-closed-error.ts b/core/errors/leapp-modal-closed-error.ts
new file mode 100644
index 000000000..f4b8fcca8
--- /dev/null
+++ b/core/errors/leapp-modal-closed-error.ts
@@ -0,0 +1,8 @@
+import { LeappBaseError } from "./leapp-base-error";
+import { LoggerLevel } from "../services/logging-service";
+
+export class LeappModalClosedError extends LeappBaseError {
+ constructor(context: any, message?: string) {
+ super("Leapp Modal Closed", context, LoggerLevel.info, message);
+ }
+}
diff --git a/core/errors/leapp-not-aws-account-error.ts b/core/errors/leapp-not-aws-account-error.ts
new file mode 100644
index 000000000..ea52cd06e
--- /dev/null
+++ b/core/errors/leapp-not-aws-account-error.ts
@@ -0,0 +1,8 @@
+import { LeappBaseError } from "./leapp-base-error";
+import { LoggerLevel } from "../services/logging-service";
+
+export class LeappNotAwsAccountError extends LeappBaseError {
+ constructor(context: any, message?: string) {
+ super("Leapp Not aws Account Error", context, LoggerLevel.warn, message);
+ }
+}
diff --git a/core/errors/leapp-not-found-error.ts b/core/errors/leapp-not-found-error.ts
new file mode 100644
index 000000000..c8658db56
--- /dev/null
+++ b/core/errors/leapp-not-found-error.ts
@@ -0,0 +1,8 @@
+import { LeappBaseError } from "./leapp-base-error";
+import { LoggerLevel } from "../services/logging-service";
+
+export class LeappNotFoundError extends LeappBaseError {
+ constructor(context: any, message?: string) {
+ super("Leapp Not Found Error", context, LoggerLevel.warn, message);
+ }
+}
diff --git a/core/errors/leapp-parse-error.ts b/core/errors/leapp-parse-error.ts
new file mode 100644
index 000000000..b3c306ba9
--- /dev/null
+++ b/core/errors/leapp-parse-error.ts
@@ -0,0 +1,8 @@
+import { LeappBaseError } from "./leapp-base-error";
+import { LoggerLevel } from "../services/logging-service";
+
+export class LeappParseError extends LeappBaseError {
+ constructor(context: any, message?: string) {
+ super("Leapp Parse Error", context, LoggerLevel.warn, message);
+ }
+}
diff --git a/core/errors/leapp-saml-error.ts b/core/errors/leapp-saml-error.ts
new file mode 100644
index 000000000..664bba0fe
--- /dev/null
+++ b/core/errors/leapp-saml-error.ts
@@ -0,0 +1,8 @@
+import { LeappBaseError } from "./leapp-base-error";
+import { LoggerLevel } from "../services/logging-service";
+
+export class LeappSamlError extends LeappBaseError {
+ constructor(context: any, message?: string) {
+ super("Leapp Saml Error", context, LoggerLevel.warn, message);
+ }
+}
diff --git a/core/gushio/bump-func.js b/core/gushio/bump-func.js
new file mode 100644
index 000000000..db2390739
--- /dev/null
+++ b/core/gushio/bump-func.js
@@ -0,0 +1,6 @@
+module.exports = async function bumpVersionFunction(path, semver) {
+ const packageJsonFile = path.join(__dirname, '../package.json')
+ const packageJson = await fs.readJson(packageJsonFile)
+ packageJson.version = semver.inc(packageJson.version, 'patch')
+ await fs.writeJson(packageJsonFile, packageJson, {spaces: 2})
+}
diff --git a/core/gushio/compile-func.js b/core/gushio/compile-func.js
new file mode 100644
index 000000000..e40c342dd
--- /dev/null
+++ b/core/gushio/compile-func.js
@@ -0,0 +1,7 @@
+module.exports = async function compileFunction(path, shellJs) {
+ shellJs.cd(path.join(__dirname, '..'))
+ const result = shellJs.exec('npx tsc')
+ if (result.code !== 0) {
+ throw new Error(result.stderr)
+ }
+}
diff --git a/scripts/gushio/delete-func.js b/core/gushio/delete-func.js
similarity index 100%
rename from scripts/gushio/delete-func.js
rename to core/gushio/delete-func.js
diff --git a/core/gushio/target-build.js b/core/gushio/target-build.js
new file mode 100644
index 000000000..d7d13e541
--- /dev/null
+++ b/core/gushio/target-build.js
@@ -0,0 +1,23 @@
+module.exports = {
+ cli: {
+ name: 'build',
+ description: 'Build the leapp core library',
+ version: '0.1',
+ },
+ run: async () => {
+ const path = require('path')
+ const shellJs = require('shelljs')
+ const compileFunction = require('./compile-func')
+
+ try {
+ await gushio.run(path.join(__dirname, './target-clean.js'))
+
+ console.log('Building leapp-core library... ')
+ await compileFunction(path, shellJs)
+ console.log('Build completed successfully')
+ } catch (e) {
+ e.message = e.message.red
+ throw e
+ }
+ },
+}
diff --git a/core/gushio/target-clean.js b/core/gushio/target-clean.js
new file mode 100644
index 000000000..4c9e0d1d9
--- /dev/null
+++ b/core/gushio/target-clean.js
@@ -0,0 +1,21 @@
+module.exports = {
+ cli: {
+ name: 'clean',
+ description: 'Cleanup all files built previously',
+ version: '0.1'
+ },
+ run: async () => {
+ const path = require('path')
+ const deleteFunction = require('./delete-func')
+
+ try {
+ console.log('Performing cleanup... ')
+ await deleteFunction(path, '../dist')
+ await deleteFunction(path, '../coverage')
+ console.log('Cleanup completed successfully')
+ } catch (e) {
+ e.message = e.message.red
+ throw e
+ }
+ }
+}
diff --git a/core/gushio/target-release.js b/core/gushio/target-release.js
new file mode 100644
index 000000000..4e6b25c51
--- /dev/null
+++ b/core/gushio/target-release.js
@@ -0,0 +1,31 @@
+module.exports = {
+ cli: {
+ name: 'release',
+ description: 'Prepare and release the leapp-core library on NPM',
+ version: '0.1',
+ },
+ deps: [{name: 'semver', version: '^7.3.5'}],
+ run: async () => {
+ const path = require('path')
+ const shellJs = require('shelljs')
+ const semver = require('semver')
+ const bumpVersionFunction = require('./bump-func')
+
+ try {
+ console.log('Publishing leapp-core library... ')
+ await bumpVersionFunction(path, semver)
+
+ await gushio.run(path.join(__dirname, './target-build.js'))
+
+ shellJs.cd(path.join(__dirname, '../dist'))
+ const result = shellJs.exec('npm publish --access public')
+ if (result.code !== 0) {
+ throw new Error(result.stderr)
+ }
+ console.log('leapp-core published on npm successfully')
+ } catch (e) {
+ e.message = e.message.red
+ throw e
+ }
+ }
+}
diff --git a/core/interfaces/i-aws-integration-delegate.ts b/core/interfaces/i-aws-integration-delegate.ts
new file mode 100644
index 000000000..ef168ab09
--- /dev/null
+++ b/core/interfaces/i-aws-integration-delegate.ts
@@ -0,0 +1,7 @@
+import { GetRoleCredentialsResponse } from "aws-sdk/clients/sso";
+
+export interface IAwsIntegrationDelegate {
+ getAccessToken(configurationId: string, region: string, portalUrl: string): Promise;
+
+ getRoleCredentials(accessToken: string, region: string, roleArn: string): Promise;
+}
diff --git a/core/interfaces/i-aws-saml-authentication-service.ts b/core/interfaces/i-aws-saml-authentication-service.ts
new file mode 100644
index 000000000..271ab66ab
--- /dev/null
+++ b/core/interfaces/i-aws-saml-authentication-service.ts
@@ -0,0 +1,7 @@
+export interface IAwsSamlAuthenticationService {
+ needAuthentication(idpUrl: string): Promise;
+
+ awsSignIn(idpUrl: string, needToAuthenticate: boolean): Promise;
+
+ closeAuthenticationWindow(): Promise;
+}
diff --git a/core/interfaces/i-aws-sso-oidc-verification-window-service.ts b/core/interfaces/i-aws-sso-oidc-verification-window-service.ts
new file mode 100644
index 000000000..8fd63c70c
--- /dev/null
+++ b/core/interfaces/i-aws-sso-oidc-verification-window-service.ts
@@ -0,0 +1,10 @@
+import { RegisterClientResponse, StartDeviceAuthorizationResponse, VerificationResponse } from "../services/session/aws/aws-sso-role-service";
+
+export interface IAwsSsoOidcVerificationWindowService {
+ openVerificationWindow(
+ registerClientResponse: RegisterClientResponse,
+ startDeviceAuthorizationResponse: StartDeviceAuthorizationResponse,
+ windowModality: string,
+ onWindowClose: () => void
+ ): Promise;
+}
diff --git a/core/interfaces/i-browser-window-closing.ts b/core/interfaces/i-browser-window-closing.ts
new file mode 100644
index 000000000..91925b15f
--- /dev/null
+++ b/core/interfaces/i-browser-window-closing.ts
@@ -0,0 +1,3 @@
+export interface BrowserWindowClosing {
+ catchClosingBrowserWindow(): void;
+}
diff --git a/core/interfaces/i-mfa-code-prompter.ts b/core/interfaces/i-mfa-code-prompter.ts
new file mode 100644
index 000000000..911f9a0c2
--- /dev/null
+++ b/core/interfaces/i-mfa-code-prompter.ts
@@ -0,0 +1,3 @@
+export interface IMfaCodePrompter {
+ promptForMFACode(sessionName: string, callback: any): void;
+}
diff --git a/core/interfaces/i-native-service.ts b/core/interfaces/i-native-service.ts
new file mode 100644
index 000000000..1ba9924ab
--- /dev/null
+++ b/core/interfaces/i-native-service.ts
@@ -0,0 +1,23 @@
+import * as ipc from "node-ipc";
+
+export interface INativeService {
+ log: any;
+ url: any;
+ fs: any;
+ rimraf: any;
+ os: any;
+ ini: any;
+ exec: any;
+ unzip: any;
+ copydir: any;
+ sudo: any;
+ path: any;
+ semver: any;
+ machineId: any;
+ keytar: any;
+ followRedirects: any;
+ httpProxyAgent: any;
+ httpsProxyAgent: any;
+ process: any;
+ nodeIpc: typeof ipc;
+}
diff --git a/core/interfaces/i-open-external-url-service.ts b/core/interfaces/i-open-external-url-service.ts
new file mode 100644
index 000000000..2f586cf9e
--- /dev/null
+++ b/core/interfaces/i-open-external-url-service.ts
@@ -0,0 +1,3 @@
+export interface IOpenExternalUrlService {
+ openExternalUrl(url: string): void;
+}
diff --git a/core/interfaces/i-session-notifier.ts b/core/interfaces/i-session-notifier.ts
new file mode 100644
index 000000000..1ffecc103
--- /dev/null
+++ b/core/interfaces/i-session-notifier.ts
@@ -0,0 +1,21 @@
+import { Session } from "../models/session";
+
+export interface ISessionNotifier {
+ getSessions(): Session[];
+
+ getSessionById(sessionId: string): Session;
+
+ setSessions(sessions: Session[]): void;
+
+ addSession(session: Session): void;
+
+ deleteSession(sessionId: string): void;
+
+ listPending(): Session[];
+
+ listActive(): Session[];
+
+ listAwsSsoRoles(): Session[];
+
+ listIamRoleChained(session: Session): Session[];
+}
diff --git a/core/models/access-method-field-type.ts b/core/models/access-method-field-type.ts
new file mode 100644
index 000000000..5b86827dc
--- /dev/null
+++ b/core/models/access-method-field-type.ts
@@ -0,0 +1,4 @@
+export enum AccessMethodFieldType {
+ input = "input",
+ list = "list",
+}
diff --git a/core/models/access-method-field.ts b/core/models/access-method-field.ts
new file mode 100644
index 000000000..263ba0268
--- /dev/null
+++ b/core/models/access-method-field.ts
@@ -0,0 +1,6 @@
+import { FieldChoice } from "../services/field-choice";
+import { AccessMethodFieldType } from "./access-method-field-type";
+
+export class AccessMethodField {
+ constructor(public creationRequestField: string, public message: string, public type: AccessMethodFieldType, public choices?: FieldChoice[]) {}
+}
diff --git a/core/models/access-method.spec.ts b/core/models/access-method.spec.ts
new file mode 100644
index 000000000..8e5b064c0
--- /dev/null
+++ b/core/models/access-method.spec.ts
@@ -0,0 +1,30 @@
+import { AccessMethod } from "./access-method";
+import { AccessMethodField } from "./access-method-field";
+import { AccessMethodFieldType } from "./access-method-field-type";
+import { SessionType } from "./session-type";
+
+describe("accessMethod", () => {
+ it("getSessionCreationRequest", () => {
+ const fieldValues = new Map([
+ ["macchina", "Clio"],
+ ["cibo", "pizza"],
+ ["sessionName", "prova"],
+ ]);
+ const accessMethod = new AccessMethod(
+ SessionType.azure,
+ "azure session",
+ [
+ new AccessMethodField("macchina", "message1", AccessMethodFieldType.input),
+ new AccessMethodField("cibo", "message1", AccessMethodFieldType.input),
+ new AccessMethodField("sessionName", "message1", AccessMethodFieldType.input),
+ ],
+ true
+ );
+ const actualRequest = accessMethod.getSessionCreationRequest(fieldValues);
+ expect(actualRequest).toEqual({
+ cibo: "pizza",
+ macchina: "Clio",
+ sessionName: "prova",
+ });
+ });
+});
diff --git a/core/models/access-method.ts b/core/models/access-method.ts
new file mode 100644
index 000000000..19bb73878
--- /dev/null
+++ b/core/models/access-method.ts
@@ -0,0 +1,16 @@
+import { CreateSessionRequest } from "../services/session/create-session-request";
+import { AccessMethodField } from "./access-method-field";
+import { SessionType } from "./session-type";
+
+export class AccessMethod {
+ constructor(public sessionType: SessionType, public label: string, public accessMethodFields: AccessMethodField[], public creatable: boolean) {}
+
+ getSessionCreationRequest(fieldValues: Map): CreateSessionRequest {
+ const requestToFill = {} as CreateSessionRequest;
+ for (const field of this.accessMethodFields) {
+ requestToFill[field.creationRequestField] = fieldValues.get(field.creationRequestField);
+ }
+
+ return requestToFill;
+ }
+}
diff --git a/src/app/models/aws-iam-role-chained-session.ts b/core/models/aws-iam-role-chained-session.ts
similarity index 82%
rename from src/app/models/aws-iam-role-chained-session.ts
rename to core/models/aws-iam-role-chained-session.ts
index d571b6e78..1eaa7e6aa 100644
--- a/src/app/models/aws-iam-role-chained-session.ts
+++ b/core/models/aws-iam-role-chained-session.ts
@@ -1,12 +1,12 @@
-import {SessionType} from './session-type';
-import {Session} from './session';
+import { SessionType } from "./session-type";
+import { Session } from "./session";
export class AwsIamRoleChainedSession extends Session {
-
roleArn: string;
profileId: string;
parentSessionId: string;
roleSessionName?: string;
+ sessionTokenExpiration: string;
constructor(sessionName: string, region: string, roleArn: string, profileId: string, parentSessionId: string, roleSessionName?: string) {
super(sessionName, region);
diff --git a/src/app/models/aws-iam-role-federated-session.ts b/core/models/aws-iam-role-federated-session.ts
similarity index 79%
rename from src/app/models/aws-iam-role-federated-session.ts
rename to core/models/aws-iam-role-federated-session.ts
index f89c5cf9f..e41480483 100644
--- a/src/app/models/aws-iam-role-federated-session.ts
+++ b/core/models/aws-iam-role-federated-session.ts
@@ -1,12 +1,12 @@
-import {SessionType} from './session-type';
-import {Session} from './session';
+import { SessionType } from "./session-type";
+import { Session } from "./session";
export class AwsIamRoleFederatedSession extends Session {
-
idpUrlId: string;
idpArn: string;
roleArn: string;
profileId: string;
+ sessionTokenExpiration: string;
constructor(sessionName: string, region: string, idpUrlId: string, idpArn: string, roleArn: string, profileId: string) {
super(sessionName, region);
diff --git a/src/app/models/aws-iam-user-session.ts b/core/models/aws-iam-user-session.ts
similarity index 81%
rename from src/app/models/aws-iam-user-session.ts
rename to core/models/aws-iam-user-session.ts
index 4cec05c00..3498b1df0 100644
--- a/src/app/models/aws-iam-user-session.ts
+++ b/core/models/aws-iam-user-session.ts
@@ -1,8 +1,7 @@
-import {SessionType} from './session-type';
-import {Session} from './session';
+import { SessionType } from "./session-type";
+import { Session } from "./session";
export class AwsIamUserSession extends Session {
-
mfaDevice?: string;
sessionTokenExpiration: string;
profileId: string;
@@ -15,4 +14,3 @@ export class AwsIamUserSession extends Session {
this.profileId = profileId;
}
}
-
diff --git a/core/models/aws-named-profile.ts b/core/models/aws-named-profile.ts
new file mode 100644
index 000000000..f18f709ff
--- /dev/null
+++ b/core/models/aws-named-profile.ts
@@ -0,0 +1,3 @@
+export class AwsNamedProfile {
+ constructor(public id: string, public name: string) {}
+}
diff --git a/core/models/aws-process-credential.ts b/core/models/aws-process-credential.ts
new file mode 100644
index 000000000..91f565986
--- /dev/null
+++ b/core/models/aws-process-credential.ts
@@ -0,0 +1,11 @@
+/* eslint-disable @typescript-eslint/naming-convention */
+
+export class AwsProcessCredentials {
+ constructor(
+ private Version: number,
+ private AccessKeyId: string,
+ private SecretAccessKey: string,
+ private SessionToken: string,
+ private Expiration: string
+ ) {}
+}
diff --git a/core/models/aws-sso-integration-token-info.ts b/core/models/aws-sso-integration-token-info.ts
new file mode 100644
index 000000000..cf5afd8cd
--- /dev/null
+++ b/core/models/aws-sso-integration-token-info.ts
@@ -0,0 +1,4 @@
+export interface AwsSsoIntegrationTokenInfo {
+ accessToken: string;
+ expiration: number;
+}
diff --git a/src/app/models/aws-sso-integration.ts b/core/models/aws-sso-integration.ts
similarity index 100%
rename from src/app/models/aws-sso-integration.ts
rename to core/models/aws-sso-integration.ts
diff --git a/src/app/models/aws-sso-role-session.ts b/core/models/aws-sso-role-session.ts
similarity index 77%
rename from src/app/models/aws-sso-role-session.ts
rename to core/models/aws-sso-role-session.ts
index 7f9d0243a..fa6c10e88 100644
--- a/src/app/models/aws-sso-role-session.ts
+++ b/core/models/aws-sso-role-session.ts
@@ -1,17 +1,17 @@
-import {SessionType} from './session-type';
-import {Session} from './session';
+import { SessionType } from "./session-type";
+import { Session } from "./session";
export class AwsSsoRoleSession extends Session {
-
email?: string;
roleArn: string;
profileId: string;
awsSsoConfigurationId: string;
+ sessionTokenExpiration: string;
constructor(sessionName: string, region: string, roleArn: string, profileId: string, awsSsoConfigurationId: string, email?: string) {
super(sessionName, region);
- this.email= email;
+ this.email = email;
this.roleArn = roleArn;
this.profileId = profileId;
this.type = SessionType.awsSsoRole;
diff --git a/src/app/models/azure-session.ts b/core/models/azure-session.ts
similarity index 80%
rename from src/app/models/azure-session.ts
rename to core/models/azure-session.ts
index c4e95c623..92b577455 100644
--- a/src/app/models/azure-session.ts
+++ b/core/models/azure-session.ts
@@ -1,8 +1,7 @@
-import {SessionType} from './session-type';
-import {Session} from './session';
+import { SessionType } from "./session-type";
+import { Session } from "./session";
export class AzureSession extends Session {
-
subscriptionId: string;
tenantId: string;
diff --git a/core/models/cloud-provider-type.ts b/core/models/cloud-provider-type.ts
new file mode 100644
index 000000000..15314e06e
--- /dev/null
+++ b/core/models/cloud-provider-type.ts
@@ -0,0 +1,4 @@
+export enum CloudProviderType {
+ aws = "aws",
+ azure = "azure",
+}
diff --git a/core/models/constants.ts b/core/models/constants.ts
new file mode 100644
index 000000000..a7bb0357b
--- /dev/null
+++ b/core/models/constants.ts
@@ -0,0 +1,46 @@
+export const constants = {
+ //General
+ appName: "Leapp",
+ lockFileDestination: ".Leapp/Leapp-lock.json",
+ latestUrl: "https://leapp.cloud/releases.html",
+
+ //Aws
+ samlRoleSessionDuration: 3600, // 1h
+ sessionDuration: 60, // 1200, // 20 min
+ sessionTokenDuration: 36000, // 10h
+ timeout: 10000,
+ credentialsDestination: ".aws/credentials",
+ defaultRegion: "us-east-1",
+
+ //Azure
+ azureAccessTokens: ".azure/accessTokens.json",
+ azureProfile: ".azure/azureProfile.json",
+ defaultLocation: "eastus",
+ defaultAwsProfileName: "default",
+ defaultAzureProfileName: "default-azure",
+
+ mac: "mac",
+ linux: "linux",
+ windows: "windows",
+ inApp: "In-app",
+ inBrowser: "In-browser",
+ forcedCloseBrowserWindow: "ForceCloseBrowserWindow",
+
+ confirmed: "**CONFIRMED**",
+ confirmClosed: "**MODAL_CLOSED**",
+ confirmClosedAndIgnoreUpdate: "**IGNORE_UPDATE_AND_MODAL_CLOSED**",
+ confirmCloseAndDownloadUpdate: "**GO_TO_DOWNLOAD_PAGE_AND_MODAL_CLOSED**",
+
+ macOsTerminal: "Terminal",
+ macOsIterm2: "iTerm2",
+ systemDefaultTheme: "System Default",
+
+ lightTheme: "Light Theme",
+ darkTheme: "Dark Theme",
+ colorTheme: "System Default",
+
+ cliStartAwsFederatedSessionChannel: "aws-federated-session-start-channel",
+ cliLogoutAwsFederatedSessionChannel: "aws-federated-session-logout-channel",
+ cliRefreshSessionsChannel: "refresh-sessions-channel",
+ ipcServerId: "leapp_da",
+};
diff --git a/src/app/models/credentials-info.ts b/core/models/credentials-info.ts
similarity index 100%
rename from src/app/models/credentials-info.ts
rename to core/models/credentials-info.ts
diff --git a/core/models/credentials.ts b/core/models/credentials.ts
new file mode 100644
index 000000000..9fb25c897
--- /dev/null
+++ b/core/models/credentials.ts
@@ -0,0 +1,9 @@
+export interface Credentials {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ aws_access_key_id: string;
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ aws_secret_access_key: string;
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ aws_session_token: string;
+ region: string;
+}
diff --git a/src/app/models/folder.ts b/core/models/folder.ts
similarity index 52%
rename from src/app/models/folder.ts
rename to core/models/folder.ts
index e6219c969..db7df0a22 100644
--- a/src/app/models/folder.ts
+++ b/core/models/folder.ts
@@ -1,4 +1,4 @@
export default interface Folder {
- name: string;
- ids: string[];
+ name: string;
+ ids: string[];
}
diff --git a/core/models/idp-url-access-method-field.spec.ts b/core/models/idp-url-access-method-field.spec.ts
new file mode 100644
index 000000000..3e2104071
--- /dev/null
+++ b/core/models/idp-url-access-method-field.spec.ts
@@ -0,0 +1,14 @@
+import { createNewIdpUrlFieldChoice } from "../services/cloud-provider-service";
+import { IdpUrlAccessMethodField } from "./idp-url-access-method-field";
+
+describe("IdpUrlAccessMethodField", () => {
+ test('isIdpUrlToCreate, field is "create new idp url"', () => {
+ const idpUrlAccessMethodField = new IdpUrlAccessMethodField(null, null, null);
+ expect(idpUrlAccessMethodField.isIdpUrlToCreate(createNewIdpUrlFieldChoice)).toBe(true);
+ });
+
+ test('isIdpUrlToCreate, field is not "create new idp url"', () => {
+ const idpUrlAccessMethodField = new IdpUrlAccessMethodField(null, null, null);
+ expect(idpUrlAccessMethodField.isIdpUrlToCreate("anotherChoice")).toBe(false);
+ });
+});
diff --git a/core/models/idp-url-access-method-field.ts b/core/models/idp-url-access-method-field.ts
new file mode 100644
index 000000000..85b77ecd7
--- /dev/null
+++ b/core/models/idp-url-access-method-field.ts
@@ -0,0 +1,14 @@
+import { FieldChoice } from "../services/field-choice";
+import { AccessMethodFieldType } from "./access-method-field-type";
+import { AccessMethodField } from "./access-method-field";
+import { createNewIdpUrlFieldChoice } from "../services/cloud-provider-service";
+
+export class IdpUrlAccessMethodField extends AccessMethodField {
+ constructor(public creationRequestField: string, public message: string, public type: AccessMethodFieldType, public choices?: FieldChoice[]) {
+ super(creationRequestField, message, type, choices);
+ }
+
+ isIdpUrlToCreate(choiceValue: string): boolean {
+ return choiceValue === createNewIdpUrlFieldChoice;
+ }
+}
diff --git a/core/models/idp-url.ts b/core/models/idp-url.ts
new file mode 100644
index 000000000..0bcb35325
--- /dev/null
+++ b/core/models/idp-url.ts
@@ -0,0 +1,3 @@
+export class IdpUrl {
+ constructor(public id: string, public url: string) {}
+}
diff --git a/core/models/segment.ts b/core/models/segment.ts
new file mode 100644
index 000000000..2d9393fb6
--- /dev/null
+++ b/core/models/segment.ts
@@ -0,0 +1,16 @@
+import { SessionType } from "./session-type";
+
+export interface GlobalFilters {
+ searchFilter: string;
+ dateFilter: boolean;
+ providerFilter: { show: boolean; id: string; name: string; value: boolean }[];
+ profileFilter: { show: boolean; id: string; name: string; value: boolean }[];
+ regionFilter: { show: boolean; name: string; value: boolean }[];
+ integrationFilter: { name: string; value: boolean }[];
+ typeFilter: { show: boolean; id: SessionType; category: string; name: string; value: boolean }[];
+}
+
+export default interface Segment {
+ name: string;
+ filterGroup: GlobalFilters;
+}
diff --git a/src/app/models/session-status.ts b/core/models/session-status.ts
similarity index 84%
rename from src/app/models/session-status.ts
rename to core/models/session-status.ts
index f912cb00f..e43199fbb 100644
--- a/src/app/models/session-status.ts
+++ b/core/models/session-status.ts
@@ -1,5 +1,5 @@
export enum SessionStatus {
inactive,
pending,
- active
+ active,
}
diff --git a/core/models/session-type.ts b/core/models/session-type.ts
new file mode 100644
index 000000000..c8cb1e748
--- /dev/null
+++ b/core/models/session-type.ts
@@ -0,0 +1,10 @@
+export enum SessionType {
+ aws = "aws",
+ awsIamRoleFederated = "awsIamRoleFederated",
+ awsIamUser = "awsIamUser",
+ awsIamRoleChained = "awsIamRoleChained",
+ awsSsoRole = "awsSsoRole",
+ azure = "azure",
+ google = "google",
+ alibaba = "alibaba",
+}
diff --git a/src/app/models/session.ts b/core/models/session.ts
similarity index 59%
rename from src/app/models/session.ts
rename to core/models/session.ts
index ab9416347..e9c1f92c9 100644
--- a/src/app/models/session.ts
+++ b/core/models/session.ts
@@ -1,14 +1,13 @@
-import * as uuid from 'uuid';
-import {environment} from '../../environments/environment';
-import {SessionStatus} from './session-status';
-import {SessionType} from './session-type';
+import * as uuid from "uuid";
+import { SessionStatus } from "./session-status";
+import { SessionType } from "./session-type";
+import { constants } from "./constants";
export class Session {
-
sessionId: string;
sessionName: string;
status: SessionStatus;
- startDateTime: string;
+ startDateTime?: string;
region: string;
type: SessionType;
@@ -21,8 +20,11 @@ export class Session {
}
expired(): boolean {
+ if (this.startDateTime === undefined) {
+ return false;
+ }
const currentTime = new Date().getTime();
const startTime = new Date(this.startDateTime).getTime();
- return (currentTime - startTime) / 1000 > environment.sessionDuration;
- };
+ return (currentTime - startTime) / 1000 > constants.sessionDuration;
+ }
}
diff --git a/src/app/models/workspace.ts b/core/models/workspace.ts
similarity index 58%
rename from src/app/models/workspace.ts
rename to core/models/workspace.ts
index a95c9ef69..94299637c 100644
--- a/src/app/models/workspace.ts
+++ b/core/models/workspace.ts
@@ -1,11 +1,13 @@
-import {Session} from './session';
-import * as uuid from 'uuid';
-import {environment} from '../../environments/environment';
-import {Type} from 'class-transformer';
-import {AwsSsoIntegration} from './aws-sso-integration';
-import Folder from './folder';
-import Segment from './Segment';
-
+import { AwsNamedProfile } from "./aws-named-profile";
+import { IdpUrl } from "./idp-url";
+import { Session } from "./session";
+import * as uuid from "uuid";
+import "reflect-metadata";
+import { Type } from "class-transformer";
+import { constants } from "./constants";
+import { AwsSsoIntegration } from "./aws-sso-integration";
+import Folder from "./folder";
+import Segment from "./segment";
export class Workspace {
@Type(() => Session)
@@ -13,8 +15,8 @@ export class Workspace {
private _defaultRegion: string;
private _defaultLocation: string;
private _macOsTerminal: string;
- private _idpUrls: { id: string; url: string }[];
- private _profiles: { id: string; name: string }[];
+ private _idpUrls: IdpUrl[];
+ private _profiles: AwsNamedProfile[];
private _awsSsoIntegrations: AwsSsoIntegration[];
@@ -22,63 +24,40 @@ export class Workspace {
private _folders: Folder[];
private _segments: Segment[];
+ private _colorTheme: string;
+
private _proxyConfiguration: {
proxyProtocol: string;
- proxyUrl: string;
+ proxyUrl?: string;
proxyPort: string;
- username: string;
- password: string;
+ username?: string;
+ password?: string;
};
- private _version: string;
-
- private _colorTheme: string;
-
constructor() {
this._pinned = [];
this._sessions = [];
this._folders = [];
this._segments = [];
- this._defaultRegion = environment.defaultRegion;
- this._defaultLocation = environment.defaultLocation;
+ this._defaultRegion = constants.defaultRegion;
+ this._defaultLocation = constants.defaultLocation;
+ this._macOsTerminal = constants.macOsTerminal;
this._idpUrls = [];
- this._profiles = [
- { id: uuid.v4(), name: environment.defaultAwsProfileName }
- ];
+ this._profiles = [{ id: uuid.v4(), name: constants.defaultAwsProfileName }];
this._awsSsoIntegrations = [];
this._proxyConfiguration = {
- proxyProtocol: 'https',
+ proxyProtocol: "https",
proxyUrl: undefined,
- proxyPort: '8080',
+ proxyPort: "8080",
username: undefined,
- password: undefined
+ password: undefined,
};
}
- get sessions(): Session[] {
- return this._sessions;
- }
-
- set sessions(value: Session[]) {
- this._sessions = value;
- }
-
- get defaultRegion(): string {
- return this._defaultRegion;
- }
-
- set defaultRegion(value: string) {
- this._defaultRegion = value;
- }
-
- get defaultLocation(): string {
- return this._defaultLocation;
- }
-
- set defaultLocation(value: string) {
- this._defaultLocation = value;
+ addIpUrl(idpUrl: IdpUrl): void {
+ this._idpUrls.push(idpUrl);
}
get macOsTerminal(): string {
@@ -89,67 +68,87 @@ export class Workspace {
this._macOsTerminal = value;
}
- get idpUrls(): { id: string; url: string }[] {
+ get idpUrls(): IdpUrl[] {
return this._idpUrls;
}
- set idpUrls(value: { id: string; url: string }[]) {
+ set idpUrls(value: IdpUrl[]) {
this._idpUrls = value;
}
- get profiles(): { id: string; name: string }[] {
+ get profiles(): AwsNamedProfile[] {
return this._profiles;
}
- set profiles(value: { id: string; name: string }[]) {
+ set profiles(value: AwsNamedProfile[]) {
this._profiles = value;
}
- get awsSsoIntegrations(): AwsSsoIntegration[] {
- return this._awsSsoIntegrations;
+ get sessions(): Session[] {
+ return this._sessions;
}
- set awsSsoIntegrations(value: AwsSsoIntegration[]) {
- this._awsSsoIntegrations = value;
+ set sessions(value: Session[]) {
+ this._sessions = value;
}
- get proxyConfiguration(): { proxyProtocol: string; proxyUrl: string; proxyPort: string; username: string; password: string } {
+ get proxyConfiguration(): { proxyProtocol: string; proxyUrl?: string; proxyPort: string; username?: string; password?: string } {
return this._proxyConfiguration;
}
- set proxyConfiguration(value: { proxyProtocol: string; proxyUrl: string; proxyPort: string; username: string; password: string }) {
+ set proxyConfiguration(value: { proxyProtocol: string; proxyUrl?: string; proxyPort: string; username?: string; password?: string }) {
this._proxyConfiguration = value;
}
- get version(): string {
- return this._version;
+ get defaultRegion(): string {
+ return this._defaultRegion;
+ }
+
+ set defaultRegion(value: string) {
+ this._defaultRegion = value;
+ }
+
+ get defaultLocation(): string {
+ return this._defaultLocation;
+ }
+
+ set defaultLocation(value: string) {
+ this._defaultLocation = value;
+ }
+
+ get awsSsoIntegrations(): AwsSsoIntegration[] {
+ return this._awsSsoIntegrations;
+ }
+
+ set awsSsoIntegrations(value: AwsSsoIntegration[]) {
+ this._awsSsoIntegrations = value;
}
- get pinned() {
+ get pinned(): string[] {
return this._pinned;
}
- set pinned(pinned: string[] ) {
+ set pinned(pinned: string[]) {
this._pinned = pinned;
}
- get folders() {
+ get folders(): Folder[] {
return this._folders;
}
- set folders(folders: Folder[] ) {
+ set folders(folders: Folder[]) {
this._folders = folders;
}
- get segments() {
+ get segments(): Segment[] {
return this._segments;
}
- set segments(segments: Segment[] ) {
+ set segments(segments: Segment[]) {
this._segments = segments;
}
- get colorTheme() {
+ get colorTheme(): string {
return this._colorTheme;
}
diff --git a/core/package.json b/core/package.json
new file mode 100644
index 000000000..4e090324c
--- /dev/null
+++ b/core/package.json
@@ -0,0 +1,74 @@
+{
+ "name": "@noovolari/leapp-core",
+ "version": "0.1.100",
+ "author": "besharp ",
+ "description": "Leapp's core module",
+ "peerDependencies": {
+ "@babel/plugin-proposal-decorators": "^7.16.5",
+ "@types/aws-sdk": "^2.7.0",
+ "@types/node": "^16.9.4",
+ "@types/node-ipc": "9.2.0",
+ "@types/uuid": "^8.3.0",
+ "assert": "2.0.0",
+ "aws-sdk": "2.928.0",
+ "aws-sdk-mock": "5.3.0",
+ "chdir": "0.0.0",
+ "class-transformer": "^0.4.0",
+ "compare-versions": "^3.6.0",
+ "copy-dir": "~1.3.0",
+ "crypto-js": "~4.0.0",
+ "date-fns": "^2.26.0",
+ "es6-shim": "^0.35.6",
+ "extract-zip": "~2.0.1",
+ "fix-path": "~3.0.0",
+ "follow-redirects": "^1.14.9",
+ "fs-extra": "~9.1.0",
+ "fs-web": "1.0.1",
+ "http-proxy-agent": "4.0.1",
+ "https-proxy-agent": "5.0.0",
+ "ini": "~2.0.0",
+ "is-url": "^1.2.4",
+ "jwt-decode": "~3.1.2",
+ "keytar": "7.7.0",
+ "ms": "^2.1.3",
+ "node-log-rotate": "~0.1.5",
+ "node-ipc": "9.2.1",
+ "node-machine-id": "~1.1.12",
+ "reflect-metadata": "^0.1.13",
+ "rimraf": "~3.0.2",
+ "rxjs": "~6.6.7",
+ "saml-encoder-decoder-js": "~1.0.1",
+ "semver": "~7.3.5",
+ "standard-version": "^9.3.0",
+ "sudo-prompt": "~9.2.1",
+ "tslib": "^2.3.1",
+ "uuid": "~8.3.2",
+ "wait-on": "^6.0.0",
+ "zlib": "~1.0.5"
+ },
+ "devDependencies": {
+ "@babel/preset-env": "^7.16.5",
+ "@babel/preset-typescript": "^7.16.5",
+ "@commitlint/cli": "16.2.1",
+ "@commitlint/config-conventional": "16.2.1",
+ "@types/aws-sdk": "^2.7.0",
+ "@types/jest": "^27.4.1",
+ "gushio": "~0.5.0",
+ "jest": "^27.4.5",
+ "typescript": "4.5.5"
+ },
+ "scripts": {
+ "clean": "gushio gushio/target-clean.js",
+ "build": "gushio gushio/target-build.js",
+ "release": "gushio gushio/target-release.js",
+ "test": "jest"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/Noovolari/leapp.git"
+ },
+ "bugs": {
+ "url": "https://github.com/Noovolari/leapp/issues"
+ },
+ "homepage": "https://github.com/Noovolari/leapp"
+}
diff --git a/core/services/aws-assumer-session-types.ts b/core/services/aws-assumer-session-types.ts
new file mode 100644
index 000000000..ad264a5b7
--- /dev/null
+++ b/core/services/aws-assumer-session-types.ts
@@ -0,0 +1,3 @@
+import { SessionType } from "../models/session-type";
+
+export const AWS_ASSUMER_SESSION_TYPES = [SessionType.awsIamUser, SessionType.awsIamRoleFederated, SessionType.awsSsoRole];
diff --git a/core/services/aws-core-service.spec.ts b/core/services/aws-core-service.spec.ts
new file mode 100644
index 000000000..326e2eeb5
--- /dev/null
+++ b/core/services/aws-core-service.spec.ts
@@ -0,0 +1,86 @@
+import { describe, test, expect } from "@jest/globals";
+import { AwsCoreService } from "./aws-core-service";
+
+describe("awsCoreService", () => {
+ test("getRegions", () => {
+ const awsCoreService = new AwsCoreService(null);
+
+ expect(awsCoreService.getRegions()).toEqual([
+ {
+ region: "af-south-1",
+ },
+ {
+ region: "ap-east-1",
+ },
+ {
+ region: "ap-northeast-1",
+ },
+ {
+ region: "ap-northeast-2",
+ },
+ {
+ region: "ap-northeast-3",
+ },
+ {
+ region: "ap-south-1",
+ },
+ {
+ region: "ap-southeast-1",
+ },
+ {
+ region: "ap-southeast-2",
+ },
+ {
+ region: "ca-central-1",
+ },
+ {
+ region: "cn-north-1",
+ },
+ {
+ region: "cn-northwest-1",
+ },
+ {
+ region: "eu-central-1",
+ },
+ {
+ region: "eu-north-1",
+ },
+ {
+ region: "eu-south-1",
+ },
+ {
+ region: "eu-west-1",
+ },
+ {
+ region: "eu-west-2",
+ },
+ {
+ region: "eu-west-3",
+ },
+ {
+ region: "me-south-1",
+ },
+ {
+ region: "sa-east-1",
+ },
+ {
+ region: "us-east-1",
+ },
+ {
+ region: "us-east-2",
+ },
+ {
+ region: "us-gov-east-1",
+ },
+ {
+ region: "us-gov-west-1",
+ },
+ {
+ region: "us-west-1",
+ },
+ {
+ region: "us-west-2",
+ },
+ ]);
+ });
+});
diff --git a/core/services/aws-core-service.ts b/core/services/aws-core-service.ts
new file mode 100644
index 000000000..77d3875a7
--- /dev/null
+++ b/core/services/aws-core-service.ts
@@ -0,0 +1,73 @@
+import { constants } from "../models/constants";
+import { Session } from "../models/session";
+import { INativeService } from "../interfaces/i-native-service";
+import { LoggerLevel } from "./logging-service";
+
+// TODO: rename it. This naming is ambiguous.
+export class AwsCoreService {
+ static stsEndpointsPerRegion: Map = new Map([
+ ["af-south-1", "https://sts.af-south-1.amazonaws.com"],
+ ["ap-east-1", "https://sts.ap-east-1.amazonaws.com"],
+ ["ap-northeast-1", "https://sts.ap-northeast-1.amazonaws.com"],
+ ["ap-northeast-2", "https://sts.ap-northeast-2.amazonaws.com"],
+ ["ap-northeast-3", "https://sts.ap-northeast-3.amazonaws.com"],
+ ["ap-south-1", "https://sts.ap-south-1.amazonaws.com"],
+ ["ap-southeast-1", "https://sts.ap-southeast-1.amazonaws.com"],
+ ["ap-southeast-2", "https://sts.ap-southeast-2.amazonaws.com"],
+ ["ca-central-1", "https://sts.ca-central-1.amazonaws.com"],
+ ["cn-north-1", "https://sts.cn-north-1.amazonaws.com.cn"],
+ ["cn-northwest-1", "https://sts.cn-northwest-1.amazonaws.com.cn"],
+ ["eu-central-1", "https://sts.eu-central-1.amazonaws.com"],
+ ["eu-north-1", "https://sts.eu-north-1.amazonaws.com"],
+ ["eu-south-1", "https://sts.eu-south-1.amazonaws.com"],
+ ["eu-west-1", "https://sts.eu-west-1.amazonaws.com"],
+ ["eu-west-2", "https://sts.eu-west-2.amazonaws.com"],
+ ["eu-west-3", "https://sts.eu-west-3.amazonaws.com"],
+ ["me-south-1", "https://sts.me-south-1.amazonaws.com"],
+ ["sa-east-1", "https://sts.sa-east-1.amazonaws.com"],
+ ["us-east-1", "https://sts.us-east-1.amazonaws.com"],
+ ["us-east-2", "https://sts.us-east-2.amazonaws.com"],
+ ["us-gov-east-1", "https://sts.us-gov-east-1.amazonaws.com"],
+ ["us-gov-west-1", "https://sts.us-gov-west-1.amazonaws.com"],
+ ["us-west-1", "https://sts.us-west-1.amazonaws.com"],
+ ["us-west-2", "https://sts.us-west-2.amazonaws.com"],
+ ]);
+
+ constructor(private nativeService: INativeService) {}
+
+ awsCredentialPath(): string {
+ return this.nativeService.path.join(this.nativeService.os.homedir(), ".aws", "credentials");
+ }
+
+ stsOptions(session: Session): any {
+ let options: any = {
+ maxRetries: 0,
+ httpOptions: { timeout: constants.timeout },
+ };
+
+ if (session.region) {
+ options = {
+ ...options,
+ endpoint: AwsCoreService.stsEndpointsPerRegion.get(session.region),
+ region: session.region,
+ };
+ }
+
+ return options;
+ }
+
+ cleanCredentialFile(): void {
+ try {
+ const awsCredentialsPath = this.awsCredentialPath();
+ // Rewrite credential file
+ this.nativeService.fs.writeFileSync(awsCredentialsPath, "");
+ } catch (error) {
+ this.nativeService.log(`Can't delete aws credential file probably missing: ${error.toString()}`, LoggerLevel.warn, this, error.stack);
+ }
+ }
+
+ getRegions(): { region: string }[] {
+ const regionKeys = [...AwsCoreService.stsEndpointsPerRegion.keys()];
+ return regionKeys.map((key) => ({ region: key }));
+ }
+}
diff --git a/core/services/aws-saml-assertion-extraction-service.spec.ts b/core/services/aws-saml-assertion-extraction-service.spec.ts
new file mode 100644
index 000000000..2a4f06e4b
--- /dev/null
+++ b/core/services/aws-saml-assertion-extraction-service.spec.ts
@@ -0,0 +1,64 @@
+import { describe, test, expect, jest } from "@jest/globals";
+import { LeappParseError } from "../errors/leapp-parse-error";
+import { AwsSamlAssertionExtractionService } from "./aws-saml-assertion-extraction-service";
+import { CloudProviderType } from "../models/cloud-provider-type";
+
+describe("AwsSamlAssertionExtractionService", () => {
+ test("isAuthenticationUrl", () => {
+ const service = new AwsSamlAssertionExtractionService();
+
+ expect(service.isAuthenticationUrl(CloudProviderType.aws, "https://XX.onelogin.com/XX")).toBe(true);
+ expect(service.isAuthenticationUrl(CloudProviderType.aws, "http://XX.onelogin.com/XX")).toBe(false);
+
+ expect(service.isAuthenticationUrl(CloudProviderType.aws, "https://XX/adfs/ls/idpinitiatedsignonXXloginToRp=urn:amazon:webservicesXXX")).toBe(
+ true
+ );
+ expect(service.isAuthenticationUrl(CloudProviderType.aws, "https://XX/adfs/ls/idpinitiatedsignonXX")).toBe(false);
+
+ expect(service.isAuthenticationUrl(CloudProviderType.aws, "https://XX.okta.com/XX")).toBe(true);
+ expect(service.isAuthenticationUrl(CloudProviderType.aws, "https://XX.okta.com")).toBe(false);
+
+ expect(service.isAuthenticationUrl(CloudProviderType.aws, "https://accounts.google.com/ServiceLoginXX")).toBe(true);
+ expect(service.isAuthenticationUrl(CloudProviderType.aws, "https://accounts.google.com")).toBe(false);
+
+ expect(service.isAuthenticationUrl(CloudProviderType.aws, "https://login.microsoftonline.com/XX/oauth2/authorizeXXXX")).toBe(true);
+ expect(service.isAuthenticationUrl(CloudProviderType.aws, "https://login.microsoftonline.com")).toBe(false);
+
+ expect(service.isAuthenticationUrl(CloudProviderType.aws, "https://signin.aws.amazon.com/saml")).toBe(false);
+ });
+
+ test("isSamlAssertionUrl", () => {
+ const service = new AwsSamlAssertionExtractionService();
+
+ expect(service.isSamlAssertionUrl(CloudProviderType.aws, "https://signin.aws.amazon.com/saml")).toBe(true);
+ expect(service.isSamlAssertionUrl(CloudProviderType.aws, "https://signin.aws.amazon.com/saml?XX")).toBe(true);
+ expect(service.isSamlAssertionUrl(CloudProviderType.aws, "http://signin.aws.amazon.com/saml")).toBe(false);
+ });
+
+ test("extractAwsSamlResponse", () => {
+ const responseHookDetails = {
+ uploadData: [{ bytes: "SAMLResponse=ABCDEFGHIJKLMNOPQRSTUVWXYZ&RelayState=abcdefghijklmnopqrstuvwxyz" }],
+ };
+
+ const service = new AwsSamlAssertionExtractionService();
+ const awsSamlResponse = service.extractAwsSamlResponse(responseHookDetails as any);
+ expect(awsSamlResponse).toBe("ABCDEFGHIJKLMNOPQRSTUVWXYZ");
+ });
+
+ test("extractAwsSamlResponse - error", () => {
+ const responseHookDetails = {
+ uploadData: [
+ {
+ bytes: {
+ toString: jest.fn(() => {
+ throw new Error("");
+ }),
+ },
+ },
+ ],
+ };
+
+ const service = new AwsSamlAssertionExtractionService();
+ expect(() => service.extractAwsSamlResponse(responseHookDetails as any)).toThrow(new LeappParseError(service, ""));
+ });
+});
diff --git a/core/services/aws-saml-assertion-extraction-service.ts b/core/services/aws-saml-assertion-extraction-service.ts
new file mode 100644
index 000000000..57c8056f6
--- /dev/null
+++ b/core/services/aws-saml-assertion-extraction-service.ts
@@ -0,0 +1,42 @@
+import { CloudProviderType } from "../models/cloud-provider-type";
+import { LeappParseError } from "../errors/leapp-parse-error";
+
+interface ResponseHookDetails {
+ uploadData: { bytes: any[] }[];
+}
+
+const authenticationUrlRegexes = new Map([
+ [
+ CloudProviderType.aws,
+ [
+ /^https:\/\/.*\.onelogin\.com\/.*/,
+ /^https:\/\/.*\/adfs\/ls\/idpinitiatedsignon.*loginToRp=urn:amazon:webservices.*/,
+ /^https:\/\/.*\.okta\.com\/.*/,
+ /^https:\/\/accounts\.google\.com\/ServiceLogin.*/,
+ /^https:\/\/login\.microsoftonline\.com\/.*\/oauth2\/authorize.*/,
+ ],
+ ],
+]);
+const samlAssertionRegexes = new Map([[CloudProviderType.aws, [/^https:\/\/signin\.aws\.amazon\.com\/saml/]]]);
+
+export class AwsSamlAssertionExtractionService {
+ isAuthenticationUrl(cloudProvider: CloudProviderType, url: string): boolean {
+ return authenticationUrlRegexes.get(cloudProvider).some((regex) => regex.test(url));
+ }
+
+ isSamlAssertionUrl(cloudProvider: CloudProviderType, url: string): boolean {
+ return samlAssertionRegexes.get(cloudProvider).some((regex) => regex.test(url));
+ }
+
+ extractAwsSamlResponse(responseHookDetails: ResponseHookDetails): string {
+ try {
+ let rawData = responseHookDetails.uploadData[0].bytes.toString();
+ const n = rawData.lastIndexOf("SAMLResponse=");
+ const n2 = rawData.lastIndexOf("&RelayState=");
+ rawData = n2 !== -1 ? rawData.substring(n + 13, n2) : rawData.substring(n + 13);
+ return decodeURIComponent(rawData);
+ } catch (err) {
+ throw new LeappParseError(this, err.message);
+ }
+ }
+}
diff --git a/core/services/aws-sso-integration-service.spec.ts b/core/services/aws-sso-integration-service.spec.ts
new file mode 100644
index 000000000..a570bfa0f
--- /dev/null
+++ b/core/services/aws-sso-integration-service.spec.ts
@@ -0,0 +1,287 @@
+import { jest, describe, test, expect } from "@jest/globals";
+import { AwsSsoIntegrationService } from "./aws-sso-integration-service";
+
+describe("AwsSsoIntegrationService", () => {
+ test("validateAlias - empty alias", () => {
+ const aliasParam = "";
+ const actualValidationResult = AwsSsoIntegrationService.validateAlias(aliasParam);
+
+ expect(actualValidationResult).toBe("Empty alias");
+ });
+
+ test("validateAlias - only spaces alias", () => {
+ const aliasParam = " ";
+ const actualValidationResult = AwsSsoIntegrationService.validateAlias(aliasParam);
+
+ expect(actualValidationResult).toBe("Empty alias");
+ });
+
+ test("validateAlias - valid alias", () => {
+ const aliasParam = "alias";
+ const actualValidationResult = AwsSsoIntegrationService.validateAlias(aliasParam);
+
+ expect(actualValidationResult).toBe(true);
+ });
+
+ test("validatePortalUrl - invalid Url", () => {
+ const portalUrlParam = "www.url.com";
+ const actualValidationPortalUrl = AwsSsoIntegrationService.validatePortalUrl(portalUrlParam);
+
+ expect(actualValidationPortalUrl).toBe("Invalid portal URL");
+ });
+
+ test("validatePortalUrl - http Url", () => {
+ const portalUrlParam = "http://www.url.com";
+ const actualValidationPortalUrl = AwsSsoIntegrationService.validatePortalUrl(portalUrlParam);
+
+ expect(actualValidationPortalUrl).toBe(true);
+ });
+
+ test("validatePortalUrl - https Url", () => {
+ const portalUrlParam = "https://www.url.com";
+ const actualValidationPortalUrl = AwsSsoIntegrationService.validatePortalUrl(portalUrlParam);
+
+ expect(actualValidationPortalUrl).toBe(true);
+ });
+
+ test("getIntegrations", () => {
+ const expectedIntegrations = [{ id: 1 }];
+ const repository = {
+ listAwsSsoIntegrations: () => expectedIntegrations,
+ } as any;
+
+ const awsIntegrationsService = new AwsSsoIntegrationService(repository, null, null, null, null, null, null);
+
+ const integrations = awsIntegrationsService.getIntegrations();
+
+ expect(integrations).toBe(expectedIntegrations);
+ });
+
+ test("getOnlineIntegrations", () => {
+ const expectedIntegrations = [{ id: 1 }];
+ const awsIntegrationsService = new AwsSsoIntegrationService(null, null, null, null, null, null, null);
+ awsIntegrationsService.getIntegrations = () => expectedIntegrations as any;
+ awsIntegrationsService.isOnline = jest.fn(() => true);
+
+ const onlineIntegrations = awsIntegrationsService.getOnlineIntegrations();
+
+ expect(onlineIntegrations).not.toBe(expectedIntegrations);
+ expect(onlineIntegrations).toEqual(expectedIntegrations);
+ expect(awsIntegrationsService.isOnline).toHaveBeenCalledWith(expectedIntegrations[0]);
+ });
+
+ test("getOfflineIntegrations", () => {
+ const expectedIntegrations = [{ id: 1 }];
+ const awsIntegrationsService = new AwsSsoIntegrationService(null, null, null, null, null, null, null);
+ awsIntegrationsService.getIntegrations = () => expectedIntegrations as any;
+ awsIntegrationsService.isOnline = jest.fn(() => false);
+
+ const offlineIntegrations = awsIntegrationsService.getOfflineIntegrations();
+
+ expect(offlineIntegrations).not.toBe(expectedIntegrations);
+ expect(offlineIntegrations).toEqual(expectedIntegrations);
+ expect(awsIntegrationsService.isOnline).toHaveBeenCalledWith(expectedIntegrations[0]);
+ });
+
+ test("isOnline, token missing", () => {
+ const awsIntegrationsService = new AwsSsoIntegrationService(null, null, null, null, null, null, null);
+ (awsIntegrationsService as any).getDate = () => new Date("2022-02-24T10:00:00");
+
+ const isOnline = awsIntegrationsService.isOnline({} as any);
+ expect(isOnline).toBe(false);
+ });
+
+ test("isOnline, token expired", () => {
+ const awsIntegrationsService = new AwsSsoIntegrationService(null, null, null, null, null, null, null);
+ (awsIntegrationsService as any).getDate = () => new Date("2022-02-24T10:00:00");
+
+ const integration = {
+ accessTokenExpiration: "2022-02-24T10:00:00",
+ } as any;
+
+ const isOnline = awsIntegrationsService.isOnline(integration);
+ expect(isOnline).toBe(false);
+ });
+
+ test("isOnline, token not expired", () => {
+ const awsIntegrationsService = new AwsSsoIntegrationService(null, null, null, null, null, null, null);
+ (awsIntegrationsService as any).getDate = () => new Date("2022-02-24T10:00:00");
+
+ const integration = {
+ accessTokenExpiration: "2022-02-24T10:00:01",
+ } as any;
+
+ const isOnline = awsIntegrationsService.isOnline(integration);
+ expect(isOnline).toBe(true);
+ });
+
+ test("remainingHours", () => {
+ const awsIntegrationService = new AwsSsoIntegrationService(null, null, null, null, null, null, null);
+ const integration = {
+ accessTokenExpiration: "2022-02-24T10:30:00",
+ } as any;
+ (awsIntegrationService as any).getDate = () => new Date("2022-02-24T10:00:00");
+ const remainingHours = awsIntegrationService.remainingHours(integration);
+ expect(remainingHours).toBe("in 30 minutes");
+ });
+
+ const cases = [
+ [
+ [
+ // awsIntegrationSessions
+ { sessionName: "sessionName1", roleArn: "roleArn1", email: "email1" },
+ { sessionName: "sessionName2", roleArn: "roleArn2", email: "email2" },
+ ],
+ [
+ // sessions
+ { sessionName: "sessionName1", roleArn: "roleArn1", email: "email1" },
+ { sessionName: "sessionName2", roleArn: "roleArn2", email: "email2" },
+ ],
+ //expectedResults
+ [[], []],
+ ],
+ [
+ [
+ // awsIntegrationSessions
+ { sessionName: "sessionName1", roleArn: "roleArn1", email: "email1" },
+ { sessionName: "sessionName2", roleArn: "roleArn2", email: "email2" },
+ ],
+ // sessions
+ [{ sessionName: "sessionName2", roleArn: "roleArn2", email: "email2" }],
+ // expectedResults
+ [[{ sessionName: "sessionName1", roleArn: "roleArn1", email: "email1" }], []],
+ ],
+ [
+ // awsIntegrationSessions
+ [
+ // awsIntegrationSessions
+ { sessionName: "sessionName1", roleArn: "roleArn1", email: "email1" },
+ ],
+ // sessions
+ [
+ { sessionName: "sessionName1", roleArn: "roleArn1", email: "email1" },
+ { sessionName: "sessionName2", roleArn: "roleArn2", email: "email2" },
+ ],
+ // expectedResults
+ [[], [{ sessionName: "sessionName2", roleArn: "roleArn2", email: "email2" }]],
+ ],
+ ];
+ test.each(cases)("loginAndGetSessionsDiff %#", async (caseAwsIntegrationSessions, caseSessions, expectedResults) => {
+ const integrationId = "integrationId";
+ const awsSsoIntegration = {
+ region: "region",
+ portalUrl: "portalUrl",
+ };
+ const awsIntegrationSessions = caseAwsIntegrationSessions;
+ const repository = {
+ getAwsSsoIntegration: jest.fn(() => awsSsoIntegration),
+ getAwsSsoIntegrationSessions: jest.fn(() => awsIntegrationSessions),
+ };
+ const accessToken = "accessToken";
+ const getAccessToken = jest.fn(async () => accessToken);
+ const sessions = caseSessions;
+ const getSessions = jest.fn(async () => sessions);
+
+ const awsSsoIntegrationService = new AwsSsoIntegrationService(repository as any, null, null, null, null, null, null);
+ (awsSsoIntegrationService as any).getAccessToken = getAccessToken;
+ (awsSsoIntegrationService as any).getSessions = getSessions;
+
+ const sessionDiff = await awsSsoIntegrationService.loginAndGetSessionsDiff(integrationId);
+
+ expect(sessionDiff.sessionsToDelete).toEqual(expectedResults[0]);
+ expect(sessionDiff.sessionsToAdd).toEqual(expectedResults[1]);
+ expect(repository.getAwsSsoIntegration).toHaveBeenCalledWith(integrationId);
+ expect(getAccessToken).toHaveBeenCalledWith(integrationId, awsSsoIntegration.region, awsSsoIntegration.portalUrl);
+ expect(getSessions).toHaveBeenCalledWith(integrationId, accessToken, awsSsoIntegration.region);
+ expect(repository.getAwsSsoIntegrationSessions).toHaveBeenCalledWith(integrationId);
+ });
+
+ test("syncSessions", async () => {
+ const integrationId = "integrationId";
+ const sessionDiff = {
+ sessionsToDelete: [
+ {
+ type: "type",
+ sessionId: "sessionId",
+ },
+ ],
+ sessionsToAdd: [
+ {
+ awsSsoConfigurationId: "configurationId",
+ },
+ ],
+ };
+ const loginAndGetSessionsDiff = jest.fn(async () => sessionDiff);
+ const awsSsoRoleService = {
+ create: jest.fn(async () => {}),
+ };
+ const sessionService = {
+ delete: jest.fn(async () => {}),
+ };
+ const sessionFactory = {
+ getSessionService: jest.fn(() => sessionService),
+ };
+ const awsSsoIntegrationService = new AwsSsoIntegrationService(null, null, awsSsoRoleService as any, null, null, null, sessionFactory as any);
+ (awsSsoIntegrationService as any).loginAndGetSessionsDiff = loginAndGetSessionsDiff;
+
+ const syncedSessions = await awsSsoIntegrationService.syncSessions(integrationId);
+
+ expect(syncedSessions).toEqual(sessionDiff);
+ expect(loginAndGetSessionsDiff).toHaveBeenCalledWith(integrationId);
+ expect(awsSsoRoleService.create).toHaveBeenCalledWith({
+ awsSsoConfigurationId: "integrationId",
+ });
+ expect(sessionFactory.getSessionService).toHaveBeenCalledWith("type");
+ expect(sessionService.delete).toHaveBeenCalledWith("sessionId");
+ });
+
+ test("getDate", () => {
+ const awsIntegrationService = new AwsSsoIntegrationService(null, null, null, null, null, null, null);
+ const time: Date = (awsIntegrationService as any).getDate();
+
+ expect(time).toBeInstanceOf(Date);
+ expect(time.getDay()).toBe(new Date().getDay());
+ });
+
+ test("getIntegrationAccessTokenKey", () => {
+ const awsIntegrationService = new AwsSsoIntegrationService(null, null, null, null, null, null, null);
+ const integrationId = "integration1";
+
+ const actualIntegrationAccessTokenKey = (awsIntegrationService as any).getIntegrationAccessTokenKey(integrationId);
+
+ expect(actualIntegrationAccessTokenKey).toBe(`aws-sso-integration-access-token-${integrationId}`);
+ });
+
+ test("createIntegration", () => {
+ const repository = {
+ addAwsSsoIntegration: jest.fn(),
+ } as any;
+
+ const awsIntegrationService = new AwsSsoIntegrationService(repository, null, null, null, null, null, null);
+
+ const creationParams = {
+ alias: "alias",
+ portalUrl: "portalUrl",
+ region: "region",
+ browserOpening: "browserOpening",
+ };
+ awsIntegrationService.createIntegration(creationParams);
+
+ expect(repository.addAwsSsoIntegration).toHaveBeenCalledWith("portalUrl", "alias", "region", "browserOpening");
+ });
+
+ test("deleteIntegration", async () => {
+ const repository = {
+ deleteAwsSsoIntegration: jest.fn(),
+ } as any;
+
+ const awsIntegrationService = new AwsSsoIntegrationService(repository, null, null, null, null, null, null);
+ awsIntegrationService.logout = jest.fn();
+
+ const integrationId = "integrationId";
+ await awsIntegrationService.deleteIntegration(integrationId);
+
+ expect(awsIntegrationService.logout).toHaveBeenCalledWith(integrationId);
+ expect(repository.deleteAwsSsoIntegration).toHaveBeenCalledWith(integrationId);
+ });
+});
diff --git a/core/services/aws-sso-integration-service.ts b/core/services/aws-sso-integration-service.ts
new file mode 100644
index 000000000..3430d3c86
--- /dev/null
+++ b/core/services/aws-sso-integration-service.ts
@@ -0,0 +1,396 @@
+import { Repository } from "./repository";
+import { AwsSsoRoleService, LoginResponse, SsoRoleSession } from "./session/aws/aws-sso-role-service";
+import { AwsSsoIntegration } from "../models/aws-sso-integration";
+import { formatDistance } from "date-fns";
+import { INativeService } from "../interfaces/i-native-service";
+import { AwsSsoOidcService } from "./aws-sso-oidc.service";
+import { KeychainService } from "./keychain-service";
+import { constants } from "../models/constants";
+import SSO, {
+ AccountInfo,
+ GetRoleCredentialsRequest,
+ GetRoleCredentialsResponse,
+ ListAccountRolesRequest,
+ ListAccountsRequest,
+ LogoutRequest,
+ RoleInfo,
+} from "aws-sdk/clients/sso";
+import { SessionType } from "../models/session-type";
+import { AwsSsoRoleSession } from "../models/aws-sso-role-session";
+import { ISessionNotifier } from "../interfaces/i-session-notifier";
+import { AwsSsoIntegrationTokenInfo } from "../models/aws-sso-integration-token-info";
+import { SessionFactory } from "./session-factory";
+
+const portalUrlValidationRegex = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*)/;
+
+export interface IntegrationCreationParams {
+ alias: string;
+ portalUrl: string;
+ region: string;
+ browserOpening: string;
+}
+
+export interface SsoSessionsDiff {
+ sessionsToDelete: AwsSsoRoleSession[];
+ sessionsToAdd: SsoRoleSession[];
+}
+
+export class AwsSsoIntegrationService {
+ private ssoPortal: SSO;
+
+ constructor(
+ private repository: Repository,
+ private awsSsoOidcService: AwsSsoOidcService,
+ private awsSsoRoleService: AwsSsoRoleService,
+ private keyChainService: KeychainService,
+ private sessionNotifier: ISessionNotifier,
+ private nativeService: INativeService,
+ private sessionFactory: SessionFactory
+ ) {}
+
+ static validateAlias(alias: string): boolean | string {
+ return alias.trim() !== "" ? true : "Empty alias";
+ }
+
+ static validatePortalUrl(portalUrl: string): boolean | string {
+ return portalUrlValidationRegex.test(portalUrl) ? true : "Invalid portal URL";
+ }
+
+ createIntegration(creationParams: IntegrationCreationParams): void {
+ this.repository.addAwsSsoIntegration(creationParams.portalUrl, creationParams.alias, creationParams.region, creationParams.browserOpening);
+ }
+
+ getIntegrations(): AwsSsoIntegration[] {
+ return this.repository.listAwsSsoIntegrations();
+ }
+
+ getOnlineIntegrations(): AwsSsoIntegration[] {
+ return this.getIntegrations().filter((integration) => this.isOnline(integration));
+ }
+
+ getOfflineIntegrations(): AwsSsoIntegration[] {
+ return this.getIntegrations().filter((integration) => !this.isOnline(integration));
+ }
+
+ isOnline(integration: AwsSsoIntegration): boolean {
+ const expiration = new Date(integration.accessTokenExpiration).getTime();
+ const now = this.getDate().getTime();
+ return !!integration.accessTokenExpiration && now < expiration;
+ }
+
+ remainingHours(integration: AwsSsoIntegration): string {
+ return formatDistance(new Date(integration.accessTokenExpiration), this.getDate(), { addSuffix: true });
+ }
+
+ async loginAndGetSessionsDiff(integrationId: string): Promise {
+ const awsSsoIntegration = this.repository.getAwsSsoIntegration(integrationId);
+ const region = awsSsoIntegration.region;
+ const portalUrl = awsSsoIntegration.portalUrl;
+ const accessToken = await this.getAccessToken(integrationId, region, portalUrl);
+
+ const onlineSessions = await this.getSessions(integrationId, accessToken, region);
+ const persistedSessions = this.repository.getAwsSsoIntegrationSessions(integrationId);
+
+ const sessionsToDelete: AwsSsoRoleSession[] = [];
+ for (const persistedSession of persistedSessions) {
+ const shouldBeDeleted = !onlineSessions.find((s) => {
+ const ssoRoleSession = persistedSession as unknown as SsoRoleSession;
+ return ssoRoleSession.sessionName === s.sessionName && ssoRoleSession.roleArn === s.roleArn && ssoRoleSession.email === s.email;
+ });
+ if (shouldBeDeleted) {
+ sessionsToDelete.push(persistedSession as AwsSsoRoleSession);
+ }
+ }
+
+ const sessionsToAdd = [];
+ for (const onlineSession of onlineSessions) {
+ const shouldBeCreated = !persistedSessions.find((persistedSession) => {
+ const session = persistedSession as unknown as SsoRoleSession;
+ return (
+ onlineSession.sessionName === session.sessionName && onlineSession.roleArn === session.roleArn && onlineSession.email === session.email
+ );
+ });
+ if (shouldBeCreated) {
+ sessionsToAdd.push(onlineSession);
+ }
+ }
+ return { sessionsToDelete, sessionsToAdd };
+ }
+
+ async syncSessions(integrationId: string): Promise {
+ const sessionsDiff = await this.loginAndGetSessionsDiff(integrationId);
+
+ for (const ssoRoleSession of sessionsDiff.sessionsToAdd) {
+ ssoRoleSession.awsSsoConfigurationId = integrationId;
+ await this.awsSsoRoleService.create(ssoRoleSession);
+ }
+
+ for (const ssoSession of sessionsDiff.sessionsToDelete) {
+ const sessionService = this.sessionFactory.getSessionService(ssoSession.type);
+ await sessionService.delete(ssoSession.sessionId);
+ }
+
+ return sessionsDiff;
+ }
+
+ async logout(integrationId: string): Promise {
+ // Obtain region and access token
+ const integration: AwsSsoIntegration = this.repository.getAwsSsoIntegration(integrationId);
+ const region = integration.region;
+ const savedAccessToken = await this.getAccessTokenFromKeychain(integrationId);
+
+ // Configure Sso Portal Client
+ this.setupSsoPortalClient(region);
+
+ // Make a logout request to Sso
+ const logoutRequest: LogoutRequest = { accessToken: savedAccessToken };
+
+ try {
+ await this.ssoPortal.logout(logoutRequest).promise();
+ } catch (_) {
+ // logout request has to be handled in reject Promise by design
+
+ // Clean clients
+ this.ssoPortal = null;
+
+ // Delete access token and remove sso integration info from workspace
+ await this.keyChainService.deletePassword(constants.appName, this.getIntegrationAccessTokenKey(integrationId));
+ this.repository.unsetAwsSsoIntegrationExpiration(integrationId);
+ await this.removeSsoSessionsFromWorkspace(integrationId);
+ }
+ }
+
+ async getAccessToken(integrationId: string, region: string, portalUrl: string): Promise {
+ const isAwsSsoAccessTokenExpired = await this.isAwsSsoAccessTokenExpired(integrationId);
+
+ if (isAwsSsoAccessTokenExpired) {
+ const loginResponse = await this.login(integrationId, region, portalUrl);
+ const integration: AwsSsoIntegration = this.repository.getAwsSsoIntegration(integrationId);
+
+ await this.configureAwsSso(
+ integrationId,
+ integration.alias,
+ region,
+ loginResponse.portalUrlUnrolled,
+ integration.browserOpening,
+ loginResponse.expirationTime.toISOString(),
+ loginResponse.accessToken
+ );
+
+ return loginResponse.accessToken;
+ } else {
+ return await this.getAccessTokenFromKeychain(integrationId);
+ }
+ }
+
+ async getRoleCredentials(accessToken: string, region: string, roleArn: string): Promise {
+ this.setupSsoPortalClient(region);
+
+ const getRoleCredentialsRequest: GetRoleCredentialsRequest = {
+ accountId: roleArn.substring(13, 25),
+ roleName: roleArn.split("/")[1],
+ accessToken,
+ };
+
+ return this.ssoPortal.getRoleCredentials(getRoleCredentialsRequest).promise();
+ }
+
+ async getAwsSsoIntegrationTokenInfo(awsSsoIntegrationId: string): Promise {
+ const accessToken = await this.keyChainService.getSecret(constants.appName, `aws-sso-integration-access-token-${awsSsoIntegrationId}`);
+ const expiration = this.repository.getAwsSsoIntegration(awsSsoIntegrationId)
+ ? new Date(this.repository.getAwsSsoIntegration(awsSsoIntegrationId).accessTokenExpiration).getTime()
+ : undefined;
+ return { accessToken, expiration };
+ }
+
+ async isAwsSsoAccessTokenExpired(awsSsoIntegrationId: string): Promise {
+ const awsSsoAccessTokenInfo = await this.getAwsSsoIntegrationTokenInfo(awsSsoIntegrationId);
+ return !awsSsoAccessTokenInfo.expiration || awsSsoAccessTokenInfo.expiration < Date.now();
+ }
+
+ async deleteIntegration(integrationId: string): Promise {
+ await this.logout(integrationId);
+ this.repository.deleteAwsSsoIntegration(integrationId);
+ }
+
+ private async getSessions(integrationId: string, accessToken: string, region: string): Promise {
+ const accounts: AccountInfo[] = await this.listAccounts(accessToken, region);
+
+ const promiseArray: Promise[] = [];
+
+ accounts.forEach((account) => {
+ promiseArray.push(this.getSessionsFromAccount(integrationId, account, accessToken, region));
+ });
+
+ return new Promise((resolve, _) => {
+ Promise.all(promiseArray).then((sessionMatrix: SsoRoleSession[][]) => {
+ resolve(sessionMatrix.flat());
+ });
+ });
+ }
+
+ private async configureAwsSso(
+ integrationId: string,
+ alias: string,
+ region: string,
+ portalUrl: string,
+ browserOpening: string,
+ expirationTime: string,
+ accessToken: string
+ ): Promise {
+ this.repository.updateAwsSsoIntegration(integrationId, alias, region, portalUrl, browserOpening, expirationTime);
+ await this.keyChainService.saveSecret(constants.appName, this.getIntegrationAccessTokenKey(integrationId), accessToken);
+ }
+
+ private async getAccessTokenFromKeychain(integrationId: string | number): Promise {
+ return await this.keyChainService.getSecret(constants.appName, this.getIntegrationAccessTokenKey(integrationId));
+ }
+
+ private getIntegrationAccessTokenKey(integrationId: string | number) {
+ return `aws-sso-integration-access-token-${integrationId}`;
+ }
+
+ private async login(integrationId: string | number, region: string, portalUrl: string): Promise {
+ const redirectClient = this.nativeService.followRedirects[this.getProtocol(portalUrl)];
+ portalUrl = await new Promise((resolve, _) => {
+ const request = redirectClient.request(portalUrl, (response) => resolve(response.responseUrl));
+ request.end();
+ });
+
+ const generateSsoTokenResponse = await this.awsSsoOidcService.login(integrationId, region, portalUrl);
+
+ return {
+ portalUrlUnrolled: portalUrl,
+ accessToken: generateSsoTokenResponse.accessToken,
+ region,
+ expirationTime: generateSsoTokenResponse.expirationTime,
+ };
+ }
+
+ private async removeSsoSessionsFromWorkspace(integrationId: string): Promise {
+ const ssoSessions = this.repository.getAwsSsoIntegrationSessions(integrationId);
+ for (const ssoSession of ssoSessions) {
+ const sessionService = this.sessionFactory.getSessionService(ssoSession.type);
+ await sessionService.delete(ssoSession.sessionId);
+ }
+ }
+
+ private setupSsoPortalClient(region: string): void {
+ if (!this.ssoPortal) {
+ this.ssoPortal = new SSO({ region });
+ }
+ }
+
+ private async listAccounts(accessToken: string, region: string): Promise {
+ this.setupSsoPortalClient(region);
+
+ const listAccountsRequest: ListAccountsRequest = { accessToken, maxResults: 30 };
+ const accountList: AccountInfo[] = [];
+
+ return new Promise((resolve, _) => {
+ this.recursiveListAccounts(accountList, listAccountsRequest, resolve);
+ });
+ }
+
+ private recursiveListAccounts(accountList: AccountInfo[], listAccountsRequest: ListAccountsRequest, promiseCallback: any) {
+ this.ssoPortal
+ .listAccounts(listAccountsRequest)
+ .promise()
+ .then((response) => {
+ accountList.push(...response.accountList);
+
+ if (response.nextToken !== null) {
+ listAccountsRequest.nextToken = response.nextToken;
+ this.recursiveListAccounts(accountList, listAccountsRequest, promiseCallback);
+ } else {
+ promiseCallback(accountList);
+ }
+ });
+ }
+
+ private async getSessionsFromAccount(
+ integrationId: string,
+ accountInfo: AccountInfo,
+ accessToken: string,
+ region: string
+ ): Promise {
+ this.setupSsoPortalClient(region);
+
+ const listAccountRolesRequest: ListAccountRolesRequest = {
+ accountId: accountInfo.accountId,
+ accessToken,
+ maxResults: 30, // TODO: find a proper value
+ };
+
+ const accountRoles: RoleInfo[] = [];
+
+ await new Promise((resolve, _) => {
+ this.recursiveListRoles(accountRoles, listAccountRolesRequest, resolve);
+ });
+
+ const awsSsoSessions: SsoRoleSession[] = [];
+
+ accountRoles.forEach((accountRole) => {
+ const oldSession = this.findOldSession(accountInfo, accountRole);
+
+ const awsSsoSession = {
+ email: accountInfo.emailAddress,
+ region: oldSession?.region || this.repository.getDefaultRegion() || constants.defaultRegion,
+ roleArn: `arn:aws:iam::${accountInfo.accountId}/${accountRole.roleName}`,
+ sessionName: accountInfo.accountName,
+ profileId: oldSession?.profileId || this.repository.getDefaultProfileId(),
+ awsSsoConfigurationId: integrationId,
+ };
+
+ awsSsoSessions.push(awsSsoSession);
+ });
+
+ return awsSsoSessions;
+ }
+
+ private recursiveListRoles(accountRoles: RoleInfo[], listAccountRolesRequest: ListAccountRolesRequest, promiseCallback: any) {
+ this.ssoPortal
+ .listAccountRoles(listAccountRolesRequest)
+ .promise()
+ .then((response) => {
+ accountRoles.push(...response.roleList);
+
+ if (response.nextToken !== null) {
+ listAccountRolesRequest.nextToken = response.nextToken;
+ this.recursiveListRoles(accountRoles, listAccountRolesRequest, promiseCallback);
+ } else {
+ promiseCallback(accountRoles);
+ }
+ });
+ }
+
+ private findOldSession(accountInfo: SSO.AccountInfo, accountRole: SSO.RoleInfo): { region: string; profileId: string } {
+ //TODO: use map and filter in order to make this method more readable
+ for (let i = 0; i < this.repository.getSessions().length; i++) {
+ const sess = this.repository.getSessions()[i];
+
+ if (sess.type === SessionType.awsSsoRole) {
+ if (
+ (sess as AwsSsoRoleSession).email === accountInfo.emailAddress &&
+ (sess as AwsSsoRoleSession).roleArn === `arn:aws:iam::${accountInfo.accountId}/${accountRole.roleName}`
+ ) {
+ return { region: (sess as AwsSsoRoleSession).region, profileId: (sess as AwsSsoRoleSession).profileId };
+ }
+ }
+ }
+
+ return undefined;
+ }
+
+ private getProtocol(aliasedUrl: string): string {
+ let protocol = aliasedUrl.split("://")[0];
+ if (protocol.indexOf("http") === -1) {
+ protocol = "https";
+ }
+ return protocol;
+ }
+
+ private getDate(): Date {
+ return new Date();
+ }
+}
diff --git a/core/services/aws-sso-oidc.service.ts b/core/services/aws-sso-oidc.service.ts
new file mode 100644
index 000000000..6b2f3a536
--- /dev/null
+++ b/core/services/aws-sso-oidc.service.ts
@@ -0,0 +1,192 @@
+import { LeappBaseError } from "../errors/leapp-base-error";
+import { LoggerLevel } from "./logging-service";
+import { constants } from "../models/constants";
+import { Repository } from "./repository";
+import {
+ GenerateSSOTokenResponse,
+ RegisterClientResponse,
+ StartDeviceAuthorizationResponse,
+ VerificationResponse,
+} from "./session/aws/aws-sso-role-service";
+import { IAwsSsoOidcVerificationWindowService } from "../interfaces/i-aws-sso-oidc-verification-window-service";
+import { BrowserWindowClosing } from "../interfaces/i-browser-window-closing";
+import SSOOIDC, { CreateTokenRequest, RegisterClientRequest, StartDeviceAuthorizationRequest } from "aws-sdk/clients/ssooidc";
+
+export class AwsSsoOidcService {
+ public readonly listeners: BrowserWindowClosing[];
+ private ssoOidc: SSOOIDC;
+ private generateSSOTokenResponse: GenerateSSOTokenResponse;
+ private setIntervalQueue: Array;
+ private mainIntervalId: any;
+ private loginMutex: boolean;
+ private timeoutOccurred: boolean;
+ private interruptOccurred: boolean;
+
+ constructor(
+ private verificationWindowService: IAwsSsoOidcVerificationWindowService,
+ private repository: Repository,
+ private disableInAppBrowser: boolean = false
+ ) {
+ this.listeners = [];
+ this.ssoOidc = null;
+ this.generateSSOTokenResponse = null;
+ this.setIntervalQueue = [];
+ this.loginMutex = false;
+ this.timeoutOccurred = false;
+ this.interruptOccurred = false;
+ }
+
+ getListeners(): BrowserWindowClosing[] {
+ return this.listeners;
+ }
+
+ appendListener(listener: BrowserWindowClosing): void {
+ this.listeners.push(listener);
+ }
+
+ async login(configurationId: string | number, region: string, portalUrl: string): Promise {
+ if (!this.loginMutex && this.setIntervalQueue.length === 0) {
+ this.loginMutex = true;
+
+ this.ssoOidc = new SSOOIDC({ region });
+ this.generateSSOTokenResponse = null;
+ this.setIntervalQueue = [];
+ this.timeoutOccurred = false;
+ this.interruptOccurred = false;
+
+ const registerClientResponse = await this.registerSsoOidcClient();
+ const startDeviceAuthorizationResponse = await this.startDeviceAuthorization(registerClientResponse, portalUrl);
+ const windowModality = this.repository.getAwsSsoIntegration(configurationId).browserOpening;
+ const verificationResponse = await this.verificationWindowService.openVerificationWindow(
+ registerClientResponse,
+ startDeviceAuthorizationResponse,
+ windowModality,
+ () => this.closeVerificationWindow()
+ );
+ try {
+ this.generateSSOTokenResponse = await this.createToken(configurationId, verificationResponse);
+ } catch (err) {
+ this.loginMutex = false;
+ throw err;
+ }
+
+ this.loginMutex = false;
+ return this.generateSSOTokenResponse;
+ } else if (!this.loginMutex && this.setIntervalQueue.length > 0) {
+ return this.generateSSOTokenResponse;
+ } else {
+ return new Promise((resolve, reject) => {
+ const repeatEvery = 500; // 0.5 second, we can make these more speedy as they just check a variable, no external calls here
+
+ const resolved = setInterval(async () => {
+ if (this.interruptOccurred) {
+ clearInterval(resolved);
+
+ const resolvedIndex = this.setIntervalQueue.indexOf(resolved);
+ this.setIntervalQueue.splice(resolvedIndex, 1);
+
+ reject(new LeappBaseError("AWS SSO Interrupted", this, LoggerLevel.info, "AWS SSO Interrupted."));
+ } else if (this.generateSSOTokenResponse) {
+ clearInterval(resolved);
+
+ const resolvedIndex = this.setIntervalQueue.indexOf(resolved);
+ this.setIntervalQueue.splice(resolvedIndex, 1);
+
+ resolve(this.generateSSOTokenResponse);
+ } else if (this.timeoutOccurred) {
+ clearInterval(resolved);
+
+ const resolvedIndex = this.setIntervalQueue.indexOf(resolved);
+ this.setIntervalQueue.splice(resolvedIndex, 1);
+
+ reject(new LeappBaseError("AWS SSO Timeout", this, LoggerLevel.error, "AWS SSO Timeout occurred. Please redo login procedure."));
+ }
+ }, repeatEvery);
+
+ this.setIntervalQueue.push(resolved);
+ });
+ }
+ }
+
+ closeVerificationWindow(): void {
+ this.loginMutex = false;
+
+ this.getListeners().forEach((listener) => {
+ listener.catchClosingBrowserWindow();
+ });
+ }
+
+ interrupt(): void {
+ clearInterval(this.mainIntervalId);
+ this.interruptOccurred = true;
+ this.loginMutex = false;
+ }
+
+ private getAwsSsoOidcClient(): SSOOIDC {
+ return this.ssoOidc;
+ }
+
+ private async registerSsoOidcClient(): Promise {
+ const registerClientRequest: RegisterClientRequest = { clientName: "leapp", clientType: "public" };
+ return await this.getAwsSsoOidcClient().registerClient(registerClientRequest).promise();
+ }
+
+ private async startDeviceAuthorization(
+ registerClientResponse: RegisterClientResponse,
+ portalUrl: string
+ ): Promise {
+ const startDeviceAuthorizationRequest: StartDeviceAuthorizationRequest = {
+ clientId: registerClientResponse.clientId,
+ clientSecret: registerClientResponse.clientSecret,
+ startUrl: portalUrl,
+ };
+
+ return await this.getAwsSsoOidcClient().startDeviceAuthorization(startDeviceAuthorizationRequest).promise();
+ }
+
+ private async createToken(configurationId: string | number, verificationResponse: VerificationResponse): Promise {
+ const createTokenRequest: CreateTokenRequest = {
+ clientId: verificationResponse.clientId,
+ clientSecret: verificationResponse.clientSecret,
+ grantType: "urn:ietf:params:oauth:grant-type:device_code",
+ deviceCode: verificationResponse.deviceCode,
+ };
+
+ let createTokenResponse;
+ // disableInAppBrowser is a client-specific parameter. If disableInAppBrowser is true, the client will open aws sso
+ // login page using the Browser instead of the Electron BrowserWindow, regardless the value specified in Leapp
+ // configuration's browserOpening parameter.
+ if (!this.disableInAppBrowser && this.repository.getAwsSsoIntegration(configurationId).browserOpening === constants.inApp) {
+ createTokenResponse = await this.getAwsSsoOidcClient().createToken(createTokenRequest).promise();
+ } else {
+ createTokenResponse = await this.waitForToken(createTokenRequest);
+ }
+
+ const expirationTime: Date = new Date(Date.now() + createTokenResponse.expiresIn * 1000);
+ return { accessToken: createTokenResponse.accessToken, expirationTime };
+ }
+
+ private async waitForToken(createTokenRequest: CreateTokenRequest): Promise {
+ return new Promise((resolve, reject) => {
+ const intervalInMilliseconds = 5000;
+
+ this.mainIntervalId = setInterval(() => {
+ this.getAwsSsoOidcClient()
+ .createToken(createTokenRequest)
+ .promise()
+ .then((createTokenResponse) => {
+ clearInterval(this.mainIntervalId);
+ resolve(createTokenResponse);
+ })
+ .catch((err) => {
+ if (err.toString().indexOf("AuthorizationPendingException") === -1) {
+ // AWS SSO Timeout occurred
+ clearInterval(this.mainIntervalId);
+ this.timeoutOccurred = true;
+ reject(new LeappBaseError("AWS SSO Timeout", this, LoggerLevel.error, "AWS SSO Timeout occurred. Please redo login procedure."));
+ }
+ });
+ }, intervalInMilliseconds);
+ });
+ }
+}
diff --git a/core/services/azure-core-service.spec.ts b/core/services/azure-core-service.spec.ts
new file mode 100644
index 000000000..2f47b7a5a
--- /dev/null
+++ b/core/services/azure-core-service.spec.ts
@@ -0,0 +1,197 @@
+import { describe, test, expect } from "@jest/globals";
+import { AzureCoreService } from "./azure-core-service";
+
+describe("azureCoreService", () => {
+ test("getLocations", () => {
+ const azureCoreService = new AzureCoreService();
+
+ expect(azureCoreService.getLocations()).toEqual([
+ {
+ location: "eastus",
+ },
+ {
+ location: "eastus2",
+ },
+ {
+ location: "southcentralus",
+ },
+ {
+ location: "australiaeast",
+ },
+ {
+ location: "southeastasia",
+ },
+ {
+ location: "northeurope",
+ },
+ {
+ location: "uksouth",
+ },
+ {
+ location: "westeurope",
+ },
+ {
+ location: "centralus",
+ },
+ {
+ location: "northcentralus",
+ },
+ {
+ location: "southafricanorth",
+ },
+ {
+ location: "centralindia",
+ },
+ {
+ location: "eastasia",
+ },
+ {
+ location: "japaneast",
+ },
+ {
+ location: "koreacentral",
+ },
+ {
+ location: "canadacentral",
+ },
+ {
+ location: "francecentral",
+ },
+ {
+ location: "germanywestcentral",
+ },
+ {
+ location: "norwayeast",
+ },
+ {
+ location: "switzerlandnorth",
+ },
+ {
+ location: "uaenorth",
+ },
+ {
+ location: "brazilsouth",
+ },
+ {
+ location: "centralusstage",
+ },
+ {
+ location: "eastusstage",
+ },
+ {
+ location: "eastus2stage",
+ },
+ {
+ location: "northcentralusstage",
+ },
+ {
+ location: "southcentralusstage",
+ },
+ {
+ location: "westusstage",
+ },
+ {
+ location: "westus2stage",
+ },
+ {
+ location: "asia",
+ },
+ {
+ location: "asiapacific",
+ },
+ {
+ location: "australia",
+ },
+ {
+ location: "brazil",
+ },
+ {
+ location: "canada",
+ },
+ {
+ location: "europe",
+ },
+ {
+ location: "global",
+ },
+ {
+ location: "india",
+ },
+ {
+ location: "japan",
+ },
+ {
+ location: "uk",
+ },
+ {
+ location: "unitedstates",
+ },
+ {
+ location: "eastasiastage",
+ },
+ {
+ location: "southeastasiastage",
+ },
+ {
+ location: "centraluseuap",
+ },
+ {
+ location: "eastus2euap",
+ },
+ {
+ location: "westcentralus",
+ },
+ {
+ location: "westus3",
+ },
+ {
+ location: "southafricawest",
+ },
+ {
+ location: "australiacentral",
+ },
+ {
+ location: "australiacentral2",
+ },
+ {
+ location: "australiasoutheast",
+ },
+ {
+ location: "japanwest",
+ },
+ {
+ location: "koreasouth",
+ },
+ {
+ location: "southindia",
+ },
+ {
+ location: "westindia",
+ },
+ {
+ location: "canadaeast",
+ },
+ {
+ location: "francesouth",
+ },
+ {
+ location: "germanynorth",
+ },
+ {
+ location: "norwaywest",
+ },
+ {
+ location: "switzerlandwest",
+ },
+ {
+ location: "ukwest",
+ },
+ {
+ location: "uaecentral",
+ },
+ {
+ location: "brazilsoutheast",
+ },
+ ]);
+ });
+});
diff --git a/core/services/azure-core-service.ts b/core/services/azure-core-service.ts
new file mode 100644
index 000000000..587a5d941
--- /dev/null
+++ b/core/services/azure-core-service.ts
@@ -0,0 +1,72 @@
+import { AzureLocation } from "./azure-location";
+
+export class AzureCoreService {
+ constructor() {}
+
+ getLocations(): AzureLocation[] {
+ return [
+ new AzureLocation("eastus"),
+ new AzureLocation("eastus2"),
+ new AzureLocation("southcentralus"),
+ new AzureLocation("australiaeast"),
+ new AzureLocation("southeastasia"),
+ new AzureLocation("northeurope"),
+ new AzureLocation("uksouth"),
+ new AzureLocation("westeurope"),
+ new AzureLocation("centralus"),
+ new AzureLocation("northcentralus"),
+ new AzureLocation("southafricanorth"),
+ new AzureLocation("centralindia"),
+ new AzureLocation("eastasia"),
+ new AzureLocation("japaneast"),
+ new AzureLocation("koreacentral"),
+ new AzureLocation("canadacentral"),
+ new AzureLocation("francecentral"),
+ new AzureLocation("germanywestcentral"),
+ new AzureLocation("norwayeast"),
+ new AzureLocation("switzerlandnorth"),
+ new AzureLocation("uaenorth"),
+ new AzureLocation("brazilsouth"),
+ new AzureLocation("centralusstage"),
+ new AzureLocation("eastusstage"),
+ new AzureLocation("eastus2stage"),
+ new AzureLocation("northcentralusstage"),
+ new AzureLocation("southcentralusstage"),
+ new AzureLocation("westusstage"),
+ new AzureLocation("westus2stage"),
+ new AzureLocation("asia"),
+ new AzureLocation("asiapacific"),
+ new AzureLocation("australia"),
+ new AzureLocation("brazil"),
+ new AzureLocation("canada"),
+ new AzureLocation("europe"),
+ new AzureLocation("global"),
+ new AzureLocation("india"),
+ new AzureLocation("japan"),
+ new AzureLocation("uk"),
+ new AzureLocation("unitedstates"),
+ new AzureLocation("eastasiastage"),
+ new AzureLocation("southeastasiastage"),
+ new AzureLocation("centraluseuap"),
+ new AzureLocation("eastus2euap"),
+ new AzureLocation("westcentralus"),
+ new AzureLocation("westus3"),
+ new AzureLocation("southafricawest"),
+ new AzureLocation("australiacentral"),
+ new AzureLocation("australiacentral2"),
+ new AzureLocation("australiasoutheast"),
+ new AzureLocation("japanwest"),
+ new AzureLocation("koreasouth"),
+ new AzureLocation("southindia"),
+ new AzureLocation("westindia"),
+ new AzureLocation("canadaeast"),
+ new AzureLocation("francesouth"),
+ new AzureLocation("germanynorth"),
+ new AzureLocation("norwaywest"),
+ new AzureLocation("switzerlandwest"),
+ new AzureLocation("ukwest"),
+ new AzureLocation("uaecentral"),
+ new AzureLocation("brazilsoutheast"),
+ ];
+ }
+}
diff --git a/core/services/azure-location.ts b/core/services/azure-location.ts
new file mode 100644
index 000000000..86bf360ba
--- /dev/null
+++ b/core/services/azure-location.ts
@@ -0,0 +1,3 @@
+export class AzureLocation {
+ constructor(public location: string) {}
+}
diff --git a/core/services/cloud-provider-service.spec.ts b/core/services/cloud-provider-service.spec.ts
new file mode 100644
index 000000000..04f45becc
--- /dev/null
+++ b/core/services/cloud-provider-service.spec.ts
@@ -0,0 +1,299 @@
+import { jest, describe, test, expect } from "@jest/globals";
+import { AccessMethod } from "../models/access-method";
+import { CloudProviderType } from "../models/cloud-provider-type";
+import { SessionType } from "../models/session-type";
+import { CloudProviderService } from "./cloud-provider-service";
+import { IdpUrlAccessMethodField } from "../models/idp-url-access-method-field";
+
+describe("CloudProviderService", () => {
+ test("availableCloudProviders", () => {
+ const service = new CloudProviderService(null, null, null, null, null);
+
+ expect(service.availableCloudProviders()).toEqual(["aws", "azure"]);
+ });
+
+ test("getSessionTypeMap", () => {
+ const map = new Map([
+ [
+ CloudProviderType.aws,
+ [new AccessMethod(SessionType.awsIamUser, "IAM User", [], true), new AccessMethod(SessionType.awsSsoRole, "SSO Role", [], false)],
+ ],
+ ]);
+ const service = new CloudProviderService(null, null, null, null, null);
+ const spy = jest.spyOn(CloudProviderService.prototype as any, "accessMethodMap", "get").mockReturnValue(map);
+
+ const expectedMap = new Map([
+ [SessionType.awsIamUser, "IAM User"],
+ [SessionType.awsSsoRole, "SSO Role"],
+ ]);
+ const sessionTypeLabelMap = service.getSessionTypeMap();
+ expect(sessionTypeLabelMap).toEqual(expectedMap);
+
+ spy.mockRestore();
+ });
+
+ test("creatableAccessMethods - AWS", () => {
+ const awsCoreService: any = { getRegions: () => [{ region: "region1" }, { region: "region2" }] };
+ const azureCoreService: any = { getLocations: () => [] };
+ const repository: any = {
+ getSessions: () => [
+ { type: SessionType.awsIamUser, sessionName: "session1", sessionId: "s1" },
+ { type: SessionType.awsIamRoleFederated, sessionName: "session2", sessionId: "s2" },
+ { type: SessionType.awsIamRoleChained, sessionName: "session3", sessionId: "s3" },
+ { type: SessionType.awsSsoRole, sessionName: "session4", sessionId: "s4" },
+ { type: SessionType.azure, sessionName: "session5", sessionId: "s5" },
+ ],
+ };
+ const namedProfileService: any = {
+ getNamedProfiles: () => [{ name: "profileName", id: "p1" }],
+ };
+ const idpUrlProfileService: any = {
+ getIdpUrls: () => [{ url: "idpUrl1", id: "id1" }],
+ };
+ const service = new CloudProviderService(awsCoreService, azureCoreService, namedProfileService, idpUrlProfileService, repository);
+
+ const expectedRegionChoices = [
+ {
+ fieldName: "region1",
+ fieldValue: "region1",
+ },
+ {
+ fieldName: "region2",
+ fieldValue: "region2",
+ },
+ ];
+
+ const expectedNamedProfilesChoices = [
+ {
+ fieldName: "profileName",
+ fieldValue: "p1",
+ },
+ ];
+
+ const expectedIdpUrlChoices = [
+ {
+ fieldName: "idpUrl1",
+ fieldValue: "id1",
+ },
+ {
+ fieldName: "Create new",
+ fieldValue: "CreateNewIdpUrlFieldChoice",
+ },
+ ];
+
+ const accessMethods = service.creatableAccessMethods(CloudProviderType.aws);
+ expect(accessMethods).toEqual([
+ {
+ accessMethodFields: [
+ {
+ creationRequestField: "sessionName",
+ message: "Insert session alias",
+ type: "input",
+ },
+ {
+ creationRequestField: "accessKey",
+ message: "Insert Access Key ID",
+ type: "input",
+ },
+ {
+ creationRequestField: "secretKey",
+ message: "Insert Secret Access Key",
+ type: "input",
+ },
+ {
+ choices: expectedRegionChoices,
+ creationRequestField: "region",
+ message: "Select region",
+ type: "list",
+ },
+ {
+ creationRequestField: "mfaDevice",
+ message: "Insert Mfa Device ARN",
+ type: "input",
+ },
+ {
+ choices: expectedNamedProfilesChoices,
+ creationRequestField: "profileId",
+ message: "Select the Named Profile",
+ type: "list",
+ },
+ ],
+ label: "IAM User",
+ sessionType: "awsIamUser",
+ creatable: true,
+ },
+ {
+ accessMethodFields: [
+ {
+ creationRequestField: "sessionName",
+ message: "Insert session alias",
+ type: "input",
+ },
+ {
+ choices: expectedRegionChoices,
+ creationRequestField: "region",
+ message: "Select region",
+ type: "list",
+ },
+ {
+ creationRequestField: "roleArn",
+ message: "Insert Role ARN",
+ type: "input",
+ },
+ {
+ choices: expectedIdpUrlChoices,
+ creationRequestField: "idpUrl",
+ message: "Select the SAML 2.0 Url",
+ type: "list",
+ },
+ {
+ creationRequestField: "idpArn",
+ message: "Insert the AWS Identity Provider ARN",
+ type: "input",
+ },
+ {
+ choices: expectedNamedProfilesChoices,
+ creationRequestField: "profileId",
+ message: "Select the Named Profile",
+ type: "list",
+ },
+ ],
+ label: "IAM Role Federated",
+ sessionType: "awsIamRoleFederated",
+ creatable: true,
+ },
+ {
+ accessMethodFields: [
+ {
+ creationRequestField: "sessionName",
+ message: "Insert session alias",
+ type: "input",
+ },
+ {
+ choices: expectedRegionChoices,
+ creationRequestField: "region",
+ message: "Select region",
+ type: "list",
+ },
+ {
+ creationRequestField: "roleArn",
+ message: "Insert Role ARN",
+ type: "input",
+ },
+ {
+ choices: [
+ {
+ fieldName: "session1",
+ fieldValue: "s1",
+ },
+ {
+ fieldName: "session2",
+ fieldValue: "s2",
+ },
+ {
+ fieldName: "session4",
+ fieldValue: "s4",
+ },
+ ],
+ creationRequestField: "parentSessionId",
+ message: "Select Assumer Session",
+ type: "list",
+ },
+ {
+ creationRequestField: "roleSessionName",
+ message: "Role Session Name",
+ type: "input",
+ },
+ {
+ choices: expectedNamedProfilesChoices,
+ creationRequestField: "profileId",
+ message: "Select the Named Profile",
+ type: "list",
+ },
+ ],
+ label: "IAM Role Chained",
+ sessionType: "awsIamRoleChained",
+ creatable: true,
+ },
+ ]);
+
+ const awsFederatedAccessMethod = accessMethods.filter((accessMethod) => accessMethod.sessionType === SessionType.awsIamRoleFederated)[0];
+ const idpUrlAccessMethodField = awsFederatedAccessMethod.accessMethodFields.filter(
+ (accessMethodField) => accessMethodField.creationRequestField === "idpUrl"
+ )[0];
+ expect(idpUrlAccessMethodField).toBeInstanceOf(IdpUrlAccessMethodField);
+ });
+
+ test("creatableAccessMethods - Azure", () => {
+ const awsCoreService: any = { getRegions: () => [] };
+ const azureCoreService: any = { getLocations: () => [{ location: "location1" }, { location: "location2" }] };
+ const repository: any = { getSessions: () => [] };
+ const namedProfileService: any = { getNamedProfiles: () => [] };
+ const idpUrlProfileService: any = { getIdpUrls: () => [] };
+ const service = new CloudProviderService(awsCoreService, azureCoreService, namedProfileService, idpUrlProfileService, repository);
+
+ expect(service.creatableAccessMethods(CloudProviderType.azure)).toEqual([
+ {
+ accessMethodFields: [
+ {
+ creationRequestField: "sessionName",
+ message: "Insert session alias",
+ type: "input",
+ },
+ {
+ choices: [
+ {
+ fieldName: "location1",
+ fieldValue: "location1",
+ },
+ {
+ fieldName: "location2",
+ fieldValue: "location2",
+ },
+ ],
+ creationRequestField: "region",
+ message: "Select Location",
+ type: "list",
+ },
+ {
+ creationRequestField: "subscriptionId",
+ message: "Insert Subscription Id",
+ type: "input",
+ },
+ {
+ creationRequestField: "tenantId",
+ message: "Insert Tenant Id",
+ type: "input",
+ },
+ ],
+ label: "Azure",
+ sessionType: "azure",
+ creatable: true,
+ },
+ ]);
+ });
+
+ test("availableRegions", () => {
+ const awsCoreService: any = { getRegions: () => [{ region: "region1" }, { region: "region2" }] };
+ const azureCoreService: any = { getLocations: () => [{ location: "location1" }, { location: "location2" }] };
+ const service = new CloudProviderService(awsCoreService, azureCoreService, null, null, null);
+
+ const awsChoices = [
+ { fieldName: "region1", fieldValue: "region1" },
+ { fieldName: "region2", fieldValue: "region2" },
+ ];
+ expect(service.availableRegions(SessionType.aws)).toEqual(awsChoices);
+ expect(service.availableRegions(SessionType.awsIamUser)).toEqual(awsChoices);
+ expect(service.availableRegions(SessionType.awsIamRoleChained)).toEqual(awsChoices);
+ expect(service.availableRegions(SessionType.awsIamRoleFederated)).toEqual(awsChoices);
+ expect(service.availableRegions(SessionType.awsSsoRole)).toEqual(awsChoices);
+
+ const azureChoices = [
+ { fieldName: "location1", fieldValue: "location1" },
+ { fieldName: "location2", fieldValue: "location2" },
+ ];
+ expect(service.availableRegions(SessionType.azure)).toEqual(azureChoices);
+
+ expect(service.availableRegions(SessionType.google)).toEqual([]);
+ });
+});
diff --git a/core/services/cloud-provider-service.ts b/core/services/cloud-provider-service.ts
new file mode 100644
index 000000000..dba69b207
--- /dev/null
+++ b/core/services/cloud-provider-service.ts
@@ -0,0 +1,152 @@
+import { AccessMethod } from "../models/access-method";
+import { AccessMethodField } from "../models/access-method-field";
+import { AccessMethodFieldType } from "../models/access-method-field-type";
+import { CloudProviderType } from "../models/cloud-provider-type";
+import { SessionType } from "../models/session-type";
+import { AwsCoreService } from "./aws-core-service";
+import { AWS_ASSUMER_SESSION_TYPES } from "./aws-assumer-session-types";
+import { AzureCoreService } from "./azure-core-service";
+import { FieldChoice } from "./field-choice";
+import { NamedProfilesService } from "./named-profiles-service";
+import { IdpUrlsService } from "./idp-urls-service";
+import { Repository } from "./repository";
+import { IdpUrlAccessMethodField } from "../models/idp-url-access-method-field";
+
+export const createNewIdpUrlFieldChoice = "CreateNewIdpUrlFieldChoice";
+
+export class CloudProviderService {
+ constructor(
+ private awsCoreService: AwsCoreService,
+ private azureCoreService: AzureCoreService,
+ private namedProfilesService: NamedProfilesService,
+ private idpUrlService: IdpUrlsService,
+ private repository: Repository
+ ) {}
+
+ availableCloudProviders(): CloudProviderType[] {
+ return [CloudProviderType.aws, CloudProviderType.azure];
+ }
+
+ creatableAccessMethods(cloudProviderType: CloudProviderType): AccessMethod[] {
+ return this.accessMethodMap.get(cloudProviderType).filter((accessMethod) => accessMethod.creatable);
+ }
+
+ getSessionTypeMap(): Map {
+ const accessMethods = [...this.accessMethodMap.values()].flatMap((method) => method);
+ return new Map(accessMethods.map((accessMethod) => [accessMethod.sessionType, accessMethod.label] as [SessionType, string]));
+ }
+
+ availableRegions(sessionType: SessionType): FieldChoice[] {
+ return this.regionMap.get(sessionType) ?? [];
+ }
+
+ private get accessMethodMap(): Map {
+ const awsRegionChoices = this.getAwsRegionChoices();
+ const awsNamedProfileChoices = this.getAwsNamedProfileChoices();
+ const idpUrlChoices = this.getIdpUrls();
+ const awsAssumerSessionChoices = this.getAwsAssumerSessionChoices();
+ const azureLocationChoices = this.getAzureLocationChoices();
+
+ return new Map([
+ [
+ CloudProviderType.aws,
+ [
+ new AccessMethod(
+ SessionType.awsIamUser,
+ "IAM User",
+ [
+ new AccessMethodField("sessionName", "Insert session alias", AccessMethodFieldType.input),
+ new AccessMethodField("accessKey", "Insert Access Key ID", AccessMethodFieldType.input),
+ new AccessMethodField("secretKey", "Insert Secret Access Key", AccessMethodFieldType.input),
+ new AccessMethodField("region", "Select region", AccessMethodFieldType.list, awsRegionChoices),
+ new AccessMethodField("mfaDevice", "Insert Mfa Device ARN", AccessMethodFieldType.input),
+ new AccessMethodField("profileId", "Select the Named Profile", AccessMethodFieldType.list, awsNamedProfileChoices),
+ ],
+ true
+ ),
+ new AccessMethod(
+ SessionType.awsIamRoleFederated,
+ "IAM Role Federated",
+ [
+ new AccessMethodField("sessionName", "Insert session alias", AccessMethodFieldType.input),
+ new AccessMethodField("region", "Select region", AccessMethodFieldType.list, awsRegionChoices),
+ new AccessMethodField("roleArn", "Insert Role ARN", AccessMethodFieldType.input),
+ new IdpUrlAccessMethodField("idpUrl", "Select the SAML 2.0 Url", AccessMethodFieldType.list, idpUrlChoices),
+ new AccessMethodField("idpArn", "Insert the AWS Identity Provider ARN", AccessMethodFieldType.input),
+ new AccessMethodField("profileId", "Select the Named Profile", AccessMethodFieldType.list, awsNamedProfileChoices),
+ ],
+ true
+ ),
+ new AccessMethod(
+ SessionType.awsIamRoleChained,
+ "IAM Role Chained",
+ [
+ new AccessMethodField("sessionName", "Insert session alias", AccessMethodFieldType.input),
+ new AccessMethodField("region", "Select region", AccessMethodFieldType.list, awsRegionChoices),
+ new AccessMethodField("roleArn", "Insert Role ARN", AccessMethodFieldType.input),
+ new AccessMethodField("parentSessionId", "Select Assumer Session", AccessMethodFieldType.list, awsAssumerSessionChoices),
+ new AccessMethodField("roleSessionName", "Role Session Name", AccessMethodFieldType.input),
+ new AccessMethodField("profileId", "Select the Named Profile", AccessMethodFieldType.list, awsNamedProfileChoices),
+ ],
+ true
+ ),
+ new AccessMethod(SessionType.awsSsoRole, "AWS Single Sign-On", [], false),
+ ],
+ ],
+ [
+ CloudProviderType.azure,
+ [
+ new AccessMethod(
+ SessionType.azure,
+ "Azure",
+ [
+ new AccessMethodField("sessionName", "Insert session alias", AccessMethodFieldType.input),
+ new AccessMethodField("region", "Select Location", AccessMethodFieldType.list, azureLocationChoices),
+ new AccessMethodField("subscriptionId", "Insert Subscription Id", AccessMethodFieldType.input),
+ new AccessMethodField("tenantId", "Insert Tenant Id", AccessMethodFieldType.input),
+ ],
+ true
+ ),
+ ],
+ ],
+ ]);
+ }
+
+ private get regionMap(): Map {
+ const awsRegionChoices = this.getAwsRegionChoices();
+ const azureLocationChoices = this.getAzureLocationChoices();
+
+ return new Map([
+ [SessionType.aws, awsRegionChoices],
+ [SessionType.awsIamUser, awsRegionChoices],
+ [SessionType.awsSsoRole, awsRegionChoices],
+ [SessionType.awsIamRoleFederated, awsRegionChoices],
+ [SessionType.awsIamRoleChained, awsRegionChoices],
+ [SessionType.azure, azureLocationChoices],
+ ]);
+ }
+
+ private getAzureLocationChoices(): FieldChoice[] {
+ return this.azureCoreService.getLocations().map((location) => new FieldChoice(location.location, location.location));
+ }
+
+ private getAwsRegionChoices(): FieldChoice[] {
+ return this.awsCoreService.getRegions().map((value) => new FieldChoice(value.region, value.region));
+ }
+
+ private getAwsAssumerSessionChoices(): FieldChoice[] {
+ return this.repository
+ .getSessions()
+ .filter((session) => AWS_ASSUMER_SESSION_TYPES.includes(session.type))
+ .map((session) => new FieldChoice(session.sessionName, session.sessionId));
+ }
+
+ private getAwsNamedProfileChoices(): FieldChoice[] {
+ return this.namedProfilesService.getNamedProfiles().map((profile) => new FieldChoice(profile.name, profile.id));
+ }
+
+ private getIdpUrls(): FieldChoice[] {
+ const idpUrlsFieldChoices = this.idpUrlService.getIdpUrls().map((idpUrl) => new FieldChoice(idpUrl.url, idpUrl.id));
+ return idpUrlsFieldChoices.concat(new FieldChoice("Create new", createNewIdpUrlFieldChoice));
+ }
+}
diff --git a/core/services/execute-service.ts b/core/services/execute-service.ts
new file mode 100644
index 000000000..7a181f316
--- /dev/null
+++ b/core/services/execute-service.ts
@@ -0,0 +1,83 @@
+import { INativeService } from "../interfaces/i-native-service";
+import { constants } from "../models/constants";
+import { Repository } from "./repository";
+
+export class ExecuteService {
+ constructor(private nativeService: INativeService, private repository: Repository) {}
+
+ getQuote(): string {
+ return this.nativeService.process.platform === "darwin" ? "'" : "";
+ }
+
+ /**
+ * Execute a command: if the command contains sudo the system launch it with sudo prompt.
+ * Note: with the current version of Electron the sandbox option for Chromium don't allow for sudo prompt on Ubuntu machines 16+
+ * Remove the note whenever a fix is found.
+ *
+ * @param command - the command to launch
+ * @param env - environment
+ * @returns an {Promise} stdout or stderr
+ */
+ execute(command: string, env?: any): Promise {
+ return new Promise((resolve, reject) => {
+ let exec = this.nativeService.exec;
+ if (command.startsWith("sudo")) {
+ exec = this.nativeService.sudo.exec;
+ command = command.substring(5, command.length);
+ }
+
+ if (this.nativeService.process.platform === "darwin") {
+ if (command.indexOf("osascript") === -1) {
+ command = "/usr/local/bin/" + command;
+ } else {
+ command = "/usr/bin/" + command;
+ }
+ }
+
+ exec(command, { env, name: "Leapp", timeout: 60000 }, (err, stdout, stderr) => {
+ this.nativeService.log.info("execute from Leapp: ", { error: err, standardout: stdout, standarderror: stderr });
+ if (err) {
+ reject(err);
+ } else {
+ resolve(stdout ? stdout : stderr);
+ }
+ });
+ });
+ }
+
+ /**
+ * Open a command terminal and launch a generic command
+ *
+ * @param command - the command to launch in terminal
+ * @param env - optional the environment object we can set to pass environment variables
+ * @param macOsTerminalType - optional to override terminal type selection on macOS
+ * @returns an {Promise} stdout or stderr
+ */
+ openTerminal(command: string, env?: any, macOsTerminalType?: string): Promise {
+ if (this.nativeService.process.platform === "darwin") {
+ const terminalType = macOsTerminalType ?? this.repository.getWorkspace().macOsTerminal;
+ if (terminalType === constants.macOsTerminal) {
+ return this.execute(
+ `osascript -e "tell app \\"Terminal\\"
+ activate (do script \\"${command} && unset AWS_SESSION_TOKEN && unset AWS_SECRET_ACCESS_KEY && unset AWS_ACCESS_KEY_ID\\")
+ end tell"`,
+ Object.assign(this.nativeService.process.env, env)
+ );
+ } else {
+ return this.execute(
+ `osascript -e "tell app \\"iTerm\\"
+ set newWindow to (create window with default profile)
+ tell current session of newWindow
+ write text \\"${command} && unset AWS_SESSION_TOKEN && unset AWS_SECRET_ACCESS_KEY && unset AWS_ACCESS_KEY_ID\\"
+ end tell
+ end tell"`,
+ Object.assign(this.nativeService.process.env, env)
+ );
+ }
+ } else if (this.nativeService.process.platform === "win32") {
+ return this.execute(`start cmd /k ${command}`, env);
+ } else {
+ return this.execute(`gnome-terminal -- sh -c "${command}; bash"`, Object.assign(this.nativeService.process.env, env));
+ }
+ }
+}
diff --git a/core/services/field-choice.ts b/core/services/field-choice.ts
new file mode 100644
index 000000000..c7521694a
--- /dev/null
+++ b/core/services/field-choice.ts
@@ -0,0 +1,3 @@
+export class FieldChoice {
+ constructor(public fieldName: string, public fieldValue: string) {}
+}
diff --git a/src/app/services/file.service.ts b/core/services/file-service.ts
similarity index 57%
rename from src/app/services/file.service.ts
rename to core/services/file-service.ts
index 6c3087b98..ac59b83f3 100644
--- a/src/app/services/file.service.ts
+++ b/core/services/file-service.ts
@@ -1,25 +1,26 @@
-import {Injectable} from '@angular/core';
-import {Observable, Subscription} from 'rxjs';
-import * as CryptoJS from 'crypto-js';
-import {ElectronService} from './electron.service';
-
-@Injectable({
- providedIn: 'root'
-})
+import { Observable, Subscription } from "rxjs";
+import { INativeService } from "../interfaces/i-native-service";
+//import * as CryptoJS from 'crypto-js';
+
+// TODO: when core will use tsconfig "moduleResolution": "ES2020", this require still generates warnings compiling the desktop app
+const cryptoJS = require("crypto-js");
+
export class FileService {
+ private readSubscription: Subscription;
+
+ constructor(private nativeService: INativeService) {}
+
/* ====================================================
* === Wrapper functions over the fs native library ===
* ==================================================== */
- private readSubscription: Subscription;
- constructor(private electronService: ElectronService) {}
/**
* Get the home directory
*
* @returns - {string} - path of the home directory
*/
homeDir(): string {
- return this.electronService.os.homedir();
+ return this.nativeService.os.homedir();
}
/**
@@ -28,8 +29,12 @@ export class FileService {
* @returns - {boolean} - exists or not
* @param path - the path of the directory
*/
- exists(path: string): boolean {
- return this.electronService.fs.existsSync(path);
+ existsSync(path: string): boolean {
+ return this.nativeService.fs.existsSync(path);
+ }
+
+ renameSync(oldPath: string, newPath: string): void {
+ this.nativeService.fs.renameSync(oldPath, newPath);
}
/**
@@ -39,7 +44,7 @@ export class FileService {
* @param path - the directory path
*/
dirname(path: string): string {
- return this.electronService.path.dirname(path);
+ return this.nativeService.path.dirname(path);
}
/**
@@ -49,8 +54,8 @@ export class FileService {
* @param filePath - directory path
*/
readFile(filePath: string): Observable {
- return new Observable(subscriber => {
- this.electronService.fs.readFile(filePath, {encoding: 'utf-8'}, (err, data) => {
+ return new Observable((subscriber) => {
+ this.nativeService.fs.readFile(filePath, { encoding: "utf-8" }, (err: any, data: any) => {
if (err) {
subscriber.error(err);
} else {
@@ -67,8 +72,8 @@ export class FileService {
* @param source - source directory
* @param target - target directory
*/
- copyDir(source: string, target: string) {
- this.electronService.copydir.sync(source, target, {mode: true});
+ copyDir(source: string, target: string): void {
+ this.nativeService.copydir.sync(source, target, { mode: true });
}
/**
@@ -78,7 +83,7 @@ export class FileService {
* @param filePath - Path to read the file
*/
readFileSync(filePath: string): string {
- return this.electronService.fs.readFileSync(filePath, {encoding: 'utf-8'});
+ return this.nativeService.fs.readFileSync(filePath, { encoding: "utf-8" });
}
/**
@@ -87,10 +92,11 @@ export class FileService {
* @returns - {any} - data
* @param source - source of the directory
*/
- getSubDirs(source: string) {
- return this.electronService.fs.readdirSync(source, {withFileTypes: true})
- .filter(dirent => dirent.isDirectory())
- .map(dirent => dirent.name);
+ getSubDirs(source: string): string[] {
+ return this.nativeService.fs
+ .readdirSync(source, { withFileTypes: true })
+ .filter((dirent: any) => dirent.isDirectory())
+ .map((dirent: any) => dirent.name);
}
/**
@@ -100,16 +106,7 @@ export class FileService {
* @param options - some options if needed - optional
*/
newDir(path: string, options: { recursive: boolean }): void {
- this.electronService.fs.mkdirSync(path, options);
- }
-
- /**
- * Choose a uses the os filedialog to lewt you choose a file
- *
- * @returns - {string} - the path of the file to open
- */
- chooseFile(): string {
- return this.electronService.dialog.showOpenDialog({properties: ['openFile']});
+ this.nativeService.fs.mkdirSync(path, options);
}
/**
@@ -120,8 +117,8 @@ export class FileService {
* @param content - the content to write
*/
writeFile(filePath: string, content: string): Observable {
- return new Observable(subscriber => {
- this.electronService.fs.writeFile(filePath, content, (err, data) => {
+ return new Observable((subscriber) => {
+ this.nativeService.fs.writeFile(filePath, content, (err: any, data: any) => {
if (err) {
subscriber.error(err);
} else {
@@ -140,7 +137,7 @@ export class FileService {
* @param content - the content to write
*/
writeFileSync(filePath: string, content: string): any {
- return this.electronService.fs.writeFileSync(filePath, content);
+ return this.nativeService.fs.writeFileSync(filePath, content);
}
/**
@@ -151,14 +148,14 @@ export class FileService {
* @param content - the content to write
*/
iniWrite(filePath: string, content: any): Observable {
- Object.keys(content).forEach(key => {
- Object.keys(content[key]).forEach(subKey => {
- if (content[key][subKey] === null || content[key][subKey] === undefined || content[key][subKey] === 'null' || content[key][subKey] === '') {
+ Object.keys(content).forEach((key) => {
+ Object.keys(content[key]).forEach((subKey) => {
+ if (content[key][subKey] === null || content[key][subKey] === undefined || content[key][subKey] === "null" || content[key][subKey] === "") {
delete content[key][subKey];
}
});
});
- return this.writeFile(filePath, this.electronService.ini.stringify(content));
+ return this.writeFile(filePath, this.nativeService.ini.stringify(content));
}
/**
@@ -168,10 +165,10 @@ export class FileService {
* @param filePath - the filepath to write to
* @param content - the content to write
*/
- iniWriteSync(filePath: string, content: any) {
- Object.keys(content).forEach(key => {
- Object.keys(content[key]).forEach(subKey => {
- if (content[key][subKey] === null || content[key][subKey] === undefined || content[key][subKey] === 'null' || content[key][subKey] === '') {
+ iniWriteSync(filePath: string, content: any): any {
+ Object.keys(content).forEach((key) => {
+ Object.keys(content[key]).forEach((subKey) => {
+ if (content[key][subKey] === null || content[key][subKey] === undefined || content[key][subKey] === "null" || content[key][subKey] === "") {
delete content[key][subKey];
}
});
@@ -179,18 +176,18 @@ export class FileService {
const old = this.iniParseSync(filePath);
const result = Object.assign(old, content);
- return this.writeFileSync(filePath, this.electronService.ini.stringify(result));
+ return this.writeFileSync(filePath, this.nativeService.ini.stringify(result));
}
- replaceWriteSync(filePath: string, content: any) {
- Object.keys(content).forEach(key => {
- Object.keys(content[key]).forEach(subKey => {
- if (content[key][subKey] === null || content[key][subKey] === undefined || content[key][subKey] === 'null' || content[key][subKey] === '') {
+ replaceWriteSync(filePath: string, content: any): any {
+ Object.keys(content).forEach((key) => {
+ Object.keys(content[key]).forEach((subKey) => {
+ if (content[key][subKey] === null || content[key][subKey] === undefined || content[key][subKey] === "null" || content[key][subKey] === "") {
delete content[key][subKey];
}
});
});
- return this.writeFileSync(filePath, this.electronService.ini.stringify(content));
+ return this.writeFileSync(filePath, this.nativeService.ini.stringify(content));
}
/**
@@ -200,22 +197,25 @@ export class FileService {
* @param filePath - the filepath to read from
*/
iniParse(filePath: string): Observable {
- return new Observable(subscriber => {
+ return new Observable((subscriber) => {
if (this.readSubscription) {
this.readSubscription.unsubscribe();
}
- this.readSubscription = this.readFile(filePath).subscribe(file => {
- try {
- subscriber.next(this.electronService.ini.parse(file));
- } catch (e) {
- subscriber.error(e);
- } finally {
+ this.readSubscription = this.readFile(filePath).subscribe(
+ (file) => {
+ try {
+ subscriber.next(this.nativeService.ini.parse(file));
+ } catch (e) {
+ subscriber.error(e);
+ } finally {
+ subscriber.complete();
+ }
+ },
+ (err) => {
+ subscriber.error(err);
subscriber.complete();
}
- }, err => {
- subscriber.error(err);
- subscriber.complete();
- });
+ );
});
}
@@ -225,21 +225,22 @@ export class FileService {
* @returns - {any} - returns the parsed string
* @param filePath - the filepath to read from
*/
- iniParseSync(filePath: string) {
- return this.electronService.ini.parse(this.readFileSync(filePath));
+ iniParseSync(filePath: string): any {
+ return this.nativeService.ini.parse(this.readFileSync(filePath));
}
+ // TODO: move these methods under another service, or try to replace them with encryptionService stuff from leapp-basement
/**
* Encrypt Text
*/
encryptText(text: string): string {
- return CryptoJS.AES.encrypt(text.trim(), this.electronService.machineId).toString();
+ return cryptoJS.AES.encrypt(text.trim(), this.nativeService.machineId).toString();
}
/**
* Decrypt Text
*/
decryptText(text: string): string {
- return CryptoJS.AES.decrypt(text.trim(), this.electronService.machineId).toString(CryptoJS.enc.Utf8);
+ return cryptoJS.AES.decrypt(text.trim(), this.nativeService.machineId).toString(cryptoJS.enc.Utf8);
}
}
diff --git a/core/services/idp-urls-service.spec.ts b/core/services/idp-urls-service.spec.ts
new file mode 100644
index 000000000..61ec7d64b
--- /dev/null
+++ b/core/services/idp-urls-service.spec.ts
@@ -0,0 +1,129 @@
+import { jest, describe, test, expect } from "@jest/globals";
+import { IdpUrlsService } from "./idp-urls-service";
+import { IdpUrl } from "../models/idp-url";
+
+describe("IdpUrlsService", () => {
+ test("getIdpUrls", () => {
+ const repository = {
+ getIdpUrls: () => [{ id: "1" }, { id: "2" }],
+ };
+ const idpUrlsService = new IdpUrlsService(null, repository as any);
+ const idpUrls = idpUrlsService.getIdpUrls();
+
+ expect(idpUrls).toEqual([{ id: "1" }, { id: "2" }]);
+ });
+
+ test("createIdpUrl", () => {
+ const repository = {
+ addIdpUrl: jest.fn(),
+ };
+ const idpUrlsService = new IdpUrlsService(null, repository as any);
+ (idpUrlsService as any).getNewId = () => "newId";
+
+ const newIdpUrl = idpUrlsService.createIdpUrl(" newUrl ");
+
+ expect(repository.addIdpUrl).toHaveBeenCalledWith(new IdpUrl("newId", "newUrl"));
+ expect(newIdpUrl).toEqual(new IdpUrl("newId", "newUrl"));
+ });
+
+ test("editIdpUrl", () => {
+ const repository = {
+ updateIdpUrl: jest.fn(),
+ };
+ const idpUrlsService = new IdpUrlsService(null, repository as any);
+
+ idpUrlsService.editIdpUrl("id", " newUrl ");
+
+ expect(repository.updateIdpUrl).toHaveBeenCalledWith("id", "newUrl");
+ });
+
+ test("getDependantSessions, includingChained", () => {
+ const session1 = { idpUrlId: "id1" };
+ const session2 = { idpUrlId: "id2" };
+ const session3 = { idpUrlId: "id1" };
+ const trustedSessions1 = [{ trusted: "1" }, { trusted: "2" }];
+ const trustedSessions3 = [{ trusted: "3" }, { trusted: "4" }];
+
+ const repository = {
+ getSessions: () => [session1, session2, session3],
+ listIamRoleChained: (session) => {
+ if (session === session1) {
+ return trustedSessions1;
+ } else {
+ expect(session).toEqual(session3);
+ return trustedSessions3;
+ }
+ },
+ };
+ const idpUrlsService = new IdpUrlsService(null, repository as any);
+ const dependantSessions = idpUrlsService.getDependantSessions("id1");
+
+ expect(dependantSessions).toEqual([session1, ...trustedSessions1, session3, ...trustedSessions3]);
+ });
+
+ test("getDependantSessions, not includingChained", () => {
+ const session1 = { idpUrlId: "id1" };
+ const session2 = { idpUrlId: "id2" };
+ const session3 = { idpUrlId: "id1" };
+
+ const repository = {
+ getSessions: () => [session1, session2, session3],
+ };
+ const idpUrlsService = new IdpUrlsService(null, repository as any);
+ const dependantSessions = idpUrlsService.getDependantSessions("id1", false);
+
+ expect(dependantSessions).toEqual([session1, session3]);
+ });
+
+ test("deleteIdpUrl", async () => {
+ const sessionService = {
+ delete: jest.fn(),
+ };
+ const sessionFactory = {
+ getSessionService: jest.fn(() => sessionService),
+ };
+ const repository = {
+ removeIdpUrl: jest.fn(),
+ };
+ const idpUrlsService = new IdpUrlsService(sessionFactory as any, repository as any);
+ idpUrlsService.getDependantSessions = jest.fn(() => [{ sessionId: "sessionId", type: "sessionType" } as any]);
+
+ await idpUrlsService.deleteIdpUrl("id");
+
+ expect(idpUrlsService.getDependantSessions).toHaveBeenCalledWith("id", false);
+ expect(sessionFactory.getSessionService).toHaveBeenCalledWith("sessionType");
+ expect(sessionService.delete).toHaveBeenCalledWith("sessionId");
+ expect(repository.removeIdpUrl).toHaveBeenCalledWith("id");
+ });
+
+ test("getNewId", () => {
+ const idpUrlsService = new IdpUrlsService(null, null) as any;
+ const id1 = idpUrlsService.getNewId();
+ const id2 = idpUrlsService.getNewId();
+ expect(id1).not.toEqual(id2);
+ });
+
+ test("validateIdpUrl", () => {
+ const idpUrlsService = new IdpUrlsService(null, null);
+ idpUrlsService.getIdpUrls = () => [];
+
+ expect(idpUrlsService.validateIdpUrl("www.url.com")).toBe(true);
+ });
+
+ test("validateIdpUrl, empty url", () => {
+ const idpUrlsService = new IdpUrlsService(null, null);
+ expect(idpUrlsService.validateIdpUrl("")).toBe("Empty IdP URL");
+ });
+
+ test("validateIdpUrl, whitespaces url", () => {
+ const idpUrlsService = new IdpUrlsService(null, null);
+ expect(idpUrlsService.validateIdpUrl(" ")).toBe("Empty IdP URL");
+ });
+
+ test("validateIdpUrl, existent url", () => {
+ const idpUrlsService = new IdpUrlsService(null, null);
+ idpUrlsService.getIdpUrls = () => [new IdpUrl("1", "url1")];
+
+ expect(idpUrlsService.validateIdpUrl(" url1 ")).toBe("IdP URL already exists");
+ });
+});
diff --git a/core/services/idp-urls-service.ts b/core/services/idp-urls-service.ts
new file mode 100644
index 000000000..8b163249d
--- /dev/null
+++ b/core/services/idp-urls-service.ts
@@ -0,0 +1,55 @@
+import { SessionFactory } from "./session-factory";
+import { Repository } from "./repository";
+import * as uuid from "uuid";
+import { Session } from "../models/session";
+import { IdpUrl } from "../models/idp-url";
+import { AwsIamRoleFederatedSession } from "../models/aws-iam-role-federated-session";
+
+export class IdpUrlsService {
+ constructor(private sessionFactory: SessionFactory, private repository: Repository) {}
+
+ getIdpUrls(): IdpUrl[] {
+ return this.repository.getIdpUrls();
+ }
+
+ createIdpUrl(idpUrl: string): IdpUrl {
+ const newIdpUrl = new IdpUrl(this.getNewId(), idpUrl.trim());
+ this.repository.addIdpUrl(newIdpUrl);
+ return newIdpUrl;
+ }
+
+ editIdpUrl(id: string, newIdpUrl: string): void {
+ this.repository.updateIdpUrl(id, newIdpUrl.trim());
+ }
+
+ async deleteIdpUrl(id: string): Promise {
+ for (const sessionToDelete of this.getDependantSessions(id, false)) {
+ const sessionService = this.sessionFactory.getSessionService(sessionToDelete.type);
+ await sessionService.delete(sessionToDelete.sessionId);
+ }
+ this.repository.removeIdpUrl(id);
+ }
+
+ validateIdpUrl(url: string): boolean | string {
+ const trimmedUrl = url.trim();
+ if (trimmedUrl.length === 0) {
+ return "Empty IdP URL";
+ }
+ const existingUrls = this.getIdpUrls().map((idpUrl) => idpUrl.url);
+ if (existingUrls.includes(trimmedUrl)) {
+ return "IdP URL already exists";
+ }
+ return true;
+ }
+
+ getDependantSessions(idpUrlId: string, includingChained: boolean = true): Session[] {
+ const dependantSessions = this.repository.getSessions().filter((session) => (session as AwsIamRoleFederatedSession).idpUrlId === idpUrlId);
+ return includingChained
+ ? dependantSessions.flatMap((parentSession) => [parentSession, ...this.repository.listIamRoleChained(parentSession)])
+ : dependantSessions;
+ }
+
+ private getNewId() {
+ return uuid.v4();
+ }
+}
diff --git a/core/services/keychain-service.ts b/core/services/keychain-service.ts
new file mode 100644
index 000000000..af6c4feac
--- /dev/null
+++ b/core/services/keychain-service.ts
@@ -0,0 +1,37 @@
+import { INativeService } from "../interfaces/i-native-service";
+
+export class KeychainService {
+ constructor(private nativeService: INativeService) {}
+
+ /**
+ * Save your secret in the keychain
+ *
+ * @param service - environment.appName
+ * @param account - unique identifier
+ * @param password - secret
+ */
+ async saveSecret(service: string, account: string, password: string): Promise {
+ return await this.nativeService.keytar.setPassword(service, account, password);
+ }
+
+ /**
+ * Retrieve a Secret from the keychain
+ *
+ * @param service - environment.appName
+ * @param account - unique identifier
+ * @returns the secret
+ */
+ async getSecret(service: string, account: string): Promise {
+ return await this.nativeService.keytar.getPassword(service, account);
+ }
+
+ /**
+ * Delete a secret from the keychain
+ *
+ * @param service - environment.appName
+ * @param account - unique identifier
+ */
+ async deletePassword(service: string, account: string): Promise {
+ return await this.nativeService.keytar.deletePassword(service, account);
+ }
+}
diff --git a/core/services/logging-service.ts b/core/services/logging-service.ts
new file mode 100644
index 000000000..6a77a9f8c
--- /dev/null
+++ b/core/services/logging-service.ts
@@ -0,0 +1,50 @@
+import { INativeService } from "../interfaces/i-native-service";
+
+export enum LoggerLevel {
+ info,
+ warn,
+ error,
+}
+
+export class LoggingService {
+ static instance: LoggingService;
+
+ constructor(private nativeService: INativeService) {}
+
+ /**
+ * Log the message to a file and also to console for development mode
+ *
+ * @param message - the message to log
+ * @param type - the LoggerLevel type
+ * @param instance - The structured data of the message
+ * @param stackTrace - Stack trace in case of error log
+ */
+ logger(message: any, type: LoggerLevel, instance?: any, stackTrace?: string): void {
+ if (typeof message !== "string") {
+ message = JSON.stringify(message, null, 3);
+ }
+
+ if (instance) {
+ message = `[${instance.constructor["name"]}] ${message}`;
+ }
+
+ if (stackTrace) {
+ message = `${message} ${stackTrace}`;
+ }
+
+ switch (type) {
+ case LoggerLevel.info:
+ this.nativeService.log.info(message);
+ break;
+ case LoggerLevel.warn:
+ this.nativeService.log.warn(message);
+ break;
+ case LoggerLevel.error:
+ this.nativeService.log.error(message);
+ break;
+ default:
+ this.nativeService.log.error(message);
+ break;
+ }
+ }
+}
diff --git a/core/services/named-profiles-service.spec.ts b/core/services/named-profiles-service.spec.ts
new file mode 100644
index 000000000..548b9a02e
--- /dev/null
+++ b/core/services/named-profiles-service.spec.ts
@@ -0,0 +1,286 @@
+import { expect } from "@jest/globals";
+import { NamedProfilesService } from "./named-profiles-service";
+import { SessionStatus } from "../models/session-status";
+import { constants } from "../models/constants";
+import { AwsNamedProfile } from "../models/aws-named-profile";
+import { AwsSessionService } from "./session/aws/aws-session-service";
+
+describe("NamedProfilesService", () => {
+ test("getNamedProfiles", () => {
+ const repository = {
+ getProfiles: () => [{ id: "profile1" }, { id: "defaultId" }],
+ };
+ const namedProfileService = new NamedProfilesService(null, repository as any, null);
+ const namedProfiles = namedProfileService.getNamedProfiles();
+
+ expect(namedProfiles).toEqual([{ id: "profile1" }, { id: "defaultId" }]);
+ });
+
+ test("getNamedProfiles, excludingDefault", () => {
+ const repository = {
+ getDefaultProfileId: () => "defaultId",
+ getProfiles: () => [{ id: "profile1" }, { id: "defaultId" }, { id: "profile2" }],
+ };
+ const namedProfileService = new NamedProfilesService(null, repository as any, null);
+ const namedProfiles = namedProfileService.getNamedProfiles(true);
+
+ expect(namedProfiles).toEqual([{ id: "profile1" }, { id: "profile2" }]);
+ });
+
+ test("getNamedProfilesMap", () => {
+ const namedProfileService = new NamedProfilesService(null, null, null);
+ namedProfileService.getNamedProfiles = () => [
+ { id: "1", name: "profile1" },
+ { id: "2", name: "profile2" },
+ ];
+
+ const namedProfilesMap = namedProfileService.getNamedProfilesMap();
+
+ expect(namedProfilesMap).toEqual(
+ new Map([
+ ["1", { id: "1", name: "profile1" }],
+ ["2", { id: "2", name: "profile2" }],
+ ])
+ );
+ });
+
+ test("getSessionsWithNamedProfile", () => {
+ const repository = {
+ getSessions: () => [{ profileId: "1" }, { profileId: "2" }],
+ };
+ const namedProfileService = new NamedProfilesService(null, repository as any, null);
+ const sessions = namedProfileService.getSessionsWithNamedProfile("2");
+
+ expect(sessions).toEqual([{ profileId: "2" }]);
+ });
+
+ test("createNamedProfile", () => {
+ const repository = {
+ addProfile: jest.fn(),
+ };
+ const namedProfileService = new NamedProfilesService(null, repository as any, null);
+ namedProfileService.getNewId = () => "newId";
+
+ namedProfileService.createNamedProfile("newName");
+
+ expect(repository.addProfile).toHaveBeenCalledWith(new AwsNamedProfile("newId", "newName"));
+ });
+
+ test("editNamedProfile", async () => {
+ let sessionIsRunning = true;
+ const sessionService = {
+ stop: jest.fn(async () => (sessionIsRunning = false)),
+ start: jest.fn(async () => (sessionIsRunning = true)),
+ };
+ const sessionFactory = {
+ getSessionService: jest.fn(() => sessionService),
+ };
+ const repository = {
+ updateProfile: jest.fn(() => {
+ expect(sessionIsRunning).toBe(false);
+ }),
+ };
+ const namedProfileService = new NamedProfilesService(sessionFactory as any, repository as any, null);
+ namedProfileService.getSessionsWithNamedProfile = jest.fn(() => [
+ { sessionId: "1", status: SessionStatus.pending, type: "type1" },
+ { sessionId: "2", status: SessionStatus.inactive, type: "type2" },
+ { sessionId: "3", status: SessionStatus.active, type: "type3" },
+ ]) as any;
+
+ await namedProfileService.editNamedProfile("profileId", "newName");
+
+ expect(sessionIsRunning).toBe(true);
+ expect(namedProfileService.getSessionsWithNamedProfile).toHaveBeenCalledWith("profileId");
+ expect(sessionFactory.getSessionService).toBeCalledTimes(2);
+ expect(sessionFactory.getSessionService).toHaveBeenCalledWith("type3");
+ expect(repository.updateProfile).toHaveBeenCalledWith("profileId", "newName");
+ expect(sessionService.stop).toHaveBeenCalledWith("3");
+ expect(sessionService.start).toHaveBeenCalledWith("3");
+ });
+
+ test("deleteNamedProfile", async () => {
+ let sessionIsRunning = true;
+ const sessionService = {
+ stop: jest.fn(async () => (sessionIsRunning = false)),
+ start: jest.fn(async () => (sessionIsRunning = true)),
+ };
+ const sessionFactory = {
+ getSessionService: jest.fn(() => sessionService),
+ };
+ const repository = {
+ getDefaultProfileId: () => "defaultProfileId",
+ updateSession: jest.fn((sessionId, session) => {
+ expect(session.profileId).toBe("defaultProfileId");
+ expect(sessionId === "3" && sessionIsRunning).toBe(false);
+ }),
+ removeProfile: jest.fn(),
+ };
+ const workspaceService = {
+ updateSession: jest.fn((sessionId, session) => {
+ expect(session.profileId).toBe("defaultProfileId");
+ expect(sessionId === "3" && sessionIsRunning).toBe(false);
+ }),
+ };
+
+ const namedProfileService = new NamedProfilesService(sessionFactory as any, repository as any, workspaceService as any);
+ const sessions = [
+ { sessionId: "1", status: SessionStatus.pending, type: "type1" },
+ { sessionId: "2", status: SessionStatus.inactive, type: "type2" },
+ { sessionId: "3", status: SessionStatus.active, type: "type3" },
+ ];
+ namedProfileService.getSessionsWithNamedProfile = jest.fn(() => sessions) as any;
+
+ await namedProfileService.deleteNamedProfile("profileId");
+
+ expect(sessionIsRunning).toBe(true);
+ expect(sessionFactory.getSessionService).toHaveBeenNthCalledWith(1, "type1");
+ expect(sessionFactory.getSessionService).toHaveBeenNthCalledWith(2, "type2");
+ expect(sessionFactory.getSessionService).toHaveBeenNthCalledWith(3, "type3");
+ expect(sessionService.stop).toHaveBeenCalledWith("3");
+ expect(repository.updateSession).toHaveBeenCalledWith("1", sessions[0]);
+ expect(repository.updateSession).toHaveBeenCalledWith("2", sessions[1]);
+ expect(repository.updateSession).toHaveBeenCalledWith("3", sessions[2]);
+ expect(workspaceService.updateSession).toHaveBeenCalledWith("1", sessions[0]);
+ expect(workspaceService.updateSession).toHaveBeenCalledWith("2", sessions[1]);
+ expect(workspaceService.updateSession).toHaveBeenCalledWith("3", sessions[2]);
+ expect(sessionService.start).toHaveBeenCalledWith("3");
+ expect(repository.removeProfile).toHaveBeenCalledWith("profileId");
+ });
+
+ test("changeNamedProfile - AwsSessionService type, active", async () => {
+ const session = {
+ sessionId: "sessionId",
+ status: SessionStatus.active,
+ type: "type",
+ profileId: "profileId",
+ } as any;
+ const sessionService = new (AwsSessionService as any)(null, null);
+ sessionService.start = jest.fn();
+ sessionService.stop = jest.fn();
+ const sessionFactory = {
+ getSessionService: jest.fn(() => sessionService),
+ } as any;
+ const repository = {
+ updateSession: jest.fn(),
+ } as any;
+ const workspaceService = {
+ updateSession: jest.fn(),
+ } as any;
+
+ const namedProfileService = new NamedProfilesService(sessionFactory, repository, workspaceService);
+
+ await namedProfileService.changeNamedProfile(session, "newProfileId");
+
+ expect(sessionFactory.getSessionService).toHaveBeenCalledWith(session.type);
+ expect(sessionService.stop).toHaveBeenCalledWith(session.sessionId);
+ expect(repository.updateSession).toHaveBeenCalledWith(session.sessionId, session);
+ expect(workspaceService.updateSession).toHaveBeenCalledWith(session.sessionId, session);
+ expect(sessionService.start).toHaveBeenCalledWith(session.sessionId);
+ });
+
+ test("changeNamedProfile - AwsSessionService type, inactive", async () => {
+ const session = {
+ sessionId: "sessionId",
+ status: SessionStatus.inactive,
+ type: "type",
+ profileId: "profileId",
+ } as any;
+ const sessionService = new (AwsSessionService as any)(null, null);
+ sessionService.start = jest.fn();
+ sessionService.stop = jest.fn();
+ const sessionFactory = {
+ getSessionService: jest.fn(() => sessionService),
+ } as any;
+ const repository = {
+ updateSession: jest.fn(),
+ } as any;
+ const workspaceService = {
+ updateSession: jest.fn(),
+ } as any;
+
+ const namedProfileService = new NamedProfilesService(sessionFactory, repository, workspaceService);
+
+ await namedProfileService.changeNamedProfile(session, "newProfileId");
+
+ expect(sessionFactory.getSessionService).toHaveBeenCalledWith(session.type);
+ expect(sessionService.stop).toHaveBeenCalledTimes(0);
+ expect(repository.updateSession).toHaveBeenCalledWith(session.sessionId, session);
+ expect(workspaceService.updateSession).toHaveBeenCalledWith(session.sessionId, session);
+ expect(sessionService.start).toHaveBeenCalledTimes(0);
+ });
+
+ test("changeNamedProfile - not AwsSessionService type", async () => {
+ const session = {
+ sessionId: "sessionId",
+ status: SessionStatus.active,
+ type: "type",
+ profileId: "profileId",
+ } as any;
+ const sessionService = {
+ start: jest.fn(),
+ stop: jest.fn(),
+ };
+ const sessionFactory = {
+ getSessionService: jest.fn(() => sessionService),
+ } as any;
+ const repository = {
+ updateSession: jest.fn(),
+ } as any;
+ const workspaceService = {
+ updateSession: jest.fn(),
+ } as any;
+
+ const namedProfileService = new NamedProfilesService(sessionFactory, repository, workspaceService);
+
+ await namedProfileService.changeNamedProfile(session, "newProfileId");
+
+ expect(sessionFactory.getSessionService).toHaveBeenCalledWith(session.type);
+ expect(sessionService.stop).toHaveBeenCalledTimes(0);
+ expect(repository.updateSession).toHaveBeenCalledTimes(0);
+ expect(workspaceService.updateSession).toHaveBeenCalledTimes(0);
+ expect(sessionService.start).toHaveBeenCalledTimes(0);
+ });
+
+ test("getNewId", () => {
+ const namedProfileService = new NamedProfilesService(null, null, null);
+ const id1 = namedProfileService.getNewId();
+ const id2 = namedProfileService.getNewId();
+ expect(id1).not.toEqual(id2);
+ });
+
+ test("validateNewProfileName", () => {
+ const namedProfileService = new NamedProfilesService(null, null, null);
+ namedProfileService.getNamedProfiles = () => [];
+
+ expect(namedProfileService.validateNewProfileName("profile")).toBe(true);
+ });
+
+ test("validateNewProfileName - valid name with spaces", () => {
+ const namedProfileService = new NamedProfilesService(null, null, null);
+ namedProfileService.getNamedProfiles = () => [];
+
+ expect(namedProfileService.validateNewProfileName(" profile ")).toBe(true);
+ });
+
+ test("validateNewProfileName, empty name", () => {
+ const namedProfileService = new NamedProfilesService(null, null, null);
+ const emptyNewProfileName = namedProfileService.validateNewProfileName(" ");
+
+ expect(emptyNewProfileName).toBe("Empty profile name");
+ });
+
+ test("validateNewProfileName, default name", () => {
+ const namedProfileService = new NamedProfilesService(null, null, null);
+ const defaultNewProfileName = namedProfileService.validateNewProfileName(constants.defaultAwsProfileName);
+
+ expect(defaultNewProfileName).toBe('"default" is not a valid profile name');
+ });
+
+ test("validateNewProfileName, existent name", () => {
+ const namedProfileService = new NamedProfilesService(null, null, null);
+ namedProfileService.getNamedProfiles = () => [{ id: "1", name: "profile" }];
+ const existentNewProfileName = namedProfileService.validateNewProfileName("profile");
+
+ expect(existentNewProfileName).toBe("Profile already exists");
+ });
+});
diff --git a/core/services/named-profiles-service.ts b/core/services/named-profiles-service.ts
new file mode 100644
index 000000000..cc2554183
--- /dev/null
+++ b/core/services/named-profiles-service.ts
@@ -0,0 +1,104 @@
+import * as uuid from "uuid";
+import { constants } from "../models/constants";
+import { AwsNamedProfile } from "../models/aws-named-profile";
+import { Repository } from "./repository";
+import { Session } from "../models/session";
+import { SessionFactory } from "./session-factory";
+import { SessionStatus } from "../models/session-status";
+import { AwsSessionService } from "./session/aws/aws-session-service";
+import { WorkspaceService } from "./workspace-service";
+
+export class NamedProfilesService {
+ constructor(private sessionFactory: SessionFactory, private repository: Repository, private workspaceService: WorkspaceService) {}
+
+ getNamedProfiles(excludingDefault: boolean = false): AwsNamedProfile[] {
+ const excludedProfileId = excludingDefault ? this.repository.getDefaultProfileId() : null;
+ return this.repository.getProfiles().filter((profile) => profile.id !== excludedProfileId);
+ }
+
+ getNamedProfilesMap(): Map {
+ return new Map(this.getNamedProfiles().map((profile) => [profile.id, profile] as [string, AwsNamedProfile]));
+ }
+
+ getSessionsWithNamedProfile(id: string): Session[] {
+ return this.repository.getSessions().filter((session) => (session as any).profileId === id);
+ }
+
+ createNamedProfile(name: string): AwsNamedProfile {
+ const namedProfile = new AwsNamedProfile(this.getNewId(), name.trim());
+ this.repository.addProfile(namedProfile);
+ return namedProfile;
+ }
+
+ async editNamedProfile(id: string, newName: string): Promise {
+ const activeSessions = this.getSessionsWithNamedProfile(id).filter((session) => session.status === SessionStatus.active);
+
+ for (const session of activeSessions) {
+ await this.sessionFactory.getSessionService(session.type).stop(session.sessionId);
+ }
+ this.repository.updateProfile(id, newName.trim());
+ for (const session of activeSessions) {
+ await this.sessionFactory.getSessionService(session.type).start(session.sessionId);
+ }
+ }
+
+ async deleteNamedProfile(id: string): Promise {
+ const sessions = this.getSessionsWithNamedProfile(id);
+ const defaultNamedProfileId = this.repository.getDefaultProfileId();
+
+ for (const session of sessions) {
+ const sessionService = this.sessionFactory.getSessionService(session.type);
+ const wasActive = session.status === SessionStatus.active;
+ if (wasActive) {
+ await sessionService.stop(session.sessionId);
+ }
+
+ (session as any).profileId = defaultNamedProfileId;
+ this.repository.updateSession(session.sessionId, session);
+ // TODO: it should call iSessionNotifier, not workspaceService
+ this.workspaceService.updateSession(session.sessionId, session);
+
+ if (wasActive) {
+ await sessionService.start(session.sessionId);
+ }
+ }
+ this.repository.removeProfile(id);
+ }
+
+ async changeNamedProfile(session: Session, newNamedProfileId: string): Promise