From 7b2b569ab016c237cb39542f70ab12c10b5c506a Mon Sep 17 00:00:00 2001 From: Joss Whittle Date: Thu, 21 Mar 2024 03:25:54 +0000 Subject: [PATCH] feat: ssh kubevirt sync demo (#9) --- README.md | 12 + .../crds/guacamole-connection.yaml | 32 +- .../controller/controller-deployment.yaml | 1 + .../templates/web/web-deployment.yaml | 6 + .../controller/api/connections/__init__.py | 0 .../src/controller/api/connections/create.py | 146 ++++++++ .../src/controller/api/connections/delete.py | 38 ++ .../src/controller/api/connections/get.py | 78 +++++ .../src/controller/api/connections/list.py | 41 +++ .../src/controller/api/connections/update.py | 62 ++++ .../controller/src/controller/api/error.py | 4 + .../src/controller/api/users/__init__.py | 0 .../api/{create_user.py => users/create.py} | 2 +- .../api/users/create_user_connection.py | 47 +++ .../api/{get_user.py => users/delete.py} | 23 +- .../api/users/delete_user_connection.py | 47 +++ .../src/controller/api/users/get.py | 79 +++++ .../src/controller/api/users/list.py | 41 +++ .../api/{update_user.py => users/update.py} | 4 +- .../controller/src/controller/api/wrapper.py | 330 ++++++++++++++++-- .../src/controller/kube/iter_objects.py | 3 +- .../sync/get_connections_by_manifest.py | 21 -- .../src/controller/sync/get_unique_users.py | 6 +- .../controller/sync/get_users_by_manifest.py | 26 +- .../controller/src/controller/sync/sync.py | 34 +- .../src/controller/sync/sync_connections.py | 58 +++ .../src/controller/sync/sync_users.py | 28 +- 27 files changed, 1043 insertions(+), 126 deletions(-) create mode 100644 containers/controller/src/controller/api/connections/__init__.py create mode 100644 containers/controller/src/controller/api/connections/create.py create mode 100644 containers/controller/src/controller/api/connections/delete.py create mode 100644 containers/controller/src/controller/api/connections/get.py create mode 100644 containers/controller/src/controller/api/connections/list.py create mode 100644 containers/controller/src/controller/api/connections/update.py create mode 100644 containers/controller/src/controller/api/users/__init__.py rename containers/controller/src/controller/api/{create_user.py => users/create.py} (98%) create mode 100644 containers/controller/src/controller/api/users/create_user_connection.py rename containers/controller/src/controller/api/{get_user.py => users/delete.py} (66%) create mode 100644 containers/controller/src/controller/api/users/delete_user_connection.py create mode 100644 containers/controller/src/controller/api/users/get.py create mode 100644 containers/controller/src/controller/api/users/list.py rename containers/controller/src/controller/api/{update_user.py => users/update.py} (94%) delete mode 100644 containers/controller/src/controller/sync/get_connections_by_manifest.py create mode 100644 containers/controller/src/controller/sync/sync_connections.py diff --git a/README.md b/README.md index 337e247..4807b96 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,18 @@ database: kubectl apply -f https://raw.githubusercontent.com/cloudnative-pg/cloudnative-pg/release-1.22/releases/cnpg-1.22.1.yaml ``` +### Install Kubevirt CRDs +```bash +kubectl create -f https://github.com/kubevirt/kubevirt/releases/download/v1.2.0/kubevirt-cr.yaml +``` + +### Install Kubevirt Operator +```bash +kubectl create -f https://github.com/kubevirt/kubevirt/releases/download/v1.2.0/kubevirt-operator.yaml + +kubectl -n kubevirt patch kubevirt kubevirt --type=merge --patch '{"spec":{"configuration":{"developerConfiguration":{"useEmulation":true}}}}' +``` + ### Build the Guacamole Controller container Tagged as controller:0.0.0 so that docker-for-windows kubernetes can access it without trying to pull it. diff --git a/charts/guacamole-crds/crds/guacamole-connection.yaml b/charts/guacamole-crds/crds/guacamole-connection.yaml index da96b8f..8437e0e 100644 --- a/charts/guacamole-crds/crds/guacamole-connection.yaml +++ b/charts/guacamole-crds/crds/guacamole-connection.yaml @@ -16,33 +16,15 @@ spec: spec: type: object properties: - url: + hostname: type: string description: Url to the host. - - rdp: - type: object - properties: - enabled: - type: boolean - description: Expose an RDP type connection to the host. - default: false - port: - type: integer - description: Port number for RDP connections to the host. - default: 3389 - - ssh: - type: object - properties: - enabled: - type: boolean - description: Expose an SHH type connection to the host. - default: false - port: - type: integer - description: Port number for SHH connections to the host. - default: 22 + port: + type: integer + description: Port number for the host. + protocol: + type: string + description: Protocol for the connection. ldap: type: object diff --git a/charts/guacamole/templates/controller/controller-deployment.yaml b/charts/guacamole/templates/controller/controller-deployment.yaml index 5d87383..9ef8b53 100644 --- a/charts/guacamole/templates/controller/controller-deployment.yaml +++ b/charts/guacamole/templates/controller/controller-deployment.yaml @@ -55,6 +55,7 @@ spec: {{- end }} spec: + terminationGracePeriodSeconds: 0 restartPolicy: Always serviceAccountName: {{ include "guacamole.fullname" . }}-controller diff --git a/charts/guacamole/templates/web/web-deployment.yaml b/charts/guacamole/templates/web/web-deployment.yaml index 0d070a9..1694cf7 100644 --- a/charts/guacamole/templates/web/web-deployment.yaml +++ b/charts/guacamole/templates/web/web-deployment.yaml @@ -94,6 +94,12 @@ spec: {{- end }} env: + - name: LOGBACK_LEVEL + value: debug + - name: GUACAMOLE_LOG_LEVEL + value: "debug" + - name: LOG_LEVEL + value: debug - name: WEBAPP_CONTEXT value: ROOT - name: GUACD_HOSTNAME diff --git a/containers/controller/src/controller/api/connections/__init__.py b/containers/controller/src/controller/api/connections/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/containers/controller/src/controller/api/connections/create.py b/containers/controller/src/controller/api/connections/create.py new file mode 100644 index 0000000..4ffa149 --- /dev/null +++ b/containers/controller/src/controller/api/connections/create.py @@ -0,0 +1,146 @@ +# { +# "parentIdentifier": "ROOT", +# "name": "test", +# "protocol": "rdp", +# "parameters": { +# "port": "", +# "read-only": "", +# "swap-red-blue": "", +# "cursor": "", +# "color-depth": "", +# "clipboard-encoding": "", +# "disable-copy": "", +# "disable-paste": "", +# "dest-port": "", +# "recording-exclude-output": "", +# "recording-exclude-mouse": "", +# "recording-include-keys": "", +# "create-recording-path": "", +# "enable-sftp": "", +# "sftp-port": "", +# "sftp-server-alive-interval": "", +# "enable-audio": "", +# "security": "", +# "disable-auth": "", +# "ignore-cert": "", +# "gateway-port": "", +# "server-layout": "", +# "timezone": "", +# "console": "", +# "width": "", +# "height": "", +# "dpi": "", +# "resize-method": "", +# "console-audio": "", +# "disable-audio": "", +# "enable-audio-input": "", +# "enable-printing": "", +# "enable-drive": "", +# "create-drive-path": "", +# "enable-wallpaper": "", +# "enable-theming": "", +# "enable-font-smoothing": "", +# "enable-full-window-drag": "", +# "enable-desktop-composition": "", +# "enable-menu-animations": "", +# "disable-bitmap-caching": "", +# "disable-offscreen-caching": "", +# "disable-glyph-caching": "", +# "preconnection-id": "", +# "hostname": "", +# "username": "", +# "password": "", +# "domain": "", +# "gateway-hostname": "", +# "gateway-username": "", +# "gateway-password": "", +# "gateway-domain": "", +# "initial-program": "", +# "client-name": "", +# "printer-name": "", +# "drive-name": "", +# "drive-path": "", +# "static-channels": "", +# "remote-app": "", +# "remote-app-dir": "", +# "remote-app-args": "", +# "preconnection-blob": "", +# "load-balance-info": "", +# "recording-path": "", +# "recording-name": "", +# "sftp-hostname": "", +# "sftp-host-key": "", +# "sftp-username": "", +# "sftp-password": "", +# "sftp-private-key": "", +# "sftp-passphrase": "", +# "sftp-root-directory": "", +# "sftp-directory": "" +# }, +# "attributes": { +# "max-connections": "", +# "max-connections-per-user": "", +# "weight": "", +# "failover-only": "", +# "guacd-port": "", +# "guacd-encryption": "", +# "guacd-hostname": "" +# } +# } + +import json +import logging +from urllib.parse import quote + +import requests + +from ..build_url import build_url + + +def api_create_connection( + hostname: str, + port: int, + token: str, + data_source: str, + conn_name: str, + conn_protocol: str, + conn_parent: str, + conn_hostname: str, + conn_port: int +) -> dict: + + logging.debug(f"Creating connection {conn_name=}") + response = requests.post( + build_url( + scheme="http", + netloc=f"{hostname}:{port}", + path=f"/api/session/data/{quote(data_source)}/connections", + query=dict( + token=token + ) + ), + data=json.dumps(dict( + parentIdentifier=conn_parent, + name=conn_name, + protocol=conn_protocol, + parameters={ + "hostname": conn_hostname, + "port": str(conn_port) + }, + attributes={ + + } + )), + verify=False, + timeout=30, + headers={"Content-Type": "application/json"} + ) + + if response.status_code not in (200,): + ex = RuntimeError(("Bad status code!", response.status_code, response.text)) + logging.exception("Bad status code!", exc_info=ex) + raise ex + + response = json.loads(response.text) + logging.debug(f"{response=}") + return response diff --git a/containers/controller/src/controller/api/connections/delete.py b/containers/controller/src/controller/api/connections/delete.py new file mode 100644 index 0000000..807e622 --- /dev/null +++ b/containers/controller/src/controller/api/connections/delete.py @@ -0,0 +1,38 @@ +import logging +from urllib.parse import quote + +import requests + +from ..build_url import build_url +from ..error import APIConnectionDoesNotExistError + + +def api_delete_connection( + hostname: str, + port: int, + token: str, + data_source: str, + conn_id: int +): + + logging.debug(f"Delete connection {conn_id=}") + response = requests.delete( + build_url( + scheme="http", + netloc=f"{hostname}:{port}", + + path=f"/api/session/data/{quote(data_source)}/connections/{quote(str(conn_id))}", + query=dict( + token=token + ) + ), + verify=False, + timeout=30, + headers={"Content-Type": "application/json"} + ) + + if response.status_code not in (204,): + # TODO this exception catches too much + ex = APIConnectionDoesNotExistError(("Bad status code!", response.status_code, response.text)) + logging.exception("Bad status code!", exc_info=ex) + raise ex diff --git a/containers/controller/src/controller/api/connections/get.py b/containers/controller/src/controller/api/connections/get.py new file mode 100644 index 0000000..358bead --- /dev/null +++ b/containers/controller/src/controller/api/connections/get.py @@ -0,0 +1,78 @@ + +import json +import logging +from urllib.parse import quote + +import requests + +from ..build_url import build_url +from ..error import APIConnectionDoesNotExistError + + +def api_get_connection( + hostname: str, + port: int, + token: str, + data_source: str, + conn_id: int +) -> dict: + + logging.debug(f"Get connection {conn_id=}") + response = requests.get( + build_url( + scheme="http", + netloc=f"{hostname}:{port}", + + path=f"/api/session/data/{quote(data_source)}/connections/{quote(str(conn_id))}", + query=dict( + token=token + ) + ), + verify=False, + timeout=30, + headers={"Content-Type": "application/json"} + ) + + if response.status_code not in (200,): + # TODO this exception catches too much + ex = APIConnectionDoesNotExistError(("Bad status code!", response.status_code, response.text)) + logging.exception("Bad status code!", exc_info=ex) + raise ex + + response = json.loads(response.text) + logging.debug(f"{response=}") + return response + + +def api_get_connection_parameters( + hostname: str, + port: int, + token: str, + data_source: str, + conn_id: int +) -> dict: + + logging.debug(f"Get connection parameters {conn_id=}") + response = requests.get( + build_url( + scheme="http", + netloc=f"{hostname}:{port}", + path=f"/api/session/data/{quote(data_source)}/connections/{quote(str(conn_id))}/parameters", + query=dict( + token=token + ) + ), + verify=False, + timeout=30, + headers={"Content-Type": "application/json"} + ) + + if response.status_code not in (200,): + # TODO this exception catches too much + ex = APIConnectionDoesNotExistError(("Bad status code!", response.status_code, response.text)) + logging.exception("Bad status code!", exc_info=ex) + raise ex + + response = json.loads(response.text) + logging.debug(f"{response=}") + return response diff --git a/containers/controller/src/controller/api/connections/list.py b/containers/controller/src/controller/api/connections/list.py new file mode 100644 index 0000000..994e3d0 --- /dev/null +++ b/containers/controller/src/controller/api/connections/list.py @@ -0,0 +1,41 @@ + +import json +import logging +from urllib.parse import quote + +import requests + +from ..build_url import build_url + + +def api_list_connections( + hostname: str, + port: int, + token: str, + data_source: str +) -> dict: + + logging.debug("List connections") + response = requests.get( + build_url( + scheme="http", + netloc=f"{hostname}:{port}", + + path=f"/api/session/data/{quote(data_source)}/connections", + query=dict( + token=token + ) + ), + verify=False, + timeout=30, + headers={"Content-Type": "application/json"} + ) + + if response.status_code not in (200,): + ex = RuntimeError(("Bad status code!", response.status_code, response.text)) + logging.exception("Bad status code!", exc_info=ex) + raise ex + + response = json.loads(response.text) + logging.debug(f"{response=}") + return response diff --git a/containers/controller/src/controller/api/connections/update.py b/containers/controller/src/controller/api/connections/update.py new file mode 100644 index 0000000..577e91b --- /dev/null +++ b/containers/controller/src/controller/api/connections/update.py @@ -0,0 +1,62 @@ + +import json +import logging +from urllib.parse import quote + +import requests + +from ..build_url import build_url +from ..error import APIConnectionDoesNotExistError + + +def api_update_connection( + hostname: str, + port: int, + token: str, + data_source: str, + conn_id: int, + conn_name: str, + conn_protocol: str, + conn_parent: str, + conn_hostname: str, + conn_port: int +): + + logging.debug(f"Updating connection {conn_name=} {conn_id=}") + response = requests.put( + build_url( + scheme="http", + netloc=f"{hostname}:{port}", + path=f"/api/session/data/{quote(data_source)}/connections/{quote(str(conn_id))}", + query=dict( + token=token + ) + ), + data=json.dumps(dict( + identifier=str(conn_id), + name=conn_name, + parentIdentifier=conn_parent, + protocol=conn_protocol, + parameters={ + "hostname": conn_hostname, + "port": str(conn_port) + }, + attributes={ + + } + )), + verify=False, + timeout=30, + headers={"Content-Type": "application/json"} + ) + + if response.status_code in (500,): + ex = RuntimeError(("Bad status code!", response.status_code, response.text)) + logging.exception("Bad status code!", exc_info=ex) + raise ex + + if response.status_code not in (204,): + # TODO this exception catches too much + ex = APIConnectionDoesNotExistError(("Bad status code!", response.status_code, response.text)) + logging.exception("Bad status code!", exc_info=ex) + raise ex diff --git a/containers/controller/src/controller/api/error.py b/containers/controller/src/controller/api/error.py index 15d539a..24a75a3 100644 --- a/containers/controller/src/controller/api/error.py +++ b/containers/controller/src/controller/api/error.py @@ -1,3 +1,7 @@ class APIUserDoesNotExistError(RuntimeError): pass + + +class APIConnectionDoesNotExistError(RuntimeError): + pass diff --git a/containers/controller/src/controller/api/users/__init__.py b/containers/controller/src/controller/api/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/containers/controller/src/controller/api/create_user.py b/containers/controller/src/controller/api/users/create.py similarity index 98% rename from containers/controller/src/controller/api/create_user.py rename to containers/controller/src/controller/api/users/create.py index 1b83e7c..5d25e25 100644 --- a/containers/controller/src/controller/api/create_user.py +++ b/containers/controller/src/controller/api/users/create.py @@ -5,7 +5,7 @@ import requests -from .build_url import build_url +from ..build_url import build_url def api_create_user( diff --git a/containers/controller/src/controller/api/users/create_user_connection.py b/containers/controller/src/controller/api/users/create_user_connection.py new file mode 100644 index 0000000..40140d0 --- /dev/null +++ b/containers/controller/src/controller/api/users/create_user_connection.py @@ -0,0 +1,47 @@ + +import json +import logging +from urllib.parse import quote + +import requests + +from ..build_url import build_url +from ..error import APIUserDoesNotExistError + + +def api_create_user_connection( + hostname: str, + port: int, + token: str, + data_source: str, + username: str, + conn_id: int +): + + logging.debug(f"Creating user connection {username=} {conn_id=}") + response = requests.patch( + build_url( + scheme="http", + netloc=f"{hostname}:{port}", + path=f"/api/session/data/{quote(data_source)}/users/{quote(username)}/permissions", + query=dict( + token=token + ) + ), + data=json.dumps([ + dict( + op="add", + path=f"/connectionPermissions/{quote(conn_id)}", + value="READ" + ) + ]), + verify=False, + timeout=30, + headers={"Content-Type": "application/json"} + ) + + if response.status_code not in (204,): + # TODO this exception catches too much + ex = APIUserDoesNotExistError(("Bad status code!", response.status_code, response.text)) + logging.exception("Bad status code!", exc_info=ex) + raise ex diff --git a/containers/controller/src/controller/api/get_user.py b/containers/controller/src/controller/api/users/delete.py similarity index 66% rename from containers/controller/src/controller/api/get_user.py rename to containers/controller/src/controller/api/users/delete.py index f37e656..275ea8b 100644 --- a/containers/controller/src/controller/api/get_user.py +++ b/containers/controller/src/controller/api/users/delete.py @@ -1,28 +1,25 @@ - -import json import logging from urllib.parse import quote import requests -from .build_url import build_url -from .error import APIUserDoesNotExistError +from ..build_url import build_url +from ..error import APIUserDoesNotExistError -def api_get_user( +def api_delete_user( hostname: str, port: int, token: str, data_source: str, - username: str, -) -> dict: + username: str +): - logging.debug(f"Get user {username=}") - response = requests.get( + logging.debug(f"Delete user {username=}") + response = requests.delete( build_url( scheme="http", netloc=f"{hostname}:{port}", - path=f"/api/session/data/{quote(data_source)}/users/{quote(username)}", query=dict( token=token @@ -33,12 +30,8 @@ def api_get_user( headers={"Content-Type": "application/json"} ) - if response.status_code not in (200,): + if response.status_code not in (204,): # TODO this exception catches too much ex = APIUserDoesNotExistError(("Bad status code!", response.status_code, response.text)) logging.exception("Bad status code!", exc_info=ex) raise ex - - response = json.loads(response.text) - logging.debug(f"{response=}") - return response diff --git a/containers/controller/src/controller/api/users/delete_user_connection.py b/containers/controller/src/controller/api/users/delete_user_connection.py new file mode 100644 index 0000000..8752d59 --- /dev/null +++ b/containers/controller/src/controller/api/users/delete_user_connection.py @@ -0,0 +1,47 @@ + +import json +import logging +from urllib.parse import quote + +import requests + +from ..build_url import build_url +from ..error import APIUserDoesNotExistError + + +def api_delete_user_connection( + hostname: str, + port: int, + token: str, + data_source: str, + username: str, + conn_id: int +): + + logging.debug(f"Removing user from connection {username=} {conn_id=}") + response = requests.patch( + build_url( + scheme="http", + netloc=f"{hostname}:{port}", + path=f"/api/session/data/{quote(data_source)}/users/{quote(username)}/permissions", + query=dict( + token=token + ) + ), + data=json.dumps([ + dict( + op="remove", + path=f"/connectionPermissions/{quote(conn_id)}", + value="READ" + ) + ]), + verify=False, + timeout=30, + headers={"Content-Type": "application/json"} + ) + + if response.status_code not in (204,): + # TODO this exception catches too much + ex = APIUserDoesNotExistError(("Bad status code!", response.status_code, response.text)) + logging.exception("Bad status code!", exc_info=ex) + raise ex diff --git a/containers/controller/src/controller/api/users/get.py b/containers/controller/src/controller/api/users/get.py new file mode 100644 index 0000000..39eb7e1 --- /dev/null +++ b/containers/controller/src/controller/api/users/get.py @@ -0,0 +1,79 @@ + +import json +import logging +from urllib.parse import quote + +import requests + +from ..build_url import build_url +from ..error import APIUserDoesNotExistError + + +def api_get_user( + hostname: str, + port: int, + token: str, + data_source: str, + username: str, +) -> dict: + + logging.debug(f"Get user {username=}") + response = requests.get( + build_url( + scheme="http", + netloc=f"{hostname}:{port}", + + path=f"/api/session/data/{quote(data_source)}/users/{quote(username)}", + query=dict( + token=token + ) + ), + verify=False, + timeout=30, + headers={"Content-Type": "application/json"} + ) + + if response.status_code not in (200,): + # TODO this exception catches too much + ex = APIUserDoesNotExistError(("Bad status code!", response.status_code, response.text)) + logging.exception("Bad status code!", exc_info=ex) + raise ex + + response = json.loads(response.text) + logging.debug(f"{response=}") + return response + + +def api_get_user_effective_permissions( + hostname: str, + port: int, + token: str, + data_source: str, + username: str, +) -> dict: + + logging.debug(f"Get user effective permissions {username=}") + response = requests.get( + build_url( + scheme="http", + netloc=f"{hostname}:{port}", + + path=f"/api/session/data/{quote(data_source)}/users/{quote(username)}/effectivePermissions", + query=dict( + token=token + ) + ), + verify=False, + timeout=30, + headers={"Content-Type": "application/json"} + ) + + if response.status_code not in (200,): + # TODO this exception catches too much + ex = APIUserDoesNotExistError(("Bad status code!", response.status_code, response.text)) + logging.exception("Bad status code!", exc_info=ex) + raise ex + + response = json.loads(response.text) + logging.debug(f"{response=}") + return response diff --git a/containers/controller/src/controller/api/users/list.py b/containers/controller/src/controller/api/users/list.py new file mode 100644 index 0000000..3229273 --- /dev/null +++ b/containers/controller/src/controller/api/users/list.py @@ -0,0 +1,41 @@ + +import json +import logging +from urllib.parse import quote + +import requests + +from ..build_url import build_url + + +def api_list_users( + hostname: str, + port: int, + token: str, + data_source: str +) -> dict: + + logging.debug("List users") + response = requests.get( + build_url( + scheme="http", + netloc=f"{hostname}:{port}", + + path=f"/api/session/data/{quote(data_source)}/users", + query=dict( + token=token + ) + ), + verify=False, + timeout=30, + headers={"Content-Type": "application/json"} + ) + + if response.status_code not in (200,): + ex = RuntimeError(("Bad status code!", response.status_code, response.text)) + logging.exception("Bad status code!", exc_info=ex) + raise ex + + response = json.loads(response.text) + logging.debug(f"{response=}") + return response diff --git a/containers/controller/src/controller/api/update_user.py b/containers/controller/src/controller/api/users/update.py similarity index 94% rename from containers/controller/src/controller/api/update_user.py rename to containers/controller/src/controller/api/users/update.py index 9cfc81a..a47388a 100644 --- a/containers/controller/src/controller/api/update_user.py +++ b/containers/controller/src/controller/api/users/update.py @@ -5,8 +5,8 @@ import requests -from .build_url import build_url -from .error import APIUserDoesNotExistError +from ..build_url import build_url +from ..error import APIUserDoesNotExistError def api_update_user( diff --git a/containers/controller/src/controller/api/wrapper.py b/containers/controller/src/controller/api/wrapper.py index 0e90ef5..5c4f03b 100644 --- a/containers/controller/src/controller/api/wrapper.py +++ b/containers/controller/src/controller/api/wrapper.py @@ -1,10 +1,19 @@ import logging +from .error import APIConnectionDoesNotExistError, APIUserDoesNotExistError from .authenticate_user import api_authenticate_user -from .error import APIUserDoesNotExistError -from .get_user import api_get_user -from .create_user import api_create_user -from .update_user import api_update_user +from .connections.create import api_create_connection +from .connections.delete import api_delete_connection +from .connections.get import api_get_connection, api_get_connection_parameters +from .connections.list import api_list_connections +from .connections.update import api_update_connection +from .users.create_user_connection import api_create_user_connection +from .users.delete_user_connection import api_delete_user_connection +from .users.get import api_get_user, api_get_user_effective_permissions +from .users.create import api_create_user +from .users.delete import api_delete_user +from .users.list import api_list_users +from .users.update import api_update_user class API: @@ -38,6 +47,85 @@ def __init__( password=password ) + def list_users(self): + # logging.info(f"List users") + return api_list_users( + hostname=self.hostname, + port=self.port, + token=self.token, + data_source=self.data_source + ) + + def get_user(self, username: str): + # logging.info(f"Get user {username=}") + return api_get_user( + hostname=self.hostname, + port=self.port, + token=self.token, + data_source=self.data_source, + username=username + ) + + def get_user_effective_permissions(self, username: str): + # logging.info(f"Get user effective permissions {username=}") + return api_get_user_effective_permissions( + hostname=self.hostname, + port=self.port, + token=self.token, + data_source=self.data_source, + username=username + ) + + def create_user( + self, + username: str, + fullname: str, + email: str, + organization: str, + role: str + ): + + if self.username == username: + raise ValueError(("Trying to create user with same name as service account!", self.username, username)) + + logging.info(f"Creating user {username=}") + api_create_user( + hostname=self.hostname, + port=self.port, + token=self.token, + data_source=self.data_source, + username=username, + fullname=fullname, + email=email, + organization=organization, + role=role + ) + + def update_user( + self, + username: str, + fullname: str, + email: str, + organization: str, + role: str + ): + + if self.username == username: + raise ValueError(("Trying to update user with same name as service account!", self.username, username)) + + logging.info(f"Updating user {username=}") + api_update_user( + hostname=self.hostname, + port=self.port, + token=self.token, + data_source=self.data_source, + username=username, + fullname=fullname, + email=email, + organization=organization, + role=role + ) + def create_or_update_user( self, username: str, @@ -48,13 +136,7 @@ def create_or_update_user( ): try: - user = api_get_user( - hostname=self.hostname, - port=self.port, - token=self.token, - data_source=self.data_source, - username=username - ) + user = self.get_user(username=username) needs_update = any([ (fullname != user["attributes"]["guac-full-name"]), @@ -64,12 +146,7 @@ def create_or_update_user( ]) if needs_update: - logging.info(f"Updating user {username=}") - return api_update_user( - hostname=self.hostname, - port=self.port, - token=self.token, - data_source=self.data_source, + self.update_user( username=username, fullname=fullname, email=email, @@ -77,20 +154,223 @@ def create_or_update_user( role=role ) - else: - logging.info(f"Skipping updating user {username=}") + # else: + # logging.info(f"Skipping updating user {username=}") except APIUserDoesNotExistError: - logging.info(f"Creating user {username=}") - return api_create_user( - hostname=self.hostname, - port=self.port, - token=self.token, - data_source=self.data_source, + self.create_user( username=username, fullname=fullname, email=email, organization=organization, role=role ) + + def delete_user(self, username: str): + + if self.username == username: + raise ValueError(("Trying to delete user with same name as service account!", self.username, username)) + + logging.info(f"Delete user {username=}") + api_delete_user( + hostname=self.hostname, + port=self.port, + token=self.token, + data_source=self.data_source, + username=username + ) + + def list_connections(self): + # logging.info(f"List connections") + return api_list_connections( + hostname=self.hostname, + port=self.port, + token=self.token, + data_source=self.data_source + ) + + def get_connection_id(self, conn_name: str): + + connections = self.list_connections() + + for connection in connections.values(): + if connection["name"] == conn_name: + return connection["identifier"] + + raise APIConnectionDoesNotExistError(("Connection does not exist!", conn_name, connections)) + + def get_connection(self, conn_id: int): + # logging.info(f"Get connection {conn_id=}") + return api_get_connection( + hostname=self.hostname, + port=self.port, + token=self.token, + data_source=self.data_source, + conn_id=conn_id + ) + + def get_connection_parameters(self, conn_id: int): + # logging.info(f"Get connection parameters {conn_id=}") + return api_get_connection_parameters( + hostname=self.hostname, + port=self.port, + token=self.token, + data_source=self.data_source, + conn_id=conn_id + ) + + def create_connection( + self, + name: str, + protocol: str, + parent: str, + hostname: str, + port: int + ): + + logging.info(f"Creating connection {name=}") + response = api_create_connection( + hostname=self.hostname, + port=self.port, + token=self.token, + data_source=self.data_source, + conn_name=name, + conn_protocol=protocol, + conn_parent=parent, + conn_hostname=hostname, + conn_port=port + ) + + return response["identifier"] + + def update_connection( + self, + conn_id: int, + name: str, + protocol: str, + parent: str, + hostname: str, + port: int + ): + logging.info(f"Updating connection {name=} {conn_id=}") + api_update_connection( + hostname=self.hostname, + port=self.port, + token=self.token, + data_source=self.data_source, + conn_id=conn_id, + conn_name=name, + conn_protocol=protocol, + conn_parent=parent, + conn_hostname=hostname, + conn_port=port + ) + + def create_or_update_connection( + self, + name: str, + protocol: str, + parent: str, + hostname: str, + port: int + ): + + try: + conn_id = self.get_connection_id(conn_name=name) + + # connection = self.get_connection(conn_id=conn_id) + # connection_parameters = self.get_connection_parameters(conn_id=conn_id) + + # TODO determine if connection needs updating + needs_update = True + + if needs_update: + + self.update_connection( + conn_id=conn_id, + name=name, + protocol=protocol, + parent=parent, + hostname=hostname, + port=port + ) + + return conn_id + + # else: + # logging.info(f"Skipping updating connection {name=}") + + except APIConnectionDoesNotExistError: + + return self.create_connection( + name=name, + protocol=protocol, + parent=parent, + hostname=hostname, + port=port + ) + + def delete_connection(self, conn_id: int): + logging.info(f"Delete connection {conn_id=}") + api_delete_connection( + hostname=self.hostname, + port=self.port, + token=self.token, + data_source=self.data_source, + conn_id=conn_id + ) + + def create_user_connection( + self, + username: str, + conn_id: int, + ): + logging.info(f"Create user connection {username=} {conn_id=}") + api_create_user_connection( + hostname=self.hostname, + port=self.port, + token=self.token, + data_source=self.data_source, + username=username, + conn_id=conn_id, + ) + + def delete_user_connection( + self, + username: str, + conn_id: int, + ): + logging.info(f"Delete user connection {username=} {conn_id=}") + api_delete_user_connection( + hostname=self.hostname, + port=self.port, + token=self.token, + data_source=self.data_source, + username=username, + conn_id=conn_id, + ) + + def list_connection_users( + self, + conn_id: int + ): + + users = self.list_users() + + connection_users = dict() + for user in users.values(): + + username = user["username"] + + permissions = self.get_user_effective_permissions(username=username) + + if str(conn_id) not in permissions["connectionPermissions"]: + continue + + if "READ" not in permissions["connectionPermissions"][str(conn_id)]: + continue + + connection_users[username] = user + + return connection_users diff --git a/containers/controller/src/controller/kube/iter_objects.py b/containers/controller/src/controller/kube/iter_objects.py index 49d1b63..2839036 100644 --- a/containers/controller/src/controller/kube/iter_objects.py +++ b/containers/controller/src/controller/kube/iter_objects.py @@ -5,8 +5,7 @@ def kube_object_name(manifest: dict) -> str: metadata = manifest["metadata"] - return "{kind}/{namespace}/{name}".format( - kind=manifest["kind"], + return "{namespace}/{name}".format( namespace=metadata["namespace"], name=metadata["name"] ) diff --git a/containers/controller/src/controller/sync/get_connections_by_manifest.py b/containers/controller/src/controller/sync/get_connections_by_manifest.py deleted file mode 100644 index 3681700..0000000 --- a/containers/controller/src/controller/sync/get_connections_by_manifest.py +++ /dev/null @@ -1,21 +0,0 @@ -import logging - - -def get_connections_by_manifest( - namespace: str, - manifests: dict -): - - connections_by_manifest = dict() - for name, manifest in manifests.items(): - - logging.info(f"Searching connections for manifest {name}") - - # Currently yields a single connection based on a url in the crd - # TODO Eventually should select pod services based on selectors in the crd. - url = manifest["spec"]["url"] - connections_by_manifest[name] = [url] - - logging.info(f"Found {len(connections_by_manifest[name])} connections") - - return connections_by_manifest diff --git a/containers/controller/src/controller/sync/get_unique_users.py b/containers/controller/src/controller/sync/get_unique_users.py index 4025eef..1733936 100644 --- a/containers/controller/src/controller/sync/get_unique_users.py +++ b/containers/controller/src/controller/sync/get_unique_users.py @@ -6,9 +6,9 @@ def get_unique_users( ): # Flatten unique users by LDAP dn expected_users = { - record["dn"]: record - for records in users_by_manifest.values() - for record in records + user["username"]: user + for users in users_by_manifest.values() + for user in users.values() } logging.info(f"Found {len(expected_users)} unique users across {len(users_by_manifest)} manifests") diff --git a/containers/controller/src/controller/sync/get_users_by_manifest.py b/containers/controller/src/controller/sync/get_users_by_manifest.py index 04d809a..8fa6850 100644 --- a/containers/controller/src/controller/sync/get_users_by_manifest.py +++ b/containers/controller/src/controller/sync/get_users_by_manifest.py @@ -7,6 +7,14 @@ def get_users_by_manifest( ldap: LDAP, manifests: dict ): + + def record_fn(record): + return dict( + username=record["attributes"][ldap.username_attribute], + fullname=record["attributes"][ldap.fullname_attribute], + email=record["attributes"][ldap.email_attribute] + ) + users_by_manifest = dict() for name, manifest in manifests.items(): @@ -17,14 +25,16 @@ def get_users_by_manifest( group_search_filter = manifest["spec"]["ldap"]["groupFilter"] logging.info(f"Searching user membership for manifest {name} {group_search_filter=}") - users_by_manifest[name] = list(ldap.iter_group_members( - group_search_filter=group_search_filter, - attributes=[ - ldap.username_attribute, - ldap.fullname_attribute, - ldap.email_attribute - ] - )) + users_by_manifest[name] = { + user["username"]: user for user in map(record_fn, ldap.iter_group_members( + group_search_filter=group_search_filter, + attributes=[ + ldap.username_attribute, + ldap.fullname_attribute, + ldap.email_attribute + ] + )) + } logging.info(f"Found {len(users_by_manifest[name])} users") diff --git a/containers/controller/src/controller/sync/sync.py b/containers/controller/src/controller/sync/sync.py index cd73c2e..f277f77 100644 --- a/containers/controller/src/controller/sync/sync.py +++ b/containers/controller/src/controller/sync/sync.py @@ -1,7 +1,7 @@ -from .get_connections_by_manifest import get_connections_by_manifest -from .get_unique_users import get_unique_users + from .get_users_by_manifest import get_users_by_manifest from .get_manifests import get_manifests +from .sync_connections import sync_connections from .sync_users import sync_users from ..api import API @@ -14,20 +14,22 @@ def sync( kube_namespace: str ): - manifests = get_manifests( - namespace=kube_namespace) - - expected_users_by_manifest = get_users_by_manifest( - ldap=ldap, manifests=manifests) + # Lookup GuacamoleConnection manifests from kubes + manifests = get_manifests(namespace=kube_namespace) - expected_users = get_unique_users( - users_by_manifest=expected_users_by_manifest) + # Search LDAP for each GuacamoleConnection manifest to get its expected users + expected_users_by_manifest = get_users_by_manifest(ldap=ldap, manifests=manifests) + # For all the unique users create or update them using the Guacamole REST api + # Set or update their `valid_until` field to expire if this sync starts failing sync_users( - api=api, ldap=ldap, expected_users=expected_users) - - expected_connections_by_manifest = get_connections_by_manifest( - namespace=kube_namespace, manifests=manifests) - - if expected_connections_by_manifest: - pass + api=api, + expected_users_by_manifest=expected_users_by_manifest + ) + + # For all the connections create or update them using the Guacamole REST api + sync_connections( + api=api, + manifests=manifests, + expected_users_by_manifest=expected_users_by_manifest + ) diff --git a/containers/controller/src/controller/sync/sync_connections.py b/containers/controller/src/controller/sync/sync_connections.py new file mode 100644 index 0000000..79d0e06 --- /dev/null +++ b/containers/controller/src/controller/sync/sync_connections.py @@ -0,0 +1,58 @@ +import logging + +from ..api import API + + +def sync_connections( + api: API, + manifests: dict, + expected_users_by_manifest: dict +): + logging.info("Syncing connections") + + observed_connections = api.list_connections() + expected_connections = set() + + # Add connections via api + for manifest_name, manifest in manifests.items(): + + name = manifest["metadata"]["name"] + namespace = manifest["metadata"]["namespace"] + + conn_protocol = manifest["spec"]["protocol"] + conn_name = f"{namespace}/{name} - {conn_protocol}" + + logging.info(f"Syncing connection {conn_name=}") + + conn_id = api.create_or_update_connection( + parent="ROOT", + name=conn_name, + protocol=conn_protocol, + hostname=manifest["spec"]["hostname"], + port=manifest["spec"]["port"] + ) + + expected_connections.add(conn_id) + + logging.info(f"Syncing connection users {conn_name=}") + + observed_connection_users = api.list_connection_users(conn_id=conn_id) + + for user in expected_users_by_manifest[manifest_name].values(): + if user["username"] not in observed_connection_users: + api.create_user_connection( + username=user["username"], + conn_id=conn_id + ) + + for observed_user in observed_connection_users.values(): + if (observed_user["username"] not in expected_users_by_manifest[manifest_name]) and (observed_user["username"] != api.username): + api.delete_user_connection( + username=observed_user["username"], + conn_id=conn_id + ) + + # Cull connections + for observed_connection in observed_connections.values(): + if observed_connection["identifier"] not in expected_connections: + api.delete_connection(conn_id=observed_connection["identifier"]) diff --git a/containers/controller/src/controller/sync/sync_users.py b/containers/controller/src/controller/sync/sync_users.py index 085e579..061ffa2 100644 --- a/containers/controller/src/controller/sync/sync_users.py +++ b/containers/controller/src/controller/sync/sync_users.py @@ -1,20 +1,32 @@ +import logging + +from .get_unique_users import get_unique_users from ..api import API -from ..directory import LDAP def sync_users( api: API, - ldap: LDAP, - expected_users: dict + expected_users_by_manifest: dict ): + logging.info("Syncing users") + + expected_users = get_unique_users(users_by_manifest=expected_users_by_manifest) + + observed_users = api.list_users() + # Add users via api for user in expected_users.values(): api.create_or_update_user( - username=user["attributes"][ldap.username_attribute], - fullname=user["attributes"][ldap.fullname_attribute], - email=user["attributes"][ldap.email_attribute], - organization="MANAGED", - role="MANAGED" + username=user["username"], + fullname=user["fullname"], + email=user["email"], + organization=f"MANAGED-BY: {api.username}", + role="MANAGED USER" ) + + # Cull users + for observed_user in observed_users.values(): + if (observed_user["username"] not in expected_users) and (observed_user["username"] != api.username): + api.delete_user(username=observed_user["username"])