From 8aad20147b07af1a5a108c49947924df32e3c1ef Mon Sep 17 00:00:00 2001 From: Dale Davies Date: Tue, 4 Apr 2023 13:34:17 +0100 Subject: [PATCH] Add support for auto-discovery of sites from docker --- docker/entrypoint.sh | 25 ++++++++++++ jumpapp/classes/Sites.php | 82 +++++++++++++++++++++++++++++++++++++-- jumpapp/composer.json | 3 +- jumpapp/composer.lock | 46 +++++++++++++++++++++- jumpapp/config.php | 12 +++++- 5 files changed, 162 insertions(+), 6 deletions(-) diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index a5a3db7..c38d97c 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -84,6 +84,31 @@ else sed -E -i 's/^(\s*)#listen \[::\]/\1listen [::]/g' /etc/nginx/nginx.conf fi +# If we have been passed something in DOCKERSOCKET then check it +# was actually mounted and is a socket, if so then create the docker group +# with GID matching the docker socket file, then add jumpapp user to the +# group. This is to give jumpapp permission to make requests to the API. +if [ -n "${DOCKERSOCKET-}" ]; then + echo >&2 ""; + echo >&2 "- Testing docker socket file was mounted correctly." + if [ -S "${DOCKERSOCKET}" ]; then + DOCKERGID=$(stat -c %g ${DOCKERSOCKET}) + # Delete existing docker group if it exists. + if grep -q "docker" /etc/group; then + echo >&2 "-- Deleting existing docker group." + delgroup docker + fi + # Create a new one with correct GID. + echo >&2 "-- Creating docker group with correct GID." + addgroup -S docker -g $DOCKERGID + # Add jumpapp user to it. + echo >&2 "-- Adding jumpapp user to docker group." + addgroup jumpapp docker + else + echo >&2 "-- Docker socket file was either not mounted or is not a socket." + fi +fi + echo >&2 ""; echo >&2 "- All done! Starting nginx/php services now." echo >&2 ""; diff --git a/jumpapp/classes/Sites.php b/jumpapp/classes/Sites.php index 4f022c6..e0895e4 100644 --- a/jumpapp/classes/Sites.php +++ b/jumpapp/classes/Sites.php @@ -13,6 +13,7 @@ namespace Jump; +use \divineomega\array_undot; use \Jump\Exceptions\ConfigException; use \Jump\Exceptions\SiteNotFoundException; use \Jump\Exceptions\TagNotFoundException; @@ -26,6 +27,7 @@ class Sites { private array $default; private string $sitesfilelocation; private array $loadedsites; + public array $tags; /** * Automatically load sites.json on instantiation. @@ -41,10 +43,10 @@ class Sites { 'newtab' => false, ]; - // Retrieve sites from cache. Load all sites from json file if not cached or - // the cache has expired. + // Retrieve sites from cache. Load all sites from json file and docker if not + // cached or the cache has expired. $this->loadedsites = $this->cache->load(cachename: 'sites', callback: function() { - return $this->load_sites_from_json(); + return array_merge($this->load_sites_from_json(), $this->load_sites_from_docker()); }); // Enumerate a list of unique tags from loaded sites. Again will retrieve from @@ -60,6 +62,80 @@ class Sites { }); } + /** + * Try to find a list of sites from correctly labelled docker containers. + * + * Throws an exception if the json response from docker cannot be + * decoded. + * + * @return array Array of Site objects sites identified from docker. + * @throws ConfigException If invalid response from docker. + */ + private function load_sites_from_docker(): array { + // Get either dockerproxy or dockersocket config and return early if + // neihter have been set. + $dockerproxy = $this->config->get('dockerproxyurl'); + $dockersocket = $this->config->get('dockersocket'); + if (!$dockerproxy && !$dockersocket) { + return []; + } + + // Determine correct guzzle client and request options to use + // for either a docker proxy or connecting directly to the socket, + // prefer to use the proxy if both seem to have been given. + $clientopts = ['timeout' => 2.0]; + $requestopts = []; + if ($dockerproxy) { + $clientopts['base_uri'] = 'http://'.rtrim($dockerproxy, '/'); + } else if (file_exists($dockersocket)) { + $clientopts['base_uri'] = 'http://localhost'; + $requestopts = [ + 'curl' => [CURLOPT_UNIX_SOCKET_PATH => '/var/run/docker.sock'] + ]; + } + + // Make a request to docker for all containers. + try { + $response = (new \GuzzleHttp\Client($clientopts))->request('GET', '/containers/json', $requestopts); + } catch (\GuzzleHttp\Exception\ConnectException $e) { + throw new ConfigException('Did not get a response from Docker API endpoint'); + } + $containers = json_decode($response->getBody()); + if (is_null($containers)) { + throw new ConfigException('Docker returned an invalid json response for containers'); + } + + // Build a new array of Site() objects based on labels that have been added to + // containers returned by docker. + $sites = []; + foreach ($containers as $container) { + $labels = (array) $container->Labels; + // We can't build a Site() without at least a name and url. + if (!isset($labels['jump.name'], $labels['jump.url'])) { + continue; + } + // Convert dot-syntax labels into a proper multidimensional array + // and just use the top-level key "jump" as our site array. + $site = array_undot($labels)['jump']; + // jump.tags will have been given as a comma separated string so make this + // into an array. + if (isset($site['tags'])) { + // Explode the comma separated string into an array and trim any elements. + $site['tags'] = array_map('trim', explode(',', $site['tags'])); + } + // Convert status array to an object and also explode list of allowed status codes to array. + if (isset($site['status'])) { + $site['status'] = (object) $site['status']; + if (isset($site['status']->allowed_status_codes)) { + $site['status']->allowed_status_codes = array_map('trim', explode(',', $site['status']->allowed_status_codes)); + } + } + // Finally add this to the list of sites we will return. + $sites[] = new Site($this->config, (array) $site, $this->default); + } + return $sites; + } + /** * Try to load the list of sites from sites.json. * diff --git a/jumpapp/composer.json b/jumpapp/composer.json index 9f70016..674b2a3 100644 --- a/jumpapp/composer.json +++ b/jumpapp/composer.json @@ -18,6 +18,7 @@ "phlak/config": "^7.0", "nette/http": "^3.1", "guzzlehttp/guzzle": "^7.0", - "unsplash/unsplash": "3.2.1" + "unsplash/unsplash": "3.2.1", + "divineomega/array_undot": "^4.1" } } diff --git a/jumpapp/composer.lock b/jumpapp/composer.lock index ee8ac57..b7d5ee0 100644 --- a/jumpapp/composer.lock +++ b/jumpapp/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e91bcdf01a945f46bf29aa3a1cef02f5", + "content-hash": "8849ca2f3c80ed00c98055bf630ba1fb", "packages": [ { "name": "arthurhoaro/favicon", @@ -64,6 +64,50 @@ }, "time": "2021-08-06T05:41:25+00:00" }, + { + "name": "divineomega/array_undot", + "version": "v4.1.0", + "source": { + "type": "git", + "url": "https://github.com/DivineOmega/array_undot.git", + "reference": "44aed525e775718e3821d670b08046fd84914d10" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/DivineOmega/array_undot/zipball/44aed525e775718e3821d670b08046fd84914d10", + "reference": "44aed525e775718e3821d670b08046fd84914d10", + "shasum": "" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "src/array_undot.php" + ], + "psr-4": { + "DivineOmega\\ArrayUndot\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-only" + ], + "authors": [ + { + "name": "Jordan Hall", + "email": "jordan@hall05.co.uk" + } + ], + "description": "array_undot (the opposite of the array_dot helper function) expands a dot notation array into a full multi-dimensional array.", + "support": { + "issues": "https://github.com/DivineOmega/array_undot/issues", + "source": "https://github.com/DivineOmega/array_undot/tree/master" + }, + "time": "2019-04-23T15:22:21+00:00" + }, { "name": "guzzlehttp/guzzle", "version": "7.5.0", diff --git a/jumpapp/config.php b/jumpapp/config.php index d98405e..e4a2023 100644 --- a/jumpapp/config.php +++ b/jumpapp/config.php @@ -63,5 +63,15 @@ return [ // Ping sites to determine availability (e.g. online, offline, errors). 'checkstatus' => getenv('CHECKSTATUS') ?: true, // Duration to cache status in minutes. - 'statuscache' => getenv('STATUSCACHE') ?: '5' + 'statuscache' => getenv('STATUSCACHE') ?: '5', + + // The URL and port on which a docker socket proxy is listening, for example + // if you have tecnativa/docker-socket-proxy named dockerproxy listening on + // port 2375 then this would be "dockerproxy:2375". + 'dockerproxyurl' => getenv('DOCKERPROXYURL') ?: false, + // Docker socket path. Note the host docker socket file must be mapped as + // a volume into the container, this must match the path it has been mapped to. + // If possible please don't use this as it can be insecure, use a docker socket + // proxy instead (see above). + 'dockersocket' => getenv('DOCKERSOCKET') ?: false ];