Skip to content

Commit

Permalink
Add features needed by Replatforming.
Browse files Browse the repository at this point in the history
On Kubernetes, we want to be able to refer to services by their short
names (single-label domains), for example the DNS name for Content Store
should be just `content-store` and the local resolver expands that to
`content-store.apps.svc.cluster.local`. This simplifies GOV.UK's Helm
charts by eliminating the need to construct domain names based on the
environment name.

We add a couple of env vars to support this usage:

- When `PLEK_USE_HTTP_FOR_SINGLE_LABEL_DOMAINS=1` (or any reasonable
  "truthy" value), we return `http://` URLs instead of the default
  `https://` when there is no domain suffix to be appended, for example
  Plek.new.find("content-store") returns `http://content-store`. This
  significantly reduces configuration complexity in our Helm charts.
  It also paves the way for retiring Plek or at least removing most of
  its url-constructing logic in future.
  (Despite appearances, this is not a retrograde step with regard to
  security. The old system doesn't actually use TLS between the load
  balancer and the EC2 VMs anyway, so this is equivalent to what we
  already have. The http-only traffic stays in-region and within the VPC
  in both the old and new systems.)

- `PLEK_UNPREFIXABLE_HOSTS` is a comma-separated list of names to be
  excluded from prefixing with `PLEK_HOSTNAME_PREFIX`. This simplifies
  the configuration of the draft stack by saving us from having to
  specify a bunch of overrides for every draft app.

Also refactor find() to make the logic easier to follow.

This change is intended to be backward compatible with all current usage
in govuk-puppet and client apps. In other words it's only supposed to
affect Replatforming and not the existing setup.

Other minor changes:

- Remove public setter methods for `parent_domain` and
  `external_domain`. We no longer use these privately and we definitely
  don't want them to be part of the public interface, because that would
  make it harder to retire Plek. Theoretically a back-compatibility
  break, but there is literally no usage of these setters in alphagov.

The implementation of `unprefixable_hosts` uses a Ruby array rather than
a hash set. This is asymptotically not good, but we anticipate low
single-digit numbers of list entries so linear search ought to be just
fine.

Tested: added tests to cover the new env vars.
  • Loading branch information
sengi committed Sep 30, 2022
1 parent 81624d1 commit e1c35dc
Show file tree
Hide file tree
Showing 4 changed files with 118 additions and 38 deletions.
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
# CHANGELOG

# Unreleased

* Allow setting `GOVUK_APP_DOMAIN=""` (empty string). Similarly for
`GOVUK_APP_DOMAIN_EXTERNAL`. This allows single-label domains to be used in
service URLs instead of FQDNs, which eliminates a lot of configuration
complexity when running on Kubernetes. This also paves the way for
eventually retiring Plek, if we want.
* Take an optional, comma-separated list of hostnames
`PLEK_UNPREFIXABLE_HOSTS` not to be prefixed even when
`PLEK_HOSTNAME_PREFIX` is set. This simplifies the configuration of the
draft stack in Kubernetes.
* Support using `http` as the URL scheme for single-label domains when
`PLEK_USE_HTTP_FOR_SINGLE_LABEL_DOMAINS=1`. (A single-label domain looks
like `content-store`, as opposed to `content-store.test.govuk.digital`.)
This is is needed in order to run in Kubernetes without a service mesh,
without hard-to-maintain configuration logic to generate domains names
depending on the environment.

# 4.0.0

* Remove #public_asset_host method since it is no longer used by any GOV.UK apps.
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,20 @@ To domain is based on the environment, and defaults to 'dev.gov.uk'. The environ

You can prepend strings to the hostnames generated using: `PLEK_HOSTNAME_PREFIX`.

If `PLEK_HOSTNAME_PREFIX` is present, it will be prepended to the hostname
unless the hostname appears in the comma-separated list
`PLEK_UNPREFIXABLE_HOSTS`.

Override the asset URL with: `GOVUK_ASSET_ROOT`. The default is to generate a URL for the `static` service.

Override the website root with `GOVUK_WEBSITE_ROOT`. The default is to generate a URL for the `www` service.

If `PLEK_USE_HTTP_FOR_SINGLE_LABEL_DOMAINS=1` (or anything beginning with `t`
or `y`), Plek will use `http` as the URL scheme instead of `https` for
single-label domains. Single-label domains are domains with just a single name
component, for example `frontend` or `content-store`, as opposed to
`frontend.example.com` or `content-store.test.govuk.digital`.

## Licence

[MIT License](LICENCE)
Expand Down
82 changes: 45 additions & 37 deletions lib/plek.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,7 @@ class NoConfigurationError < StandardError; end
# The fallback parent domain to use in development mode.
DEV_DOMAIN = "dev.gov.uk".freeze

# Domains to return http URLs for.
HTTP_DOMAINS = [DEV_DOMAIN].freeze

attr_accessor :parent_domain, :external_domain
attr_reader :parent_domain, :external_domain

# Construct a new Plek instance.
#
Expand All @@ -37,8 +34,12 @@ class NoConfigurationError < StandardError; end
# +GOVUK_APP_DOMAIN_EXTERNAL+ and if that is unavailable the parent domain
# will be used
def initialize(domain_to_use = nil, external_domain = nil)
self.parent_domain = domain_to_use || env_var_or_dev_fallback("GOVUK_APP_DOMAIN", DEV_DOMAIN)
self.external_domain = external_domain || ENV["GOVUK_APP_DOMAIN_EXTERNAL"] || parent_domain
truth_re = /^[1ty]/i
@parent_domain = domain_to_use || env_var_or_dev_fallback("GOVUK_APP_DOMAIN", DEV_DOMAIN)
@external_domain = external_domain || ENV.fetch("GOVUK_APP_DOMAIN_EXTERNAL", @parent_domain)
@host_prefix = ENV.fetch("PLEK_HOSTNAME_PREFIX", "")
@unprefixable_hosts = ENV.fetch("PLEK_UNPREFIXABLE_HOSTS", "").split(",").map(&:strip)
@use_http_for_single_label_domains = truth_re.match?(ENV.fetch("PLEK_USE_HTTP_FOR_SINGLE_LABEL_DOMAINS", ""))
end

# Find the base URL for a service/application. This constructs the URL from
Expand All @@ -47,12 +48,20 @@ def initialize(domain_to_use = nil, external_domain = nil)
# will be https.
#
# If PLEK_HOSTNAME_PREFIX is present in the environment, it will be prepended
# to the hostname.
# to the hostname unless the hostname appears in the comma-separated list
# PLEK_UNPREFIXABLE_HOSTS.
#
# If PLEK_USE_HTTP_FOR_SINGLE_LABEL_DOMAINS=1 in the environment, Plek will use
# "http" as the URL scheme instead of "https" for single-label domains.
# Single-label domains are domains with just a single name component, for
# example "frontend" or "content-store", as opposed to
# "frontend.example.com" or "content-store.test.govuk.digital".
#
# The URL for a given service can be overridden by setting a corresponding
# environment variable. eg if +PLEK_SERVICE_EXAMPLE_CHEESE_THING_URI+ was
# set, +Plek.new.find('example-cheese-thing')+ would return the value of that
# variable.
# variable. This overrides both the "internal" and "external" URL for the
# service. It is not possible to override them separately.
#
# @param service [String] the name of the service to lookup. This should be
# the hostname of the service.
Expand All @@ -62,24 +71,25 @@ def initialize(domain_to_use = nil, external_domain = nil)
# scheme (eg `//foo.example.com`)
# @return [String] The base URL for the service.
def find(service, options = {})
name = name_for(service)
name = clean_name(service)
if (service_uri = defined_service_uri_for(name))
return service_uri
end

host = "#{name}.#{options[:external] ? external_domain : parent_domain}"
name = "#{host_prefix}#{name}" unless unprefixable_hosts.include?(name)

if (host_prefix = ENV["PLEK_HOSTNAME_PREFIX"])
host = "#{host_prefix}#{host}"
end
domain = options[:external] ? external_domain : parent_domain
domain_suffix = domain.empty? ? "" : ".#{domain}"

if options[:scheme_relative]
"//#{host}".freeze
elsif options[:force_http] || HTTP_DOMAINS.include?(parent_domain)
"http://#{host}".freeze
else
"https://#{host}".freeze
end
scheme = if options[:scheme_relative]
""
elsif options[:force_http] || http_domain?(domain)
"http:"
else
"https:"
end

