From 42cca1ad8d9072dc1bd84ef3a4b18b67057c3205 Mon Sep 17 00:00:00 2001 From: Bastien Bonnefoy Date: Wed, 3 Apr 2019 13:18:29 +0000 Subject: [PATCH] - All parameters are named - Add create_stack method - Add update_stack_acl method - Add get_stack_id method --- README.md | 120 +++++++++++++++- portainer_cli/__init__.py | 291 ++++++++++++++++++++++++++++++-------- 2 files changed, 346 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index 6a484b7..fc500c8 100644 --- a/README.md +++ b/README.md @@ -47,24 +47,47 @@ portainer-cli login username password portainer-cli login douglas d1234 ``` -### update_stack command +### create_stack command -Update stack. +Create a stack. ```bash -portainer-cli update_stack id endpoint_id [stack_file] [-env-file] +portainer-cli create_stack -n stack_name -e endpoint_id -sf stack_file ``` **E.g:** ```bash -portainer-cli update_stack 2 1 docker-compose.yml +portainer-cli create_stack -n stack_name -e 1 stack-test -sf docker-compose.yml +``` + +#### Flags + +| Flag | Description | +|--|--| +| `-n` or `-stack-name` | Stack name | +| `-e` or `-endpoint-id` | Endpoint id (required) | +| `-sf` or `-stack-file` |Stack file | +| `-ef` or `-env-file` | Pass env file path, usually `.env` | + +### update_stack command + +Update a stack. + +```bash +portainer-cli update_stack -s stack_id -e endpoint_id -sf stack_file +``` + +**E.g:** + +```bash +portainer-cli update_stack -s 18 -e 1 -sf docker-compose.yml ``` #### Environment variables arguments ```bash -portainer-cli update_stack id endpoint_id [stack_file] --env.var=value +portainer-cli update_stack id -s stack_id -e endpoint_id -sf stack_file --env.var=value ``` Where `var` is the environment variable name and `value` is the environment variable value. @@ -73,10 +96,95 @@ Where `var` is the environment variable name and `value` is the environment vari | Flag | Description | |--|--| -| `-env-file` | Pass env file path, usually `.env` | +| `-s` or `-stack-id` | Stack id | +| `-e` or `-endpoint-id` | Endpoint id (required) | +| `-sf` or `-stack-file` |Stack file | +| `-ef` or `-env-file` | Pass env file path, usually `.env` | | `-p` or `--prune` | Prune services | | `-c` or `--clear-env` | Clear all environment variables | +### create_or_update_stack command + +Create or update a stack based on it's name. + +```bash +portainer-cli create_or_update_stack -n stack_name -e endpoint_id -sf stack_file +``` + +**E.g:** + +```bash +portainer-cli update_stack -s 18 -e 1 -sf docker-compose.yml +``` + +#### Environment variables arguments + +```bash +portainer-cli create_or_update_stack -n stack_name -e endpoint_id -sf stack_file --env.var=value +``` + +Where `var` is the environment variable name and `value` is the environment variable value. + +#### Flags + +| Flag | Description | +|--|--| +| `-n` or `-stack-name` | Stack name | +| `-e` or `-endpoint-id` | Endpoint id (required) | +| `-sf` or `-stack-file` |Stack file | +| `-ef` or `-env-file` | Pass env file path, usually `.env` | +| `-p` or `--prune` | Prune services | +| `-c` or `--clear-env` | Clear all environment variables | + +### update_stack_acl command + +Update acl associated to a stack + +```bash +portainer-cli update_stack_acl -s stack_id -e endpoint_id -o ownership_type +``` + +Remark : you can either update by stack_id or stack_name (`-s` or `-n`) + +**E.g:** + +```bash +portainer-cli update_stack_acl -n stack-test -e 1 -o restricted -u user1,user2 -t team1,team2 +``` + +#### Flags + +| Flag | Description | +|--|--| +| `-s` or `-stack-id` | Stack id | +| `-n` or `-stack-name` | Stack name | +| `-e` or `-endpoint-id` | Endpoint id (required) | +| `-o` or `-ownership-type` | Ownership type (`admin`|`restricted`,`public`) (required) | +| `-u` or `-users` | Comma separated list of user names (when `restricted`) | +| `-t` or `-teams` | Comma separated list of team names (when `restricted`) | +| `-c` or `-clear` | Clear users and teams before updateing them (when `restricted`) | + +### get_stack_id command + +Get stack id by it's name. return -1 if the stack does not exist + +```bash +portainer-cli get_stack_id -n stack_name -e endpoint_id +``` + +**E.g:** + +```bash +portainer-cli get_stack_id -n stack-test -e 1 +``` + +#### Flags + +| Flag | Description | +|--|--| +| `-n` or `-stack-name` | Stack name | +| `-e` or `-endpoint-id` | Endpoint id (required) | + ### update_registry command Update registry. diff --git a/portainer_cli/__init__.py b/portainer_cli/__init__.py index f361578..92c6a60 100755 --- a/portainer_cli/__init__.py +++ b/portainer_cli/__init__.py @@ -29,19 +29,28 @@ class PortainerCLI(object): COMMAND_CONFIGURE = 'configure' COMMAND_LOGIN = 'login' COMMAND_REQUEST = 'request' + COMMAND_CREATE_STACK = 'create_stack' + COMMAND_UPDATE_STACK = 'update_stack' + COMMAND_UPDATE_STACK_ACL = 'update_stack_acl' COMMAND_CREATE_OR_UPDATE_STACK = 'create_or_update_stack' + COMMAND_GET_STACK_ID = 'get_stack_id' COMMAND_UPDATE_REGISTRY = 'update_registry' COMMANDS = [ COMMAND_CONFIGURE, COMMAND_LOGIN, COMMAND_REQUEST, + COMMAND_CREATE_STACK, + COMMAND_UPDATE_STACK, + COMMAND_UPDATE_STACK_ACL, COMMAND_CREATE_OR_UPDATE_STACK, + COMMAND_GET_STACK_ID, COMMAND_UPDATE_REGISTRY ] METHOD_GET = 'GET' METHOD_POST = 'POST' METHOD_PUT = 'PUT' + METHOD_DELETE = 'DELETE' local = False _base_url = 'http://localhost:9000/' @@ -143,42 +152,109 @@ class PortainerCLI(object): logger.info('logged with jwt: {}'.format(jwt)) self.jwt = jwt - def stack_exists(self, endpoint_id, stack_name): - stack_url = 'stacks'.format(endpoint_id) - result = self.request( - stack_url, - self.METHOD_GET - ).json() - if not result: - return -1 - else: - for stack in result: - if stack['Name'] == stack_name: - return stack['Id'] - return -1 + def get_users(self): + users_url = 'users' + return self.request(users_url, self.METHOD_GET).json() + + # retrieve users by their names + def get_users_by_name(self, names): + all_users = self.get_users() + if not all_users: + logger.debug('No users found') + return [] + users=[] + for name in names: + # searching for user + user = next(u for u in all_users if u['Username'] == name) + if not user: + logger.warn('User with name \'{}\' not found'.format(name)) + else: + logger.debug('User with name \'{}\' found'.format(name)) + users.append(user) + return users + + # retrieve users by their names + def get_users_by_name(self, names): + all_users = self.get_users() + all_users_by_name = dict(map( + lambda u: (u['Username'], u), + all_users, + )) + users = [] + for name in names: + user = all_users_by_name.get(name) + if not user: + logger.warn('User with name \'{}\' not found'.format(name)) + else: + logger.debug('User with name \'{}\' found'.format(name)) + users.append(user) + return users + + def get_teams(self): + teams_url = 'teams' + return self.request(teams_url, self.METHOD_GET).json() - def create_or_update_stack(self, endpoint_id, stack_file, stack_name, *args): - id = self.stack_exists(endpoint_id, stack_name) - if id == -1: - self.create_stack(endpoint_id, stack_file, stack_name) - else: - self.update_stack(id, endpoint_id, stack_file, *args) + # retrieve teams by their names + def get_teams_by_name(self, names): + all_teams = self.get_teams() + all_teams_by_name = dict(map( + lambda u: (u['Name'], u), + all_teams, + )) + teams = [] + for name in names: + team = all_teams_by_name.get(name) + if not team: + logger.warn('Team with name \'{}\' not found'.format(name)) + else: + logger.debug('Team with name \'{}\' found'.format(name)) + teams.append(team) + return teams - def create_stack(self, endpoint_id, stack_file, stack_name, env_file='', *args): - stack_url = 'stacks?type=1&method=string&endpointId={}'.format( - endpoint_id + def get_stacks(self): + stack_url = 'stacks' + return self.request(stack_url, self.METHOD_GET).json() + + def get_stack_by_id(self, stack_id, endpoint_id): + stack_url = 'stacks/{}?endpointId={}'.format( + stack_id, + endpoint_id, ) - swarm_url = 'endpoints/{}/docker/swarm'.format(endpoint_id) - swarm_id = self.request(swarm_url, self.METHOD_GET).json().get('ID') - self.swarm_id = swarm_id - stack_file_content = open(stack_file).read() + stack = self.request(stack_url).json() + if not stack: + raise Exception('Stack with id={} does not exist'.format(stack_id)) + return stack + + def get_stack_by_name(self, stack_name, endpoint_id, mandatory=False): + result = self.get_stacks() + if result: + for stack in result: + if stack['Name'] == stack_name and stack['EndpointId'] == endpoint_id: + return stack + if mandatory: + raise Exception('Stack with name={} and endpoint_id={} does not exist'.format(stack_name, endpoint_id)) + else: + return None + + # Retrieve the stack if. -1 if the stack does not exist + @plac.annotations( + stack_name=('Stack name', 'option', 'n'), + endpoint_id=('Endpoint id', 'option', 'e', int) + ) + def get_stack_id(self, stack_name, endpoint_id): + stack = self.get_stack_by_name(stack_name, endpoint_id) + if not stack: + logger.debug('Stack with name={} does not exist'.format(stack_name)) + return -1 + logger.debug('Stack with name={} -> id={}'.format(stack_name, stack['Id'])) + return stack['Id'] + + def extract_env(self, env_file='', *args): if env_file: env = {} for env_line in open(env_file).readlines(): env_line = env_line.strip() - if not env_line \ - or env_line.startswith('#') \ - or '=' not in env_line: + if not env_line or env_line.startswith('#') or '=' not in env_line: continue k, v = env_line.split('=', 1) k, v = k.strip(), v.strip() @@ -188,10 +264,45 @@ class PortainerCLI(object): lambda x: re.match(env_arg_regex, x), args, ) - env = dict(map( - lambda x: env_arg_to_dict(x), - env_args, - )) + env = dict(map( + lambda x: env_arg_to_dict(x), + env_args, + )) + return env + + @plac.annotations( + stack_name=('Stack name', 'option', 'n', str), + endpoint_id=('Endpoint id', 'option', 'e', int), + stack_file=('Stack file', 'option', 'sf'), + env_file=('Environment Variable file', 'option'), + prune=('Prune services', 'flag', 'p'), + clear_env=('Clear all env vars', 'flag', 'c'), + ) + def create_or_update_stack(self, stack_name, endpoint_id, stack_file='', env_file='', prune=False, clear_env=False, *args): + logger.debug('create_or_update_stack') + stack_id = self.get_stack_id(stack_name, endpoint_id) + if stack_id == -1: + self.create_stack(stack_name, endpoint_id, stack_file, env_file, *args) + else: + self.update_stack(stack_id, endpoint_id, stack_file, env_file, prune, clear_env, *args) + + @plac.annotations( + stack_name=('Stack name', 'option', 'n'), + endpoint_id=('Endpoint id', 'option', 'e', int), + stack_file=('Environment Variable file', 'option', 'sf'), + env_file=('Environment Variable file', 'option', 'ef') + ) + def create_stack(self, stack_name, endpoint_id, stack_file='', env_file='', *args): + logger.info('Creating stack name={}'.format(stack_name)) + stack_url = 'stacks?type=1&method=string&endpointId={}'.format( + endpoint_id + ) + swarm_url = 'endpoints/{}/docker/swarm'.format(endpoint_id) + swarm_id = self.request(swarm_url, self.METHOD_GET).json().get('ID') + self.swarm_id = swarm_id + stack_file_content = open(stack_file).read() + + env = self.extract_env(env_file, *args) final_env = list( map( lambda x: {'name': x[0], 'value': x[1]}, @@ -210,49 +321,103 @@ class PortainerCLI(object): self.METHOD_POST, data, ) + + def create_or_update_resource_control(self, stack, public, users, teams): + resource_control = stack['ResourceControl'] + if resource_control and resource_control['Id'] != 0: + resource_path = 'resource_controls/{}'.format(resource_control['Id']) + data = { + 'Public': public, + 'Users': users, + 'Teams': teams + } + logger.debug('Updating stack acl {} for stack {}: {}'.format(resource_control['Id'], stack['Id'], data)) + self.request(resource_path, self.METHOD_PUT, data) + else: + resource_path = 'resource_controls' + data = { + 'Type': 'stack', + 'ResourceID': stack['Name'], + 'Public': public, + 'Users': users, + 'Teams': teams + } + logger.debug('Creating stack acl for stack {}: {}'.format(stack['Id'], data)) + self.request(resource_path, self.METHOD_POST, data) + @plac.annotations( - env_file=('Environment Variable file', 'option'), + stack_id=('Stack id', 'option', 's', int), + stack_name=('Stack name', 'option', 'n', str), + endpoint_id=('Endpoint id', 'option', 'e', int), + ownership_type=('Ownership type', 'option', 'o', str, ['admin', 'restricted', 'public']), + users=('Allowed usernames (comma separated - restricted ownership_type only)', 'option', 'u'), + teams=('Allowed teams (comma separated - restricted ownership_type only)', 'option', 't'), + clear=('Clear acl (restricted ownership_type only)', 'flag', 'c') + ) + def update_stack_acl(self, stack_id, stack_name, endpoint_id, ownership_type, users, teams, clear=False): + stack = None + if stack_id: + stack = self.get_stack_by_id(stack_id, endpoint_id) + elif stack_name: + stack = self.get_stack_by_name(stack_name, endpoint_id, True) + else: + raise Exception('Please provide either stack_name or stack_id') + + logger.info('Updating acl of stack name={} - type={}'.format(stack['Name'], ownership_type)) + + resource_control = stack['ResourceControl'] + + if ownership_type == 'admin': + if resource_control and resource_control['Id'] != 0: + logger.debug('Deleting resource control with id {}'.format(resource_control['Id'])) + resource_path = 'resource_controls/{}'.format(resource_control['Id']) + logger.debug('resource_path : {}'.format(resource_path)) + self.request(resource_path, self.METHOD_DELETE) + else: + logger.debug('Nothing to do') + elif ownership_type == 'public': + self.create_or_update_resource_control(stack, True, [], []) + elif ownership_type == 'restricted': + users = map(lambda u: u['Id'], self.get_users_by_name(users.split(','))) + teams = map(lambda t: t['Id'], self.get_teams_by_name(teams.split(','))) + + if (not clear) and resource_control: + logger.debug('Merging existing users / teams') + users = list(set().union(users, map(lambda u: u['UserId'], resource_control['UserAccesses']))) + teams = list(set().union(teams, map(lambda t: t['TeamId'], resource_control['TeamAccesses']))) + + self.create_or_update_resource_control(stack, False, users, teams) + + @plac.annotations( + stack_id=('Stack id', 'option', 's', int), + endpoint_id=('Endpoint id', 'option', 'e', int), + stack_file=('Stack file', 'option', 'sf'), + env_file=('Environment Variable file', 'option', 'ef'), prune=('Prune services', 'flag', 'p'), clear_env=('Clear all env vars', 'flag', 'c'), ) - def update_stack(self, id, endpoint_id, stack_file='', env_file='', + def update_stack(self, stack_id, endpoint_id, stack_file='', env_file='', prune=False, clear_env=False, *args): + logger.info('Updating stack id={}'.format(stack_id)) stack_url = 'stacks/{}?endpointId={}'.format( - id, + stack_id, endpoint_id, ) - current = self.request(stack_url).json() + current = self.get_stack_by_id(stack_id, endpoint_id) stack_file_content = '' if stack_file: stack_file_content = open(stack_file).read() else: stack_file_content = self.request( 'stacks/{}/file?endpointId={}'.format( - id, + stack_id, endpoint_id, ) ).json().get('StackFileContent') - if env_file: - env = {} - for env_line in open(env_file).readlines(): - env_line = env_line.strip() - if not env_line \ - or env_line.startswith('#') \ - or '=' not in env_line: - continue - k, v = env_line.split('=', 1) - k, v = k.strip(), v.strip() - env[k] = v - else: - env_args = filter( - lambda x: re.match(env_arg_regex, x), - args, - ) - env = dict(map( - lambda x: env_arg_to_dict(x), - env_args, - )) + + env = self.extract_env(env_file, *args) + if not clear_env: current_env = dict( map( @@ -269,7 +434,7 @@ class PortainerCLI(object): ), ) data = { - 'Id': id, + 'Id': stack_id, 'StackFileContent': stack_file_content, 'Prune': prune, 'Env': final_env if len(final_env) > 0 else current.get('Env'), @@ -355,8 +520,16 @@ class PortainerCLI(object): plac.call(self.configure, args) elif command == self.COMMAND_LOGIN: plac.call(self.login, args) + elif command == self.COMMAND_CREATE_STACK: + plac.call(self.create_stack, args) + elif command == self.COMMAND_UPDATE_STACK: + plac.call(self.update_stack, args) + elif command == self.COMMAND_UPDATE_STACK_ACL: + plac.call(self.update_stack_acl, args) elif command == self.COMMAND_CREATE_OR_UPDATE_STACK: plac.call(self.create_or_update_stack, args) + elif command == self.COMMAND_GET_STACK_ID: + plac.call(self.get_stack_id, args) elif command == self.COMMAND_UPDATE_REGISTRY: plac.call(self.update_registry, args) elif command == self.COMMAND_REQUEST: