diff --git a/bin/workspace b/bin/workspace index 9c22906a..ee13d415 100755 --- a/bin/workspace +++ b/bin/workspace @@ -18,6 +18,9 @@ function main(): void install(); } + $workspaceBin = $_SERVER['argv'][0] ?? __FILE__; + $workspaceBin = realpath($workspaceBin) ?: $workspaceBin; + putenv('MY127WS_WORKSPACE_BIN='.$workspaceBin); putenv('PATH='.home().'/.my127/workspace/bin:'.getenv('PATH')); $exitStatus = application()->run(); diff --git a/box.json b/box.json index cedfecac..50ddbc10 100644 --- a/box.json +++ b/box.json @@ -10,7 +10,8 @@ "files-bin": [ "home/service/logger/.env", "home/service/mail/.env", - "home/service/proxy/.env" + "home/service/proxy/.env", + "home/service/tracing/.env" ], "blacklist": [ "tools/scripts/compile.sh", diff --git a/config/workspace/global.yml b/config/workspace/global.yml index a1d7f55b..4e9149f3 100644 --- a/config/workspace/global.yml +++ b/config/workspace/global.yml @@ -1,23 +1,27 @@ -command('global service logger (enable|disable)', 'global service logger'): | - #!bash|= - ws-service logger ={input.command(4)} +command('global service logger (enable|disable)', 'global service logger'): + description: Actions for the logging service + exec: | + #!bash|= + ws-service logger ={input.command(4)} command('global service mail (enable|disable)', 'global service mail'): - description: Actions for mailhog (mail.my127.site) + description: Actions for mailhog exec: | #!bash|= ws-service mail ={input.command(4)} command('global service proxy (enable|disable|restart)', 'global service proxy'): - description: Actions for Traefik proxy (my127.site) + description: Actions for Traefik proxy exec: | #!bash|= ws-service proxy ={input.command(4)} -command('global service tracing (start|stop|restart)', 'global service tracing'): | - #!bash|= - ws-service tracing ={input.command(4)} +command('global service tracing (start|stop|restart)', 'global service tracing'): + description: Actions for the tracing service + exec: | + #!bash|= + ws-service tracing ={input.command(4)} command('global service', 'global service'): description: Perform an action (start/stop etc.) on a global service diff --git a/docs/cheatsheet.md b/docs/cheatsheet.md index f7061466..903a6adf 100644 --- a/docs/cheatsheet.md +++ b/docs/cheatsheet.md @@ -10,6 +10,24 @@ You need only to restart the Traefik proxy service: ws global service proxy restart ``` +## Custom proxy domain + +See [Custom Global Proxy Domain](custom-proxy-domain.md) for the full setup. + +Minimal global config file: + +```text +~/.config/my127/workspace/proxy.yml +``` + +```yaml +attribute('global.service.proxy.domain'): dev.example.test +attribute('global.service.proxy.https.crt'): https://example.test/dev.example.test.crt +attribute('global.service.proxy.https.key'): https://example.test/dev.example.test.key +``` + +Restart the proxy after changing these values. + ## How-to receive email The email service is not running by default. It can be started with: @@ -18,6 +36,6 @@ The email service is not running by default. It can be started with: ws global service mail enable ``` -This will allow email to be viewed at `https://mail.my127.site/` +This will allow email to be viewed at `https://mail./` -This will collect email sent from any server through native `sendmail`. \ No newline at end of file +This will collect email sent from any server through native `sendmail`. diff --git a/docs/custom-proxy-domain.md b/docs/custom-proxy-domain.md new file mode 100644 index 00000000..a2d87cea --- /dev/null +++ b/docs/custom-proxy-domain.md @@ -0,0 +1,312 @@ +# Custom Global Proxy Domain + +Workspace uses `my127.site` by default for local HTTPS hostnames. You can +replace that single Global Proxy domain with another domain by adding a global +Workspace config file on the developer machine. + +This feature supports one proxy domain at a time. It does not register multiple +domains. + +## Contents + +- [How it fits together](#how-it-fits-together) +- [Create the proxy config file](#create-the-proxy-config-file) +- [Host the certificate files](#host-the-certificate-files) +- [Configure DNS](#configure-dns) +- [Apply the change](#apply-the-change) +- [Revert to the default domain](#revert-to-the-default-domain) +- [Use the domain in a project](#use-the-domain-in-a-project) +- [Renew certificates](#renew-certificates) + +## How it fits together + +Workspace automatically loads global config files from: + +```text +~/.config/my127/workspace/*.yml +``` + +Use `proxy.yml` for the proxy override: + +```text +~/.config/my127/workspace/proxy.yml +``` + +`proxy.yml` is a normal Workspace global config file. It is not fetched from a +remote URL and it is not managed by a Workspace import command. + +```mermaid +flowchart LR + configFile["~/.config/my127/workspace/proxy.yml"] + attributes["global.service.proxy.* attributes"] + commands["ws global service ...
ws install required services"] + serviceWrapper["home/bin/ws-service"] + init["home/service/proxy/init.sh"] + compose["docker-compose labels"] + tlsFiles["traefik/root/tls/"] + tlsConfig["traefik/root/config/tls.yaml"] + traefik["Single Traefik Global Proxy"] + + configFile --> attributes + attributes --> commands + commands --> serviceWrapper + serviceWrapper --> init + init --> compose + init --> tlsFiles + init --> tlsConfig + compose --> traefik + tlsFiles --> traefik + tlsConfig --> traefik +``` + +The internal `ws-service` wrapper resolves proxy attributes from the installed +global Workspace config before it calls a service `init.sh`. For the proxy +service it exports the domain and certificate settings; for the other global +services it exports the domain used by Docker Compose labels. + +Project `workspace.yml` files do not override the Global Proxy runtime. Use +`~/.config/my127/workspace/proxy.yml` to switch the machine-global proxy. + +## Create the proxy config file + +Minimal `~/.config/my127/workspace/proxy.yml`: + +```yaml +attribute('global.service.proxy.domain'): dev.example.test +attribute('global.service.proxy.https.crt'): https://proxy-config.example.internal/certs/dev.example.test/fullchain.pem +attribute('global.service.proxy.https.key'): https://proxy-config.example.internal/certs/dev.example.test/privkey.pem +``` + +The local certificate filenames default to: + +```text +.crt +.key +``` + +For the example above, Workspace writes: + +```text +traefik/root/tls/dev.example.test.crt +traefik/root/tls/dev.example.test.key +``` + +Override the local filenames only when needed: + +```yaml +attribute('global.service.proxy.https.crt_file'): proxy.crt +attribute('global.service.proxy.https.key_file'): proxy.key +``` + +Full example: + +```yaml +attribute('global.service.proxy.domain'): dev.example.test +attribute('global.service.proxy.https.crt'): https://proxy-config.example.internal/certs/dev.example.test/fullchain.pem +attribute('global.service.proxy.https.key'): https://proxy-config.example.internal/certs/dev.example.test/privkey.pem +attribute('global.service.proxy.https.crt_file'): dev.example.test.crt +attribute('global.service.proxy.https.key_file'): dev.example.test.key +``` + +## Host the certificate files + +The certificate and key URLs must be reachable from each developer machine when +`ws global service proxy restart` runs. + +The files do not need to live beside `proxy.yml`. `proxy.yml` only stores the +URLs where Workspace can download them. + +Suggested hosted structure: + +```text +proxy-config/ +└── certs/ + └── dev.example.test/ + ├── fullchain.pem + └── privkey.pem +``` + +GitHub raw URLs work only for public repositories because Workspace does not +authenticate to GitHub. Use public GitHub raw URLs only for disposable test +certificates. Do not publish a real private key in a public repository. + +Example public GitHub repository structure: + +```text +workspace-proxy-certs/ +└── certs/ + └── dev.example.test/ + ├── fullchain.pem + └── privkey.pem +``` + +Example `proxy.yml` using GitHub raw URLs: + +```yaml +attribute('global.service.proxy.domain'): dev.example.test +attribute('global.service.proxy.https.crt'): https://raw.githubusercontent.com/my-org/workspace-proxy-certs/main/certs/dev.example.test/fullchain.pem +attribute('global.service.proxy.https.key'): https://raw.githubusercontent.com/my-org/workspace-proxy-certs/main/certs/dev.example.test/privkey.pem +``` + +For organisation/private certificates, use an internal HTTPS location that is +reachable from developer machines, such as a private website available on the +company network or VPN. Plain HTTP should only be used for disposable test +material or when the local-network risk is explicitly accepted. + +The certificate must cover the configured domain and the subdomains used by +projects and global services. For `dev.example.test`, the certificate should +cover: + +```text +dev.example.test +*.dev.example.test +``` + +The wildcard covers project hosts and global service hosts such as: + +```text +mail.dev.example.test +kibana.dev.example.test +tracing.dev.example.test +``` + +## Configure DNS + +The configured domain and wildcard subdomains must resolve to the developer +machine running the Workspace Global Proxy. + +For local development, common options are: + +- public DNS records that point the domain and wildcard to `127.0.0.1`; +- private DNS records available only on the organisation network; +- local DNS tools such as Pi-hole, dnsmasq, or `/etc/hosts` for individual + hostnames. + +Wildcard support is recommended because project hostnames are usually generated +under the proxy domain. + +## Apply the change + +After creating or changing `proxy.yml`, verify that Workspace resolves the new +domain: + +```bash +ws global config get global.service.proxy.domain +``` + +Then restart the proxy: + +```bash +ws global service proxy restart +``` + +The restart downloads the certificate and key, renders Traefik TLS config, and +recreates the proxy container. + +Restart only the optional global services that the project or harness requires. +These commands can start services, so skip any service the project does not use: + +```bash +# Only if the project/harness requires mail. +ws global service mail enable + +# Only if the project/harness requires logger. +ws global service logger enable + +# Only if the project/harness requires tracing. +ws global service tracing restart +``` + +## Revert to the default domain + +Workspace supports one Global Proxy domain at a time. If another project needs +the default `my127.site` domain again, remove or rename the user-global proxy +override: + +```bash +mv ~/.config/my127/workspace/proxy.yml ~/.config/my127/workspace/proxy.yml.disabled +``` + +Verify that Workspace resolves the default domain: + +```bash +ws global config get global.service.proxy.domain +``` + +Expected output: + +```text +my127.site +``` + +Then recreate the proxy: + +```bash +ws global service proxy restart +``` + +Restart only the optional global services that the project or harness requires +so their Docker labels are regenerated with `my127.site`: + +```bash +# Only if the project/harness requires mail. +ws global service mail enable + +# Only if the project/harness requires logger. +ws global service logger enable + +# Only if the project/harness requires tracing. +ws global service tracing restart +``` + +If a project also overrides `domain`, remove or restore that project override, +run `ws harness prepare`, then restart or recreate the project containers using +the project's normal workflow. + +## Use the domain in a project + +Most Workspace harnesses define `domain: my127.site` in their harness +attributes. For those projects, override the project `domain` attribute to match +the active Global Proxy domain: + +```yaml +attributes: + domain: dev.example.test +``` + +Project hostnames should then be under that suffix, such as: + +```text +my-project.dev.example.test +``` + +After changing the project domain, regenerate the harness output: + +```bash +ws harness prepare +``` + +Then restart or recreate the project containers using the project's normal +workflow. + +For custom harnesses or manually maintained Compose files, update whichever +hostname configuration the project uses. Workspace only changes the Global Proxy +domain and certificate; it does not rewrite project hostnames automatically. + +This proxy configuration supports one certificate/domain set at a time, so +change `proxy.yml` and restart the global services before working on a project +that uses a different suffix. + +## Renew certificates + +When a certificate is renewed, keep the hosted certificate URLs stable and +replace the file contents at those URLs. + +Then run: + +```bash +ws global service proxy restart +``` + +You only need to edit `proxy.yml` when the domain, certificate URL, key URL, or +local filename changes. diff --git a/home/bin/ws-service b/home/bin/ws-service index 51cae109..5cdb8221 100755 --- a/home/bin/ws-service +++ b/home/bin/ws-service @@ -24,9 +24,31 @@ main() shift + cd "$DIR" + populate_proxy_environment "$service" + "${DIR}/service/${service}/init.sh" "$@" } +populate_proxy_environment() +{ + local workspace_bin="${MY127WS_WORKSPACE_BIN:-ws}" + + MY127WS_PROXY_DOMAIN="$("$workspace_bin" global config get global.service.proxy.domain)" + export MY127WS_PROXY_DOMAIN + + if [ "$1" = "proxy" ]; then + MY127WS_PROXY_HTTPS_CRT="$("$workspace_bin" global config get global.service.proxy.https.crt)" + MY127WS_PROXY_HTTPS_KEY="$("$workspace_bin" global config get global.service.proxy.https.key)" + MY127WS_PROXY_HTTPS_CRT_FILE="$("$workspace_bin" global config get global.service.proxy.https.crt_file)" + MY127WS_PROXY_HTTPS_KEY_FILE="$("$workspace_bin" global config get global.service.proxy.https.key_file)" + export MY127WS_PROXY_HTTPS_CRT + export MY127WS_PROXY_HTTPS_KEY + export MY127WS_PROXY_HTTPS_CRT_FILE + export MY127WS_PROXY_HTTPS_KEY_FILE + fi +} + bootstrap() { DIR="$(cd "$(dirname "$0")" && cd ../ && pwd)" diff --git a/home/service/logger/docker-compose.yml b/home/service/logger/docker-compose.yml index 798c6d2d..b707eeab 100644 --- a/home/service/logger/docker-compose.yml +++ b/home/service/logger/docker-compose.yml @@ -6,7 +6,7 @@ services: - elasticsearch labels: - traefik.enable=true - - traefik.http.routers.kibana.rule=Host(`kibana.my127.site`) + - traefik.http.routers.kibana.rule=Host(`kibana.${MY127WS_PROXY_DOMAIN:-my127.site}`) - traefik.http.services.kibana.loadbalancer.server.port=5601 - traefik.docker.network=${TRAEFIK_NETWORK} networks: diff --git a/home/service/logger/init.sh b/home/service/logger/init.sh index c1b91aec..14ae1bf3 100755 --- a/home/service/logger/init.sh +++ b/home/service/logger/init.sh @@ -21,12 +21,8 @@ main() enable() { - if [ ! -f .flag-built ]; then - run docker-compose -p my127ws-logger up -d --build - touch .flag-built - else - run docker-compose -p my127ws-logger start - fi + run docker-compose -p my127ws-logger up -d --build + touch .flag-built } disable() diff --git a/home/service/mail/docker-compose.yml b/home/service/mail/docker-compose.yml index 2d6edef9..41618f65 100644 --- a/home/service/mail/docker-compose.yml +++ b/home/service/mail/docker-compose.yml @@ -4,7 +4,7 @@ services: restart: unless-stopped labels: - traefik.enable=true - - traefik.http.routers.mail.rule=Host(`mail.my127.site`) + - traefik.http.routers.mail.rule=Host(`mail.${MY127WS_PROXY_DOMAIN:-my127.site}`) - traefik.http.services.mail.loadbalancer.server.port=8025 networks: - private diff --git a/home/service/mail/init.sh b/home/service/mail/init.sh index 1e10f2f2..fb0312c1 100755 --- a/home/service/mail/init.sh +++ b/home/service/mail/init.sh @@ -21,12 +21,8 @@ main() enable() { - if [ ! -f .flag-built ]; then - run docker-compose -p my127ws-mail up -d --build - touch .flag-built - else - run docker-compose -p my127ws-mail start - fi + run docker-compose -p my127ws-mail up -d --build + touch .flag-built } disable() diff --git a/home/service/proxy/docker-compose.yml b/home/service/proxy/docker-compose.yml index 7cf24807..b450b1b0 100644 --- a/home/service/proxy/docker-compose.yml +++ b/home/service/proxy/docker-compose.yml @@ -9,7 +9,7 @@ services: - 443:443/udp labels: - traefik.enable=true - - traefik.http.routers.traefik.rule=Host(`my127.site`) + - traefik.http.routers.traefik.rule=Host(`${MY127WS_PROXY_DOMAIN:-my127.site}`) - traefik.http.services.traefik.loadbalancer.server.port=8080 - co.elastic.logs/module=traefik volumes: diff --git a/home/service/proxy/init.sh b/home/service/proxy/init.sh index 53b80c89..8b9240a6 100755 --- a/home/service/proxy/init.sh +++ b/home/service/proxy/init.sh @@ -29,13 +29,20 @@ enable() cd "$DIR" if ! docker ps | grep my127ws-proxy > /dev/null; then + local PROXY_ENV_ERROR_MESSAGE="is required. Use ws global service proxy enable or ws global service proxy restart." - if [ ! -d "traefik/root/tls" ]; then - run mkdir -p traefik/root/tls - fi + : "${MY127WS_PROXY_HTTPS_CRT:?$PROXY_ENV_ERROR_MESSAGE}" + : "${MY127WS_PROXY_HTTPS_KEY:?$PROXY_ENV_ERROR_MESSAGE}" + : "${MY127WS_PROXY_HTTPS_CRT_FILE:?$PROXY_ENV_ERROR_MESSAGE}" + : "${MY127WS_PROXY_HTTPS_KEY_FILE:?$PROXY_ENV_ERROR_MESSAGE}" - run curl --fail --location --output traefik/root/tls/my127.site.crt "$(ws global config get global.service.proxy.https.crt)" - run curl --fail --location --output traefik/root/tls/my127.site.key "$(ws global config get global.service.proxy.https.key)" + validate_tls_filenames "$MY127WS_PROXY_HTTPS_CRT_FILE" "$MY127WS_PROXY_HTTPS_KEY_FILE" + + run mkdir -p traefik/root/tls traefik/root/config + + run curl --fail --location --output "traefik/root/tls/${MY127WS_PROXY_HTTPS_CRT_FILE}" "${MY127WS_PROXY_HTTPS_CRT}" + run curl --fail --location --output "traefik/root/tls/${MY127WS_PROXY_HTTPS_KEY_FILE}" "${MY127WS_PROXY_HTTPS_KEY}" + write_tls_config "${MY127WS_PROXY_HTTPS_CRT_FILE}" "${MY127WS_PROXY_HTTPS_KEY_FILE}" run docker-compose -p my127ws-proxy up --force-recreate --build -d traefik fi ) @@ -55,6 +62,41 @@ restart() enable } +validate_tls_filenames() +{ + validate_tls_filename "$1" + validate_tls_filename "$2" + + if [ "$1" = "$2" ]; then + echo "TLS certificate and key filenames must be different." >&2 + exit 1 + fi +} + +validate_tls_filename() +{ + case "$1" in + ""|.|..|*/*) + echo "Invalid TLS filename: $1" >&2 + exit 1 + ;; + *) + ;; + esac +} + +write_tls_config() +{ + cat > traefik/root/config/tls.yaml < "${TRAEFIK_CONFIG}" rm "${TRAEFIK_CONFIG}.before-tracing-active" - passthru ws global service proxy restart + passthru "${MY127WS_WORKSPACE_BIN:-ws}" global service proxy restart fi ) @@ -51,7 +51,7 @@ stop() sed 's/\[tracing\]/\[inactive.tracing\]/' "${TRAEFIK_CONFIG}.before-tracing-inactive" > "${TRAEFIK_CONFIG}" rm "${TRAEFIK_CONFIG}.before-tracing-inactive" if [ "$DO_PROXY_RESTART" = "yes" ]; then - passthru ws global service proxy restart + passthru "${MY127WS_WORKSPACE_BIN:-ws}" global service proxy restart fi fi run docker-compose -p my127ws-tracing down -v --rmi local diff --git a/home/workspace.yml b/home/workspace.yml index 1a53940b..a8c1df53 100644 --- a/home/workspace.yml +++ b/home/workspace.yml @@ -3,21 +3,26 @@ workspace('global'): description: Manage and create your various workspaces -command('create [ [--no-install] ]', 'create'): | - #!php(cwd:/) - $name = $input->argument('name'); - $harness = $input->argument('harness'); +command('create [ [--no-install] ]', 'create'): + description: Create a new workspace, optionally using a harness + exec: | + #!php(cwd:/) + $name = $input->argument('name'); + $harness = $input->argument('harness'); - $ws->create($name, $harness); + $ws->create($name, $harness); - if ($input->option('no-install') !== true) { - $ws->passthru("cd $name; ws install"); - } + if ($input->option('no-install') !== true) { + $ws->passthru("cd $name; ws install"); + } attributes.default: global: service: proxy: + domain: my127.site https: crt: https://my127.io/workspace/my127.site.crt key: https://my127.io/workspace/my127.site.key + crt_file: = @('global.service.proxy.domain') ~ '.crt' + key_file: = @('global.service.proxy.domain') ~ '.key' diff --git a/src/Types/Attribute/Collection.php b/src/Types/Attribute/Collection.php index d62b049f..1ac4d367 100644 --- a/src/Types/Attribute/Collection.php +++ b/src/Types/Attribute/Collection.php @@ -47,10 +47,12 @@ function (&$value) { $value['source'], function (&$source) { // de-dupe when attribute defined twice in same file with attribute('...') and yaml - $source = is_array($source) ? $source[0] : $source; + $source = is_array($source) ? end($source) : $source; } ); - ksort($value['source']); + uksort($value['source'], function ($a, $b) { + return (int) substr($a, 1) <=> (int) substr($b, 1); + }); } ); } diff --git a/src/Types/Workspace/Workspace.php b/src/Types/Workspace/Workspace.php index 090de90d..e50576ca 100644 --- a/src/Types/Workspace/Workspace.php +++ b/src/Types/Workspace/Workspace.php @@ -141,7 +141,7 @@ public function offsetExists($offset): bool return isset($this->attributes[$offset]); } - public function offsetGet($offset): ?string + public function offsetGet($offset): mixed { return $this->attributes->get($offset); } diff --git a/tests/Test/Application/GlobalProxyConfigurationTest.php b/tests/Test/Application/GlobalProxyConfigurationTest.php new file mode 100644 index 00000000..521af182 --- /dev/null +++ b/tests/Test/Application/GlobalProxyConfigurationTest.php @@ -0,0 +1,308 @@ +isolatedHomeEnvironment(); + + self::assertSame("my127.site\n", $this->workspaceCommand('global config get global.service.proxy.domain', null, $env)->getOutput()); + self::assertSame("https://my127.io/workspace/my127.site.crt\n", $this->workspaceCommand('global config get global.service.proxy.https.crt', null, $env)->getOutput()); + self::assertSame("https://my127.io/workspace/my127.site.key\n", $this->workspaceCommand('global config get global.service.proxy.https.key', null, $env)->getOutput()); + self::assertSame("my127.site.crt\n", $this->workspaceCommand('global config get global.service.proxy.https.crt_file', null, $env)->getOutput()); + self::assertSame("my127.site.key\n", $this->workspaceCommand('global config get global.service.proxy.https.key_file', null, $env)->getOutput()); + } + + public function testProxyDomainCanBeOverriddenFromGlobalConfigFile(): void + { + $env = $this->isolatedHomeEnvironment(<<<'YAML' +attribute('global.service.proxy.domain'): dev.example.test +attribute('global.service.proxy.https.crt'): https://certs.example.test/dev.example.test.crt +attribute('global.service.proxy.https.key'): https://certs.example.test/dev.example.test.key +YAML + ); + + self::assertSame("dev.example.test\n", $this->workspaceCommand('global config get global.service.proxy.domain', null, $env)->getOutput()); + self::assertSame("https://certs.example.test/dev.example.test.crt\n", $this->workspaceCommand('global config get global.service.proxy.https.crt', null, $env)->getOutput()); + self::assertSame("https://certs.example.test/dev.example.test.key\n", $this->workspaceCommand('global config get global.service.proxy.https.key', null, $env)->getOutput()); + self::assertSame("dev.example.test.crt\n", $this->workspaceCommand('global config get global.service.proxy.https.crt_file', null, $env)->getOutput()); + self::assertSame("dev.example.test.key\n", $this->workspaceCommand('global config get global.service.proxy.https.key_file', null, $env)->getOutput()); + } + + public function testProxyCertificateFilenamesCanBeOverriddenFromGlobalConfigFile(): void + { + $env = $this->isolatedHomeEnvironment(<<<'YAML' +attribute('global.service.proxy.domain'): dev.example.test +attribute('global.service.proxy.https.crt_file'): proxy.crt +attribute('global.service.proxy.https.key_file'): proxy.key +YAML + ); + + self::assertSame("proxy.crt\n", $this->workspaceCommand('global config get global.service.proxy.https.crt_file', null, $env)->getOutput()); + self::assertSame("proxy.key\n", $this->workspaceCommand('global config get global.service.proxy.https.key_file', null, $env)->getOutput()); + } + + public function testGlobalServiceComposeFilesUseConfiguredProxyDomainVariable(): void + { + $root = dirname(__DIR__, 3); + + self::assertStringContainsString('Host(`${MY127WS_PROXY_DOMAIN:-my127.site}`)', file_get_contents($root . '/home/service/proxy/docker-compose.yml')); + self::assertStringContainsString('Host(`mail.${MY127WS_PROXY_DOMAIN:-my127.site}`)', file_get_contents($root . '/home/service/mail/docker-compose.yml')); + self::assertStringContainsString('Host(`kibana.${MY127WS_PROXY_DOMAIN:-my127.site}`)', file_get_contents($root . '/home/service/logger/docker-compose.yml')); + self::assertStringContainsString('Host(`tracing.${MY127WS_PROXY_DOMAIN:-my127.site}`)', file_get_contents($root . '/home/service/tracing/docker-compose.yml')); + self::assertFileDoesNotExist($root . '/home/service/proxy/traefik/root/config/tls.yaml'); + } + + public function testDirectMailServicePathExportsConfiguredProxyDomain(): void + { + $env = $this->isolatedHomeEnvironment(<<<'YAML' +attribute('global.service.proxy.domain'): dev.example.test +YAML + ); + $this->workspaceCommand('', null, $env); + + $this->prepareFakeServiceTools($env, <<<'BASH' +#!/bin/bash +echo "$MY127WS_PROXY_DOMAIN" > "$MY127WS_TEST_OUTPUT" +BASH + ); + $env['MY127WS_TEST_OUTPUT'] = $this->workspace()->path('proxy-domain-output'); + + $this->workspace()->put('workspace.yml', <<<'YAML' +command('direct service mail enable'): | + #!bash + ws-service mail enable +YAML + ); + + $this->workspaceCommand('direct service mail enable', null, $env); + + self::assertSame("dev.example.test\n", file_get_contents($env['MY127WS_TEST_OUTPUT'])); + } + + public function testDirectMailServicePathIgnoresProjectProxyConfiguration(): void + { + $env = $this->isolatedHomeEnvironment(<<<'YAML' +attribute('global.service.proxy.domain'): global.example.test +YAML + ); + $this->workspaceCommand('', null, $env); + + $this->prepareFakeServiceTools($env, <<<'BASH' +#!/bin/bash +echo "$MY127WS_PROXY_DOMAIN" > "$MY127WS_TEST_OUTPUT" +BASH + ); + $env['MY127WS_TEST_OUTPUT'] = $this->workspace()->path('proxy-domain-output'); + + $this->workspace()->put('workspace.yml', <<<'YAML' +workspace('proxy-test'): ~ +attribute.override('global.service.proxy.domain'): project.example.test +command('direct service mail enable'): | + #!bash + ws-service mail enable +YAML + ); + + $this->workspaceCommand('direct service mail enable', null, $env); + + self::assertSame("global.example.test\n", file_get_contents($env['MY127WS_TEST_OUTPUT'])); + } + + public function testGlobalProxyServiceCommandUsesGlobalConfigurationFromProject(): void + { + $env = $this->isolatedHomeEnvironment(<<<'YAML' +attribute('global.service.proxy.domain'): global.example.test +attribute('global.service.proxy.https.crt'): https://certs.example.test/global.crt +attribute('global.service.proxy.https.key'): https://certs.example.test/global.key +YAML + ); + $this->workspaceCommand('', null, $env); + + $this->prepareFakeProxyServiceTools($env); + $env['MY127WS_TEST_OUTPUT'] = $this->workspace()->path('proxy-service-output'); + + $this->workspace()->put('workspace.yml', <<<'YAML' +workspace('proxy-test'): ~ +attribute.override('global.service.proxy.domain'): project.example.test +attribute.override('global.service.proxy.https.crt'): https://certs.example.test/project.crt +attribute.override('global.service.proxy.https.key'): https://certs.example.test/project.key +YAML + ); + + $this->workspaceCommand('global service proxy restart', null, $env); + + $expected = <<<'TEXT' +global.example.test +https://certs.example.test/global.crt +https://certs.example.test/global.key +global.example.test.crt +global.example.test.key +tls: + stores: + default: + defaultCertificate: + certFile: /tls/global.example.test.crt + keyFile: /tls/global.example.test.key +TEXT + ; + self::assertSame($expected . "\n", file_get_contents($env['MY127WS_TEST_OUTPUT'])); + } + + public function testGlobalProxyServiceCommandUsesInstalledDefaultsFromProject(): void + { + $env = $this->isolatedHomeEnvironment(); + $this->workspaceCommand('', null, $env); + + $this->prepareFakeProxyServiceTools($env); + $env['MY127WS_TEST_OUTPUT'] = $this->workspace()->path('proxy-service-output'); + + $this->createProxyTestWorkspace(); + + $this->workspaceCommand('global service proxy restart', null, $env); + + $expected = <<<'TEXT' +my127.site +https://my127.io/workspace/my127.site.crt +https://my127.io/workspace/my127.site.key +my127.site.crt +my127.site.key +tls: + stores: + default: + defaultCertificate: + certFile: /tls/my127.site.crt + keyFile: /tls/my127.site.key +TEXT + ; + self::assertSame($expected . "\n", file_get_contents($env['MY127WS_TEST_OUTPUT'])); + } + + public function testGlobalProxyServiceCommandRejectsInvalidTlsFilename(): void + { + $env = $this->isolatedHomeEnvironment(<<<'YAML' +attribute('global.service.proxy.domain'): global.example.test +attribute('global.service.proxy.https.crt'): https://certs.example.test/global.crt +attribute('global.service.proxy.https.key'): https://certs.example.test/global.key +attribute('global.service.proxy.https.crt_file'): ../global.crt +attribute('global.service.proxy.https.key_file'): global.key +YAML + ); + $this->workspaceCommand('', null, $env); + $this->prepareFakeNoopProxyServiceTools($env); + + $this->createProxyTestWorkspace(); + $process = $this->workspaceProcess('global service proxy restart', null, $env); + $process->run(); + + self::assertNotSame(0, $process->getExitCode()); + self::assertStringContainsString('Invalid TLS filename: ../global.crt', $process->getErrorOutput()); + } + + public function testGlobalProxyServiceCommandRejectsSameTlsFilenames(): void + { + $env = $this->isolatedHomeEnvironment(<<<'YAML' +attribute('global.service.proxy.domain'): global.example.test +attribute('global.service.proxy.https.crt'): https://certs.example.test/global.crt +attribute('global.service.proxy.https.key'): https://certs.example.test/global.key +attribute('global.service.proxy.https.crt_file'): global.pem +attribute('global.service.proxy.https.key_file'): global.pem +YAML + ); + $this->workspaceCommand('', null, $env); + $this->prepareFakeNoopProxyServiceTools($env); + + $this->createProxyTestWorkspace(); + $process = $this->workspaceProcess('global service proxy restart', null, $env); + $process->run(); + + self::assertNotSame(0, $process->getExitCode()); + self::assertStringContainsString('TLS certificate and key filenames must be different.', $process->getErrorOutput()); + } + + private function isolatedHomeEnvironment(?string $globalConfig = null): array + { + $home = $this->workspace()->path('home'); + + if ($globalConfig !== null) { + $this->workspace()->put('home/.config/my127/workspace/proxy.yml', $globalConfig); + } + + return ['MY127WS_HOME' => $home]; + } + + private function createProxyTestWorkspace(): void + { + $this->workspace()->put('workspace.yml', "workspace('proxy-test'): ~\n"); + } + + private function prepareFakeProxyServiceTools(array &$env): void + { + $dockerComposeScript = <<<'BASH' +#!/bin/bash +{ + printf '%s\n' "$MY127WS_PROXY_DOMAIN" + printf '%s\n' "$MY127WS_PROXY_HTTPS_CRT" + printf '%s\n' "$MY127WS_PROXY_HTTPS_KEY" + printf '%s\n' "$MY127WS_PROXY_HTTPS_CRT_FILE" + printf '%s\n' "$MY127WS_PROXY_HTTPS_KEY_FILE" + cat traefik/root/config/tls.yaml +} > "$MY127WS_TEST_OUTPUT" +BASH; + + $this->prepareFakeServiceTools($env, $dockerComposeScript, true); + } + + private function prepareFakeNoopProxyServiceTools(array &$env): void + { + $dockerComposeScript = <<<'BASH' +#!/bin/bash +exit 0 +BASH; + + $this->prepareFakeServiceTools($env, $dockerComposeScript, true); + } + + private function prepareFakeServiceTools(array &$env, string $dockerComposeScript, bool $includeCurl = false): void + { + $root = dirname(__DIR__, 3); + $fakeBin = $this->workspace()->path('fake-bin'); + mkdir($fakeBin); + symlink($root . '/bin/workspace', $fakeBin . '/ws'); + $this->workspace()->put('fake-bin/docker', <<<'BASH' +#!/bin/bash +exit 0 +BASH + ); + $this->workspace()->put('fake-bin/docker-compose', $dockerComposeScript); + chmod($this->workspace()->path('fake-bin/docker'), 0755); + chmod($this->workspace()->path('fake-bin/docker-compose'), 0755); + + if ($includeCurl) { + $this->workspace()->put('fake-bin/curl', <<<'BASH' +#!/bin/bash +output="" + +while [ "$#" -gt 0 ]; do + if [ "$1" = "--output" ]; then + shift + output="$1" + fi + shift || true +done + +if [ -n "$output" ]; then + printf 'fake certificate\n' > "$output" +fi +BASH + ); + chmod($this->workspace()->path('fake-bin/curl'), 0755); + } + + $env['PATH'] = $fakeBin . ':' . getenv('PATH'); + } +} diff --git a/tests/Test/Types/AttributeTest.php b/tests/Test/Types/AttributeTest.php index 10dbb89f..f81ee732 100644 --- a/tests/Test/Types/AttributeTest.php +++ b/tests/Test/Types/AttributeTest.php @@ -214,4 +214,36 @@ public function duplicateAttributeDefinitionDoesNotCauseErrorInConfigDump() $this->assertFalse(strpos($this->workspaceCommand('config dump --key=message')->getOutput(), 'World')); } + + /** @test */ + public function attributeMetadataSourceUsesLatestSourceAtTheSamePrecedence() + { + $attributes = new AttributeCollection(new Expression(new CWD())); + + $attributes->add(['message' => 'first'], 'first.yml', 1); + $attributes->add(['message' => 'second'], 'second.yml', 1); + + $metadata = $attributes->getAttributeMetadata('message'); + $this->assertNotNull($metadata); + + $sources = $metadata['source']; + $this->assertEquals('second.yml', array_pop($sources)); + $this->assertEquals('second', $attributes->get('message')); + } + + /** @test */ + public function attributeMetadataSourceUsesHighestNumericPrecedence() + { + $attributes = new AttributeCollection(new Expression(new CWD())); + + $attributes->add(['message' => 'normal'], 'normal.yml', 6); + $attributes->add(['message' => 'environment'], 'environment.yml', 10); + + $metadata = $attributes->getAttributeMetadata('message'); + $this->assertNotNull($metadata); + + $sources = $metadata['source']; + $this->assertEquals('environment.yml', array_pop($sources)); + $this->assertEquals('environment', $attributes->get('message')); + } } diff --git a/tests/Test/Types/FunctionTest.php b/tests/Test/Types/FunctionTest.php index c9e4f038..ee5d0692 100644 --- a/tests/Test/Types/FunctionTest.php +++ b/tests/Test/Types/FunctionTest.php @@ -116,4 +116,21 @@ function('array', [v1, v2]): | $this->assertEquals('["2","2"]', $this->workspaceCommand('array 2 2')->getOutput()); } + + /** @test */ + public function workspaceArrayAccessCanReturnArrayAttributes() + { + $this->createWorkspaceYml(<<<'EOD' +attribute('values'): + - one + - two + +command('values'): | + #!php + echo json_encode($ws['values']); +EOD + ); + + $this->assertEquals('["one","two"]', $this->workspaceCommand('values')->getOutput()); + } }