"#{scheme}//#{name}#{domain_suffix}".freeze
end

# Find the external URL for a service/application.
Expand Down Expand Up @@ -128,15 +138,7 @@ def website_uri
URI(website_root)
end

# @api private
def name_for(service)
name = service.to_s.dup
name.downcase!
name.strip!
name.gsub!(/[^a-z.-]+/, "")
name
end

# TODO: clean up all references to these and then remove them.
class << self
# This alias allows calls to be made in the old style:
# Plek.current.find('foo')
Expand All @@ -159,6 +161,17 @@ def find_uri(*args)

private

attr_reader :host_prefix, :unprefixable_hosts, :use_http_for_single_label_domains

# TODO: clean up call sites throughout alphagov and then delete clean_name.
def clean_name(service)
service.to_s.downcase.strip.gsub(/[^a-z.-]+/, "")
end

def http_domain?(domain)
domain == DEV_DOMAIN || domain == "" && use_http_for_single_label_domains
end

def env_var_or_dev_fallback(var_name, fallback_str = nil)
if (var = ENV[var_name])
var
Expand All @@ -172,13 +185,8 @@ def env_var_or_dev_fallback(var_name, fallback_str = nil)
end

def defined_service_uri_for(service)
service_name = service.upcase.gsub(/-/, "_")
var_name = "PLEK_SERVICE_#{service_name}_URI"

if (uri = ENV[var_name]) && !uri.empty?
return uri
end

nil
service_name = service.upcase.tr("-", "_")
uri = ENV.fetch("PLEK_SERVICE_#{service_name}_URI", "")
uri.empty? ? nil : uri
end
end
46 changes: 45 additions & 1 deletion test/plek_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -97,14 +97,58 @@ def test_should_prepend_data_from_the_environment
end
end

def test_unprefixable_hosts_are_not_prefixed
ClimateControl.modify PLEK_HOSTNAME_PREFIX: "draft-",
PLEK_UNPREFIXABLE_HOSTS: "signon,feedback" do
p = Plek.new("test.govuk.digital")
assert_equal "https://draft-content-store.test.govuk.digital", p.find("content-store")
assert_equal "https://signon.test.govuk.digital", p.find("signon")
assert_equal "https://feedback.test.govuk.digital", p.find("feedback")
end
end

def test_use_http_for_single_label_domains
ClimateControl.modify PLEK_USE_HTTP_FOR_SINGLE_LABEL_DOMAINS: "1",
GOVUK_APP_DOMAIN: "" do
p = Plek.new
assert_equal "http://frontend", p.find("frontend")
end
end

def test_http_for_single_label_domains_doesnt_affect_others
ClimateControl.modify PLEK_USE_HTTP_FOR_SINGLE_LABEL_DOMAINS: "1",
GOVUK_APP_DOMAIN: "",
GOVUK_APP_DOMAIN_EXTERNAL: "example.com" do
p = Plek.new
assert_equal "https://foo.example.com", p.external_url_for("foo")
end
end

def test_dev_domain_is_http_if_no_http_domains_specified
p = Plek.new
assert_equal "http://signon.dev.gov.uk", p.find("signon")
end

def test_scheme_relative_urls
url = Plek.new("dev.gov.uk").find("service", scheme_relative: true)
assert_equal "//service.dev.gov.uk", url
end

def test_should_return_external_domain
ClimateControl.modify GOVUK_APP_DOMAIN_EXTERNAL: "baz.external" do
assert_equal "http://foo.baz.external", Plek.new.external_url_for("foo")
assert_equal "https://foo.baz.external", Plek.new.external_url_for("foo")
end
end

def test_accepts_empty_domain_suffix
p = Plek.new("")
assert_equal "https://content-store", p.find("content-store")
end

def test_accepts_empty_domain_suffix_via_environment
ClimateControl.modify GOVUK_APP_DOMAIN: "",
GOVUK_APP_DOMAIN_EXTERNAL: "example.com" do
assert_equal "https://content-store", Plek.new.find("content-store")
end
end
end

0 comments on commit e1c35dc

Please sign in to comment.