From 693847025149dc7750bb1b69d5e502dda471016f Mon Sep 17 00:00:00 2001 From: Jayasimha Raghavan Date: Thu, 14 Sep 2023 09:32:55 -0700 Subject: [PATCH 01/11] Adding Creds UI Wrapper --- build/templates/Dockerfile.template | 8 +- unskript-ctl/creds_app.py | 946 ++++++++++++++++++++++++++++ unskript-ctl/creds_ui.py | 26 +- unskript-ctl/stub_creds.json | 217 +++++++ 4 files changed, 1192 insertions(+), 5 deletions(-) create mode 100644 unskript-ctl/creds_app.py create mode 100644 unskript-ctl/stub_creds.json diff --git a/build/templates/Dockerfile.template b/build/templates/Dockerfile.template index e0a374f62..c228feb3d 100644 --- a/build/templates/Dockerfile.template +++ b/build/templates/Dockerfile.template @@ -1,6 +1,8 @@ FROM unskript/awesome-runbooks:latest as base -COPY custom/actions /unskript/data -COPY custom/runbooks /unskript/data +COPY custom/actions/. /unskript/data/actions/ +COPY custom/runbooks/. /unskript/data/runbooks/ -CMD ["./start.sh"] +# The creds directory is the directory you mentioned while running the creds_app.py with the -o option +COPY /. /unskript/credential/.local/share/jupyter/metadata/credential-save/ +CMD ["./start.sh"] \ No newline at end of file diff --git a/unskript-ctl/creds_app.py b/unskript-ctl/creds_app.py new file mode 100644 index 000000000..8e022b922 --- /dev/null +++ b/unskript-ctl/creds_app.py @@ -0,0 +1,946 @@ +"""This file implements a wrapper over creds-ui""" +#!/usr/bin/env python +# +# Copyright (c) 2023 unSkript.com +# All rights reserved. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE +# +# +import os +import sys +import json + +from creds_ui import main as ui +from argparse import ArgumentParser, REMAINDER + +# CONSTANTS USED IN THIS FILE +STUB_FILE = "stub_creds.json" + +# Note: Any change in credential_schema should also be followed by +# the corresponding change in creds-ui too. +credential_schemas = ''' +[ + { + "title": "AWSSchema", + "type": "object", + "properties": { + "authentication": { + "title": "Authentication", + "discriminator": "auth_type", + "anyOf": [ + { + "$ref": "#/definitions/AccessKeySchema" + } + ] + } + }, + "required": [ + "authentication" + ], + "definitions": { + "AccessKeySchema": { + "title": "Access Key", + "type": "object", + "properties": { + "auth_type": { + "title": "Auth Type", + "enum": [ + "Access Key" + ], + "type": "string" + }, + "access_key": { + "title": "Access Key", + "description": "Access Key to use for authentication.", + "type": "string" + }, + "secret_access_key": { + "title": "Secret Access Key", + "description": "Secret Access Key to use for authentication.", + "type": "string", + "writeOnly": true, + "format": "password" + } + }, + "required": [ + "auth_type", + "access_key", + "secret_access_key" + ] + } + } + }, + { + "title": "GCPSchema", + "type": "object", + "properties": { + "credentials": { + "title": "Google Cloud Credentials JSON", + "description": "Contents of the Google Cloud Credentials JSON file.", + "type": "string" + } + }, + "required": [ + "credentials" + ] + }, + { + "title": "ElasticSearchSchema", + "type": "object", + "properties": { + "host": { + "title": "Host Name", + "description": "Elasticsearch Node URL. For eg: https://localhost:9200", + "type": "string" + }, + "username": { + "title": "Username", + "description": "Username for Basic Auth.", + "default": "", + "type": "string" + }, + "password": { + "title": "Password", + "description": "Password for Basic Auth.", + "default": "", + "type": "string", + "writeOnly": true, + "format": "password" + }, + "api_key": { + "title": "API Key", + "description": "API Key based authentication.", + "default": "", + "type": "string" + } + }, + "required": [ + "host" + ] + }, + { + "title": "GrafanaSchema", + "type": "object", + "properties": { + "api_key": { + "title": "API Token", + "description": "API Token to authenticate to grafana.", + "default": "", + "type": "string" + }, + "username": { + "title": "Username", + "description": "Username of the grafana user.", + "default": "", + "type": "string" + }, + "password": { + "title": "Password", + "description": "Password to authenticate to grafana.", + "default": "", + "type": "string", + "writeOnly": true, + "format": "password" + }, + "host": { + "title": "Hostname", + "description": "Hostname of the grafana.", + "type": "string" + } + }, + "required": [ + "host" + ] + }, + { + "title": "RedisSchema", + "type": "object", + "properties": { + "db": { + "title": "Database", + "description": "ID of the database to connect to.", + "default": 0, + "type": "integer" + }, + "host": { + "title": "Hostname", + "description": "Hostname of the redis server.", + "type": "string" + }, + "username": { + "title": "Username", + "description": "Username to authenticate to redis.", + "default": "", + "type": "string" + }, + "password": { + "title": "Password", + "description": "Password to authenticate to redis.", + "default": "", + "type": "string", + "writeOnly": true, + "format": "password" + }, + "port": { + "title": "Port", + "description": "Port on which redis server is listening.", + "default": 6379, + "type": "integer" + }, + "use_ssl": { + "title": "Use SSL", + "description": "Use SSL for communicating to Redis host.", + "default": false, + "type": "boolean" + } + }, + "required": [ + "host" + ] + }, + { + "title": "JenkinsSchema", + "type": "object", + "properties": { + "url": { + "title": "Jenkins url", + "description": "Full Jenkins URL.", + "type": "string" + }, + "user_name": { + "title": "Username", + "description": "Username to authenticate with Jenkins.", + "default": "", + "type": "string" + }, + "password": { + "title": "Password", + "description": "Password or API Token to authenticate with Jenkins.", + "default": "", + "type": "string", + "writeOnly": true, + "format": "password" + } + }, + "required": [ + "url" + ] + }, + { + "title": "GithubSchema", + "type": "object", + "properties": { + "token": { + "title": "Access token", + "description": "Github Personal Access Token.", + "type": "string", + "writeOnly": true, + "format": "password" + }, + "hostname": { + "title": "Custom Hostname", + "description": "Custom hostname for Github Enterprise Version.", + "default": "", + "type": "string" + } + }, + "required": [ + "token" + ] + }, + { + "title": "NetboxSchema", + "type": "object", + "properties": { + "host": { + "title": "Netbox Host", + "description": "Address of Netbox host", + "type": "string" + }, + "token": { + "title": "Token", + "description": "Token value to authenticate write requests.", + "default": "", + "type": "string", + "writeOnly": true, + "format": "password" + }, + "threading": { + "title": "Threading", + "description": "Enable for multithreaded calls like .filter() and .all() queries. To enable set to True ", + "type": "boolean" + } + }, + "required": [ + "host" + ] + }, + { + "title": "NomadSchema", + "type": "object", + "properties": { + "host": { + "title": "Nomad IP address", + "description": "IP address of Nomad host", + "type": "string" + }, + "timeout": { + "title": "Timeout(seconds)", + "description": "Timeout in seconds to retry connection", + "default": 5, + "type": "integer" + }, + "token": { + "title": "Token", + "description": "Token value to authenticate requests to the cluster when using namespace", + "default": "", + "type": "string", + "writeOnly": true, + "format": "password" + }, + "verify_certs": { + "title": "Verify certs", + "description": "Verify server ssl certs. This can be set to true when working with private certs.", + "type": "boolean" + }, + "secure": { + "title": "Secure", + "description": "HTTPS enabled?", + "type": "boolean" + }, + "namespace": { + "title": "Namespace", + "description": "Name of Nomad Namespace. By default, the default namespace will be considered.", + "type": "string" + } + }, + "required": [ + "host" + ] + }, + { + "title": "ChatGPTSchema", + "type": "object", + "properties": { + "organization": { + "title": "Organization ID", + "description": "Identifier for the organization which is sometimes used in API requests. Eg: org-s8OPLNKVjsDAjjdbfTuhqAc", + "default": "", + "type": "string" + }, + "api_token": { + "title": "API Token", + "description": "API Token value to authenticate requests.", + "type": "string", + "writeOnly": true, + "format": "password" + } + }, + "required": [ + "api_token" + ] + }, + { + "title": "OpsgenieSchema", + "type": "object", + "properties": { + "api_token": { + "title": "Api Token", + "description": "Api token to authenticate Opsgenie: GenieKey", + "type": "string", + "writeOnly": true, + "format": "password" + } + }, + "required": [ + "api_token" + ] + }, + { + "title": "JiraSchema", + "type": "object", + "properties": { + "url": { + "title": "URL", + "description": "URL of jira server.", + "type": "string" + }, + "email": { + "title": "Email", + "description": "Email to authenticate to jira.", + "type": "string" + }, + "api_token": { + "title": "Api Token", + "description": "Api token to authenticate to jira.", + "type": "string", + "writeOnly": true, + "format": "password" + } + }, + "required": [ + "url", + "email", + "api_token" + ] + }, + { + "title": "K8SSchema", + "type": "object", + "properties": { + "kubeconfig": { + "title": "Kubeconfig", + "description": "Contents of the kubeconfig file.", + "type": "string" + } + }, + "required": [ + "kubeconfig" + ] + }, + { + "title": "KafkaSchema", + "type": "object", + "properties": { + "broker": { + "title": "Broker", + "description": "host[:port] that the producer should contact to bootstrap initial cluster metadata. Default port is 9092", + "type": "string" + }, + "sasl_username": { + "title": "SASL Username", + "description": "Username for SASL PlainText Authentication.", + "default": "", + "type": "string" + }, + "sasl_password": { + "title": "SASL Password", + "description": "Password for SASL PlainText Authentication.", + "default": "", + "type": "string", + "writeOnly": true, + "format": "password" + } + }, + "required": [ + "broker" + ] + }, + { + "title": "MongoDBSchema", + "type": "object", + "properties": { + "host": { + "title": "Host", + "description": "Full MongoDB URI, in addition to simple hostname. It also supports mongodb+srv:// URIs", + "type": "string" + }, + "port": { + "title": "Port", + "description": "Port on which mongoDB server is listening.", + "default": 27017, + "type": "integer" + }, + "authentication": { + "title": "Authentication", + "discriminator": "auth_type", + "anyOf": [ + { + "$ref": "#/definitions/AtlasSchema" + }, + { + "$ref": "#/definitions/AuthSchema" + } + ] + } + }, + "required": [ + "host", + "authentication" + ], + "definitions": { + "AtlasSchema": { + "title": "AtlasSchema", + "type": "object", + "properties": { + "auth_type": { + "title": "Auth Type", + "enum": [ + "Atlas Administrative API using HTTP Digest Authentication" + ], + "type": "string" + }, + "atlas_public_key": { + "title": "Atlas API Public Key", + "description": "The public key acts as the username when making API requests", + "default": "", + "type": "string" + }, + "atlas_private_key": { + "title": "Atlas API Private Key", + "description": "The private key acts as the password when making API requests", + "default": "", + "type": "string", + "writeOnly": true, + "format": "password" + } + }, + "required": [ + "auth_type" + ] + }, + "AuthSchema": { + "title": "AuthSchema", + "type": "object", + "properties": { + "auth_type": { + "title": "Auth Type", + "enum": [ + "Basic Auth" + ], + "type": "string" + }, + "user_name": { + "title": "Username", + "description": "Username to authenticate with MongoDB.", + "default": "", + "type": "string" + }, + "password": { + "title": "Password", + "description": "Password to authenticate with MongoDB.", + "default": "", + "type": "string", + "writeOnly": true, + "format": "password" + } + }, + "required": [ + "auth_type" + ] + } + } + }, + { + "title": "MySQLSchema", + "type": "object", + "properties": { + "DBName": { + "title": "Database name", + "description": "Name of the database to connect to MySQL.", + "type": "string" + }, + "User": { + "title": "Username", + "description": "Username to authenticate to MySQL.", + "default": "", + "type": "string" + }, + "Password": { + "title": "Password", + "description": "Password to authenticate to MySQL.", + "default": "", + "type": "string", + "writeOnly": true, + "format": "password" + }, + "Host": { + "title": "Hostname", + "description": "Hostname of the MySQL server.", + "type": "string" + }, + "Port": { + "title": "Port", + "description": "Port on which MySQL server is listening.", + "default": 5432, + "type": "integer" + } + }, + "required": [ + "DBName", + "Host" + ] + }, + { + "title": "PostgreSQLSchema", + "type": "object", + "properties": { + "DBName": { + "title": "Database name", + "description": "Name of the database to connect to.", + "type": "string" + }, + "User": { + "title": "Username", + "description": "Username to authenticate to postgres.", + "default": "", + "type": "string" + }, + "Password": { + "title": "Password", + "description": "Password to authenticate to postgres.", + "default": "", + "type": "string", + "writeOnly": true, + "format": "password" + }, + "Host": { + "title": "Hostname", + "description": "Hostname of the postgres server.", + "type": "string" + }, + "Port": { + "title": "Port", + "description": "Port on which postgres server is listening.", + "default": 5432, + "type": "integer" + } + }, + "required": [ + "DBName", + "Host" + ] + }, + { + "title": "RESTSchema", + "type": "object", + "properties": { + "base_url": { + "title": "Base URL", + "description": "Base URL of REST server", + "type": "string" + }, + "username": { + "title": "Username", + "description": "Username for Basic Authentication", + "default": "", + "type": "string" + }, + "password": { + "title": "Password", + "description": "Password for the Given User for Basic Auth", + "default": "", + "type": "string", + "writeOnly": true, + "format": "password" + }, + "headers": { + "title": "Headers", + "description": "A dictionary of http headers to be used to communicate with the host.Example: Authorization: bearer my_oauth_token_to_the_host .These headers will be included in all requests.", + "type": "object" + } + }, + "required": [ + "base_url" + ] + }, + { + "title": "SlackSchema", + "type": "object", + "properties": { + "bot_user_oauth_token": { + "title": "OAuth Access Token", + "description": "OAuth Access Token of the Slack app.", + "type": "string" + } + }, + "required": [ + "bot_user_oauth_token" + ] + }, + { + "title": "SSHSchema", + "type": "object", + "properties": { + "port": { + "title": "Port", + "description": "SSH port to connect to.", + "default": 22, + "type": "integer" + }, + "username": { + "title": "Username", + "description": "Username to use for authentication", + "default": "", + "type": "string" + }, + "proxy_host": { + "title": "Proxy host", + "description": "SSH host to tunnel connection through so that SSH clients connect to host via client -> proxy_host -> host.", + "type": "string" + }, + "proxy_user": { + "title": "Proxy user", + "description": "User to login to proxy_host as. Defaults to username.", + "type": "string" + }, + "proxy_port": { + "title": "Proxy port", + "description": "SSH port to use to login to proxy host if set. Defaults to 22.", + "default": 22, + "type": "integer" + }, + "authentication": { + "title": "Authentication", + "discriminator": "auth_type", + "anyOf": [ + { + "$ref": "#/definitions/AuthSchema" + }, + { + "$ref": "#/definitions/PrivateKeySchema" + }, + { + "$ref": "#/definitions/VaultSchema" + }, + { + "$ref": "#/definitions/KerberosSchema" + } + ] + } + }, + "required": [ + "authentication" + ], + "definitions": { + "AuthSchema": { + "title": "Basic Auth", + "type": "object", + "properties": { + "auth_type": { + "title": "Auth Type", + "enum": [ + "Basic Auth" + ], + "type": "string" + }, + "password": { + "title": "Password", + "description": "Password to use for password authentication.", + "default": "", + "type": "string", + "writeOnly": true, + "format": "password" + }, + "proxy_password": { + "title": "Proxy user password", + "description": "Password to login to proxy_host with. Defaults to no password.", + "type": "string" + } + }, + "required": [ + "auth_type" + ] + }, + "PrivateKeySchema": { + "title": "Pem File", + "type": "object", + "properties": { + "auth_type": { + "title": "Auth Type", + "enum": [ + "API Token" + ], + "type": "string" + }, + "private_key": { + "title": "Private Key File", + "description": "Contents of the Private Key File to use for authentication.", + "default": "", + "type": "string" + }, + "proxy_private_key": { + "title": "Proxy Private Key File", + "description": "Private key file to be used for authentication with proxy_host.", + "type": "string" + } + }, + "required": [ + "auth_type" + ] + }, + "VaultSchema": { + "title": "Vault", + "type": "object", + "properties": { + "auth_type": { + "title": "Auth Type", + "enum": [ + "Vault" + ], + "type": "string" + }, + "vault_url": { + "title": "Vault URL", + "description": "Vault URL eg: http://127.0.0.1:8200", + "type": "string" + }, + "vault_secret_path": { + "title": "SSH Secret Path", + "description": "The is the path in the Vault Configuration tab of ssh secret. eg: ssh", + "type": "string" + }, + "vault_role": { + "title": "Vault Role", + "description": "Vault role associated with the above ssh secret.", + "type": "string" + } + }, + "required": [ + "auth_type", + "vault_url", + "vault_secret_path", + "vault_role" + ] + }, + "KerberosSchema": { + "title": "Kerberos", + "type": "object", + "properties": { + "auth_type": { + "title": "Auth Type", + "enum": [ + "Kerberos" + ], + "type": "string" + }, + "user_with_realm": { + "title": "Kerberos user@REALM", + "description": "Kerberos UserName like user@EXAMPLE.COM REALM is usually defined as UPPER-CASE", + "type": "string" + }, + "kdc_server": { + "title": "KDC Server", + "description": "KDC Server Domain Name. like kdc.example.com", + "type": "string" + }, + "admin_server": { + "title": "Admin Server", + "description": "Kerberos Admin Server. Normally same as KDC Server", + "default": "", + "type": "string" + }, + "password": { + "title": "Password", + "description": "Password for the above Username", + "default": "", + "type": "string", + "writeOnly": true, + "format": "password" + }, + "proxy_password": { + "title": "Proxy user password", + "description": "Password to login to proxy_host with. Defaults is no password.", + "default": "", + "type": "string", + "writeOnly": true, + "format": "password" + } + }, + "required": [ + "auth_type", + "user_with_realm", + "kdc_server" + ] + } + } + }, + { + "title": "SalesforceSchema", + "type": "object", + "properties": { + "Username": { + "title": "Username", + "description": "Username to authenticate to Salesforce.", + "type": "string" + }, + "Password": { + "title": "Password", + "description": "Password to authenticate to Salesforce.", + "type": "string", + "writeOnly": true, + "format": "password" + }, + "Security_Token": { + "title": "Security token", + "description": "Token to authenticate to Salesforce.", + "type": "string" + } + }, + "required": [ + "Username", + "Password", + "Security_Token" + ] + } + ] +''' + +def create_stub_cred_files(dirname: str): + """create_stub_cred_files This function creates the stub files needed by creds-ui""" + if not os.path.exists(dirname): + return + + # Lets read the Stubs Creds file and create placeholder files + if not os.path.exists(STUB_FILE): + print("Credential placeholder file JSON is missing. Please run this at unskript-ctl directory!") + sys.exit(0) + + with open(STUB_FILE, 'r') as f: + stub_creds_json = json.load(f) + + for cred in stub_creds_json: + f_name = os.path.join(dirname, cred.get('display_name')) + f_name = f_name + '.json' + # Lets check if file already exists, if it does not, then create it + if not os.path.exists(f_name): + with open(f_name, 'w') as f: + f.write(json.dumps(cred, indent=4)) + +def main(): + """main: This is the Main function that interfaces with the creds-ui""" + try: + schema_json = json.loads(credential_schemas) + except Exception as e: + print(f"Exception occured {e}") + return + + parser = ArgumentParser(prog='unskript-ctl') + description = "" + description = description + str("\n") + description = description + str("\t Credential App \n") + parser.description = description + + parser.add_argument('-o', '--output-directory', type=str, required=True, + help="Output Directory to store credentials, should be absolute path") + + + args = parser.parse_args() + + if len(sys.argv) == 1: + parser.print_help() + sys.exit(0) + + if args.output_directory not in ('', None): + if os.path.exists(args.output_directory): + if os.path.isdir(args.output_directory): + os.environ['CREDS_DIR'] = args.output_directory + else: + print(f"ERROR: Given Path {args.output_directory} is not a directory. A File by that name already exists!") + sys.exit(0) + else: + print(f"CREATING DIRECTORY: {args.output_directory}") + os.makedirs(args.output_directory) + os.environ['CREDS_DIR'] = args.output_directory + else: + print(f"Output Directory Name is empty {args.output_directory}") + sys.exit(0) + + create_stub_cred_files(args.output_directory) + ui(schema_json=schema_json, creds_dir=args.output_directory) + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/unskript-ctl/creds_ui.py b/unskript-ctl/creds_ui.py index 22e8ee1a6..353c5a8db 100644 --- a/unskript-ctl/creds_ui.py +++ b/unskript-ctl/creds_ui.py @@ -53,7 +53,10 @@ # This variable is used to hold the Credential directory # Where all the creds are saved -CREDS_DIR = os.environ.get('HOME') + "/.local/share/jupyter/metadata/credential-save/" +if os.environ.get('CREDS_DIR') != None: + CREDS_DIR = os.environ.get('CREDS_DIR') +else: + CREDS_DIR = os.environ.get('HOME') + "/.local/share/jupyter/metadata/credential-save/" def read_existing_creds(creds_file: str) -> dict: """read_existing_creds This is a utility function that simply @@ -149,6 +152,17 @@ def on_cancel(self,t): def change_form(self, name): self.switchForm(name) self.resetHistory() + + def set_schemas(self, schema_json): + if not schema_json: + return + try: + self.schema_json = schema_json + except Exception as e: + print(f"Unable to store the Json Schema, please check Schema Json content: {e}") + return + + # This is a custom class that inherits from npyscreen.ActionForm. This is @@ -1047,8 +1061,16 @@ def on_ok(self): # from the unskript-client.py. It can also # be used as a standalone application too. -def main(): +def main(schema_json: str = None, creds_dir: str = None): + global CREDS_DIR creds_app = CredsApp() + if schema_json: + creds_app.set_schemas(schema_json=schema_json) + if creds_dir: + if creds_dir.endswith('/'): + CREDS_DIR = creds_dir + else: + CREDS_DIR = creds_dir + '/' creds_app.run() diff --git a/unskript-ctl/stub_creds.json b/unskript-ctl/stub_creds.json new file mode 100644 index 000000000..62f812e81 --- /dev/null +++ b/unskript-ctl/stub_creds.json @@ -0,0 +1,217 @@ +[ + { + "display_name": "awscreds", + "metadata": { + "id": "e29374b4-1edd-4098-88e2-0da2a290dbb8", + "connectorData": "{}", + "type": "CONNECTOR_TYPE_AWS", + "name": "awscreds" + }, + "schema_name": "credential-save", + "type": "CONNECTOR_TYPE_AWS", + "id": "ebca9178-aadc-478d-bc87-7c895874d3ab" + }, + { + "display_name": "chatgptcreds", + "metadata": { + "name": "chatgptcreds", + "connectorData": "{}", + "type": "CONNECTOR_TYPE_CHATGPT" + }, + "schema_name": "credential-save", + "type": "CONNECTOR_TYPE_CHATGPT", + "id": "7ab97aa6-3016-4818-afde-1a9eb3cb32cb" + }, + { + "display_name": "escreds", + "metadata": { + "id": "025e0bcd-1ddf-426c-bf24-864dfe3adf9a", + "connectorData": "{}", + "type": "CONNECTOR_TYPE_ELASTICSEARCH", + "name": "escreds" + }, + "schema_name": "credential-save", + "type": "CONNECTOR_TYPE_ELASTICSEARCH", + "id": "d287cf62-ad4e-40c1-a595-ee1b52454b1d" + }, + { + "display_name": "gcpcreds", + "metadata": { + "id": "ab1d3ae1-70cc-4d8f-8fa1-3a1f6de63b9e", + "connectorData": "{}", + "type": "CONNECTOR_TYPE_GCP", + "name": "gcpcreds" + }, + "schema_name": "credential-save", + "type": "CONNECTOR_TYPE_GCP", + "id": "794371b5-3c1c-4f3b-87e9-4aa0cdb43011" + }, + { + "display_name": "githubcreds", + "metadata": { + "name": "githubcreds", + "connectorData": "{}", + "type": "CONNECTOR_TYPE_GITHUB" + }, + "schema_name": "credential-save", + "type": "CONNECTOR_TYPE_GITHUB", + "id": "95f1aa88-c661-4525-b536-56d627395e92" + }, + { + "display_name": "grafanacreds", + "metadata": { + "name": "grafanacreds", + "connectorData": "{}", + "type": "CONNECTOR_TYPE_GRAFANA" + }, + "schema_name": "credential-save", + "type": "CONNECTOR_TYPE_GRAFANA", + "id": "582673a2-e493-4c37-bda7-118aeeb715b5" + }, + { + "display_name": "jenkinscreds", + "metadata": { + "name": "jenkinscreds", + "connectorData": "{}", + "type": "CONNECTOR_TYPE_JENKINS" + }, + "schema_name": "credential-save", + "type": "CONNECTOR_TYPE_JENKINS", + "id": "6dc1dd1d-b616-4c89-a88f-0f23bd6ad408" + }, + { + "display_name": "jiracreds", + "metadata": { + "name": "jiracreds", + "connectorData": "{}", + "type": "CONNECTOR_TYPE_JIRA" + }, + "schema_name": "credential-save", + "type": "CONNECTOR_TYPE_JIRA", + "id": "486460a4-72b9-4759-8f8f-5a3ccb0096f9" + }, + { + "display_name": "k8screds", + "metadata": { + "name": "k8screds", + "connectorData": "{}", + "type": "CONNECTOR_TYPE_K8S", + "env": "Global", + "service_id": "" + }, + "schema_name": "credential-save", + "type": "CONNECTOR_TYPE_K8S", + "id": "e5e22603-9586-4b8a-9635-4dcc3058c465" + }, + { + "display_name": "kafkacreds", + "metadata": { + "name": "kafkacreds", + "connectorData": "{}", + "type": "CONNECTOR_TYPE_KAFKA" + }, + "schema_name": "credential-save", + "type": "CONNECTOR_TYPE_KAFKA", + "id": "9c7e58e1-a7d6-4211-8b0c-ac5b5a6b951b" + }, + { + "display_name": "mongodbcreds", + "metadata": { + "name": "mongodbcreds", + "connectorData": "{}", + "type": "CONNECTOR_TYPE_MONGODB" + }, + "schema_name": "credential-save", + "type": "CONNECTOR_TYPE_MONGODB", + "id": "8af91554-f190-48f6-8407-43cf4d3a2eaa" + }, + { + "display_name": "mysqlcreds", + "metadata": { + "name": "mysqlcreds", + "connectorData": "{}", + "type": "CONNECTOR_TYPE_MYSQL" + }, + "schema_name": "credential-save", + "type": "CONNECTOR_TYPE_MYSQL", + "id": "9751cb50-6660-4ad3-b2ae-14f894de638f" + }, + { + "display_name": "netboxcreds", + "metadata": { + "name": "netboxcreds", + "connectorData": "{}", + "type": "CONNECTOR_TYPE_NETBOX" + }, + "schema_name": "credential-save", + "type": "CONNECTOR_TYPE_NETBOX", + "id": "ca137fd8-5a1d-48d0-8d49-57576179e550" + }, + { + "display_name": "nomadcreds", + "metadata": { + "name": "nomadcreds", + "connectorData": "{}", + "type": "CONNECTOR_TYPE_NOMAD" + }, + "schema_name": "credential-save", + "type": "CONNECTOR_TYPE_NOMAD", + "id": "907fcd0c-df0e-44b6-bc98-e97fe898cfcf" + }, + { + "display_name": "postgrescreds", + "metadata": { + "name": "postgrescreds", + "connectorData": "{}", + "type": "CONNECTOR_TYPE_POSTGRESQL" + }, + "schema_name": "credential-save", + "type": "CONNECTOR_TYPE_POSTGRESQL", + "id": "8c537a0c-bfd8-4333-8cf1-9c933cdd2d7e" + }, + { + "display_name": "rediscreds", + "metadata": { + "id": "383f0a57-cbc9-4dab-959f-d762a4d736c6", + "connectorData": "{}", + "type": "CONNECTOR_TYPE_REDIS", + "name": "rediscreds" + }, + "schema_name": "credential-save", + "type": "CONNECTOR_TYPE_REDIS", + "id": "454026ef-d6bc-426b-b9f9-5d67c74c60bb" + }, + { + "display_name": "restcreds", + "metadata": { + "name": "restcreds", + "connectorData": "{}", + "type": "CONNECTOR_TYPE_REST" + }, + "schema_name": "credential-save", + "type": "CONNECTOR_TYPE_REST", + "id": "75dafeae-b46d-45dc-9614-a58ba94187be" + }, + { + "display_name": "slackcreds", + "metadata": { + "name": "slackcreds", + "connectorData": "{}", + "type": "CONNECTOR_TYPE_SLACK" + }, + "schema_name": "credential-save", + "type": "CONNECTOR_TYPE_SLACK", + "id": "3adb23af-4207-441c-ad3a-4737aa0e8635" + }, + { + "display_name": "sshcreds", + "metadata": { + "name": "sshcreds", + "connectorData": "{}", + "type": "CONNECTOR_TYPE_SSH" + }, + "schema_name": "credential-save", + "type": "CONNECTOR_TYPE_SSH", + "id": "d27208f2-ff71-4ff6-bedc-6403add8b184" + } +] \ No newline at end of file From 5c36947207eb4572f0155c65c8f578e00c107848 Mon Sep 17 00:00:00 2001 From: Jayasimha Raghavan Date: Thu, 14 Sep 2023 09:51:01 -0700 Subject: [PATCH 02/11] Lint error Fix --- unskript-ctl/creds_ui.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/unskript-ctl/creds_ui.py b/unskript-ctl/creds_ui.py index 353c5a8db..a0ebcc622 100644 --- a/unskript-ctl/creds_ui.py +++ b/unskript-ctl/creds_ui.py @@ -53,7 +53,7 @@ # This variable is used to hold the Credential directory # Where all the creds are saved -if os.environ.get('CREDS_DIR') != None: +if os.environ.get('CREDS_DIR') is not None: CREDS_DIR = os.environ.get('CREDS_DIR') else: CREDS_DIR = os.environ.get('HOME') + "/.local/share/jupyter/metadata/credential-save/" From 1a3b53b5f7e278de8799611cf5a68c3031a317d7 Mon Sep 17 00:00:00 2001 From: Jayasimha Raghavan Date: Thu, 14 Sep 2023 10:58:51 -0700 Subject: [PATCH 03/11] Spell mistake fixed --- build/templates/Dockerfile.template | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build/templates/Dockerfile.template b/build/templates/Dockerfile.template index c228feb3d..ba7e9a269 100644 --- a/build/templates/Dockerfile.template +++ b/build/templates/Dockerfile.template @@ -3,6 +3,6 @@ COPY custom/actions/. /unskript/data/actions/ COPY custom/runbooks/. /unskript/data/runbooks/ # The creds directory is the directory you mentioned while running the creds_app.py with the -o option -COPY /. /unskript/credential/.local/share/jupyter/metadata/credential-save/ +COPY /. /unskript/credentials/.local/share/jupyter/metadata/credential-save/ -CMD ["./start.sh"] \ No newline at end of file +CMD ["./start.sh"] From 2b26b46f3c8fb85e93ca302e1bf2891a1d6afe12 Mon Sep 17 00:00:00 2001 From: Jayasimha Raghavan Date: Thu, 14 Sep 2023 11:01:36 -0700 Subject: [PATCH 04/11] Moved copying of Dockerfile to a new target --- build/templates/Makefile.extend-docker.template | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/build/templates/Makefile.extend-docker.template b/build/templates/Makefile.extend-docker.template index c122e7414..90770699a 100644 --- a/build/templates/Makefile.extend-docker.template +++ b/build/templates/Makefile.extend-docker.template @@ -25,6 +25,10 @@ CUSTOM_DIRECTORY = custom CUSTOM_DOCKER_NAME ?= my-awesome-docker CUSTOM_DOCKER_VERSION ?= 0.1.0 +copy: + @echo "Copying Docker file" + @cp $(AWESOME_DIRECTORY)/build/templates/Dockerfile.template Dockerfile + pre-build: @echo "Preparing To create custom Docker build" if [ ! -d "$(ACTION_DIRECTORY)" ]; then\ @@ -43,7 +47,6 @@ pre-build: @mkdir -p $(CUSTOM_DIRECTORY) @cp -Rf $(ACTION_DIRECTORY) $(CUSTOM_DIRECTORY)/actions @cp -Rf $(RUNBOOK_DIRECTORY) $(CUSTOM_DIRECTORY)/runbooks - @cp $(AWESOME_DIRECTORY)/build/templates/Dockerfile.template Dockerfile build: pre-build From d2c85d58275422479d879eead92d7e51f1880e92 Mon Sep 17 00:00:00 2001 From: Jayasimha Raghavan Date: Thu, 14 Sep 2023 11:22:14 -0700 Subject: [PATCH 05/11] PR Comments --- unskript-ctl/creds_app.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/unskript-ctl/creds_app.py b/unskript-ctl/creds_app.py index 8e022b922..cc0e4a46d 100644 --- a/unskript-ctl/creds_app.py +++ b/unskript-ctl/creds_app.py @@ -908,7 +908,7 @@ def main(): print(f"Exception occured {e}") return - parser = ArgumentParser(prog='unskript-ctl') + parser = ArgumentParser(prog='creds-app') description = "" description = description + str("\n") description = description + str("\t Credential App \n") @@ -943,4 +943,4 @@ def main(): ui(schema_json=schema_json, creds_dir=args.output_directory) if __name__ == '__main__': - main() \ No newline at end of file + main() From 77db5040092432542e5c3457834eca23ca522eab Mon Sep 17 00:00:00 2001 From: Amit Chandak Date: Thu, 14 Sep 2023 21:32:18 -0700 Subject: [PATCH 06/11] Modified creds_app to be standalone cli based utility to add credentials --- unskript-ctl/creds_app.py | 262 ++++++++++++++++++++++++++++++++------ 1 file changed, 221 insertions(+), 41 deletions(-) diff --git a/unskript-ctl/creds_app.py b/unskript-ctl/creds_app.py index cc0e4a46d..a193b649c 100644 --- a/unskript-ctl/creds_app.py +++ b/unskript-ctl/creds_app.py @@ -13,13 +13,13 @@ import sys import json -from creds_ui import main as ui +#from creds_ui import main as ui from argparse import ArgumentParser, REMAINDER # CONSTANTS USED IN THIS FILE STUB_FILE = "stub_creds.json" -# Note: Any change in credential_schema should also be followed by +# Note: Any change in credential_schema should also be followed by # the corresponding change in creds-ui too. credential_schemas = ''' [ @@ -887,11 +887,11 @@ def create_stub_cred_files(dirname: str): # Lets read the Stubs Creds file and create placeholder files if not os.path.exists(STUB_FILE): print("Credential placeholder file JSON is missing. Please run this at unskript-ctl directory!") - sys.exit(0) - + sys.exit(0) + with open(STUB_FILE, 'r') as f: stub_creds_json = json.load(f) - + for cred in stub_creds_json: f_name = os.path.join(dirname, cred.get('display_name')) f_name = f_name + '.json' @@ -900,47 +900,227 @@ def create_stub_cred_files(dirname: str): with open(f_name, 'w') as f: f.write(json.dumps(cred, indent=4)) -def main(): - """main: This is the Main function that interfaces with the creds-ui""" - try: +#CREDS_DIR = os.environ.get('HOME') + "/.local/share/jupyter/metadata/credential-save/" +CREDS_DIR = os.environ.get('HOME') + "/creds/" + +class CredentialsAdd(): + def __init__(self): + create_stub_cred_files(CREDS_DIR) + try: schema_json = json.loads(credential_schemas) - except Exception as e: + except Exception as e: print(f"Exception occured {e}") return - - parser = ArgumentParser(prog='creds-app') - description = "" - description = description + str("\n") - description = description + str("\t Credential App \n") - parser.description = description + mainParser = ArgumentParser(prog='add_creds') + description = "" + description = description + str("\n") + description = description + str("\t Add credentials \n") + mainParser.description = description + mainParser.add_argument('-c', '--credential-type', choices=[ + 'AWS', + 'K8S', + 'GCP', + 'Elasticsearch', + 'Redis', + 'PostGRES', + 'MongoDB', + 'Kafka' + ], help='Credential type') - parser.add_argument('-o', '--output-directory', type=str, required=True, - help="Output Directory to store credentials, should be absolute path") - + args = mainParser.parse_args(sys.argv[1:3]) + if len(sys.argv) == 1: + mainParser.print_help() + sys.exit(0) - args = parser.parse_args() + getattr(self, args.credential_type)() - if len(sys.argv) == 1: - parser.print_help() - sys.exit(0) - - if args.output_directory not in ('', None): - if os.path.exists(args.output_directory): - if os.path.isdir(args.output_directory): - os.environ['CREDS_DIR'] = args.output_directory - else: - print(f"ERROR: Given Path {args.output_directory} is not a directory. A File by that name already exists!") - sys.exit(0) - else: - print(f"CREATING DIRECTORY: {args.output_directory}") - os.makedirs(args.output_directory) - os.environ['CREDS_DIR'] = args.output_directory - else: - print(f"Output Directory Name is empty {args.output_directory}") - sys.exit(0) - - create_stub_cred_files(args.output_directory) - ui(schema_json=schema_json, creds_dir=args.output_directory) + def write_creds_to_file(self, json_file_name, data): + creds_file = CREDS_DIR + json_file_name + if os.path.exists(creds_file) is False: + raise AssertionError(f"credential file {json_file_name} missing") + + with open(creds_file, 'r', encoding="utf-8") as f: + contents = json.loads(f.read()) + if not contents: + raise AssertionError(f"credential file {json_file_name} is invalid") + + contents['metadata']['connectorData'] = data + + with open(creds_file, 'w', encoding="utf-8") as f: + f.write(json.dumps(contents, indent=2)) + + def AWS(self): + parser = ArgumentParser(description='Add AWS credential') + parser.add_argument('-a', '--access-key', required=True, help='AWS Access Key') + parser.add_argument('-s', '--secret-access-key', required=True, help='AWS Secret Access Key') + args = parser.parse_args(sys.argv[3:]) + + if len(sys.argv[3:]) != 4: + parser.print_help() + sys.exit(0) + + if args.access_key is None or args.secret_access_key is None: + raise AssertionError('Access Key or Secret Access Key missing') + + d = {} + d['authentication'] = {} + d['authentication']['auth_type'] = "Access Key" + d['authentication']['access_key'] = args.access_key + d['authentication']['secret_access_key'] = args.secret_access_key + self.write_creds_to_file('awscreds.json', json.dumps(d)) + + def K8S(self): + parser = ArgumentParser(description='Add K8S credential') + parser.add_argument('-k', '--kubeconfig', required=True, help='Contents of the kubeconfig file') + args = parser.parse_args(sys.argv[3:]) + + if len(sys.argv[3:]) != 2: + parser.print_help() + sys.exit(0) + + d = {} + d['kubeconfig'] = args.kubeconfig + self.write_creds_to_file('k8screds.json', json.dumps(d)) + + def GCP(self): + parser = ArgumentParser(description='Add GCP credential') + parser.add_argument('-g', '--gcp-credentials', help='Contents of the GCP credentials json file') + args = parser.parse_args(sys.argv[3:]) + + if len(sys.argv[3:]) != 2: + parser.print_help() + sys.exit(0) + + d = {} + d['credentials'] = args.gcp_credentials + self.write_creds_to_file('gcpcreds.json', json.dumps(d)) + + def Elasticsearch(self): + parser = ArgumentParser(description='Add Elasticsearch credential') + parser.add_argument('-s', '--host', required=True, help=''' + Elasticsearch Node URL. For eg: https://localhost:9200. + NOTE: Please ensure that this is the Elastisearch URL and NOT Kibana URL. + ''') + parser.add_argument('-a', '--api-key', help='API key') + parser.add_argument('--no-verify-certs', action='store_true', help='Not verify server ssl certs. This can be set to true when working with private certs.') + args = parser.parse_args(sys.argv[3:]) + + if len(sys.argv[3:]) < 2: + parser.print_help() + sys.exit(0) + + d = {} + d['host'] = args.host + if args.api_key is not None: + d['api_key'] = args.api_key + + if args.no_verify_certs == True: + d['verify_certs'] = False + else: + d['verify_certs'] = True + + self.write_creds_to_file('escreds.json', json.dumps(d)) + + def Redis(self): + parser = ArgumentParser(description='Add Redis credential') + parser.add_argument('-s', '--host', required=True, help='Hostname of the redis server') + parser.add_argument('-p', '--port', help='Port on which redis server is listening', type=int, default=6379) + parser.add_argument('-u', '--username', help='Username') + parser.add_argument('-pa', '--password', help='Password') + parser.add_argument('-db', '--database', help='ID of the database to connect to', type=int) + parser.add_argument('--use-ssl', action='store_false', help='Use SSL to connect to redis host') + args = parser.parse_args(sys.argv[3:]) + + if len(sys.argv[3:]) < 2: + parser.print_help() + sys.exit(0) + + d = {} + d['host'] = args.host + d['port'] = args.port + if args.username is not None: + d['username'] = args.username + if args.password is not None: + d['password'] = args.password + if args.database is not None: + d['db'] = args.database + d['use_ssl'] = args.use_ssl + self.write_creds_to_file('rediscreds.json', json.dumps(d)) + + def PostGRES(self): + parser = ArgumentParser(description='Add POSTGRES credential') + parser.add_argument('-s', '--host', required=True, help='Hostname of the PostGRES server') + parser.add_argument('-p', '--port', help='Port on which PostGRES server is listening', type=int, default=5432) + parser.add_argument('-db', '--database-name', help='Name of the database to connect to', required=True) + parser.add_argument('-u', '--username', help='Username') + parser.add_argument('-pa', '--password', help='Password') + args = parser.parse_args(sys.argv[3:]) + + if len(sys.argv[3:]) < 4: + parser.print_help() + sys.exit(0) + + d = {} + d['Host'] = args.host + d['Port'] = args.port + d['DBName'] = args.database_name + if args.username is not None: + d['User'] = args.username + if args.password is not None: + d['Password'] = args.password + self.write_creds_to_file('postgrescreds.json', json.dumps(d)) + + def MongoDB(self): + parser = ArgumentParser(description='Add MongoDB credential') + parser.add_argument('-s', '--host', required=True, help='Full MongoDB URI, in addition to simple hostname. It also supports mongodb+srv:// URIs"') + parser.add_argument('-p', '--port', help='Port on which MongoDB server is listening', type=int, default=27017) + parser.add_argument('-u', '--username', help='Username') + parser.add_argument('-pa', '--password', help='Password') + args = parser.parse_args(sys.argv[3:]) + + if len(sys.argv[3:]) < 2: + parser.print_help() + sys.exit(0) + + d = {} + d['host'] = args.host + d['port'] = args.port + #TBD: Add support for atlas + d['authentication'] = {} + d['authentication']['auth_type'] = "Basic Auth" + if args.username is not None: + d['authentication']['user_name'] = args.username + if args.password is not None: + d['authentication']['password'] = args.password + + self.write_creds_to_file('mongodbcreds.json', json.dumps(d)) + + def Kafka(self): + parser = ArgumentParser(description='Add Kafka credential') + parser.add_argument('-b', '--broker', required=True, help=''' + host[:port] that the producer should contact to bootstrap initial cluster metadata. Default port is 9092. + ''') + parser.add_argument('-u', '--sasl-username', help='Username for SASL PlainText Authentication.') + parser.add_argument('-p', '--sasl-password', help='Password for SASL PlainText Authentication.') + parser.add_argument('-z', '--zookeeper', help='Zookeeper connection string. This is needed to do health checks. Eg: host[:port]. The default port is 2182') + args = parser.parse_args(sys.argv[3:]) + + if len(sys.argv[3:]) < 2: + parser.print_help() + sys.exit(0) + + d = {} + d['broker'] = args.broker + if args.sasl_username is not None: + d['sasl_username'] = args.sasl_username + + if args.sasl_password is not None: + d['sasl_password'] = args.sasl_password + + if args.zookeeper is not None: + d['zookeeper'] = args.zookeeper + + self.write_creds_to_file('kafkacreds.json', json.dumps(d)) if __name__ == '__main__': - main() + CredentialsAdd() From f81b4843c68a2d4ac46b9e950cb715e90627a3b3 Mon Sep 17 00:00:00 2001 From: Amit Chandak Date: Thu, 14 Sep 2023 21:35:26 -0700 Subject: [PATCH 07/11] Rename the file --- unskript-ctl/{creds_app.py => add_creds.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename unskript-ctl/{creds_app.py => add_creds.py} (100%) diff --git a/unskript-ctl/creds_app.py b/unskript-ctl/add_creds.py similarity index 100% rename from unskript-ctl/creds_app.py rename to unskript-ctl/add_creds.py From b6fbf559616c5d2796946c1cce8b12f87004353d Mon Sep 17 00:00:00 2001 From: Amit Chandak Date: Thu, 14 Sep 2023 21:36:26 -0700 Subject: [PATCH 08/11] Change the location --- unskript-ctl/add_creds.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/unskript-ctl/add_creds.py b/unskript-ctl/add_creds.py index a193b649c..841ce3cc5 100644 --- a/unskript-ctl/add_creds.py +++ b/unskript-ctl/add_creds.py @@ -900,8 +900,8 @@ def create_stub_cred_files(dirname: str): with open(f_name, 'w') as f: f.write(json.dumps(cred, indent=4)) -#CREDS_DIR = os.environ.get('HOME') + "/.local/share/jupyter/metadata/credential-save/" -CREDS_DIR = os.environ.get('HOME') + "/creds/" +CREDS_DIR = os.environ.get('HOME') + "/.local/share/jupyter/metadata/credential-save/" +#CREDS_DIR = os.environ.get('HOME') + "/creds/" class CredentialsAdd(): def __init__(self): From 01f1bc968c1459db1bd2ac4e9bad6479c0627ece Mon Sep 17 00:00:00 2001 From: Amit Chandak Date: Thu, 14 Sep 2023 21:43:16 -0700 Subject: [PATCH 09/11] fix pylint --- unskript-ctl/add_creds.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/unskript-ctl/add_creds.py b/unskript-ctl/add_creds.py index 841ce3cc5..35870d503 100644 --- a/unskript-ctl/add_creds.py +++ b/unskript-ctl/add_creds.py @@ -1014,7 +1014,7 @@ def Elasticsearch(self): if args.api_key is not None: d['api_key'] = args.api_key - if args.no_verify_certs == True: + if args.no_verify_certs is True: d['verify_certs'] = False else: d['verify_certs'] = True From 4df0d1c9766814f1f5700487585783f0dcac2ccd Mon Sep 17 00:00:00 2001 From: Amit Chandak Date: Thu, 14 Sep 2023 21:54:08 -0700 Subject: [PATCH 10/11] Fix the template --- build/templates/Dockerfile.template | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/build/templates/Dockerfile.template b/build/templates/Dockerfile.template index ba7e9a269..80da5adbf 100644 --- a/build/templates/Dockerfile.template +++ b/build/templates/Dockerfile.template @@ -1,8 +1,9 @@ -FROM unskript/awesome-runbooks:latest as base +FROM unskript/awesome-runbooks:latest as base COPY custom/actions/. /unskript/data/actions/ COPY custom/runbooks/. /unskript/data/runbooks/ -# The creds directory is the directory you mentioned while running the creds_app.py with the -o option -COPY /. /unskript/credentials/.local/share/jupyter/metadata/credential-save/ +# Copy the populate_credentials.sh file to ./ +COPY populate_credentials.sh . +RUN chmod +x populate_credentials.sh CMD ["./start.sh"] From 2fcb12956bd139afb983d49c23b9b711b4a9c035 Mon Sep 17 00:00:00 2001 From: Amit Chandak Date: Thu, 14 Sep 2023 22:06:52 -0700 Subject: [PATCH 11/11] add add_creds.sh --- unskript-ctl/add_creds.sh | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100755 unskript-ctl/add_creds.sh diff --git a/unskript-ctl/add_creds.sh b/unskript-ctl/add_creds.sh new file mode 100755 index 000000000..bdb2801ca --- /dev/null +++ b/unskript-ctl/add_creds.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +# Add creds script +# This script can be used to add credentials. + +cd /usr/local/bin +/usr/bin/env python ./add_creds.py "$@"