diff --git a/Dockerfile b/Dockerfile index 66b6483..80fdd57 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,6 @@ RUN zypper addrepo https://download.opensuse.org/repositories/SUSE:/CA/openSUSE_ python3-cachetools \ python3-cryptography \ python3-Jinja2 \ - python3-pyramid \ python3-python-dateutil \ python3-pytz \ python3-PyYAML && \ diff --git a/README.md b/README.md index e0403a4..46f84ea 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Docker image available at `ghcr.io/ricardobranco777/cloudview:latest` ## Usage ``` -usage: cloudview.py [-h] [-c CONFIG] [-f FORMAT] [-l {none,debug,info,warning,error,critical}] [-o {text,html,json}] [-p PORT] [-P {ec2,gce,azure_arm,openstack}] [-r] +usage: cloudview.py [-h] [-c CONFIG] [-f FORMAT] [-l {none,debug,info,warning,error,critical}] [-o {text,html,json}] [-P {ec2,gce,azure_arm,openstack}] [-r] [-s {name,state,time}] [-S {error,migrating,normal,paused,pending,rebooting,reconfiguring,running,starting,stopped,stopping,suspended,terminated,unknown,updating}] [-t TIME_FORMAT] [-v] [--version] @@ -27,7 +27,6 @@ options: logging level (default: error) -o {text,html,json}, --output {text,html,json} output type (default: text) - -p PORT, --port PORT run a web server on specified port (default: None) -P {ec2,gce,azure_arm,openstack}, --providers {ec2,gce,azure_arm,openstack} list only specified providers (default: None) -r, --reverse reverse sort (default: False) @@ -59,16 +58,6 @@ NOTES: The [cloudview](scripts/cloudview) script scans `clouds.yaml` and environment variables to execute the proper `docker` command. -## To run the web server with Docker Compose: - -If you have a TLS key pair, put the certificates in `cert.pem`, the private key in `key.pem` and the file containing the passphrase to the private key in `key.txt`. Then edit the [docker-compose.yml](examples/docker-compose.yml) file to mount the directory to `/etc/nginx/ssl` in the container like this: `- "/path/to/tls:/etc/nginx/ssl:ro"`. Set and export the `NGINX_HOST` environment variable with the FQDN of your host. - -For HTTP Basic Authentication, create a file named `auth.htpasswd` in the same directory with the TLS key pair. - -If you don't have a TLS key pair, a self-signed certificate and a random password for logging in will be generated. You can see the latter with `docker compose logs`. The user is `test`. - -After running `docker compose build` & `docker compose up -d` you can browse to [https://localhost:8443](https://localhost:8443) - ## Debugging - For debugging you can set the `LIBCLOUD_DEBUG` environment variable to a path like `/dev/stderr` @@ -79,7 +68,3 @@ After running `docker compose build` & `docker compose up -d` you can browse to - [Azure](https://libcloud.readthedocs.io/en/stable/compute/drivers/azure_arm.html) - [GCE](https://libcloud.readthedocs.io/en/stable/compute/drivers/gce.html) - [Openstack](https://libcloud.readthedocs.io/en/stable/compute/drivers/openstack.html) - -## Similar projects - - - [public cloud watch](https://github.com/SUSE/pcw/) diff --git a/cloudview/cloudview.py b/cloudview/cloudview.py index 1ff39b8..c60b26f 100644 --- a/cloudview/cloudview.py +++ b/cloudview/cloudview.py @@ -8,18 +8,10 @@ import logging import sys from concurrent.futures import ThreadPoolExecutor -from json import JSONEncoder -from io import StringIO from operator import itemgetter from typing import Any from urllib.parse import urlencode, quote, unquote -from wsgiref.simple_server import make_server -from pyramid.view import view_config -from pyramid.config import Configurator -from pyramid.response import Response -from pyramid.request import Request - import yaml from libcloud.compute.types import Provider, LibcloudError @@ -102,49 +94,16 @@ def print_instances(client: CSP) -> None: Output().info(instance) -def print_info() -> Response | None: +def print_info() -> None: """ Print information about instances """ clients = get_clients(config_file=args.config) - sys.stdout = StringIO() if args.port else sys.stdout Output().header() if len(clients) > 0: with ThreadPoolExecutor(max_workers=len(clients)) as executor: executor.map(print_instances, clients) Output().footer() - if args.port: - response = sys.stdout.getvalue() # type: ignore - sys.stdout.close() - return response - return None - - -def handle_requests(request: Request) -> Response | None: - """ - Handle HTTP requests - """ - logging.info(request) - response = print_info() - return Response(response) - - -def test(request: Request | None = None) -> Response | None: - """ - Used for testing - """ - if request: - logging.info(request) - response = "OK" - return Response(response) - return None - - -def not_found() -> Response: - """ - Not found! - """ - return Response("Not found!", status=404) def valid_elem(elem: str) -> bool: @@ -159,50 +118,6 @@ def valid_elem(elem: str) -> bool: ) -@view_config(route_name="instance") -def handle_instance(request: Request) -> Response: - """ - Handle HTTP requests for instances - """ - logging.info(request) - provider = request.matchdict["provider"] - cloud = request.matchdict["cloud"] - instance_id = request.matchdict["instance_id"] - if ( - provider not in PROVIDERS - or not valid_elem(cloud) - or not valid_elem(instance_id) - ): - return not_found() - client = list(get_clients(config_file=args.config, provider=provider, cloud=cloud))[ - 0 - ] - if client is None: - return not_found() - if client is not None: - info = client.get_instance(instance_id, **request.params) - if info is None: - return not_found() - response = JSONEncoder(default=str, indent=4, sort_keys=True).encode(info.extra) - return Response(response, content_type="application/json; charset=utf-8") - - -def web_server() -> None: - """ - Setup the WSGI server - """ - with Configurator() as config: - config.add_route("handle_requests", "/") - config.add_view(handle_requests, route_name="handle_requests") - config.add_route("test", "/test") - config.add_view(test, route_name="test") - config.add_route("instance", "instance/{provider}/{cloud}/{instance_id}") - config.scan() - app = config.make_wsgi_app() - server = make_server("0.0.0.0", args.port, app) - server.serve_forever() - - def parse_args() -> argparse.Namespace: """ Parse command line options @@ -233,9 +148,6 @@ def parse_args() -> argparse.Namespace: choices=["text", "html", "json"], help="output type", ) - argparser.add_argument( - "-p", "--port", type=port_number, help="run a web server on specified port" - ) argparser.add_argument( "-P", "--providers", @@ -309,14 +221,7 @@ def main() -> None: if args.verbose: keys |= {"id": ""} - if args.port: - args.output = "html" Output(type=args.output.lower(), keys=keys, refresh_seconds=600) - - if args.port: - web_server() - sys.exit(1) - print_info() diff --git a/examples/docker-compose.yml b/examples/docker-compose.yml deleted file mode 100644 index 970a283..0000000 --- a/examples/docker-compose.yml +++ /dev/null @@ -1,23 +0,0 @@ -version: '3.8' - -services: - app: - build: . - command: --config /clouds.yaml --sort time --port 7777 --verbose - environment: - - TZ=Europe/Berlin - volumes: - - ${PWD}/examples/clouds.yaml:/clouds.yaml:ro - # Must be the same path mentioned in clouds.yaml - - ${PWD}/gce.json:/gce.json:ro - restart: always - nginx: - build: nginx - environment: - - APP_PORT=7777 - - NGINX_HOST=${NGINX_HOST:-localhost} - - PASS=${PASS:-} - ports: - - "8443:443" - read_only: true - restart: always diff --git a/nginx/Dockerfile b/nginx/Dockerfile deleted file mode 100644 index b58f1ea..0000000 --- a/nginx/Dockerfile +++ /dev/null @@ -1,16 +0,0 @@ -FROM nginx:alpine - -RUN apk --no-cache add bash openssl - -COPY site.conf.template /etc/nginx/conf.d/ -COPY entrypoint.sh /run -RUN chmod +x /run/entrypoint.sh && \ - ln -sf /run/site.conf /etc/nginx/conf.d/default.conf - -COPY favicon.ico /usr/share/nginx/html/ - -EXPOSE 443 - -VOLUME ["/var/cache/nginx", "/run", "/etc/nginx/ssl"] - -ENTRYPOINT ["/run/entrypoint.sh"] diff --git a/nginx/entrypoint.sh b/nginx/entrypoint.sh deleted file mode 100644 index f2a852b..0000000 --- a/nginx/entrypoint.sh +++ /dev/null @@ -1,51 +0,0 @@ -#!/bin/bash - -set -e -umask 077 - -# shellcheck disable=SC2016 -envsubst '$APP_PORT $NGINX_HOST' < /etc/nginx/conf.d/site.conf.template > /run/site.conf - -openssl_config() { - cat /etc/ssl/openssl.cnf - cat <<- EOF - [custom] - basicConstraints = critical,CA:false - nsCertType = server - subjectKeyIdentifier = hash - authorityKeyIdentifier = keyid,issuer:always - keyUsage = critical,digitalSignature,keyEncipherment - extendedKeyUsage = serverAuth - subjectAltName = DNS:${NGINX_HOST:-localhost} - EOF -} - -# On read-only containers, openssl won't be able to write to ~/.rnd -export RANDFILE=/dev/null - -SSL=/etc/nginx/ssl -if [ ! -f $SSL/key.pem ] ; then - # Generate a random Base64 password - openssl rand -base64 48 > $SSL/key.txt - # Generate a self-signed certificate - openssl req -x509 -sha512 -newkey rsa:4096 -keyout $SSL/key.pem -out $SSL/cert.pem -days 365 -subj "/CN=${NGINX_HOST:-localhost}" -passout file:$SSL/key.txt -extensions custom -config <(openssl_config) - - # Generate a random salt of 16 characters - SALT=$(openssl rand -base64 12) - # The salt character set is [a-zA-Z0-9./] - SALT=${SALT//+/.} - if [ -z "$PASS" ] ; then - # Generate a random password - PASS=$(openssl rand -base64 48) - echo "Password for HTTP Basic Authentication is $PASS" - fi - # Hash password with salted SHA-512 - PASS=$(mkpasswd -S "$SALT" "$PASS") - echo "test:$PASS" >> $SSL/auth.htpasswd - unset PASS - chmod 644 $SSL/auth.htpasswd -elif [ ! -f $SSL/auth.htpasswd ] ; then - sed -i '/auth_basic/d' /run/site.conf -fi - -exec nginx -g 'daemon off;' diff --git a/nginx/favicon.ico b/nginx/favicon.ico deleted file mode 100644 index 0ed3145..0000000 Binary files a/nginx/favicon.ico and /dev/null differ diff --git a/nginx/site.conf.template b/nginx/site.conf.template deleted file mode 100644 index 026c04a..0000000 --- a/nginx/site.conf.template +++ /dev/null @@ -1,59 +0,0 @@ -# Hide Nginx version -server_tokens off; - -server { - listen 80; - server_name ${NGINX_HOST}; - return 301 https://$server_name$request_uri; -} - -server { - listen 443 ssl; - server_name ${NGINX_HOST}; - - ssl_certificate_key /etc/nginx/ssl/key.pem; - ssl_password_file /etc/nginx/ssl/key.txt; - ssl_certificate /etc/nginx/ssl/cert.pem; - ssl_ciphers TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256; - ssl_protocols TLSv1.3 TLSv1.2; - ssl_prefer_server_ciphers on; - ssl_session_cache shared:SSL:20m; - ssl_session_timeout 1d; - ssl_session_tickets off; - #ssl_stapling on; - #ssl_stapling_verify on; - - proxy_connect_timeout 300; - proxy_send_timeout 300; - proxy_read_timeout 300; - send_timeout 300; - - location /favicon.ico { - root /usr/share/nginx/html; - } - - location / { - limit_except GET { - deny all; - } - auth_basic on; - auth_basic_user_file /etc/nginx/ssl/auth.htpasswd; - proxy_pass http://app:${APP_PORT}; - proxy_set_header Host $http_host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - # Remove authentication header - proxy_set_header Authorization ""; - # Protect against HTTPoxy - proxy_set_header Proxy ""; - # Protect against gzip attacks - proxy_set_header Accept-Encoding ""; - #add_header Strict-Transport-Security max-age=15768000; - # Enable keepalive - proxy_http_version 1.1; - proxy_set_header Connection ""; - proxy_redirect off; - proxy_buffering off; - } -} diff --git a/requirements-dev.txt b/requirements-dev.txt index e6151bc..24098d4 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -2,7 +2,6 @@ apache-libcloud cachetools cryptography Jinja2 -pyramid python-dateutil pytz PyYAML diff --git a/requirements-test.txt b/requirements-test.txt index f355a82..0c286a6 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -13,6 +13,3 @@ types-pytz types-PyYAML types-cachetools types-requests -podman -docker -selenium diff --git a/requirements.txt b/requirements.txt index 472f9cc..10fed3d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,27 +1,16 @@ apache-libcloud==3.8.0 -cachetools==5.4.0 -certifi==2024.7.4 +cachetools==5.5.0 +certifi==2024.8.30 cffi==1.17.0 charset-normalizer==3.3.2 cryptography==43.0.0 -hupper==1.12.1 -idna==3.7 +idna==3.8 Jinja2==3.1.4 MarkupSafe==2.1.5 -PasteDeploy==3.1.0 -plaster==1.1.2 -plaster-pastedeploy==1.0.1 pycparser==2.22 -pyramid==2.0.2 python-dateutil==2.9.0.post0 pytz==2024.1 PyYAML==6.0.2 requests==2.32.3 -setuptools==72.2.0 six==1.16.0 -translationstring==1.4 urllib3==2.2.2 -venusian==3.1.0 -WebOb==1.8.8 -zope.deprecation==5.0 -zope.interface==7.0.1 diff --git a/tests/test_selenium.py b/tests/test_selenium.py deleted file mode 100644 index 998ec48..0000000 --- a/tests/test_selenium.py +++ /dev/null @@ -1,130 +0,0 @@ -# pylint: disable=missing-module-docstring,missing-function-docstring,missing-class-docstring,redefined-outer-name,no-member,unused-argument - -import contextlib -import logging -import random -import os -import shlex -import shutil -import sys -from subprocess import DEVNULL - -import pytest -import docker -from docker.errors import DockerException -from requests.exceptions import RequestException -from podman import PodmanClient -from podman.errors import APIError, PodmanError -from selenium.webdriver import firefox -from selenium.common.exceptions import WebDriverException - - -@pytest.fixture(scope="session") -def client(): - if os.getenv("SKIP_SELENIUM"): - pytest.skip("Skipping because SKIP_SELENIUM is set") - - if not shutil.which("geckodriver"): - pytest.skip("Please install geckodriver in your PATH. Skipping...") - - try: - client = docker.from_env() - except (DockerException, RequestException) as exc: - logging.warning("%s", exc) - try: - client = PodmanClient() - except (APIError, PodmanError) as exc: - pytest.skip(f"Broken Podman environment: {exc}") - if not client.info()["host"]["remoteSocket"]["exists"]: - pytest.skip("Please run systemctl --user enable --now podman.socket") - - yield client - - client.close() - - -@pytest.fixture(scope="session") -def random_port(): - # Get random number for ephemeral port, container and image name - # Typical values from /proc/sys/net/ipv4/ip_local_port_range - return random.randint(32768, 60999) - - -@pytest.fixture(scope="session") -def image(random_port, client): - image_name = f"cloudview-test{random_port}" - - # Build image - try: - client.images.build( - path=".", - dockerfile="Dockerfile", - tag=image_name, - ) - except APIError as exc: - pytest.skip(f"Broken Podman environment: {exc}") - except RequestException as exc: - pytest.skip(f"Broken Docker environment: {exc}") - except (DockerException, PodmanError) as exc: - pytest.fail(f"{exc}") - - yield image_name - - # Cleanup - with contextlib.suppress(APIError, PodmanError, DockerException, RequestException): - client.images.remove(image_name) - - -@pytest.fixture(scope="session") -def container(random_port, image, client): - command = shlex.split(f"--port {7777}") - environment = {} - if os.getenv("DEBUG"): - command.extend(shlex.split("--log debug")) - environment.update({"LIBCLOUD_DEBUG": 1}) - try: - # Run container - container = client.containers.run( - image=image, - name=image, - detach=True, - command=command, - environment=environment, - ports={f"{7777}/tcp": random_port}, - ) - except (APIError, PodmanError, DockerException, RequestException) as exc: - pytest.fail(f"{exc}") - - yield container - - # Cleanup - with contextlib.suppress(APIError, PodmanError, DockerException, RequestException): - print(container.logs(), file=sys.stderr) - with contextlib.suppress(APIError, PodmanError, DockerException, RequestException): - container.stop() - with contextlib.suppress(APIError, PodmanError, DockerException, RequestException): - container.remove() - - -@pytest.fixture -def browser(container): - service = firefox.service.Service( - log_output=sys.stderr if os.getenv("DEBUG") else DEVNULL - ) - options = firefox.options.Options() - options.add_argument("--headless") - try: - driver = firefox.webdriver.WebDriver(options=options, service=service) - except WebDriverException as exc: - pytest.fail(f"{exc}") - driver.set_page_load_timeout(30) - yield driver - driver.quit() - - -def test_web(random_port, browser): - try: - browser.get(f"http://127.0.0.1:{random_port}") - except WebDriverException as exc: - pytest.fail(f"{exc}") - assert "Instances" in browser.title