From 1946ab75d67628cfa7d57c26fc502c4980941d65 Mon Sep 17 00:00:00 2001 From: aidenvaines-cgi Date: Wed, 8 Apr 2026 11:00:27 +0100 Subject: [PATCH 01/10] CCM-16446 Adding repo support and pre-commit fixes --- .devcontainer/devcontainer.json | 14 +- .gitignore | 10 + .tool-versions | 3 +- .vscode/settings.json | 19 - docs/.vscode/launch.json | 8 +- docs/.vscode/settings.json | 14 +- docs/assets/js/nhsuk.min.js | 2 +- docs/package.json | 30 +- .../using-nhs-notify/(draft)email-branding.md | 1 - .../(draft)letter-branding.md | 1 - .../.gitignore | 6 +- infrastructure/terraform/.gitignore | 67 ++ infrastructure/terraform/bin/terraform.sh | 809 ++++++++++++++++++ .../terraform/components/cms/.tool-versions | 1 + infrastructure/terraform/etc/.gitkeep | 0 infrastructure/terraform/modules/.gitkeep | 0 scripts/config/pre-commit.yaml | 67 +- .../config/vocabularies/words/accept.txt | 74 +- scripts/docker/docker.lib.sh | 135 +++ scripts/docker/docker.mk | 105 +-- .../examples/python/.tool-versions.example | 2 - scripts/docker/examples/python/Dockerfile | 33 - .../examples/python/Dockerfile.effective | 54 -- scripts/docker/examples/python/VERSION | 1 - .../examples/python/assets/hello_world/app.py | 12 - .../assets/hello_world/requirements.txt | 12 - .../docker/examples/python/tests/goss.yaml | 8 - scripts/docker/tests/.gitignore | 1 - scripts/docker/tests/.tool-versions.test | 2 - scripts/docker/tests/Dockerfile | 3 - scripts/docker/tests/VERSION | 3 - scripts/docker/tests/docker.test.sh | 162 ---- .../examples/terraform-state-aws-s3/main.tf | 46 - .../terraform-state-aws-s3/provider.tf | 3 - .../terraform-state-aws-s3/variables.tf | 9 - .../terraform-state-aws-s3/versions.tf | 8 - scripts/terraform/terraform-docs.sh | 82 ++ scripts/terraform/terraform.lib.sh | 93 -- scripts/terraform/terraform.mk | 224 +++-- 39 files changed, 1415 insertions(+), 709 deletions(-) delete mode 100644 .vscode/settings.json rename {scripts/terraform/examples/terraform-state-aws-s3 => infrastructure}/.gitignore (91%) create mode 100644 infrastructure/terraform/.gitignore create mode 100755 infrastructure/terraform/bin/terraform.sh create mode 100644 infrastructure/terraform/components/cms/.tool-versions create mode 100644 infrastructure/terraform/etc/.gitkeep create mode 100644 infrastructure/terraform/modules/.gitkeep delete mode 100644 scripts/docker/examples/python/.tool-versions.example delete mode 100644 scripts/docker/examples/python/Dockerfile delete mode 100644 scripts/docker/examples/python/Dockerfile.effective delete mode 100644 scripts/docker/examples/python/VERSION delete mode 100644 scripts/docker/examples/python/assets/hello_world/app.py delete mode 100644 scripts/docker/examples/python/assets/hello_world/requirements.txt delete mode 100644 scripts/docker/examples/python/tests/goss.yaml delete mode 100644 scripts/docker/tests/.gitignore delete mode 100644 scripts/docker/tests/.tool-versions.test delete mode 100644 scripts/docker/tests/Dockerfile delete mode 100644 scripts/docker/tests/VERSION delete mode 100755 scripts/docker/tests/docker.test.sh delete mode 100644 scripts/terraform/examples/terraform-state-aws-s3/main.tf delete mode 100644 scripts/terraform/examples/terraform-state-aws-s3/provider.tf delete mode 100644 scripts/terraform/examples/terraform-state-aws-s3/variables.tf delete mode 100644 scripts/terraform/examples/terraform-state-aws-s3/versions.tf create mode 100755 scripts/terraform/terraform-docs.sh delete mode 100644 scripts/terraform/terraform.lib.sh diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index c8a6205f..b6e56c60 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,7 +1,11 @@ { "customizations": { "codespaces": { - "openFiles": ["README.md", ".github/SECURITY.md", "docs/pages/index.md"] + "openFiles": [ + "README.md", + ".github/SECURITY.md", + "docs/pages/index.md" + ] }, "vscode": { "extensions": [ @@ -55,8 +59,12 @@ } } }, - "forwardPorts": [4000], + "forwardPorts": [ + 4000 + ], "image": "ghcr.io/nhsdigital/nhs-notify-template-repository:latest", "name": "Jekyll", - "runArgs": ["--platform=linux/amd64"] + "runArgs": [ + "--platform=linux/amd64" + ] } diff --git a/.gitignore b/.gitignore index dffddb17..3cbb7eb5 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,13 @@ version.json # Please, add your custom content below! .reports +# dependencies +node_modules +.node-version +*/node_modules +/.pnp +.pnp.js +/build +dist +.DS_Store +.reports diff --git a/.tool-versions b/.tool-versions index 3e5e8ed3..b702444d 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,7 +1,8 @@ gitleaks 8.18.4 nodejs 18.18.2 pre-commit 3.6.0 -terraform 1.7.0 +terraform 1.14.8 +terraform-docs 0.19.0 vale 3.6.0 python 3.13.2 diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 1b06d69a..00000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "files.exclude": { - "**/.git": true, - "**/.svn": true, - "**/.hg": true, - "**/CVS": true, - "**/.DS_Store": true, - "**/Thumbs.db": true, - "**/.ruby-lsp": true, - "docs": true, - ".devcontainer": true, - ".github": true, - ".vscode": false, - "infrastructure": true, - "scripts": true, - "terraform": true, - "tests": true - } -} diff --git a/docs/.vscode/launch.json b/docs/.vscode/launch.json index d1a5f947..c70893cb 100644 --- a/docs/.vscode/launch.json +++ b/docs/.vscode/launch.json @@ -5,9 +5,13 @@ "name": "Debug", "preLaunchTask": "stop-already-running", "request": "launch", - "runtimeArgs": ["debug"], + "runtimeArgs": [ + "debug" + ], "runtimeExecutable": "make", - "skipFiles": ["/**"], + "skipFiles": [ + "/**" + ], "type": "node" } ], diff --git a/docs/.vscode/settings.json b/docs/.vscode/settings.json index f2c972c5..49e4e58b 100644 --- a/docs/.vscode/settings.json +++ b/docs/.vscode/settings.json @@ -1,16 +1,16 @@ { "files.exclude": { + "**/.DS_Store": true, "**/.git": true, - "**/.svn": true, "**/.hg": true, + "**/.ruby-lsp": true, + "**/.svn": true, "**/CVS": true, - "**/.DS_Store": true, "**/Thumbs.db": true, - "**/.ruby-lsp": true, - "_site": true, - ".jekyll-cache": true, ".bundle": true, - "vendor": true, - "node_modules": true + ".jekyll-cache": true, + "_site": true, + "node_modules": true, + "vendor": true } } diff --git a/docs/assets/js/nhsuk.min.js b/docs/assets/js/nhsuk.min.js index 17b405ca..8f58af68 100644 --- a/docs/assets/js/nhsuk.min.js +++ b/docs/assets/js/nhsuk.min.js @@ -1 +1 @@ -(()=>{var n={405:()=>{NodeList.prototype.forEach||(NodeList.prototype.forEach=Array.prototype.forEach),Array.prototype.includes||Object.defineProperty(Array.prototype,"includes",{enumerable:!1,value:function(t){return 0{"use strict";function n(e){return(n="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e})(e)}function s(e,t,n){(t=r(t))in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n}function r(e){e=function(e){if("object"!==n(e)||null===e)return e;var t=e[Symbol.toPrimitive];if(void 0===t)return String(e);t=t.call(e,"string");if("object"!==n(t))return t;throw new TypeError("@@toPrimitive must return a primitive value.")}(e);return"symbol"===n(e)?e:String(e)}var i=function(){function t(e){if(!(this instanceof t))throw new TypeError("Cannot call a class as a function");s(this,"KEY_SPACE",32),s(this,"DEBOUNCE_TIMEOUT_IN_SECONDS",1),this.$module=e,this.debounceFormSubmitTimer=null}for(var e=t,n=e.prototype,i=[{key:"handleKeyDown",value:function(e){var t=e.target;"button"===t.getAttribute("role")&&e.keyCode===this.KEY_SPACE&&(e.preventDefault(),t.click())}},{key:"debounce",value:function(e){var t=this;if("true"===e.target.getAttribute("data-prevent-double-click"))return this.debounceFormSubmitTimer?(e.preventDefault(),!1):void(this.debounceFormSubmitTimer=setTimeout(function(){t.debounceFormSubmitTimer=null},1e3*this.DEBOUNCE_TIMEOUT_IN_SECONDS))}},{key:"init",value:function(){this.$module.addEventListener("keydown",this.handleKeyDown.bind(this)),this.$module.addEventListener("click",this.debounce.bind(this))}}],o=0;o=e.lastInputTimestamp)&&e.checkIfValueChanged()},1e3)}},{key:"handleBlur",value:function(){clearInterval(this.valueChecker)}}]),e(m,p),Object.defineProperty(m,"prototype",{writable:!1});var o=u;function u(e){if(!(this instanceof u))throw new TypeError("Cannot call a class as a function");this.$module=e,this.$textarea=e.querySelector(".nhsuk-js-character-count"),this.$visibleCountMessage=null,this.$screenReaderCountMessage=null,this.lastInputTimestamp=null}o.prototype.defaults={characterCountAttribute:"data-maxlength",wordCountAttribute:"data-maxwords"};function l(e,t){var n;e&&t&&(n="true"===e.getAttribute(t)?"false":"true",e.setAttribute(t,n))}function c(e,t){var n;e&&t&&(n=e.getAttribute("aria-controls"))&&(n=document.getElementById(n))&&(e.checked?(n.classList.remove(t),e.setAttribute("aria-expanded",!0)):(n.classList.add(t),e.setAttribute("aria-expanded",!1)))}var h=function(e){e.form.querySelectorAll('input[type="checkbox"]').forEach(function(e){return c(e,"nhsuk-checkboxes__conditional--hidden")})};function d(e){!function(e){if("A"===e.tagName&&!1!==e.href){var t,e=document.querySelector(e.hash);if(e)return(t=function(e){var t=e.closest("fieldset");if(t){t=t.getElementsByTagName("legend");if(t.length){t=t[0];if("checkbox"===e.type||"radio"===e.type)return t;var n=t.getBoundingClientRect().top,i=e.getBoundingClientRect();if(i.height&&window.innerHeight&&i.top+i.height-nthis.breakpoints[t])for(;e>this.breakpoints[t];)this.navigationList.insertBefore(this.mobileMenu.removeChild(this.mobileMenu.firstChild),this.mobileMenuContainer),t+=1;this.mobileMenu.children.length||(this.mobileMenuToggleButton.classList.remove("nhsuk-header__menu-toggle--visible"),this.mobileMenuContainer.classList.remove("nhsuk-mobile-menu-container--visible")),document.body.offsetWidth!==this.width&&this.menuIsOpen&&this.closeMobileMenu()}},{key:"doOnOrientationChange",value:function(){var e=this;90===window.orientation&&setTimeout(function(){e.calculateBreakpoints(),e.updateNavigation()},200)}}],o=n.prototype,a=i,s=0;s{var n={405:()=>{NodeList.prototype.forEach||(NodeList.prototype.forEach=Array.prototype.forEach),Array.prototype.includes||Object.defineProperty(Array.prototype,"includes",{enumerable:!1,value:function(t){return 0{"use strict";function n(e){return(n="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e})(e)}function s(e,t,n){(t=r(t))in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n}function r(e){e=function(e){if("object"!==n(e)||null===e)return e;var t=e[Symbol.toPrimitive];if(void 0===t)return String(e);t=t.call(e,"string");if("object"!==n(t))return t;throw new TypeError("@@toPrimitive must return a primitive value.")}(e);return"symbol"===n(e)?e:String(e)}var i=function(){function t(e){if(!(this instanceof t))throw new TypeError("Cannot call a class as a function");s(this,"KEY_SPACE",32),s(this,"DEBOUNCE_TIMEOUT_IN_SECONDS",1),this.$module=e,this.debounceFormSubmitTimer=null}for(var e=t,n=e.prototype,i=[{key:"handleKeyDown",value:function(e){var t=e.target;"button"===t.getAttribute("role")&&e.keyCode===this.KEY_SPACE&&(e.preventDefault(),t.click())}},{key:"debounce",value:function(e){var t=this;if("true"===e.target.getAttribute("data-prevent-double-click"))return this.debounceFormSubmitTimer?(e.preventDefault(),!1):void(this.debounceFormSubmitTimer=setTimeout(function(){t.debounceFormSubmitTimer=null},1e3*this.DEBOUNCE_TIMEOUT_IN_SECONDS))}},{key:"init",value:function(){this.$module.addEventListener("keydown",this.handleKeyDown.bind(this)),this.$module.addEventListener("click",this.debounce.bind(this))}}],o=0;o=e.lastInputTimestamp)&&e.checkIfValueChanged()},1e3)}},{key:"handleBlur",value:function(){clearInterval(this.valueChecker)}}]),e(m,p),Object.defineProperty(m,"prototype",{writable:!1});var o=u;function u(e){if(!(this instanceof u))throw new TypeError("Cannot call a class as a function");this.$module=e,this.$textarea=e.querySelector(".nhsuk-js-character-count"),this.$visibleCountMessage=null,this.$screenReaderCountMessage=null,this.lastInputTimestamp=null}o.prototype.defaults={characterCountAttribute:"data-maxlength",wordCountAttribute:"data-maxwords"};function l(e,t){var n;e&&t&&(n="true"===e.getAttribute(t)?"false":"true",e.setAttribute(t,n))}function c(e,t){var n;e&&t&&(n=e.getAttribute("aria-controls"))&&(n=document.getElementById(n))&&(e.checked?(n.classList.remove(t),e.setAttribute("aria-expanded",!0)):(n.classList.add(t),e.setAttribute("aria-expanded",!1)))}var h=function(e){e.form.querySelectorAll('input[type="checkbox"]').forEach(function(e){return c(e,"nhsuk-checkboxes__conditional--hidden")})};function d(e){!function(e){if("A"===e.tagName&&!1!==e.href){var t,e=document.querySelector(e.hash);if(e)return(t=function(e){var t=e.closest("fieldset");if(t){t=t.getElementsByTagName("legend");if(t.length){t=t[0];if("checkbox"===e.type||"radio"===e.type)return t;var n=t.getBoundingClientRect().top,i=e.getBoundingClientRect();if(i.height&&window.innerHeight&&i.top+i.height-nthis.breakpoints[t])for(;e>this.breakpoints[t];)this.navigationList.insertBefore(this.mobileMenu.removeChild(this.mobileMenu.firstChild),this.mobileMenuContainer),t+=1;this.mobileMenu.children.length||(this.mobileMenuToggleButton.classList.remove("nhsuk-header__menu-toggle--visible"),this.mobileMenuContainer.classList.remove("nhsuk-mobile-menu-container--visible")),document.body.offsetWidth!==this.width&&this.menuIsOpen&&this.closeMobileMenu()}},{key:"doOnOrientationChange",value:function(){var e=this;90===window.orientation&&setTimeout(function(){e.calculateBreakpoints(),e.updateNavigation()},200)}}],o=n.prototype,a=i,s=0;s&2; + exit 1; +}; + +## +# Print Script Version +## +function version() { + echo "${script_ver}"; +} + +## +# Print Usage Text +## +function usage() { + +cat < + +action: + - Special actions: + * plan / plan-destroy + * apply / destroy + * graph + * taint / untaint + * shell + - Generic actions: + * See https://www.terraform.io/docs/commands/ + +bucket_prefix (optional): + Defaults to: "\${project_name}-tfscaffold" + - myproject-terraform + - terraform-yourproject + - my-first-tfscaffold-project + +build_id (optional): + - testing + - \$BUILD_ID (jenkins) + +component_name: + - the name of the terraform component module in the components directory + +environment: + - dev + - test + - prod + - management + +group: + - dev + - live + - mytestgroup + +project: + - The name of the project being deployed + +region (optional): + Defaults to value of \$AWS_DEFAULT_REGION + - the AWS region name unique to all components and terraform processes + +detailed-exitcode (optional): + When not provided, false. + Changes the plan operation to exit 0 only when there are no changes. + Will be ignored for actions other than plan. + +no-color (optional): + Append -no-color to all terraform calls + +compact-warnings (optional): + Append -compact-warnings to all terraform calls + +lockfile: + Append -lockfile=MODE to calls to terraform init + +additional arguments: + Any arguments provided after "--" will be passed directly to terraform as its own arguments +EOF +}; + +## +# Test for GNU getopt +## +getopt_out=$(getopt -T) +if (( $? != 4 )) && [[ -n $getopt_out ]]; then + error_and_die "Non GNU getopt detected. If you're using a Mac then try \"brew install gnu-getopt\""; +fi + +## +# Execute getopt and process script arguments +## +readonly raw_arguments="${*}"; +ARGS=$(getopt \ + -o dhnvwa:b:c:e:g:i:l:p:r: \ + -l "help,version,bootstrap,action:,bucket-prefix:,build-id:,component:,environment:,group:,project:,region:,lockfile:,detailed-exitcode,no-color,compact-warnings" \ + -n "${0}" \ + -- \ + "$@"); + +#Bad arguments +if [ $? -ne 0 ]; then + usage; + error_and_die "command line argument parse failure"; +fi; + +eval set -- "${ARGS}"; + +declare bootstrap="false"; +declare component_arg; +declare region_arg; +declare environment_arg; +declare group; +declare action; +declare bucket_prefix; +declare build_id; +declare project; +declare detailed_exitcode; +declare no_color; +declare compact_warnings; +declare lockfile; + +while true; do + case "${1}" in + -h|--help) + usage; + exit 0; + ;; + -v|--version) + version; + exit 0; + ;; + -c|--component) + shift; + if [ -n "${1}" ]; then + component_arg="${1}"; + shift; + fi; + ;; + -r|--region) + shift; + if [ -n "${1}" ]; then + region_arg="${1}"; + shift; + fi; + ;; + -e|--environment) + shift; + if [ -n "${1}" ]; then + environment_arg="${1}"; + shift; + fi; + ;; + -g|--group) + shift; + if [ -n "${1}" ]; then + group="${1}"; + shift; + fi; + ;; + -a|--action) + shift; + if [ -n "${1}" ]; then + action="${1}"; + shift; + fi; + ;; + -b|--bucket-prefix) + shift; + if [ -n "${1}" ]; then + bucket_prefix="${1}"; + shift; + fi; + ;; + -i|--build-id) + shift; + if [ -n "${1}" ]; then + build_id="${1}"; + shift; + fi; + ;; + -l|--lockfile) + shift; + if [ -n "${1}" ]; then + lockfile="-lockfile=${1}"; + shift; + fi; + ;; + -p|--project) + shift; + if [ -n "${1}" ]; then + project="${1}"; + shift; + fi; + ;; + --bootstrap) + shift; + bootstrap="true"; + ;; + -d|--detailed-exitcode) + shift; + detailed_exitcode="true"; + ;; + -n|--no-color) + shift; + no_color="-no-color"; + ;; + -w|--compact-warnings) + shift; + compact_warnings="-compact-warnings"; + ;; + --) + shift; + break; + ;; + esac; +done; + +declare extra_args="${@} ${no_color} ${compact_warnings}"; # All arguments supplied after "--" + +## +# Script Set-Up +## + +# Determine where I am and from that derive basepath and project name +script_path="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"; +base_path="${script_path%%\/bin}"; +project_name_default="${base_path##*\/}"; + +status=0; + +echo "Args ${raw_arguments}"; + +# Ensure script console output is separated by blank line at top and bottom to improve readability +trap echo EXIT; +echo; + +## +# Munge Params +## + +# Set Region from args or environment. Exit if unset. +readonly region="${region_arg:-${AWS_DEFAULT_REGION}}"; +[ -n "${region}" ] \ + || error_and_die "No AWS region specified. No -r/--region argument supplied and AWS_DEFAULT_REGION undefined"; + +[ -n "${project}" ] \ + || error_and_die "Required argument -p/--project not specified"; + +# Bootstrapping is special +if [ "${bootstrap}" == "true" ]; then + [ -n "${component_arg}" ] \ + && error_and_die "The --bootstrap parameter and the -c/--component parameter are mutually exclusive"; + [ -n "${build_id}" ] \ + && error_and_die "The --bootstrap parameter and the -i/--build-id parameter are mutually exclusive. We do not currently support plan files for bootstrap"; + [ -n "${environment_arg}" ] && readonly environment="${environment_arg}"; +else + # Validate component to work with + [ -n "${component_arg}" ] \ + || error_and_die "Required argument missing: -c/--component"; + readonly component="${component_arg}"; + + # Validate environment to work with + [ -n "${environment_arg}" ] \ + || error_and_die "Required argument missing: -e/--environment"; + readonly environment="${environment_arg}"; +fi; + +[ -n "${action}" ] \ + || error_and_die "Required argument missing: -a/--action"; + +# Validate AWS Credentials Available +iam_iron_man="$(aws sts get-caller-identity --query 'Arn' --output text)"; +if [ -n "${iam_iron_man}" ]; then + echo -e "AWS Credentials Found. Using ARN '${iam_iron_man}'"; +else + error_and_die "No AWS Credentials Found. \"aws sts get-caller-identity --query 'Arn' --output text\" responded with ARN '${iam_iron_man}'"; +fi; + +# Query canonical AWS Account ID +aws_account_id="$(aws sts get-caller-identity --query 'Account' --output text)"; +if [ -n "${aws_account_id}" ]; then + echo -e "AWS Account ID: ${aws_account_id}"; +else + error_and_die "Couldn't determine AWS Account ID. \"aws sts get-caller-identity --query 'Account' --output text\" provided no output"; +fi; + +# Validate S3 bucket. Set default if undefined +if [ -n "${bucket_prefix}" ]; then + readonly bucket="${bucket_prefix}-${aws_account_id}-${region}" + echo -e "Using S3 bucket s3://${bucket}"; +else + readonly bucket="${project}-tfscaffold-${aws_account_id}-${region}"; + echo -e "No bucket prefix specified. Using S3 bucket s3://${bucket}"; +fi; + +declare component_path; +if [ "${bootstrap}" == "true" ]; then + component_path="${base_path}/bootstrap"; +else + component_path="${base_path}/components/${component}"; +fi; + +# Get the absolute path to the component +if [[ "${component_path}" != /* ]]; then + component_path="$(cd "$(pwd)/${component_path}" && pwd)"; +else + component_path="$(cd "${component_path}" && pwd)"; +fi; + +[ -d "${component_path}" ] || error_and_die "Component path ${component_path} does not exist"; + +## Debug +#echo $component_path; + +## +# Begin parameter-dependent logic +## + +case "${action}" in + apply) + refresh="-refresh=true"; + ;; + destroy) + destroy='-destroy'; + refresh="-refresh=true"; + ;; + plan) + refresh="-refresh=true"; + ;; + plan-destroy) + action="plan"; + destroy="-destroy"; + refresh="-refresh=true"; + ;; + *) + ;; +esac; + +# Tell terraform to moderate its output to be a little +# more friendly to automation wrappers +# Value is irrelavant, just needs to be non-null +export TF_IN_AUTOMATION="true"; + +for rc_path in "${base_path}" "${base_path}/etc" "${component_path}"; do + if [ -f "${rc_path}/.terraformrc" ]; then + echo "Found .terraformrc at ${rc_path}/.terraformrc. Overriding."; + export TF_CLI_CONFIG_FILE="${rc_path}/.terraformrc"; + fi; +done; + +# Configure the plugin-cache location so plugins are not +# downloaded to individual components +declare default_plugin_cache_dir="$(pwd)/plugin-cache"; +export TF_PLUGIN_CACHE_DIR="${TF_PLUGIN_CACHE_DIR:-${default_plugin_cache_dir}}" +mkdir -p "${TF_PLUGIN_CACHE_DIR}" \ + || error_and_die "Failed to created the plugin-cache directory (${TF_PLUGIN_CACHE_DIR})"; +[ -w "${TF_PLUGIN_CACHE_DIR}" ] \ + || error_and_die "plugin-cache directory (${TF_PLUGIN_CACHE_DIR}) not writable"; + +# Clear cache, safe enough as we enforce plugin cache +rm -rf ${component_path}/.terraform; + +# Run global pre.sh +if [ -f "pre.sh" ]; then + PROJECT="${project}" REGION="${region}" COMPONENT="${component}" AWS_ACCOUNT_ID="${aws_account_id}" ENVIRONMENT="${environment}" ACTION="${action}" \ + source pre.sh || error_and_die "Global pre script execution failed with exit code ${?}"; +fi; + +# Make sure we're running in the component directory +pushd "${component_path}"; +readonly component_name=$(basename ${component_path}); + +# install terraform +# verify terraform version matches .tool-versions +echo ${PWD} +tool_version=$(grep "terraform " .tool-versions | cut -d ' ' -f 2) +asdf plugin add terraform && asdf install terraform "${tool_version}" +current_version=$(terraform --version | head -n 1 | cut -d 'v' -f 2) + +if [ -z "${current_version}" ] || [ "${current_version}" != "${tool_version}" ]; then + error_and_die "Terraform version mismatch. Expected: ${tool_version}, Actual: ${current_version}" +fi + +# Regardless of bootstrapping or not, we'll be using this string. +# If bootstrapping, we will fill it with variables, +# if not we will fill it with variable file parameters +declare tf_var_params; + +if [ "${bootstrap}" == "true" ]; then + if [ "${action}" == "destroy" ]; then + error_and_die "You cannot destroy a bootstrap bucket using tfscaffold, it's just too dangerous. If you're absolutely certain that you want to delete the bucket and all contents, including any possible state files environments and components within this project, then you will need to do it from the AWS Console. Note you cannot do this from the CLI because the bootstrap bucket is versioned, and even the --force CLI parameter will not empty the bucket of versions"; + fi; + + # Bootstrap requires this parameter as explicit as it is constructed here + # for multiple uses, so we cannot just depend on it being set in tfvars + tf_var_params+=" -var bucket_name=${bucket}"; +fi; + +# Run pre.sh +if [ -f "pre.sh" ]; then + PROJECT="${project}" REGION="${region}" COMPONENT="${component}" AWS_ACCOUNT_ID="${aws_account_id}" ENVIRONMENT="${environment}" ACTION="${action}" \ + source pre.sh || error_and_die "Component pre script execution failed with exit code ${?}"; +fi; + +# Pull down secret TFVAR file from S3 +# Anti-pattern and security warning: This secrets mechanism provides very little additional security. +# It permits you to inject secrets directly into terraform without storing them in source control or unencrypted in S3. +# Secrets will still be stored in all copies of your state file - which will be stored on disk wherever this script is run and in S3. +# This script does not currently support encryption of state files. +# Use this feature only if you're sure it's the right pattern for your use case. +declare -a secrets=(); +readonly secrets_file_name="secret.tfvars.enc"; +readonly secrets_file_path="build/${secrets_file_name}"; +aws s3 ls s3://${bucket}/${project}/${aws_account_id}/${region}/${environment}/${secrets_file_name} >/dev/null 2>&1; +if [ $? -eq 0 ]; then + mkdir -p build; + aws s3 cp s3://${bucket}/${project}/${aws_account_id}/${region}/${environment}/${secrets_file_name} ${secrets_file_path} \ + || error_and_die "S3 secrets file is present, but inaccessible. Ensure you have permission to read s3://${bucket}/${project}/${aws_account_id}/${region}/${environment}/${secrets_file_name}"; + if [ -f "${secrets_file_path}" ]; then + secrets=($(aws kms decrypt --ciphertext-blob fileb://${secrets_file_path} --output text --query Plaintext | base64 --decode)); + fi; +fi; + +if [ -n "${secrets[0]}" ]; then + secret_regex='^[A-Za-z0-9_-]+=.+$'; + secret_count=1; + for secret_line in "${secrets[@]}"; do + if [[ "${secret_line}" =~ ${secret_regex} ]]; then + var_key="${secret_line%=*}"; + var_val="${secret_line##*=}"; + export TF_VAR_${var_key}="${var_val}"; + ((secret_count++)); + else + echo "Malformed secret on line ${secret_count} - ignoring"; + fi; + done; +fi; + +# Pull down additional dynamic plaintext tfvars file from S3 +# Anti-pattern warning: Your variables should almost always be in source control. +# There are a very few use cases where you need constant variability in input variables, +# and even in those cases you should probably pass additional -var parameters to this script +# from your automation mechanism. +# Use this feature only if you're sure it's the right pattern for your use case. +readonly dynamic_file_name="dynamic.tfvars"; +readonly dynamic_file_path="build/${dynamic_file_name}"; +aws s3 ls s3://${bucket}/${project}/${aws_account_id}/${region}/${environment}/${dynamic_file_name} >/dev/null 2>&1; +if [ $? -eq 0 ]; then + aws s3 cp s3://${bucket}/${project}/${aws_account_id}/${region}/${environment}/${dynamic_file_name} ${dynamic_file_path} \ + || error_and_die "S3 tfvars file is present, but inaccessible. Ensure you have permission to read s3://${bucket}/${project}/${aws_account_id}/${region}/${environment}/${dynamic_file_name}"; +fi; + +# Use versions TFVAR files if exists +readonly versions_file_name="versions_${region}_${environment}.tfvars"; +readonly versions_file_path="${base_path}/etc/${versions_file_name}"; + +# Check for presence of an environment variables file, and use it if readable +if [ -n "${environment}" ]; then + readonly env_file_path="${base_path}/etc/env_${region}_${environment}.tfvars"; +fi; + +# Check for presence of a global variables file, and use it if readable +readonly global_vars_file_name="global.tfvars"; +readonly global_vars_file_path="${base_path}/etc/${global_vars_file_name}"; + +# Check for presence of a region variables file, and use it if readable +readonly region_vars_file_name="${region}.tfvars"; +readonly region_vars_file_path="${base_path}/etc/${region_vars_file_name}"; + +# Check for presence of a group variables file if specified, and use it if readable +if [ -n "${group}" ]; then + readonly group_vars_file_name="group_${group}.tfvars"; + readonly group_vars_file_path="${base_path}/etc/${group_vars_file_name}"; +fi; + +# Collect the paths of the variables files to use +declare -a tf_var_file_paths; + +# Use Global and Region first, to allow potential for terraform to do the +# honourable thing and override global and region settings with environment +# specific ones; however we do not officially support the same variable +# being declared in multiple locations, and we warn when we find any duplicates +[ -f "${global_vars_file_path}" ] && tf_var_file_paths+=("${global_vars_file_path}"); +[ -f "${region_vars_file_path}" ] && tf_var_file_paths+=("${region_vars_file_path}"); + +# If a group has been specified, load the vars for the group. If we are to assume +# terraform correctly handles override-ordering (which to be fair we don't hence +# the warning about duplicate variables below) we add this to the list after +# global and region-global variables, but before the environment variables +# so that the environment can explicitly override variables defined in the group. +if [ -n "${group}" ]; then + if [ -f "${group_vars_file_path}" ]; then + tf_var_file_paths+=("${group_vars_file_path}"); + else + echo -e "[WARNING] Group \"${group}\" has been specified, but no group variables file is available at ${group_vars_file_path}"; + fi; +fi; + +# Environment is normally expected, but in bootstrapping it may not be provided +if [ -n "${environment}" ]; then + if [ -f "${env_file_path}" ]; then + tf_var_file_paths+=("${env_file_path}"); + else + echo -e "[WARNING] Environment \"${environment}\" has been specified, but no environment variables file is available at ${env_file_path}"; + fi; +fi; + +# If present and readable, use versions and dynamic variables too +[ -f "${versions_file_path}" ] && tf_var_file_paths+=("${versions_file_path}"); +[ -f "${dynamic_file_path}" ] && tf_var_file_paths+=("${dynamic_file_path}"); + +# Warn on duplication +if [ ${#tf_var_file_paths[@]} -gt 0 ]; then + duplicate_variables="$(cat "${tf_var_file_paths[@]}" | sed -n -e 's/\(^[a-zA-Z0-9_\-]\+\)\s*=.*$/\1/p' | sort | uniq -d)"; + [ -n "${duplicate_variables}" ] \ + && echo -e " + ################################################################### + # WARNING WARNING WARNING WARNING WARNING WARNING WARNING WARNING # + ################################################################### + The following input variables appear to be duplicated: + + ${duplicate_variables} + + This could lead to unexpected behaviour. Overriding of variables + has previously been unpredictable and is not currently supported, + but it may work. + + Recent changes to terraform might give you useful overriding and + map-merging functionality, please use with caution and report back + on your successes & failures. + ###################################################################"; +fi + +# Build up the tfvars arguments for terraform command line +for file_path in "${tf_var_file_paths[@]}"; do + tf_var_params+=" -var-file=${file_path}"; +done; + +## +# Start Doing Real Things +## + +# Really Hashicorp? Really?! +# +# In order to work with terraform >=0.9.2 (I say 0.9.2 because 0.9 prior +# to 0.9.2 is barely usable due to key bugs and missing features) +# we now need to do some ugly things to our terraform remote backend configuration. +# The long term hope is that they will fix this, and maybe remove the need for it +# altogether by supporting interpolation in the backend config stanza. +# +# For now we're left with this garbage, and no more support for <0.9.0. +if [ -f backend_tfscaffold.tf ]; then + echo -e "WARNING: backend_tfscaffold.tf exists and will be overwritten!" >&2; +fi; + +declare backend_prefix; +declare backend_filename; + +if [ "${bootstrap}" == "true" ]; then + backend_prefix="${project}/${aws_account_id}/${region}/bootstrap"; + backend_filename="bootstrap.tfstate"; +else + backend_prefix="${project}/${aws_account_id}/${region}/${environment}"; + backend_filename="${component_name}.tfstate"; +fi; + +readonly backend_key="${backend_prefix}/${backend_filename}"; +readonly backend_config="terraform { + backend \"s3\" { + region = \"${region}\" + bucket = \"${bucket}\" + key = \"${backend_key}\" + use_lockfile = true + } +}"; + +# We're now all ready to go. All that's left is to: +# * Write the backend config +# * terraform init +# * terraform ${action} +# +# But if we're dealing with the special bootstrap component +# we can't remotely store the backend until we've bootstrapped it +# +# So IF the S3 bucket already exists, we will continue as normal +# because we want to be able to manage changes to an existing +# bootstrap bucket. But if it *doesn't* exist, then we need to be +# able to plan and apply it with a local state, and *then* configure +# the remote state. + +# In default operations we assume we are already bootstrapped +declare bootstrapped="true"; + +# If we are in bootstrap mode, we need to know if we have already bootstrapped +# or we are working with or modifying an existing bootstrap bucket +if [ "${bootstrap}" == "true" ]; then + # For this exist check we could do many things, but we explicitly perform + # an ls against the key we will be working with so as to not require + # permissions to, for example, list all buckets, or the bucket root keyspace + aws s3 ls s3://${bucket}/${backend_prefix}/${backend_filename} >/dev/null 2>&1; + [ $? -eq 0 ] || bootstrapped="false"; +fi; + +if [ "${bootstrapped}" == "true" ]; then + echo -e "${backend_config}" > backend_tfscaffold.tf \ + || error_and_die "Failed to write backend config to $(pwd)/backend_tfscaffold.tf"; + + # Nix the horrible hack on exit + trap "rm -f $(pwd)/backend_tfscaffold.tf" EXIT; + + declare lockfile_or_upgrade; + [ -n ${lockfile} ] && lockfile_or_upgrade='-upgrade' || lockfile_or_upgrade="${lockfile}"; + + # Configure remote state storage + echo "Setting up S3 remote state from s3://${bucket}/${backend_key}"; + terraform init ${no_color} ${compact_warnings} ${lockfile_or_upgrade} \ + || error_and_die "Terraform init failed"; +else + # We are bootstrapping. Download the providers, skip the backend config. + terraform init \ + -backend=false \ + ${no_color} \ + ${compact_warnings} \ + ${lockfile} \ + || error_and_die "Terraform init failed"; +fi; + +case "${action}" in + 'plan') + if [ -n "${build_id}" ]; then + mkdir -p build; + + plan_file_name="${component_name}_${build_id}.tfplan"; + plan_file_remote_key="${backend_prefix}/plans/${plan_file_name}"; + + out="-out=build/${plan_file_name}"; + fi; + + if [ "${detailed_exitcode}" == "true" ]; then + detailed="-detailed-exitcode"; + fi; + + terraform "${action}" \ + -input=false \ + ${refresh} \ + ${tf_var_params} \ + ${extra_args} \ + ${destroy} \ + ${out} \ + ${detailed} \ + -parallelism=300; + + status="${?}"; + + # Even when detailed exitcode is set, a 1 is still a fail, + # so exit + # (detailed exit codes are 0 and 2) + if [ "${status}" -eq 1 ]; then + error_and_die "Terraform plan failed"; + fi; + + if [ -n "${build_id}" ]; then + aws s3 cp build/${plan_file_name} s3://${bucket}/${plan_file_remote_key} \ + || error_and_die "Plan file upload to S3 failed (s3://${bucket}/${plan_file_remote_key})"; + fi; + + exit ${status}; + ;; + 'graph') + mkdir -p build || error_and_die "Failed to create output directory '$(pwd)/build'"; + terraform graph ${extra_args} -draw-cycles | dot -Tpng > build/${project}-${aws_account_id}-${region}-${environment}.png \ + || error_and_die "Terraform simple graph generation failed"; + terraform graph ${extra_args} -draw-cycles -verbose | dot -Tpng > build/${project}-${aws_account_id}-${region}-${environment}-verbose.png \ + || error_and_die "Terraform verbose graph generation failed"; + exit 0; + ;; + 'apply'|'destroy'|'refresh') + + # Support for terraform <0.10 is now deprecated + if [ "${action}" == "apply" ]; then + echo "Compatibility: Adding to terraform arguments: -auto-approve=true"; + extra_args+=" -auto-approve=true"; + else # action is `destroy` + # Check terraform version - if pre-0.15, need to add `-force`; 0.15 and above instead use `-auto-approve` + if [ $(terraform version | head -n1 | cut -d" " -f2 | cut -d"." -f1) == "v0" ] && [ $(terraform version | head -n1 | cut -d" " -f2 | cut -d"." -f2) -lt 15 ]; then + echo "Compatibility: Adding to terraform arguments: -force"; + force='-force'; + elif [ "${action}" != "refresh" ]; then + extra_args+=" -auto-approve"; + fi; + fi; + + if [ -n "${build_id}" ]; then + mkdir -p build; + plan_file_name="${component_name}_${build_id}.tfplan"; + plan_file_remote_key="${backend_prefix}/plans/${plan_file_name}"; + + aws s3 cp s3://${bucket}/${plan_file_remote_key} build/${plan_file_name} \ + || error_and_die "Plan file download from S3 failed (s3://${bucket}/${plan_file_remote_key})"; + + apply_plan="build/${plan_file_name}"; + + terraform "${action}" \ + -input=false \ + ${refresh} \ + -parallelism=300 \ + ${extra_args} \ + ${force} \ + ${apply_plan}; + exit_code=$?; + else + terraform "${action}" \ + -input=false \ + ${refresh} \ + ${tf_var_params} \ + -parallelism=300 \ + ${extra_args} \ + ${force}; + exit_code=$?; + + if [ "${bootstrapped}" == "false" ]; then + # If we are here, and we are in bootstrap mode, and not already bootstrapped, + # Then we have just bootstrapped for the first time! Congratulations. + # Now we need to copy our state file into the bootstrap bucket + echo -e "${backend_config}" > backend_tfscaffold.tf \ + || error_and_die "Failed to write backend config to $(pwd)/backend_tfscaffold.tf"; + + # Nix the horrible hack on exit + trap "rm -f $(pwd)/backend_tfscaffold.tf" EXIT; + + # Push Terraform Remote State to S3 + # TODO: Add -upgrade to init when we drop support for <0.10 + echo "yes" | terraform init ${lockfile} || error_and_die "Terraform init failed"; + + # Hard cleanup + rm -f backend_tfscaffold.tf; + rm -f terraform.tfstate # Prime not the backup + rm -rf .terraform; + + # This doesn't mean anything here, we're just celebrating! + bootstrapped="true"; + fi; + + fi; + + if [ ${exit_code} -ne 0 ]; then + error_and_die "Terraform ${action} failed with exit code ${exit_code}"; + fi; + + if [ -f "post.sh" ]; then + source post.sh "${region}" "${environment}" "${action}" \ + || error_and_die "Component post script execution failed with exit code ${?}"; + fi; + ;; + '*taint') + terraform "${action}" ${extra_args} || error_and_die "Terraform ${action} failed."; + ;; + 'import') + terraform "${action}" ${tf_var_params} ${extra_args} || error_and_die "Terraform ${action} failed."; + ;; + 'shell') + echo -e "Here's a shell for the ${component} component.\nIf you want to run terraform actions specific to the ${environment}, pass the following options:\n\n${tf_var_params} ${extra_args}\n\n'exit 0' / 'Ctrl-D' to continue, other exit codes will abort tfscaffold with the same code."; + bash -l || exit "${?}"; + ;; + *) + echo -e "Generic action case invoked. Only the additional arguments will be passed to terraform, you break it you fix it:"; + echo -e "\tterraform ${action} ${extra_args} | tee terraform_output"; + terraform "${action}" ${extra_args} | tee terraform_output \ + || error_and_die "Terraform ${action} failed."; + ;; +esac; + +popd + +if [ -f "post.sh" ]; then + source post.sh "${region}" "${environment}" "${action}" \ + || error_and_die "Global post script execution failed with exit code ${?}"; +fi; + +exit 0; diff --git a/infrastructure/terraform/components/cms/.tool-versions b/infrastructure/terraform/components/cms/.tool-versions new file mode 100644 index 00000000..4b8ec165 --- /dev/null +++ b/infrastructure/terraform/components/cms/.tool-versions @@ -0,0 +1 @@ +terraform 1.14.8 diff --git a/infrastructure/terraform/etc/.gitkeep b/infrastructure/terraform/etc/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/infrastructure/terraform/modules/.gitkeep b/infrastructure/terraform/modules/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/scripts/config/pre-commit.yaml b/scripts/config/pre-commit.yaml index 3156979c..b4301f5c 100644 --- a/scripts/config/pre-commit.yaml +++ b/scripts/config/pre-commit.yaml @@ -1,33 +1,36 @@ repos: -- repo: local - hooks: - - id: scan-secrets - name: Scan secrets - entry: ./scripts/githooks/scan-secrets.sh - args: ["check=staged-changes"] - language: script - pass_filenames: false -- repo: local - hooks: - - id: check-file-format - name: Check file format - entry: ./scripts/githooks/check-file-format.sh - args: ["check=staged-changes"] - language: script - pass_filenames: false -- repo: local - hooks: - - id: check-markdown-format - name: Check Markdown format - entry: ./scripts/githooks/check-markdown-format.sh - args: ["check=staged-changes"] - language: script - pass_filenames: false -- repo: local - hooks: - - id: check-english-usage - name: Check English usage - entry: ./scripts/githooks/check-english-usage.sh - args: ["check=staged-changes"] - language: script - pass_filenames: false + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 # Use the ref you want to point at + hooks: + - id: trailing-whitespace + - id: detect-aws-credentials + args: [--allow-missing-credentials] + - id: check-added-large-files + - id: check-symlinks + - id: detect-private-key + - id: end-of-file-fixer + - id: forbid-new-submodules + - id: mixed-line-ending + - id: pretty-format-json + exclude: | + (?x)^( + .*/?package-lock.json + )$ + args: ['--autofix'] + + - repo: https://github.com/NHSDigital/nhs-notify-shared-modules + rev: 3.0.8 + hooks: + - id: sort-dictionary + - id: scan-secrets + args: [check=whole-history] + - id: check-file-format + args: [check=branch] + - id: check-markdown-format + args: [check=branch] + - id: check-english-usage + args: [check=branch] + - id: lint-terraform + - id: generate-terraform-docs + - id: check-todo-usage + args: [check=branch] diff --git a/scripts/config/vale/styles/config/vocabularies/words/accept.txt b/scripts/config/vale/styles/config/vocabularies/words/accept.txt index b31a6a50..353872c5 100644 --- a/scripts/config/vale/styles/config/vocabularies/words/accept.txt +++ b/scripts/config/vale/styles/config/vocabularies/words/accept.txt @@ -1,53 +1,53 @@ +: +APIM +[A-Z]+s Bitwarden +bot +bundler +Burkina [cC]yber +clientRef +Cohorting +ctrl Dependabot +endfor +fullName +Futuna Gitleaks Grype +idempotence +[iI]nset +Maarten +Marino +namePrefix +Notify's +Noto +npm OAuth Octokit +onboarding +pending_enrichment +permanent_failure +phoneNumber Podman +precompiled Python +realtime +Rica +rollout +Sao +SCAL +Sint +src Syft +technical_failure +temporary_failure Terraform -Trufflehog -bot -idempotence -onboarding +Tokelau toolchain -bundler -endfor -npm -src -[iI]nset +Trufflehog +unnotified urlset -[A-Z]+s -Noto [Uu][Rr][Ll] -ctrl -pending_enrichment -unnotified -permanent_failure -temporary_failure -technical_failure -precompiled validation_failed -fullName -namePrefix -clientRef -Cohorting -Sint -Maarten -Burkina -Sao -Marino -Rica -Futuna -Tokelau Wayfinder -phoneNumber -: -SCAL -APIM -realtime -Notify's -rollout diff --git a/scripts/docker/docker.lib.sh b/scripts/docker/docker.lib.sh index 18787105..e5df3cf6 100644 --- a/scripts/docker/docker.lib.sh +++ b/scripts/docker/docker.lib.sh @@ -301,3 +301,138 @@ function _get-git-branch-name() { echo "$branch_name" } + +# ============================================================================== +# NHS Notify Project-Specific Functions - Container Support + +# Get git-based version suffix for containers. +# Returns either "release--" for tagged commits +# or "sha-" for untagged commits. +function docker-get-git-version-suffix() { + + local short_sha=$(git rev-parse --short HEAD) + local git_tag=$(git describe --tags --exact-match 2>/dev/null || true) + + if [ -n "$git_tag" ]; then + local release_version="${git_tag#v}" + echo "release-${release_version}-${short_sha}" + else + echo "sha-${short_sha}" + fi +} + +# Authenticate Docker with AWS ECR. +# Arguments (provided as environment variables): +# AWS_ACCOUNT_ID=[AWS account ID] +# AWS_REGION=[AWS region, e.g., eu-west-2] +function docker-ecr-login() { + + if [ -z "${AWS_ACCOUNT_ID:-}" ]; then + echo "Error: AWS_ACCOUNT_ID environment variable is required" >&2 + return 1 + fi + + if [ -z "${AWS_REGION:-}" ]; then + echo "Error: AWS_REGION environment variable is required" >&2 + return 1 + fi + + echo "Authenticating Docker with ECR..." + aws ecr get-login-password --region "${AWS_REGION}" | \ + docker login --username AWS --password-stdin \ + "${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com" +} + +# Authenticate Docker with GitHub Container Registry. +# Arguments (provided as environment variables): +# GITHUB_TOKEN=[GitHub personal access token with packages:read/write scope] +function docker-ghcr-login() { + + if [ -z "${GITHUB_TOKEN:-}" ]; then + echo "Error: GITHUB_TOKEN environment variable is required" >&2 + return 1 + fi + + echo "Authenticating Docker with GitHub Container Registry..." + echo "${GITHUB_TOKEN}" | docker login ghcr.io --username "$(git config user.name)" --password-stdin +} + +# Build container image. +# Arguments (provided as environment variables): +# BASE_IMAGE=[base Docker image, e.g., node:22-alpine] +# dir=[path to container directory, default is '.'] +# DOCKER_IMAGE=[full ECR image URI with tag] +# Prerequisites: +# - Container directory must have build.sh script +# - Container directory must have docker/lambda/Dockerfile +function docker-build-container() { + + local dir=${dir:-$PWD} + + if [ -z "${BASE_IMAGE:-}" ]; then + echo "Error: BASE_IMAGE environment variable is required" >&2 + return 1 + fi + + if [ ! -f "${dir}/build.sh" ]; then + echo "Error: build.sh not found in ${dir}" >&2 + return 1 + fi + + if [ ! -f "${dir}/docker/Dockerfile" ]; then + echo "Error: docker/Dockerfile not found in ${dir}" >&2 + return 1 + fi + + # Run the container build script first + echo "Running build.sh in ${dir}..." + current_dir=$(pwd) + cd "$dir" + chmod +x ./build.sh + ./build.sh + + # Build the Docker image + echo "Building container image..." + docker buildx build \ + -f docker/Dockerfile \ + --platform=linux/amd64 \ + --provenance=false \ + --sbom=false \ + --build-arg BASE_IMAGE="${BASE_IMAGE}" \ + -t "${DOCKER_IMAGE}" \ + --load \ + . + + cd "$current_dir" +} + +# Push container image to ECR. +# Arguments (provided as environment variables): +# DOCKER_IMAGE=[full ECR image URI with tag] +# PUBLISH_CONTAINER_IMAGE=[true to push, false to skip, default is true] +function docker-push-container() { + + if [ "${PUBLISH_CONTAINER_IMAGE:-true}" = "true" ]; then + echo "Pushing to ECR..." + echo "Pushing ${DOCKER_IMAGE}..." + docker push "${DOCKER_IMAGE}" + echo "Push complete." + else + echo "PUBLISH_CONTAINER_IMAGE is false. Skipping push." + echo "Built image is available locally as: ${DOCKER_IMAGE}" + fi +} + +# Calculate and print Docker image name for NHS Notify containers. +# Arguments (provided as environment variables): +# CONTAINER_IMAGE_PREFIX, AWS_ACCOUNT_ID, AWS_REGION (required) +# CONTAINER_IMAGE_SUFFIX, ECR_REPO, CONTAINER_NAME, dir (optional) +function docker-calculate-image-name() { + local dir=${dir:-$PWD} + local container_name="${CONTAINER_NAME:-$(basename "$dir")}" + local ecr_repo="${ECR_REPO:-nhs-main-acct-admail}" + local image_suffix="${CONTAINER_IMAGE_SUFFIX:-$(docker-get-git-version-suffix)}" + local image_tag="${CONTAINER_IMAGE_PREFIX}-${container_name}" + local ecr_repo_uri="${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/${ecr_repo}" + echo "${ecr_repo_uri}:${image_tag}-${image_suffix}" +} diff --git a/scripts/docker/docker.mk b/scripts/docker/docker.mk index a31ad9db..d94c95c5 100644 --- a/scripts/docker/docker.mk +++ b/scripts/docker/docker.mk @@ -1,30 +1,52 @@ # This file is for you! Edit it to implement your own Docker make targets. # ============================================================================== -# Custom implementation - implementation of a make target should not exceed 5 lines of effective code. -# In most cases there should be no need to modify the existing make targets. +# NHS Notify Container Support (ECR with container name prefix) -docker-build: # Build Docker image - optional: docker_dir|dir=[path to the Dockerfile to use, default is '.'] @Development - make _docker cmd="build" \ - dir=$(or ${docker_dir}, ${dir}) - file=$(or ${docker_dir}, ${dir})/Dockerfile.effective - scripts/docker/dockerfile-linter.sh +docker-build: # Build container image - required: DOCKER_IMAGE, base_image=[base image]; optional: dir=[container directory] @Development + source scripts/docker/docker.lib.sh; \ + dir=$(or ${dir}, .); \ + BASE_IMAGE="${base_image}" \ + DOCKER_IMAGE="$${DOCKER_IMAGE}" \ + dir="$${dir}" \ + docker-build-container -docker-push: # Push Docker image - optional: docker_dir|dir=[path to the image directory where the Dockerfile is located, default is '.'] @Development - make _docker cmd="push" \ - dir=$(or ${docker_dir}, ${dir}) +docker-push: # Push container image to registry - required: DOCKER_IMAGE @Development + source scripts/docker/docker.lib.sh; \ + DOCKER_IMAGE="$${DOCKER_IMAGE}" \ + docker-push-container -clean:: # Remove Docker resources (docker) - optional: docker_dir|dir=[path to the image directory where the Dockerfile is located, default is '.'] @Operations - make _docker cmd="clean" \ - dir=$(or ${docker_dir}, ${dir}) +docker-build-and-push: # Build and push container in one workflow - required: base_image=[base image]; optional: dir=[container directory], ecr_repo=[ECR repo name], container_name=[container name] @Development + @dir=$(or ${dir}, .); \ + export DOCKER_IMAGE=$$(source scripts/docker/docker.lib.sh && \ + CONTAINER_IMAGE_PREFIX="$${CONTAINER_IMAGE_PREFIX}" \ + CONTAINER_IMAGE_SUFFIX="$${CONTAINER_IMAGE_SUFFIX:-}" \ + AWS_ACCOUNT_ID="$${AWS_ACCOUNT_ID}" \ + AWS_REGION="$${AWS_REGION}" \ + ECR_REPO="$${ECR_REPO:-${ecr_repo}}" \ + CONTAINER_NAME="$${CONTAINER_NAME:-${container_name}}" \ + dir="$${dir}" \ + docker-calculate-image-name); \ + echo "Building and pushing: $${DOCKER_IMAGE}"; \ + ${MAKE} docker-ecr-login; \ + ${MAKE} docker-build base_image=${base_image} dir="$${dir}" DOCKER_IMAGE="$${DOCKER_IMAGE}"; \ + ${MAKE} docker-push DOCKER_IMAGE="$${DOCKER_IMAGE}" -_docker: # Docker command wrapper - mandatory: cmd=[command to execute]; optional: dir=[path to the image directory where the Dockerfile is located, relative to the project's top-level directory, default is '.'] - # 'DOCKER_IMAGE' and 'DOCKER_TITLE' are passed to the functions as environment variables - DOCKER_IMAGE=$(or ${DOCKER_IMAGE}, $(or ${docker_image}, $(or ${IMAGE}, $(or ${image}, ghcr.io/org/repo)))) - DOCKER_TITLE=$(or "${DOCKER_TITLE}", $(or "${docker_title}", $(or "${TITLE}", $(or "${title}", "Service Docker image")))) - source scripts/docker/docker.lib.sh - dir=$(realpath ${dir}) - docker-${cmd} # 'dir' is accessible by the function as environment variable +docker-ecr-login: # Authenticate Docker with AWS ECR - required: AWS_ACCOUNT_ID, AWS_REGION @Development + source scripts/docker/docker.lib.sh; \ + AWS_ACCOUNT_ID="$${AWS_ACCOUNT_ID}" \ + AWS_REGION="$${AWS_REGION}" \ + docker-ecr-login + +docker-ghcr-login: # Authenticate Docker with GitHub Container Registry - required: GITHUB_TOKEN @Development + source scripts/docker/docker.lib.sh; \ + GITHUB_TOKEN="$${GITHUB_TOKEN}" \ + docker-ghcr-login + +clean:: # Remove container image and resources - required: DOCKER_IMAGE @Development + source scripts/docker/docker.lib.sh; \ + DOCKER_IMAGE="$${DOCKER_IMAGE:-}" \ + docker-clean # ============================================================================== # Quality checks - please DO NOT edit this section! @@ -34,50 +56,13 @@ docker-shellscript-lint: # Lint all Docker module shell scripts @Quality file=$${file} scripts/shellscript-linter.sh done -# ============================================================================== -# Module tests and examples - please DO NOT edit this section! - -docker-test-suite-run: # Run Docker test suite @ExamplesAndTests - scripts/docker/tests/docker.test.sh - -docker-example-build: # Build Docker example @ExamplesAndTests - source scripts/docker/docker.lib.sh - cd scripts/docker/examples/python - DOCKER_IMAGE=repository-template/docker-example-python - DOCKER_TITLE="Repository Template Docker Python Example" - TOOL_VERSIONS="$(shell git rev-parse --show-toplevel)/scripts/docker/examples/python/.tool-versions.example" - docker-build - -docker-example-lint: # Lint Docker example @ExamplesAndTests - dockerfile=scripts/docker/examples/python/Dockerfile - file=$${dockerfile} scripts/docker/dockerfile-linter.sh - -docker-example-run: # Run Docker example @ExamplesAndTests - source scripts/docker/docker.lib.sh - cd scripts/docker/examples/python - DOCKER_IMAGE=repository-template/docker-example-python - args=" \ - -it \ - --publish 8000:8000 \ - " - docker-run - -docker-example-clean: # Remove Docker example resources @ExamplesAndTests - source scripts/docker/docker.lib.sh - cd scripts/docker/examples/python - DOCKER_IMAGE=repository-template/docker-example-python - docker-clean - # ============================================================================== ${VERBOSE}.SILENT: \ - _docker \ clean \ docker-build \ - docker-example-build \ - docker-example-clean \ - docker-example-lint \ - docker-example-run \ + docker-build-and-push \ + docker-ecr-login \ + docker-ghcr-login \ docker-push \ docker-shellscript-lint \ - docker-test-suite-run \ diff --git a/scripts/docker/examples/python/.tool-versions.example b/scripts/docker/examples/python/.tool-versions.example deleted file mode 100644 index 92093116..00000000 --- a/scripts/docker/examples/python/.tool-versions.example +++ /dev/null @@ -1,2 +0,0 @@ -# python, SEE: https://hub.docker.com/_/python/tags -# docker/python 3.11.4-alpine3.18@sha256:0135ae6442d1269379860b361760ad2cf6ab7c403d21935a8015b48d5bf78a86 diff --git a/scripts/docker/examples/python/Dockerfile b/scripts/docker/examples/python/Dockerfile deleted file mode 100644 index d0780aa4..00000000 --- a/scripts/docker/examples/python/Dockerfile +++ /dev/null @@ -1,33 +0,0 @@ -# `*:latest` will be replaced with a corresponding version stored in the '.tool-versions' file -# hadolint ignore=DL3007 -FROM python:latest as base - -# === Builder ================================================================== - -FROM base AS builder -COPY ./assets/hello_world/requirements.txt /requirements.txt -WORKDIR /packages -RUN set -eux; \ - \ - # Install dependencies - pip install \ - --requirement /requirements.txt \ - --prefix=/packages \ - --no-warn-script-location \ - --no-cache-dir - -# === Runtime ================================================================== - -FROM base -ENV \ - LANG="C.UTF-8" \ - LC_ALL="C.UTF-8" \ - PYTHONDONTWRITEBYTECODE="1" \ - PYTHONUNBUFFERED="1" \ - TZ="UTC" -COPY --from=builder /packages /usr/local -COPY ./assets/hello_world /hello_world -WORKDIR /hello_world -USER nobody -CMD [ "python", "app.py" ] -EXPOSE 8000 diff --git a/scripts/docker/examples/python/Dockerfile.effective b/scripts/docker/examples/python/Dockerfile.effective deleted file mode 100644 index 3f1ea6b0..00000000 --- a/scripts/docker/examples/python/Dockerfile.effective +++ /dev/null @@ -1,54 +0,0 @@ -# `*:latest` will be replaced with a corresponding version stored in the '.tool-versions' file -FROM python:3.11.4-alpine3.18@sha256:0135ae6442d1269379860b361760ad2cf6ab7c403d21935a8015b48d5bf78a86 as base - -# === Builder ================================================================== - -FROM base AS builder -COPY ./assets/hello_world/requirements.txt /requirements.txt -WORKDIR /packages -RUN set -eux; \ - \ - # Install dependencies - pip install \ - --requirement /requirements.txt \ - --prefix=/packages \ - --no-warn-script-location \ - --no-cache-dir - -# === Runtime ================================================================== - -FROM base -ENV \ - LANG="C.UTF-8" \ - LC_ALL="C.UTF-8" \ - PYTHONDONTWRITEBYTECODE="1" \ - PYTHONUNBUFFERED="1" \ - TZ="UTC" -COPY --from=builder /packages /usr/local -COPY ./assets/hello_world /hello_world -WORKDIR /hello_world -USER nobody -CMD [ "python", "app.py" ] -EXPOSE 8000 - -# === Metadata ================================================================= - -ARG IMAGE -ARG TITLE -ARG DESCRIPTION -ARG LICENCE -ARG GIT_URL -ARG GIT_BRANCH -ARG GIT_COMMIT_HASH -ARG BUILD_DATE -ARG BUILD_VERSION -LABEL \ - org.opencontainers.image.base.name=$IMAGE \ - org.opencontainers.image.title="$TITLE" \ - org.opencontainers.image.description="$DESCRIPTION" \ - org.opencontainers.image.licenses="$LICENCE" \ - org.opencontainers.image.url=$GIT_URL \ - org.opencontainers.image.ref.name=$GIT_BRANCH \ - org.opencontainers.image.revision=$GIT_COMMIT_HASH \ - org.opencontainers.image.created=$BUILD_DATE \ - org.opencontainers.image.version=$BUILD_VERSION diff --git a/scripts/docker/examples/python/VERSION b/scripts/docker/examples/python/VERSION deleted file mode 100644 index 8acdd82b..00000000 --- a/scripts/docker/examples/python/VERSION +++ /dev/null @@ -1 +0,0 @@ -0.0.1 diff --git a/scripts/docker/examples/python/assets/hello_world/app.py b/scripts/docker/examples/python/assets/hello_world/app.py deleted file mode 100644 index 4844e89c..00000000 --- a/scripts/docker/examples/python/assets/hello_world/app.py +++ /dev/null @@ -1,12 +0,0 @@ -from flask import Flask -from flask_wtf.csrf import CSRFProtect - -app = Flask(__name__) -csrf = CSRFProtect() -csrf.init_app(app) - -@app.route("/") -def index(): - return "Hello World!" - -app.run(host='0.0.0.0', port=8000) diff --git a/scripts/docker/examples/python/assets/hello_world/requirements.txt b/scripts/docker/examples/python/assets/hello_world/requirements.txt deleted file mode 100644 index c92e3f74..00000000 --- a/scripts/docker/examples/python/assets/hello_world/requirements.txt +++ /dev/null @@ -1,12 +0,0 @@ -blinker==1.6.2 -click==8.1.7 -Flask-WTF==1.2.0 -Flask==2.3.3 -itsdangerous==2.1.2 -Jinja2==3.1.3 -MarkupSafe==2.1.3 -pip==23.3 -setuptools==78.1.1 -Werkzeug==3.0.1 -wheel==0.41.1 -WTForms==3.0.1 diff --git a/scripts/docker/examples/python/tests/goss.yaml b/scripts/docker/examples/python/tests/goss.yaml deleted file mode 100644 index 589db37b..00000000 --- a/scripts/docker/examples/python/tests/goss.yaml +++ /dev/null @@ -1,8 +0,0 @@ -package: - python: - installed: true - -command: - pip list | grep -i flask: - exit-status: 0 - timeout: 60000 diff --git a/scripts/docker/tests/.gitignore b/scripts/docker/tests/.gitignore deleted file mode 100644 index c50e8c0a..00000000 --- a/scripts/docker/tests/.gitignore +++ /dev/null @@ -1 +0,0 @@ -Dockerfile.effective diff --git a/scripts/docker/tests/.tool-versions.test b/scripts/docker/tests/.tool-versions.test deleted file mode 100644 index 92093116..00000000 --- a/scripts/docker/tests/.tool-versions.test +++ /dev/null @@ -1,2 +0,0 @@ -# python, SEE: https://hub.docker.com/_/python/tags -# docker/python 3.11.4-alpine3.18@sha256:0135ae6442d1269379860b361760ad2cf6ab7c403d21935a8015b48d5bf78a86 diff --git a/scripts/docker/tests/Dockerfile b/scripts/docker/tests/Dockerfile deleted file mode 100644 index b5ea5606..00000000 --- a/scripts/docker/tests/Dockerfile +++ /dev/null @@ -1,3 +0,0 @@ -# `*:latest` will be replaced with a corresponding version stored in the '.tool-versions' file -# hadolint ignore=DL3007 -FROM python:latest diff --git a/scripts/docker/tests/VERSION b/scripts/docker/tests/VERSION deleted file mode 100644 index fb366351..00000000 --- a/scripts/docker/tests/VERSION +++ /dev/null @@ -1,3 +0,0 @@ -${yyyy}${mm}${dd}-${hash} -$yyyy.$mm.$dd-$hash -somme-name-yyyyeah diff --git a/scripts/docker/tests/docker.test.sh b/scripts/docker/tests/docker.test.sh deleted file mode 100755 index 8f487b8f..00000000 --- a/scripts/docker/tests/docker.test.sh +++ /dev/null @@ -1,162 +0,0 @@ -#!/bin/bash -# shellcheck disable=SC1091,SC2034,SC2317 - -# WARNING: Please DO NOT edit this file! It is maintained in the Repository Template (https://github.com/nhs-england-tools/repository-template). Raise a PR instead. - -set -euo pipefail - -# Test suite for Docker functions. -# -# Usage: -# $ ./docker.test.sh -# -# Arguments (provided as environment variables): -# VERBOSE=true # Show all the executed commands, default is 'false' - -# ============================================================================== - -function main() { - - cd "$(git rev-parse --show-toplevel)" - source ./scripts/docker/docker.lib.sh - cd ./scripts/docker/tests - - DOCKER_IMAGE=repository-template/docker-test - DOCKER_TITLE="Repository Template Docker Test" - - test-docker-suite-setup - tests=( \ - test-docker-build \ - test-docker-image-from-signature \ - test-docker-version-file \ - test-docker-test \ - test-docker-run \ - test-docker-clean \ - test-docker-get-image-version-and-pull \ - ) - local status=0 - for test in "${tests[@]}"; do - { - echo -n "$test" - # shellcheck disable=SC2015 - $test && echo " PASS" || { echo " FAIL"; ((status++)); } - } - done - echo "Total: ${#tests[@]}, Passed: $(( ${#tests[@]} - status )), Failed: $status" - test-docker-suite-teardown - [ $status -gt 0 ] && return 1 || return 0 -} - -# ============================================================================== - -function test-docker-suite-setup() { - - : -} - -function test-docker-suite-teardown() { - - : -} - -# ============================================================================== - -function test-docker-build() { - - # Arrange - export BUILD_DATETIME="2023-09-04T15:46:34+0000" - # Act - docker-build > /dev/null 2>&1 - # Assert - docker image inspect "${DOCKER_IMAGE}:$(_get-effective-version)" > /dev/null 2>&1 && return 0 || return 1 -} - -function test-docker-image-from-signature() { - - # Arrange - TOOL_VERSIONS="$(git rev-parse --show-toplevel)/scripts/docker/tests/.tool-versions.test" - cp Dockerfile Dockerfile.effective - # Act - _replace-image-latest-by-specific-version - # Assert - grep -q "FROM python:.*-alpine.*@sha256:.*" Dockerfile.effective && return 0 || return 1 -} - -function test-docker-version-file() { - - # Arrange - export BUILD_DATETIME="2023-09-04T15:46:34+0000" - # Act - version-create-effective-file - # Assert - # shellcheck disable=SC2002 - ( - cat .version | grep -q "20230904-" && - cat .version | grep -q "2023.09.04-" && - cat .version | grep -q "somme-name-yyyyeah" - ) && return 0 || return 1 -} - -function test-docker-test() { - - # Arrange - cmd="python --version" - check="Python" - # Act - output=$(docker-check-test) - # Assert - echo "$output" | grep -q "PASS" -} - -function test-docker-run() { - - # Arrange - cmd="python --version" - # Act - output=$(docker-run) - # Assert - echo "$output" | grep -Eq "Python [0-9]+\.[0-9]+\.[0-9]+" -} - -function test-docker-clean() { - - # Arrange - version="$(_get-effective-version)" - # Act - docker-clean - # Assert - docker image inspect "${DOCKER_IMAGE}:${version}" > /dev/null 2>&1 && return 1 || return 0 -} - -function test-docker-get-image-version-and-pull() { - - # Arrange - name="ghcr.io/nhs-england-tools/github-runner-image" - match_version=".*-rt.*" - # Act - docker-get-image-version-and-pull > /dev/null 2>&1 - # Assert - docker images \ - --filter=reference="$name" \ - --format "{{.Tag}}" \ - | grep -vq "" -} - -# ============================================================================== - -function is-arg-true() { - - if [[ "$1" =~ ^(true|yes|y|on|1|TRUE|YES|Y|ON)$ ]]; then - return 0 - else - return 1 - fi -} - -# ============================================================================== - -is-arg-true "${VERBOSE:-false}" && set -x - -main "$@" - -exit 0 diff --git a/scripts/terraform/examples/terraform-state-aws-s3/main.tf b/scripts/terraform/examples/terraform-state-aws-s3/main.tf deleted file mode 100644 index a4ca5b0e..00000000 --- a/scripts/terraform/examples/terraform-state-aws-s3/main.tf +++ /dev/null @@ -1,46 +0,0 @@ -resource "aws_s3_bucket" "terraform_state_store" { - bucket = var.terraform_state_bucket_name - lifecycle { - prevent_destroy = false // FIXME: Normally, this should be 'true' - this is just an example - } -} - -resource "aws_s3_bucket_versioning" "enabled" { - bucket = aws_s3_bucket.terraform_state_store.id - versioning_configuration { - status = "Enabled" - } -} - -resource "aws_s3_bucket_server_side_encryption_configuration" "default" { - bucket = aws_s3_bucket.terraform_state_store.id - rule { - apply_server_side_encryption_by_default { - sse_algorithm = "AES256" - } - } -} - -resource "aws_s3_bucket_public_access_block" "public_access" { - bucket = aws_s3_bucket.terraform_state_store.id - block_public_acls = true - block_public_policy = true - ignore_public_acls = true - restrict_public_buckets = true -} - -resource "aws_dynamodb_table" "dynamodb_terraform_state_lock" { - name = var.terraform_state_table_name - billing_mode = "PAY_PER_REQUEST" - hash_key = "LockID" - attribute { - name = "LockID" - type = "S" - } - server_side_encryption { - enabled = true - } - point_in_time_recovery { - enabled = true - } -} diff --git a/scripts/terraform/examples/terraform-state-aws-s3/provider.tf b/scripts/terraform/examples/terraform-state-aws-s3/provider.tf deleted file mode 100644 index b64be2af..00000000 --- a/scripts/terraform/examples/terraform-state-aws-s3/provider.tf +++ /dev/null @@ -1,3 +0,0 @@ -provider "aws" { - region = "eu-west-2" -} diff --git a/scripts/terraform/examples/terraform-state-aws-s3/variables.tf b/scripts/terraform/examples/terraform-state-aws-s3/variables.tf deleted file mode 100644 index 07f60cb1..00000000 --- a/scripts/terraform/examples/terraform-state-aws-s3/variables.tf +++ /dev/null @@ -1,9 +0,0 @@ -variable "terraform_state_bucket_name" { - description = "The S3 bucket name to store Terraform state" - default = "repository-template-example-terraform-state-store" -} - -variable "terraform_state_table_name" { - description = "The DynamoDB table name to acquire Terraform lock" - default = "repository-template-example-terraform-state-lock" -} diff --git a/scripts/terraform/examples/terraform-state-aws-s3/versions.tf b/scripts/terraform/examples/terraform-state-aws-s3/versions.tf deleted file mode 100644 index 18fd04af..00000000 --- a/scripts/terraform/examples/terraform-state-aws-s3/versions.tf +++ /dev/null @@ -1,8 +0,0 @@ -terraform { - required_version = ">= 1.5.0" - required_providers { - aws = { - version = ">= 5.14.0" - } - } -} diff --git a/scripts/terraform/terraform-docs.sh b/scripts/terraform/terraform-docs.sh new file mode 100755 index 00000000..446d30d0 --- /dev/null +++ b/scripts/terraform/terraform-docs.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash + +# WARNING: Please DO NOT edit this file! It is maintained in the Repository Template (https://github.com/NHSDigital/nhs-notify-repository-template). Raise a PR instead. + +set -euo pipefail + +# Terraform-docs command wrapper. It will run the command natively if terraform-docs is +# installed, otherwise it will run it in a Docker container. +# Run terraform-docs for generating Terraform module documentation code. +# +# Usage: +# $ ./terraform-docs.sh [directory] +# ============================================================================== + +function main() { + + cd "$(git rev-parse --show-toplevel)" + + local dir_to_document=${1:-.} + + if command -v terraform-docs > /dev/null 2>&1 && ! is-arg-true "${FORCE_USE_DOCKER:-false}"; then + # shellcheck disable=SC2154 + run-terraform-docs-natively "$dir_to_document" + else + run-terraform-docs-in-docker "$dir_to_document" + fi +} + +# Run terraform-docs on the specified directory. +# Arguments: +# $1 - Directory to document +function run-terraform-docs-natively() { + + local dir_to_scan="$1" + echo "Terraform-docs found locally, running natively" + if [ -d "$dir_to_scan" ]; then + echo "Running Terraform-docs on directory: $dir_to_scan" + terraform-docs \ + -c scripts/config/terraform-docs.yml \ + --output-file README.md \ + "$dir_to_scan" + fi +} + +function run-terraform-docs-in-docker() { + + # shellcheck disable=SC1091 + source ./scripts/docker/docker.lib.sh + local dir_to_scan="$1" + + # shellcheck disable=SC2155 + local image=$(name=quay.io/terraform-docs/terraform-docs docker-get-image-version-and-pull) + # shellcheck disable=SC2086 + echo "Terraform-docs not found locally, running in Docker Container" + echo "Running Terraform-docs on directory: $dir_to_scan" + docker run --rm --platform linux/amd64 \ + --volume "$PWD":/workdir \ + --workdir /workdir \ + "$image" \ + -c scripts/config/terraform-docs.yml \ + --output-file README.md \ + "$dir_to_scan" + +} +# ============================================================================== + +function is-arg-true() { + + if [[ "$1" =~ ^(true|yes|y|on|1|TRUE|YES|Y|ON)$ ]]; then + return 0 + else + return 1 + fi +} + +# ============================================================================== + +is-arg-true "${VERBOSE:-false}" && set -x + +main "$@" + +exit 0 diff --git a/scripts/terraform/terraform.lib.sh b/scripts/terraform/terraform.lib.sh deleted file mode 100644 index 7793b9b0..00000000 --- a/scripts/terraform/terraform.lib.sh +++ /dev/null @@ -1,93 +0,0 @@ -#!/bin/bash - -# WARNING: Please DO NOT edit this file! It is maintained in the Repository Template (https://github.com/nhs-england-tools/repository-template). Raise a PR instead. - -set -euo pipefail - -# A set of Terraform functions written in Bash. -# -# Usage: -# $ source ./terraform.lib.sh - -# ============================================================================== -# Common Terraform functions. - -# Initialise Terraform. -# Arguments (provided as environment variables): -# dir=[path to a directory where the command will be executed, relative to the project's top-level directory, default is '.'] -# opts=[options to pass to the Terraform init command, default is none/empty] -function terraform-init() { - - _terraform init # 'dir' and 'opts' are passed to the function as environment variables, if set -} - -# Plan Terraform changes. -# Arguments (provided as environment variables): -# dir=[path to a directory where the command will be executed, relative to the project's top-level directory, default is '.'] -# opts=[options to pass to the Terraform plan command, default is none/empty] -function terraform-plan() { - - _terraform plan # 'dir' and 'opts' are passed to the function as environment variables, if set -} - -# Apply Terraform changes. -# Arguments (provided as environment variables): -# dir=[path to a directory where the command will be executed, relative to the project's top-level directory, default is '.'] -# opts=[options to pass to the Terraform apply command, default is none/empty] -function terraform-apply() { - - _terraform apply # 'dir' and 'opts' are passed to the function as environment variables, if set -} - -# Destroy Terraform resources. -# Arguments (provided as environment variables): -# dir=[path to a directory where the command will be executed, relative to the project's top-level directory, default is '.'] -# opts=[options to pass to the Terraform destroy command, default is none/empty] -function terraform-destroy() { - - _terraform apply -destroy # 'dir' and 'opts' are passed to the function as environment variables, if set -} - -# Format Terraform code. -# Arguments (provided as environment variables): -# dir=[path to a directory where the command will be executed, relative to the project's top-level directory, default is '.'] -# opts=[options to pass to the Terraform fmt command, default is '-recursive'] -function terraform-fmt() { - - _terraform fmt -recursive # 'dir' and 'opts' are passed to the function as environment variables, if set -} - -# Validate Terraform code. -# Arguments (provided as environment variables): -# dir=[path to a directory where the command will be executed, relative to the project's top-level directory, default is '.'] -# opts=[options to pass to the Terraform validate command, default is none/empty] -function terraform-validate() { - - _terraform validate # 'dir' and 'opts' are passed to the function as environment variables, if set -} - -# shellcheck disable=SC2001,SC2155 -function _terraform() { - - local dir="$(echo "${dir:-$PWD}" | sed "s#$PWD#.#")" - local cmd="-chdir=$dir $* ${opts:-}" - local project_dir="$(git rev-parse --show-toplevel)" - - cmd="$cmd" "$project_dir/scripts/terraform/terraform.sh" -} - -# Remove Terraform files. -# Arguments (provided as environment variables): -# dir=[path to a directory where the command will be executed, relative to the project's top-level directory, default is '.'] -function terraform-clean() { - - ( - cd "${dir:-$PWD}" - rm -rf \ - .terraform \ - terraform.log \ - terraform.tfplan \ - terraform.tfstate \ - terraform.tfstate.backup - ) -} diff --git a/scripts/terraform/terraform.mk b/scripts/terraform/terraform.mk index 120a0591..10899b72 100644 --- a/scripts/terraform/terraform.mk +++ b/scripts/terraform/terraform.mk @@ -1,96 +1,174 @@ -# This file is for you! Edit it to implement your own Terraform make targets. +# Terraform Make Targets for TFScaffold +# NHS Notify standard for production infrastructure +# Requires infrastructure/terraform/bin/terraform.sh # ============================================================================== -# Custom implementation - implementation of a make target should not exceed 5 lines of effective code. -# In most cases there should be no need to modify the existing make targets. - -terraform-init: # Initialise Terraform - optional: terraform_dir|dir=[path to a directory where the command will be executed, relative to the project's top-level directory, default is one of the module variables or the example directory, if not set], terraform_opts|opts=[options to pass to the Terraform init command, default is none/empty] @Development - make _terraform cmd="init" \ - dir=$(or ${terraform_dir}, ${dir}) \ - opts=$(or ${terraform_opts}, ${opts}) - -terraform-plan: # Plan Terraform changes - optional: terraform_dir|dir=[path to a directory where the command will be executed, relative to the project's top-level directory, default is one of the module variables or the example directory, if not set], terraform_opts|opts=[options to pass to the Terraform plan command, default is none/empty] @Development - make _terraform cmd="plan" \ - dir=$(or ${terraform_dir}, ${dir}) \ - opts=$(or ${terraform_opts}, ${opts}) - -terraform-apply: # Apply Terraform changes - optional: terraform_dir|dir=[path to a directory where the command will be executed, relative to the project's top-level directory, default is one of the module variables or the example directory, if not set], terraform_opts|opts=[options to pass to the Terraform apply command, default is none/empty] @Development - make _terraform cmd="apply" \ - dir=$(or ${terraform_dir}, ${dir}) \ - opts=$(or ${terraform_opts}, ${opts}) - -terraform-destroy: # Destroy Terraform resources - optional: terraform_dir|dir=[path to a directory where the command will be executed, relative to the project's top-level directory, default is one of the module variables or the example directory, if not set], terraform_opts|opts=[options to pass to the Terraform destroy command, default is none/empty] @Development - make _terraform \ - cmd="destroy" \ - dir=$(or ${terraform_dir}, ${dir}) \ - opts=$(or ${terraform_opts}, ${opts}) - -terraform-fmt: # Format Terraform files - optional: terraform_dir|dir=[path to a directory where the command will be executed, relative to the project's top-level directory, default is one of the module variables or the example directory, if not set], terraform_opts|opts=[options to pass to the Terraform fmt command, default is '-recursive'] @Quality - make _terraform cmd="fmt" \ - dir=$(or ${terraform_dir}, ${dir}) \ - opts=$(or ${terraform_opts}, ${opts}) - -terraform-validate: # Validate Terraform configuration - optional: terraform_dir|dir=[path to a directory where the command will be executed, relative to the project's top-level directory, default is one of the module variables or the example directory, if not set], terraform_opts|opts=[options to pass to the Terraform validate command, default is none/empty] @Quality - make _terraform cmd="validate" \ - dir=$(or ${terraform_dir}, ${dir}) \ - opts=$(or ${terraform_opts}, ${opts}) - -clean:: # Remove Terraform files (terraform) - optional: terraform_dir|dir=[path to a directory where the command will be executed, relative to the project's top-level directory, default is one of the module variables or the example directory, if not set] @Operations - make _terraform cmd="clean" \ - dir=$(or ${terraform_dir}, ${dir}) \ - opts=$(or ${terraform_opts}, ${opts}) - -_terraform: # Terraform command wrapper - mandatory: cmd=[command to execute]; optional: dir=[path to a directory where the command will be executed, relative to the project's top-level directory, default is one of the module variables or the example directory, if not set], opts=[options to pass to the Terraform command, default is none/empty] - # 'TERRAFORM_STACK' is passed to the functions as environment variable - TERRAFORM_STACK=$(or ${TERRAFORM_STACK}, $(or ${terraform_stack}, $(or ${STACK}, $(or ${stack}, scripts/terraform/examples/terraform-state-aws-s3)))) - dir=$(or ${dir}, ${TERRAFORM_STACK}) - source scripts/terraform/terraform.lib.sh - terraform-${cmd} # 'dir' and 'opts' are accessible by the function as environment variables, if set +# TFScaffold Terraform Operations + +terraform-plan: # Plan Terraform changes - mandatory: component=[component_name], environment=[environment]; optional: project=[default: nhs], region=[default: eu-west-2], group=[default: dev], opts=[additional options] @Development + # Example: make terraform-plan component=mycomp environment=myenv group=mygroup + # Args: --project nhs --region eu-west-2 --component mycomp --environment myenv --group mygroup --action plan + make _terraform-scaffold action=plan \ + component=$(component) \ + environment=$(environment) \ + project=$(or ${project}, nhs) \ + region=$(or ${region}, eu-west-2) \ + group=$(or ${group}, dev) \ + opts=$(or ${opts}, ) + +terraform-plan-destroy: # Plan Terraform destroy - mandatory: component=[component_name], environment=[environment]; optional: project, region, group, opts @Development + # Example: make terraform-plan-destroy component=mycomp environment=myenv group=mygroup + # Args: --project nhs --region eu-west-2 --component mycomp --environment myenv --group mygroup --action plan-destroy + make _terraform-scaffold action=plan-destroy \ + component=$(component) \ + environment=$(environment) \ + project=$(or ${project}, nhs) \ + region=$(or ${region}, eu-west-2) \ + group=$(or ${group}, dev) \ + opts=$(or ${opts}, ) + +terraform-apply: # Apply Terraform changes - mandatory: component=[component_name], environment=[environment]; optional: project, region, group, build_id, opts @Development + # Example: make terraform-apply component=mycomp environment=myenv group=mygroup + # Args: --project nhs --region eu-west-2 --component mycomp --environment myenv --group mygroup --action apply + make _terraform-scaffold action=apply \ + component=$(component) \ + environment=$(environment) \ + project=$(or ${project}, nhs) \ + region=$(or ${region}, eu-west-2) \ + group=$(or ${group}, dev) \ + build_id=$(or ${build_id}, ) \ + opts=$(or ${opts}, ) + +terraform-destroy: # Destroy Terraform resources - mandatory: component=[component_name], environment=[environment]; optional: project, region, group, opts @Development + # Example: make terraform-destroy component=mycomp environment=myenv group=mygroup + # Args: --project nhs --region eu-west-2 --component mycomp --environment myenv --group mygroup --action destroy + make _terraform-scaffold action=destroy \ + component=$(component) \ + environment=$(environment) \ + project=$(or ${project}, nhs) \ + region=$(or ${region}, eu-west-2) \ + group=$(or ${group}, dev) \ + opts=$(or ${opts}, ) + +terraform-output: # Get Terraform outputs - mandatory: component=[component_name], environment=[environment]; optional: project, region, group @Development + # Example: make terraform-output component=mycomp environment=myenv group=mygroup + # Args: --project nhs --region eu-west-2 --component mycomp --environment myenv --group mygroup --action output + make _terraform-scaffold action=output \ + component=$(component) \ + environment=$(environment) \ + project=$(or ${project}, nhs) \ + region=$(or ${region}, eu-west-2) \ + group=$(or ${group}, dev) + +_terraform-scaffold: # Internal wrapper for terraform.sh - mandatory: action=[terraform action]; optional: component, environment, project, region, group, bootstrap, build_id, opts + cd infrastructure/terraform && \ + if [ "$(bootstrap)" = "true" ]; then \ + ./bin/terraform.sh \ + --bootstrap \ + --project $(project) \ + --region $(region) \ + --group $(group) \ + --action $(action) \ + $(if $(opts),-- $(opts),); \ + else \ + ./bin/terraform.sh \ + --project $(project) \ + --region $(region) \ + --component $(component) \ + --environment $(environment) \ + --group $(group) \ + $(if $(build_id),--build-id $(build_id),) \ + --action $(action) \ + $(if $(opts),-- $(opts),); \ + fi # ============================================================================== -# Quality checks - please DO NOT edit this section! - -terraform-shellscript-lint: # Lint all Terraform module shell scripts @Quality - for file in $$(find scripts/terraform -type f -name "*.sh"); do - file=$${file} scripts/shellscript-linter.sh +# Formatting and Validation + +terraform-fmt: # Format Terraform files in components/ and modules/ (excludes etc/) @Quality + # Example: make terraform-fmt + @cd infrastructure/terraform && \ + for dir in components modules; do \ + [ -d "$$dir" ] && terraform fmt -recursive "$$dir"; \ + done + +terraform-fmt-check: # Check Terraform formatting in components/ and modules/ (excludes etc/) @Quality + # Example: make terraform-fmt-check + @cd infrastructure/terraform && \ + for dir in components modules; do \ + [ -d "$$dir" ] && terraform fmt -check -recursive "$$dir"; \ + done + +terraform-validate: # Validate Terraform configuration - mandatory: component=[component_name] @Quality + # Example: make terraform-validate component=mycomp + # Note: Validation does not require environment/group as it checks syntax only + cd infrastructure/terraform/components/$(component) && \ + terraform init -backend=false && \ + terraform validate + +terraform-validate-all: # Validate all Terraform components @Quality + # Example: make terraform-validate-all + for dir in infrastructure/terraform/components/*; do \ + if [ -d "$$dir" ]; then \ + echo "Validating $$(basename $$dir)..."; \ + cd $$dir && \ + terraform init -backend=false && \ + terraform validate && \ + cd - > /dev/null; \ + fi; \ done -# ============================================================================== -# Module tests and examples - please DO NOT edit this section! - -terraform-example-provision-aws-infrastructure: # Provision example of AWS infrastructure @ExamplesAndTests - make terraform-init - make terraform-plan opts="-out=terraform.tfplan" - make terraform-apply opts="-auto-approve terraform.tfplan" +# TODO - Re-visit Trivy usage https://nhsd-jira.digital.nhs.uk/browse/CCM-15549 +# terraform-sec: # Run Trivy IaC security scanning on Terraform code @Quality +# # Example: make terraform-sec +# ./scripts/terraform/trivy-scan.sh --mode iac infrastructure/terraform + +terraform-docs: # Generate Terraform documentation - optional: component=[specific component, or all if omitted] @Quality + # Example: make terraform-docs component=mycomp + # Example: make terraform-docs (generates for all components) + @if [ -n "$(component)" ]; then \ + ./scripts/terraform/terraform-docs.sh infrastructure/terraform/components/$(component); \ + else \ + for dir in infrastructure/terraform/components/* infrastructure/terraform/modules/*; do \ + if [ -d "$$dir" ]; then \ + ./scripts/terraform/terraform-docs.sh $$dir; \ + fi; \ + done; \ + fi -terraform-example-destroy-aws-infrastructure: # Destroy example of AWS infrastructure @ExamplesAndTests - make terraform-destroy opts="-auto-approve" +# ============================================================================== +# Cleanup -terraform-example-clean: # Remove Terraform example files @ExamplesAndTests - dir=$(or ${dir}, ${TERRAFORM_STACK}) - source scripts/terraform/terraform.lib.sh - terraform-clean - rm -f ${TERRAFORM_STACK}/.terraform.lock.hcl +clean:: # Remove Terraform build artifacts and cache @Operations + # Example: make clean + rm -rf infrastructure/terraform/components/*/build + rm -rf infrastructure/terraform/components/*/.terraform + rm -rf infrastructure/terraform/components/*/.terraform.lock.hcl + rm -rf infrastructure/terraform/bootstrap/.terraform + rm -rf infrastructure/terraform/bootstrap/.terraform.lock.hcl + rm -rf infrastructure/terraform/plugin-cache/* # ============================================================================== -# Configuration - please DO NOT edit this section! +# Installation -terraform-install: # Install Terraform @Installation +terraform-install: # Install Terraform using asdf @Installation + # Example: make terraform-install make _install-dependency name="terraform" # ============================================================================== ${VERBOSE}.SILENT: \ - _terraform \ + _terraform-scaffold \ clean \ terraform-apply \ terraform-destroy \ - terraform-example-clean \ - terraform-example-destroy-aws-infrastructure \ - terraform-example-provision-aws-infrastructure \ + terraform-docs \ terraform-fmt \ - terraform-init \ + terraform-fmt-check \ terraform-install \ + terraform-output \ terraform-plan \ - terraform-shellscript-lint \ + terraform-plan-destroy \ +# terraform-sec \ terraform-validate \ + terraform-validate-all \ From 20c2f54b5cda52ab268ec3699518f3e10f58d48a Mon Sep 17 00:00:00 2001 From: aidenvaines-cgi Date: Wed, 8 Apr 2026 17:10:30 +0100 Subject: [PATCH 02/10] CCM-16446 Adding repo support and pre-commit fixes --- package-lock.json | 21 +++++++++++++++++++++ package.json | 16 ++++++++++++++++ scripts/docker/docker.lib.sh | 2 +- 3 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 package-lock.json create mode 100644 package.json diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..b3acdf93 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,21 @@ +{ + "name": "nhs-notify-web-cms", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "nhs-notify-web-cms", + "workspaces": [ + "containers/wagtail" + ] + }, + "containers/wagtail": { + "name": "nhs-notify-web-cms-wagtail", + "version": "0.0.1" + }, + "node_modules/nhs-notify-web-cms-wagtail": { + "resolved": "containers/wagtail", + "link": true + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..9aede47f --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "nhs-notify-web-cms", + "scripts": { + "build:container": "npm run build:container --workspaces --if-present", + "build:archive": "npm run build:archive --workspaces --if-present", + "generate-dependencies": "npm run generate-dependencies --workspaces --if-present", + "lint": "npm run lint --workspaces", + "lint:fix": "npm run lint:fix --workspaces", + "start": "npm run start --workspace frontend", + "test:unit": "npm run test:unit --workspaces", + "typecheck": "npm run typecheck --workspaces" + }, + "workspaces": [ + "containers/wagtail" + ] +} diff --git a/scripts/docker/docker.lib.sh b/scripts/docker/docker.lib.sh index e5df3cf6..fb27fcbc 100644 --- a/scripts/docker/docker.lib.sh +++ b/scripts/docker/docker.lib.sh @@ -430,7 +430,7 @@ function docker-push-container() { function docker-calculate-image-name() { local dir=${dir:-$PWD} local container_name="${CONTAINER_NAME:-$(basename "$dir")}" - local ecr_repo="${ECR_REPO:-nhs-main-acct-admail}" + local ecr_repo="${ECR_REPO:-nhs-main-acct-main}" local image_suffix="${CONTAINER_IMAGE_SUFFIX:-$(docker-get-git-version-suffix)}" local image_tag="${CONTAINER_IMAGE_PREFIX}-${container_name}" local ecr_repo_uri="${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/${ecr_repo}" From 9d988b2cc05e011a5585b3ea32b3316df0d35a3c Mon Sep 17 00:00:00 2001 From: aidenvaines-cgi Date: Wed, 15 Apr 2026 09:07:20 +0100 Subject: [PATCH 03/10] Adding base wagtail config --- .tool-versions | 1 + containers/wagtail/.env.example | 29 ++ containers/wagtail/.gitignore | 29 ++ containers/wagtail/README.md | 188 ++++++++ containers/wagtail/app.py | 32 ++ containers/wagtail/build.sh | 5 + containers/wagtail/docker-compose.yml | 57 +++ containers/wagtail/docker/Dockerfile | 32 ++ containers/wagtail/package.json | 9 + containers/wagtail/requirements.txt | 3 + .../terraform/components/cms/README.md | 48 ++ .../cms/cloudwatch_log_group_ecs.tf | 20 + .../cms/cloudwatch_log_group_elasticache.tf | 25 + .../cms/cloudwatch_log_group_rds.tf | 12 + .../terraform/components/cms/db_instance.tf | 61 +++ .../components/cms/db_parameter_group.tf | 33 ++ .../components/cms/db_subnet_group.tf | 12 + .../terraform/components/cms/ecs_cluster.tf | 15 + .../cms/ecs_express_gateway_service.tf | 114 +++++ .../cms/elasticache_parameter_group.tf | 27 ++ .../cms/elasticache_serverless_cache.tf | 74 +++ .../cms/elasticache_subnet_group.tf | 12 + .../cms/iam_role_ecs_infrastructure.tf | 431 ++++++++++++++++++ .../components/cms/iam_role_ecs_task.tf | 47 ++ .../cms/iam_role_ecs_task_execution.tf | 82 ++++ .../components/cms/iam_role_rds_monitoring.tf | 28 ++ .../terraform/components/cms/locals.tf | 17 + .../components/cms/locals_remote_state.tf | 21 + .../components/cms/locals_tfscaffold.tf | 47 ++ .../terraform/components/cms/module_kms.tf | 117 +++++ .../components/cms/module_s3_bucket_media.tf | 28 ++ .../terraform/components/cms/outputs.tf | 99 ++++ .../terraform/components/cms/pre.sh | 74 +++ .../terraform/components/cms/provider_aws.tf | 24 + .../components/cms/route53_record.tf | 9 + .../cms/security_group_ecs_tasks.tf | 24 + .../cms/security_group_elasticache.tf | 32 ++ .../components/cms/security_group_rds.tf | 32 ++ .../cms/ssm_parameter_db_password.tf | 25 + .../cms/ssm_parameter_django_secret_key.tf | 26 ++ .../cms/ssm_parameter_redis_auth_token.tf | 26 ++ .../terraform/components/cms/variables.tf | 164 +++++++ .../terraform/components/cms/versions.tf | 10 + scripts/docker/docker.lib.sh | 1 + 44 files changed, 2202 insertions(+) create mode 100644 containers/wagtail/.env.example create mode 100644 containers/wagtail/.gitignore create mode 100644 containers/wagtail/README.md create mode 100644 containers/wagtail/app.py create mode 100755 containers/wagtail/build.sh create mode 100644 containers/wagtail/docker-compose.yml create mode 100644 containers/wagtail/docker/Dockerfile create mode 100644 containers/wagtail/package.json create mode 100644 containers/wagtail/requirements.txt create mode 100644 infrastructure/terraform/components/cms/README.md create mode 100644 infrastructure/terraform/components/cms/cloudwatch_log_group_ecs.tf create mode 100644 infrastructure/terraform/components/cms/cloudwatch_log_group_elasticache.tf create mode 100644 infrastructure/terraform/components/cms/cloudwatch_log_group_rds.tf create mode 100644 infrastructure/terraform/components/cms/db_instance.tf create mode 100644 infrastructure/terraform/components/cms/db_parameter_group.tf create mode 100644 infrastructure/terraform/components/cms/db_subnet_group.tf create mode 100644 infrastructure/terraform/components/cms/ecs_cluster.tf create mode 100644 infrastructure/terraform/components/cms/ecs_express_gateway_service.tf create mode 100644 infrastructure/terraform/components/cms/elasticache_parameter_group.tf create mode 100644 infrastructure/terraform/components/cms/elasticache_serverless_cache.tf create mode 100644 infrastructure/terraform/components/cms/elasticache_subnet_group.tf create mode 100644 infrastructure/terraform/components/cms/iam_role_ecs_infrastructure.tf create mode 100644 infrastructure/terraform/components/cms/iam_role_ecs_task.tf create mode 100644 infrastructure/terraform/components/cms/iam_role_ecs_task_execution.tf create mode 100644 infrastructure/terraform/components/cms/iam_role_rds_monitoring.tf create mode 100644 infrastructure/terraform/components/cms/locals.tf create mode 100644 infrastructure/terraform/components/cms/locals_remote_state.tf create mode 100644 infrastructure/terraform/components/cms/locals_tfscaffold.tf create mode 100644 infrastructure/terraform/components/cms/module_kms.tf create mode 100644 infrastructure/terraform/components/cms/module_s3_bucket_media.tf create mode 100644 infrastructure/terraform/components/cms/outputs.tf create mode 100644 infrastructure/terraform/components/cms/pre.sh create mode 100644 infrastructure/terraform/components/cms/provider_aws.tf create mode 100644 infrastructure/terraform/components/cms/route53_record.tf create mode 100644 infrastructure/terraform/components/cms/security_group_ecs_tasks.tf create mode 100644 infrastructure/terraform/components/cms/security_group_elasticache.tf create mode 100644 infrastructure/terraform/components/cms/security_group_rds.tf create mode 100644 infrastructure/terraform/components/cms/ssm_parameter_db_password.tf create mode 100644 infrastructure/terraform/components/cms/ssm_parameter_django_secret_key.tf create mode 100644 infrastructure/terraform/components/cms/ssm_parameter_redis_auth_token.tf create mode 100644 infrastructure/terraform/components/cms/variables.tf create mode 100644 infrastructure/terraform/components/cms/versions.tf diff --git a/.tool-versions b/.tool-versions index b702444d..09a61cc3 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,5 +1,6 @@ gitleaks 8.18.4 nodejs 18.18.2 +jq 1.8.1 pre-commit 3.6.0 terraform 1.14.8 terraform-docs 0.19.0 diff --git a/containers/wagtail/.env.example b/containers/wagtail/.env.example new file mode 100644 index 00000000..80654818 --- /dev/null +++ b/containers/wagtail/.env.example @@ -0,0 +1,29 @@ +# Django Configuration +DJANGO_SETTINGS_MODULE=config.settings.development +DJANGO_SECRET_KEY=local-dev-secret-key-change-in-production +DEBUG=true +ALLOWED_HOSTS=localhost,127.0.0.1,wagtail + +# Database Configuration +DATABASE_NAME=wagtail +DATABASE_USER=wagtail +DATABASE_PASSWORD=wagtail-dev-password +DATABASE_HOST=postgres +DATABASE_PORT=5432 +DATABASE_SSLMODE=disable + +# Redis Configuration +REDIS_HOST=redis +REDIS_PORT=6379 +REDIS_AUTH_TOKEN=redis-dev-password +REDIS_SSL=false + +# AWS S3 Configuration (optional for local dev) +AWS_STORAGE_BUCKET_NAME= +AWS_S3_REGION_NAME=eu-west-2 + +# Wagtail Configuration +WAGTAILADMIN_BASE_URL=http://localhost:8080 + +# Logging +DJANGO_LOG_LEVEL=DEBUG diff --git a/containers/wagtail/.gitignore b/containers/wagtail/.gitignore new file mode 100644 index 00000000..b8046cf3 --- /dev/null +++ b/containers/wagtail/.gitignore @@ -0,0 +1,29 @@ +# Local environment +.env + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +*.pyc + +# Django +*.log +db.sqlite3 +db.sqlite3-journal +/media +/staticfiles +/static + +# Local development +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# Docker +.dockerignore diff --git a/containers/wagtail/README.md b/containers/wagtail/README.md new file mode 100644 index 00000000..e62154e4 --- /dev/null +++ b/containers/wagtail/README.md @@ -0,0 +1,188 @@ +# NHS Notify Wagtail CMS Container + +Production-ready Wagtail CMS container for NHS Notify. + +## Quick Start + +```bash +# Start all services (PostgreSQL, Redis, Wagtail) +make up + +# Create a superuser account +make superuser + +# View logs +make logs + +# Access at http://localhost:8080 +``` + +For all available commands: `make help` + +## Features + +- **Wagtail 6.3**: Latest stable Wagtail CMS +- **Django 5.0**: Modern Django framework +- **PostgreSQL**: Production database backend +- **Redis**: Caching and session storage +- **S3 Storage**: Media files stored in AWS S3 +- **Gunicorn**: Production WSGI server +- **Health checks**: `/health/` endpoint for ALB +- **Prometheus metrics**: `/metrics` endpoint for monitoring +- **Security hardened**: CSP, HSTS, secure cookies + +## Environment Variables + +The container expects these environment variables (provided by ECS): + +### Django Settings +- `DJANGO_SECRET_KEY`: Django secret key (from SSM Parameter Store) +- `DJANGO_SETTINGS_MODULE`: `config.settings.production` (default) +- `ALLOWED_HOSTS`: Comma-separated list of allowed hostnames +- `DEBUG`: Set to `false` in production + +### Database +- `DATABASE_HOST`: RDS PostgreSQL endpoint +- `DATABASE_PORT`: Database port (default: `5432`) +- `DATABASE_NAME`: Database name (default: `wagtail`) +- `DATABASE_USER`: Database username +- `DATABASE_PASSWORD`: Database password (from SSM Parameter Store) +- `DATABASE_SSLMODE`: SSL mode for database connection (default: `prefer`) + +### Redis Cache +- `REDIS_HOST`: ElastiCache Redis endpoint +- `REDIS_PORT`: Redis port (default: `6379`) +- `REDIS_AUTH_TOKEN`: Redis authentication token (from SSM Parameter Store) +- `REDIS_SSL`: Enable SSL for Redis (default: `true`) + +### S3 Storage +- `AWS_STORAGE_BUCKET_NAME`: S3 bucket for media files +- `AWS_S3_REGION_NAME`: AWS region (default: `eu-west-2`) + +### Wagtail +- `WAGTAILADMIN_BASE_URL`: Full URL to the Wagtail admin (e.g., `https://cms.example.com`) + +## Local Development + +### Using Docker Compose (Recommended) + +1. **Start all services** (PostgreSQL, Redis, Wagtail): + ```bash + docker-compose up -d + ``` + +2. **View logs**: + ```bash + docker-compose logs -f wagtail + ``` + +3. **Create a superuser**: + ```bash + docker-compose exec wagtail python manage.py createsuperuser + ``` + +4. **Access the application**: + - Wagtail CMS: http://localhost:8080 + - Admin interface: http://localhost:8080/admin/ + - Health check: http://localhost:8080/health/ + +5. **Stop services**: + ```bash + docker-compose down + ``` + +6. **Reset database** (removes all data): + ```bash + docker-compose down -v + ``` + +### Manual Local Setup + +1. Install dependencies: + ```bash + pip install -r requirements.txt + ``` + +2. Set environment variables: + ```bash + export DJANGO_SETTINGS_MODULE=config.settings.development + export DATABASE_HOST=localhost + export DATABASE_PASSWORD=password + export REDIS_SSL=false + ``` + +3. Run migrations: + ```bash + python src/manage.py migrate + ``` + +4. Create superuser: + ```bash + python src/manage.py createsuperuser + ``` + +5. Run development server: + ```bash + python src/manage.py runserver 8080 + ``` + +## Environment Variables + +See [.env.example](.env.example) for a complete list of environment variables. Copy it to `.env` for docker-compose: + +```bash +cp .env.example .env +``` + + +## Building the Container + +```bash +npm run build:container +``` + +Or directly: +```bash +cd ../.. && make docker-build-and-push base_image=python:3.12-slim dir=containers/wagtail +``` + +## Running Migrations in ECS + +After deployment, run migrations via ECS Exec or one-off task: + +```bash +aws ecs run-task \ + --cluster \ + --task-definition \ + --launch-type FARGATE \ + --network-configuration "awsvpcConfiguration={subnets=[],securityGroups=[]}" \ + --overrides '{"containerOverrides": [{"name": "wagtail", "command": ["python", "manage.py", "migrate"]}]}' +``` + +## Creating a Superuser + +```bash +aws ecs run-task \ + --cluster \ + --task-definition \ + --launch-type FARGATE \ + --network-configuration "awsvpcConfiguration={subnets=[],securityGroups=[]}" \ + --overrides '{"containerOverrides": [{"name": "wagtail", "command": ["python", "manage.py", "createsuperuser", "--noinput", "--username", "admin", "--email", "admin@example.com"]}]}' +``` + +## Health Check + +The container exposes `/health/` endpoint that returns HTTP 200 with "OK" text. + +## Metrics + +Prometheus metrics are available at `/metrics` endpoint. + +## Security + +- All secrets managed via SSM Parameter Store +- S3 media files use IAM role authentication +- SSL/TLS for database and Redis connections +- CSP, HSTS, and other security headers enabled +- Non-root user execution +- Read-only root filesystem compatible diff --git a/containers/wagtail/app.py b/containers/wagtail/app.py new file mode 100644 index 00000000..55c70607 --- /dev/null +++ b/containers/wagtail/app.py @@ -0,0 +1,32 @@ +"""Minimal Flask app for testing ECS Express Gateway Service.""" + +from flask import Flask, jsonify + +app = Flask(__name__) + + +@app.route("/") +def home(): + """Home page.""" + return """ + + + + NHS Notify CMS + + +

Hello World!

+

NHS Notify Wagtail CMS - Test Deployment

+ + + """ + + +@app.route("/health/") +def health(): + """Health check endpoint for ECS.""" + return jsonify({"status": "healthy"}), 200 + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=8080) diff --git a/containers/wagtail/build.sh b/containers/wagtail/build.sh new file mode 100755 index 00000000..dbc09f46 --- /dev/null +++ b/containers/wagtail/build.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +set -euo pipefail + +echo "No build step required for Python container - dependencies installed during Docker build" diff --git a/containers/wagtail/docker-compose.yml b/containers/wagtail/docker-compose.yml new file mode 100644 index 00000000..abac1446 --- /dev/null +++ b/containers/wagtail/docker-compose.yml @@ -0,0 +1,57 @@ +services: + # postgres: + # image: postgres:16.6 + # container_name: wagtail-db + # environment: + # POSTGRES_DB: wagtail + # POSTGRES_USER: wagtail + # POSTGRES_PASSWORD: wagtail-dev-password + # ports: + # - "5432:5432" + # volumes: + # - postgres_data:/var/lib/postgresql/data + # healthcheck: + # test: ["CMD-SHELL", "pg_isready -U wagtail"] + # interval: 10s + # timeout: 5s + # retries: 5 + + # redis: + # image: redis:7-alpine + # container_name: wagtail-redis + # command: redis-server --requirepass redis-dev-password + # ports: + # - "6379:6379" + # volumes: + # - redis_data:/data + # healthcheck: + # test: ["CMD", "redis-cli", "-a", "redis-dev-password", "ping"] + # interval: 10s + # timeout: 3s + # retries: 5 + + wagtail: + build: + context: . + dockerfile: docker/Dockerfile + args: + BASE_IMAGE: python:3.12-slim + container_name: wagtail-app + ports: + - "8080:8080" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health/"] + interval: 10s + timeout: 5s + retries: 3 + start_period: 10s + +# volumes: +# postgres_data: +# driver: local +# redis_data: +# driver: local +# media_files: +# driver: local +# static_files: +# driver: local diff --git a/containers/wagtail/docker/Dockerfile b/containers/wagtail/docker/Dockerfile new file mode 100644 index 00000000..ccedf080 --- /dev/null +++ b/containers/wagtail/docker/Dockerfile @@ -0,0 +1,32 @@ +ARG BASE_IMAGE + +FROM ${BASE_IMAGE} + +# Install curl for health checks +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Create non-root user +RUN useradd -m -u 1000 wagtail && \ + mkdir -p /app && \ + chown -R wagtail:wagtail /app + +WORKDIR /app + +# Install Python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY --chown=wagtail:wagtail app.py . + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 + +USER wagtail + +EXPOSE 8080 + +# Run with gunicorn +CMD ["gunicorn", "app:app", "--bind", "0.0.0.0:8080", "--workers", "2", "--timeout", "60", "--access-logfile", "-", "--error-logfile", "-"] diff --git a/containers/wagtail/package.json b/containers/wagtail/package.json new file mode 100644 index 00000000..36ef2b66 --- /dev/null +++ b/containers/wagtail/package.json @@ -0,0 +1,9 @@ +{ + "name": "nhs-notify-web-cms-wagtail", + "version": "0.0.1", + "private": true, + "description": "Wagtail CMS for NHS Notify", + "scripts": { + "build:container": "cd ../.. && make docker-build-and-push base_image=python:3.12-slim dir=containers/wagtail" + } +} diff --git a/containers/wagtail/requirements.txt b/containers/wagtail/requirements.txt new file mode 100644 index 00000000..c41747c6 --- /dev/null +++ b/containers/wagtail/requirements.txt @@ -0,0 +1,3 @@ +# Minimal Flask app for testing +Flask>=3.0,<4.0 +gunicorn>=22.0,<23.0 diff --git a/infrastructure/terraform/components/cms/README.md b/infrastructure/terraform/components/cms/README.md new file mode 100644 index 00000000..4fee7f3e --- /dev/null +++ b/infrastructure/terraform/components/cms/README.md @@ -0,0 +1,48 @@ + + + + +## Requirements + +| Name | Version | +|------|---------| +| [terraform](#requirement\_terraform) | >= 1.9.0 | +| [aws](#requirement\_aws) | ~> 6.0 | +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [aws\_account\_id](#input\_aws\_account\_id) | The AWS Account ID (numeric) | `string` | n/a | yes | +| [container\_image\_tag\_suffix](#input\_container\_image\_tag\_suffix) | Suffix used for container/image based Lambda image tags | `string` | `"latest"` | no | +| [default\_tags](#input\_default\_tags) | A map of default tags to apply to all taggable resources within the component | `map(string)` | `{}` | no | +| [environment](#input\_environment) | The name of the tfscaffold environment | `string` | n/a | yes | +| [evaluation\_evaluator\_model\_identifier](#input\_evaluation\_evaluator\_model\_identifier) | Full identifier of the model to use for the evaluation evaluator | `string` | n/a | yes | +| [evaluation\_inference\_model\_identifier](#input\_evaluation\_inference\_model\_identifier) | Full identifier of the model to use for the evaluation inferance | `string` | n/a | yes | +| [evaluation\_schedule\_days](#input\_evaluation\_schedule\_days) | The amount of days between automated evaluations being run NOTE: Set quite high for dev envrionments, to lower costs | `string` | n/a | yes | +| [force\_destroy](#input\_force\_destroy) | Flag to force deletion of S3 buckets | `bool` | `false` | no | +| [force\_lambda\_code\_deploy](#input\_force\_lambda\_code\_deploy) | If the lambda package in s3 has the same commit id tag as the terraform build branch, the lambda will not update automatically. Set to True if making changes to Lambda code from on the same commit for example during development | `bool` | `false` | no | +| [group](#input\_group) | The group variables are being inherited from (often synonmous with account short-name) | `string` | n/a | yes | +| [kms\_deletion\_window](#input\_kms\_deletion\_window) | When a kms key is deleted, how long should it wait in the pending deletion state? | `string` | `"30"` | no | +| [log\_level](#input\_log\_level) | The log level to be used in lambda functions within the component. Any log with a lower severity than the configured value will not be logged: https://docs.python.org/3/library/logging.html#levels | `string` | `"INFO"` | no | +| [log\_retention\_in\_days](#input\_log\_retention\_in\_days) | The retention period in days for the Cloudwatch Logs events to be retained, default of 0 is indefinite | `number` | `0` | no | +| [parent\_acct\_environment](#input\_parent\_acct\_environment) | Name of the environment responsible for the acct resources used, affects things like DNS zone. Useful for named dev environments | `string` | `"main"` | no | +| [project](#input\_project) | The name of the tfscaffold project | `string` | n/a | yes | +| [prompt\_max\_tokens\_to\_sample](#input\_prompt\_max\_tokens\_to\_sample) | Maximum number of tokens to sample for the prompt | `number` | n/a | yes | +| [prompt\_model](#input\_prompt\_model) | Model name to use for the prompt | `string` | n/a | yes | +| [prompt\_temperature](#input\_prompt\_temperature) | Temperature setting for the prompt | `number` | n/a | yes | +| [prompt\_top\_p](#input\_prompt\_top\_p) | Top-p setting for the prompt | `number` | n/a | yes | +| [region](#input\_region) | The AWS Region | `string` | n/a | yes | +| [shared\_infra\_account\_id](#input\_shared\_infra\_account\_id) | The AWS Account ID of the shared infrastructure account | `string` | `"000000000000"` | no | +## Modules + +| Name | Source | Version | +|------|--------|---------| +| [bedrock\_messager](#module\_bedrock\_messager) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-lambda.zip | n/a | +| [kms](#module\_kms) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-kms.zip | n/a | +| [s3bucket\_lambda\_prompt\_logging](#module\_s3bucket\_lambda\_prompt\_logging) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-s3bucket.zip | n/a | +## Outputs + +No outputs. + + + diff --git a/infrastructure/terraform/components/cms/cloudwatch_log_group_ecs.tf b/infrastructure/terraform/components/cms/cloudwatch_log_group_ecs.tf new file mode 100644 index 00000000..29aa091a --- /dev/null +++ b/infrastructure/terraform/components/cms/cloudwatch_log_group_ecs.tf @@ -0,0 +1,20 @@ +resource "aws_cloudwatch_log_group" "ecs" { + name = "/ecs/${local.csi}" + retention_in_days = var.log_retention_in_days + kms_key_id = module.kms.key_arn + + tags = merge( + local.default_tags, + { + Name = "${local.csi}-ecs-logs" + } + ) +} + +resource "aws_cloudwatch_log_subscription_filter" "ecs" { + name = "${local.csi}-ecs-subscription" + log_group_name = aws_cloudwatch_log_group.ecs.name + filter_pattern = "" + destination_arn = local.log_destination_arn + role_arn = local.acct.log_subscription_role_arn +} diff --git a/infrastructure/terraform/components/cms/cloudwatch_log_group_elasticache.tf b/infrastructure/terraform/components/cms/cloudwatch_log_group_elasticache.tf new file mode 100644 index 00000000..7df51dce --- /dev/null +++ b/infrastructure/terraform/components/cms/cloudwatch_log_group_elasticache.tf @@ -0,0 +1,25 @@ +resource "aws_cloudwatch_log_group" "elasticache_slow" { + name = "/aws/elasticache/${local.csi}/slow-log" + retention_in_days = var.log_retention_in_days + kms_key_id = module.kms.key_arn + + tags = merge( + local.default_tags, + { + Name = "${local.csi}-elasticache-slow-logs" + } + ) +} + +resource "aws_cloudwatch_log_group" "elasticache_engine" { + name = "/aws/elasticache/${local.csi}/engine-log" + retention_in_days = var.log_retention_in_days + kms_key_id = module.kms.key_arn + + tags = merge( + local.default_tags, + { + Name = "${local.csi}-elasticache-engine-logs" + } + ) +} diff --git a/infrastructure/terraform/components/cms/cloudwatch_log_group_rds.tf b/infrastructure/terraform/components/cms/cloudwatch_log_group_rds.tf new file mode 100644 index 00000000..a0a361f4 --- /dev/null +++ b/infrastructure/terraform/components/cms/cloudwatch_log_group_rds.tf @@ -0,0 +1,12 @@ +resource "aws_cloudwatch_log_group" "rds" { + name = "/aws/rds/${local.csi}/postgresql" + retention_in_days = var.log_retention_in_days + kms_key_id = module.kms.key_arn + + tags = merge( + local.default_tags, + { + Name = "${local.csi}-rds-logs" + } + ) +} diff --git a/infrastructure/terraform/components/cms/db_instance.tf b/infrastructure/terraform/components/cms/db_instance.tf new file mode 100644 index 00000000..baf73fee --- /dev/null +++ b/infrastructure/terraform/components/cms/db_instance.tf @@ -0,0 +1,61 @@ +resource "aws_db_instance" "main" { + identifier = "${local.csi}-db" + engine = "postgres" + engine_version = "16.6" + instance_class = var.db_instance_class + allocated_storage = var.db_allocated_storage + storage_type = "gp3" + storage_encrypted = true + kms_key_id = module.kms.key_arn + + db_name = "wagtail" + username = "wagtail" + password = random_password.db_password.result + + # Multi-AZ for high availability + multi_az = var.db_multi_az + + # Network configuration + db_subnet_group_name = aws_db_subnet_group.main.name + publicly_accessible = false + vpc_security_group_ids = [aws_security_group.rds.id] + + # Backup configuration + backup_retention_period = var.db_backup_retention_period + backup_window = "03:00-04:00" + maintenance_window = "sun:04:00-sun:05:00" + + # Enhanced monitoring + enabled_cloudwatch_logs_exports = ["postgresql", "upgrade"] + monitoring_interval = 60 + monitoring_role_arn = aws_iam_role.rds_monitoring.arn + + # Performance Insights + performance_insights_enabled = true + performance_insights_kms_key_id = module.kms.key_arn + performance_insights_retention_period = 7 + + # Deletion protection + deletion_protection = var.force_destroy + skip_final_snapshot = var.db_skip_final_snapshot + final_snapshot_identifier = var.db_skip_final_snapshot ? null : "${local.csi}-final-snapshot-${formatdate("YYYY-MM-DD-hhmm", timestamp())}" + + parameter_group_name = aws_db_parameter_group.main.name + + # Auto minor version upgrade + auto_minor_version_upgrade = true + + tags = merge( + local.default_tags, + { + Name = "${local.csi}-database" + } + ) + + lifecycle { + ignore_changes = [ + password, + final_snapshot_identifier + ] + } +} diff --git a/infrastructure/terraform/components/cms/db_parameter_group.tf b/infrastructure/terraform/components/cms/db_parameter_group.tf new file mode 100644 index 00000000..6531c833 --- /dev/null +++ b/infrastructure/terraform/components/cms/db_parameter_group.tf @@ -0,0 +1,33 @@ +resource "aws_db_parameter_group" "main" { + name_prefix = "${local.csi}-" + family = "postgres16" + description = "Custom parameter group for Wagtail CMS PostgreSQL" + + # Wagtail/Django optimisations from https://docs.djangoproject.com/en/4.2/ref/databases/#postgresql-notes + parameter { + name = "shared_preload_libraries" + value = "pg_stat_statements" + apply_method = "pending-reboot" + } + + parameter { + name = "log_statement" + value = "ddl" + } + + parameter { + name = "log_min_duration_statement" + value = "1000" # Log queries slower than 1 second + } + + tags = merge( + local.default_tags, + { + Name = "${local.csi}-db-params" + } + ) + + lifecycle { + create_before_destroy = true + } +} diff --git a/infrastructure/terraform/components/cms/db_subnet_group.tf b/infrastructure/terraform/components/cms/db_subnet_group.tf new file mode 100644 index 00000000..78b8291f --- /dev/null +++ b/infrastructure/terraform/components/cms/db_subnet_group.tf @@ -0,0 +1,12 @@ +resource "aws_db_subnet_group" "main" { + name_prefix = "${local.csi}-" + description = "Subnet group for Wagtail CMS RDS PostgreSQL" + subnet_ids = local.private_subnets + + tags = merge( + local.default_tags, + { + Name = "${local.csi}-db-subnet-group" + } + ) +} diff --git a/infrastructure/terraform/components/cms/ecs_cluster.tf b/infrastructure/terraform/components/cms/ecs_cluster.tf new file mode 100644 index 00000000..c54c5ba3 --- /dev/null +++ b/infrastructure/terraform/components/cms/ecs_cluster.tf @@ -0,0 +1,15 @@ +resource "aws_ecs_cluster" "main" { + name = local.csi + + setting { + name = "containerInsights" + value = "enabled" + } + + tags = merge( + local.default_tags, + { + Name = local.csi + } + ) +} diff --git a/infrastructure/terraform/components/cms/ecs_express_gateway_service.tf b/infrastructure/terraform/components/cms/ecs_express_gateway_service.tf new file mode 100644 index 00000000..d36a1e38 --- /dev/null +++ b/infrastructure/terraform/components/cms/ecs_express_gateway_service.tf @@ -0,0 +1,114 @@ +resource "aws_ecs_express_gateway_service" "main" { + service_name = local.csi + cluster = aws_ecs_cluster.main.name + + execution_role_arn = aws_iam_role.ecs_task_execution.arn + infrastructure_role_arn = aws_iam_role.ecs_infrastructure.arn + task_role_arn = aws_iam_role.ecs_task.arn + + cpu = var.ecs_task_cpu + memory = var.ecs_task_memory + health_check_path = "/health/" + + primary_container { + image = "${local.ecr_repository_url}:${var.project}-${var.environment}-${local.component}-wagtail-${var.container_image_tag_suffix}" + container_port = 8080 + + aws_logs_configuration { + log_group = aws_cloudwatch_log_group.ecs.name + log_stream_prefix = "wagtail" + } + + environment { + name = "DJANGO_SETTINGS_MODULE" + value = "config.settings.production" + } + + environment { + name = "DATABASE_HOST" + value = aws_db_instance.main.address + } + + environment { + name = "DATABASE_PORT" + value = tostring(aws_db_instance.main.port) + } + + environment { + name = "DATABASE_NAME" + value = aws_db_instance.main.db_name + } + + environment { + name = "DATABASE_USER" + value = aws_db_instance.main.username + } + + environment { + name = "REDIS_HOST" + value = aws_elasticache_serverless_cache.main.endpoint[0].address + } + + environment { + name = "REDIS_PORT" + value = tostring(aws_elasticache_serverless_cache.main.endpoint[0].port) + } + + environment { + name = "REDIS_SSL" + value = "true" + } + + environment { + name = "AWS_STORAGE_BUCKET_NAME" + value = module.s3bucket_media.bucket + } + + environment { + name = "AWS_S3_REGION_NAME" + value = var.region + } + + environment { + name = "ALLOWED_HOSTS" + value = local.root_domain_name + } + + # Secrets from SSM Parameter Store + secret { + name = "DATABASE_PASSWORD" + value_from = aws_ssm_parameter.db_password.arn + } + + secret { + name = "REDIS_AUTH_TOKEN" + value_from = aws_ssm_parameter.redis_auth_token.arn + } + + secret { + name = "DJANGO_SECRET_KEY" + value_from = aws_ssm_parameter.django_secret_key.arn + } + } + + network_configuration { + subnets = local.private_subnets + security_groups = [aws_security_group.ecs_tasks.id] + } + + scaling_target { + min_task_count = var.ecs_min_capacity + max_task_count = var.ecs_max_capacity + auto_scaling_metric = "AVERAGE_CPU" + auto_scaling_target_value = 70 + } + + wait_for_steady_state = false + + tags = merge( + local.default_tags, + { + Name = local.csi + } + ) +} diff --git a/infrastructure/terraform/components/cms/elasticache_parameter_group.tf b/infrastructure/terraform/components/cms/elasticache_parameter_group.tf new file mode 100644 index 00000000..4502714c --- /dev/null +++ b/infrastructure/terraform/components/cms/elasticache_parameter_group.tf @@ -0,0 +1,27 @@ +resource "aws_elasticache_parameter_group" "main" { + name = "${local.csi}-valkey" + family = "valkey7" + description = "Custom parameter group for Wagtail CMS Valkey" + + # Enable Valkey slowlog + parameter { + name = "slowlog-log-slower-than" + value = "10000" # 10ms in microseconds + } + + parameter { + name = "slowlog-max-len" + value = "128" + } + + tags = merge( + local.default_tags, + { + Name = "${local.csi}-cache-params" + } + ) + + lifecycle { + create_before_destroy = true + } +} diff --git a/infrastructure/terraform/components/cms/elasticache_serverless_cache.tf b/infrastructure/terraform/components/cms/elasticache_serverless_cache.tf new file mode 100644 index 00000000..8c087510 --- /dev/null +++ b/infrastructure/terraform/components/cms/elasticache_serverless_cache.tf @@ -0,0 +1,74 @@ +resource "aws_elasticache_serverless_cache" "main" { + name = "${local.csi}-cache" + description = "Valkey Serverless cache for Wagtail CMS" + engine = "valkey" + + # Serverless capacity configuration + cache_usage_limits { + data_storage { + maximum = 10 # GB + unit = "GB" + } + ecpu_per_second { + maximum = 5000 + } + } + + subnet_ids = local.private_subnets + security_group_ids = [aws_security_group.elasticache.id] + + daily_snapshot_time = "05:00" + snapshot_retention_limit = var.cache_snapshot_retention_limit + + kms_key_id = module.kms.key_arn + user_group_id = aws_elasticache_user_group.main.id + + tags = merge( + local.default_tags, + { + Name = "${local.csi}-valkey-serverless" + } + ) +} + +# Create Valkey user for authentication +resource "aws_elasticache_user" "main" { + user_id = "${local.csi}-cache-user" + user_name = "default" + access_string = "on ~* +@all" + engine = "valkey" + + authentication_mode { + type = "password" + passwords = [random_password.redis_auth_token.result] + } + + tags = merge( + local.default_tags, + { + Name = "${local.csi}-valkey-user" + } + ) + + lifecycle { + ignore_changes = [authentication_mode] + } +} + +# Create user group for serverless cache +resource "aws_elasticache_user_group" "main" { + user_group_id = "${local.csi}-cache-users" + engine = "valkey" + user_ids = [aws_elasticache_user.main.user_id] + + tags = merge( + local.default_tags, + { + Name = "${local.csi}-valkey-user-group" + } + ) + + lifecycle { + ignore_changes = [user_ids] + } +} diff --git a/infrastructure/terraform/components/cms/elasticache_subnet_group.tf b/infrastructure/terraform/components/cms/elasticache_subnet_group.tf new file mode 100644 index 00000000..5c4f54b9 --- /dev/null +++ b/infrastructure/terraform/components/cms/elasticache_subnet_group.tf @@ -0,0 +1,12 @@ +resource "aws_elasticache_subnet_group" "main" { + name = "${local.csi}-cache-subnet-group" + description = "Subnet group for Wagtail CMS ElastiCache Redis" + subnet_ids = local.private_subnets + + tags = merge( + local.default_tags, + { + Name = "${local.csi}-cache-subnet-group" + } + ) +} diff --git a/infrastructure/terraform/components/cms/iam_role_ecs_infrastructure.tf b/infrastructure/terraform/components/cms/iam_role_ecs_infrastructure.tf new file mode 100644 index 00000000..45dcf972 --- /dev/null +++ b/infrastructure/terraform/components/cms/iam_role_ecs_infrastructure.tf @@ -0,0 +1,431 @@ +resource "aws_iam_role" "ecs_infrastructure" { + name_prefix = "${local.csi}-ecs-infra-" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Principal = { + Service = "ecs.amazonaws.com" + } + Action = "sts:AssumeRole" + } + ] + }) + + tags = merge( + local.default_tags, + { + Name = "${local.csi}-ecs-infrastructure" + } + ) +} + +# AWS managed policy for Express Gateway Services (inline replica) +# Source: AmazonECSInfrastructureRoleforExpressGatewayServices +resource "aws_iam_role_policy" "ecs_infrastructure_express_gateway_replica" { + name_prefix = "${local.csi}-express-gateway-" + role = aws_iam_role.ecs_infrastructure.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "ServiceLinkedRoleCreateOperations" + Effect = "Allow" + Action = "iam:CreateServiceLinkedRole" + Resource = "*" + Condition = { + StringEquals = { + "iam:AWSServiceName" = [ + "ecs.application-autoscaling.amazonaws.com", + "elasticloadbalancing.amazonaws.com" + ] + } + } + }, + { + Sid = "ELBOperations" + Effect = "Allow" + Action = [ + "elasticloadbalancing:CreateListener", + "elasticloadbalancing:CreateLoadBalancer", + "elasticloadbalancing:CreateRule", + "elasticloadbalancing:CreateTargetGroup", + "elasticloadbalancing:ModifyListener", + "elasticloadbalancing:ModifyRule", + "elasticloadbalancing:AddListenerCertificates", + "elasticloadbalancing:RemoveListenerCertificates", + "elasticloadbalancing:RegisterTargets", + "elasticloadbalancing:DeregisterTargets", + "elasticloadbalancing:DeleteTargetGroup", + "elasticloadbalancing:DeleteLoadBalancer", + "elasticloadbalancing:DeleteRule", + "elasticloadbalancing:DeleteListener" + ] + Resource = [ + "arn:aws:elasticloadbalancing:*:*:loadbalancer/app/*/*", + "arn:aws:elasticloadbalancing:*:*:listener/app/*/*/*", + "arn:aws:elasticloadbalancing:*:*:listener-rule/app/*/*/*/*", + "arn:aws:elasticloadbalancing:*:*:targetgroup/*/*" + ] + Condition = { + StringEquals = { + "aws:ResourceTag/AmazonECSManaged" = "true" + } + } + }, + { + Sid = "TagOnCreateELBResources" + Effect = "Allow" + Action = "elasticloadbalancing:AddTags" + Resource = [ + "arn:aws:elasticloadbalancing:*:*:loadbalancer/app/*/*", + "arn:aws:elasticloadbalancing:*:*:listener/app/*/*/*", + "arn:aws:elasticloadbalancing:*:*:listener-rule/app/*/*/*/*", + "arn:aws:elasticloadbalancing:*:*:targetgroup/*/*" + ] + Condition = { + StringEquals = { + "elasticloadbalancing:CreateAction" = [ + "CreateLoadBalancer", + "CreateListener", + "CreateRule", + "CreateTargetGroup" + ] + } + } + }, + { + Sid = "BlanketAllowCreateSecurityGroupsInVPCs" + Effect = "Allow" + Action = "ec2:CreateSecurityGroup" + Resource = "arn:aws:ec2:*:*:vpc/*" + }, + { + Sid = "CreateSecurityGroupResourcesWithTags" + Effect = "Allow" + Action = [ + "ec2:CreateSecurityGroup", + "ec2:AuthorizeSecurityGroupEgress", + "ec2:AuthorizeSecurityGroupIngress" + ] + Resource = [ + "arn:aws:ec2:*:*:security-group/*", + "arn:aws:ec2:*:*:security-group-rule/*", + "arn:aws:ec2:*:*:vpc/*" + ] + Condition = { + StringEquals = { + "aws:RequestTag/AmazonECSManaged" = "true" + } + } + }, + { + Sid = "ModifySecurityGroupRules" + Effect = "Allow" + Action = [ + "ec2:AuthorizeSecurityGroupEgress", + "ec2:AuthorizeSecurityGroupIngress", + "ec2:RevokeSecurityGroupEgress", + "ec2:RevokeSecurityGroupIngress" + ] + Resource = "arn:aws:ec2:*:*:security-group/*" + }, + { + Sid = "DeleteManagedSecurityGroups" + Effect = "Allow" + Action = "ec2:DeleteSecurityGroup" + Resource = "arn:aws:ec2:*:*:security-group/*" + Condition = { + StringEquals = { + "aws:ResourceTag/AmazonECSManaged" = "true" + } + } + }, + { + Sid = "ModifyVPCForSecurityGroups" + Effect = "Allow" + Action = [ + "ec2:AuthorizeSecurityGroupEgress", + "ec2:AuthorizeSecurityGroupIngress", + "ec2:RevokeSecurityGroupEgress", + "ec2:RevokeSecurityGroupIngress" + ] + Resource = "arn:aws:ec2:*:*:vpc/*" + }, + { + Sid = "TagOnCreateEC2Resources" + Effect = "Allow" + Action = "ec2:CreateTags" + Resource = [ + "arn:aws:ec2:*:*:security-group/*", + "arn:aws:ec2:*:*:security-group-rule/*" + ] + Condition = { + StringEquals = { + "ec2:CreateAction" = [ + "CreateSecurityGroup", + "AuthorizeSecurityGroupIngress", + "AuthorizeSecurityGroupEgress" + ] + } + } + }, + { + Sid = "CertificateOperations" + Effect = "Allow" + Action = [ + "acm:RequestCertificate", + "acm:AddTagsToCertificate", + "acm:DeleteCertificate", + "acm:DescribeCertificate" + ] + Resource = "arn:aws:acm:*:*:certificate/*" + Condition = { + StringEquals = { + "aws:ResourceTag/AmazonECSManaged" = "true" + } + } + }, + { + Sid = "ApplicationAutoscalingCreateOperations" + Effect = "Allow" + Action = [ + "application-autoscaling:RegisterScalableTarget", + "application-autoscaling:TagResource" + ] + Resource = "arn:aws:application-autoscaling:*:*:scalable-target/*" + Condition = { + StringEquals = { + "aws:RequestTag/AmazonECSManaged" = "true" + } + } + }, + { + Sid = "ApplicationAutoscalingDeregisterOperations" + Effect = "Allow" + Action = "application-autoscaling:DeregisterScalableTarget" + Resource = "arn:aws:application-autoscaling:*:*:scalable-target/*" + }, + { + Sid = "ApplicationAutoscalingPolicyOperations" + Effect = "Allow" + Action = [ + "application-autoscaling:PutScalingPolicy", + "application-autoscaling:DeleteScalingPolicy" + ] + Resource = "arn:aws:application-autoscaling:*:*:scalable-target/*" + Condition = { + StringEquals = { + "application-autoscaling:service-namespace" = "ecs" + } + } + }, + { + Sid = "ApplicationAutoscalingReadOperations" + Effect = "Allow" + Action = [ + "application-autoscaling:DescribeScalableTargets", + "application-autoscaling:DescribeScalingPolicies", + "application-autoscaling:DescribeScalingActivities" + ] + Resource = "arn:aws:application-autoscaling:*:*:scalable-target/*" + }, + { + Sid = "ECSServiceOperations" + Effect = "Allow" + Action = [ + "ecs:DescribeServices", + "ecs:UpdateService" + ] + Resource = "arn:aws:ecs:*:*:service/*/*" + }, + { + Sid = "CloudWatchAlarmCreateOperations" + Effect = "Allow" + Action = [ + "cloudwatch:PutMetricAlarm", + "cloudwatch:TagResource" + ] + Resource = "arn:aws:cloudwatch:*:*:alarm:*" + Condition = { + StringEquals = { + "aws:RequestTag/AmazonECSManaged" = "true" + } + } + }, + { + Sid = "CloudWatchAlarmDeleteOperations" + Effect = "Allow" + Action = "cloudwatch:DeleteAlarms" + Resource = "arn:aws:cloudwatch:*:*:alarm:*" + }, + { + Sid = "CloudWatchAlarmReadOperations" + Effect = "Allow" + Action = "cloudwatch:DescribeAlarms" + Resource = "*" + }, + { + Sid = "ELBReadOperations" + Effect = "Allow" + Action = [ + "elasticloadbalancing:DescribeLoadBalancers", + "elasticloadbalancing:DescribeTargetGroups", + "elasticloadbalancing:DescribeTargetHealth", + "elasticloadbalancing:DescribeListeners", + "elasticloadbalancing:DescribeRules" + ] + Resource = "*" + }, + { + Sid = "VPCReadOperations" + Effect = "Allow" + Action = [ + "ec2:DescribeSecurityGroups", + "ec2:DescribeSubnets", + "ec2:DescribeRouteTables", + "ec2:DescribeVpcs" + ] + Resource = "*" + }, + { + Sid = "CloudWatchLogsCreateOperations" + Effect = "Allow" + Action = [ + "logs:CreateLogGroup", + "logs:TagResource" + ] + Resource = "arn:aws:logs:*:*:log-group:*" + Condition = { + StringEquals = { + "aws:RequestTag/AmazonECSManaged" = "true" + } + } + }, + { + Sid = "CloudWatchLogsReadOperations" + Effect = "Allow" + Action = "logs:DescribeLogGroups" + Resource = "*" + } + ] + }) +} + +# TODO: Remove once Express Gateway is working - kept for reference +resource "aws_iam_role_policy" "ecs_infrastructure_express" { + name_prefix = "${local.csi}-express-" + role = aws_iam_role.ecs_infrastructure.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "ec2:CreateNetworkInterface", + "ec2:DeleteNetworkInterface", + "ec2:DescribeNetworkInterfaces", + "ec2:DescribeRouteTables", + "ec2:DescribeSubnets", + "ec2:DescribeSecurityGroups", + "ec2:DescribeVpcs", + "ec2:CreateSecurityGroup", + "ec2:DeleteSecurityGroup", + "ec2:AuthorizeSecurityGroupIngress", + "ec2:AuthorizeSecurityGroupEgress", + "ec2:RevokeSecurityGroupIngress", + "ec2:RevokeSecurityGroupEgress", + "ec2:CreateTags", + "elasticloadbalancing:CreateLoadBalancer", + "elasticloadbalancing:CreateTargetGroup", + "elasticloadbalancing:CreateListener", + "elasticloadbalancing:DeleteLoadBalancer", + "elasticloadbalancing:DeleteTargetGroup", + "elasticloadbalancing:DeleteListener", + "elasticloadbalancing:Describe*", + "elasticloadbalancing:ModifyLoadBalancerAttributes", + "elasticloadbalancing:ModifyTargetGroup", + "elasticloadbalancing:ModifyTargetGroupAttributes", + "elasticloadbalancing:RegisterTargets", + "elasticloadbalancing:DeregisterTargets", + "elasticloadbalancing:AddTags" + ] + Resource = "*" + }, + { + Effect = "Allow" + Action = [ + "logs:CreateLogGroup", + "logs:DescribeLogGroups", + "logs:PutRetentionPolicy", + "logs:TagLogGroup" + ] + Resource = "*" + }, + { + Effect = "Allow" + Action = [ + "application-autoscaling:RegisterScalableTarget", + "application-autoscaling:DeregisterScalableTarget", + "application-autoscaling:DescribeScalableTargets", + "application-autoscaling:PutScalingPolicy", + "application-autoscaling:DeleteScalingPolicy", + "application-autoscaling:DescribeScalingPolicies", + "application-autoscaling:DescribeScalingActivities", + "application-autoscaling:TagResource" + ] + Resource = "*" + }, + { + Effect = "Allow" + Action = [ + "cloudwatch:PutMetricAlarm", + "cloudwatch:DeleteAlarms", + "cloudwatch:DescribeAlarms" + ] + Resource = "*" + }, + { + Effect = "Allow" + Action = "iam:CreateServiceLinkedRole" + Resource = [ + "arn:aws:iam::*:role/aws-service-role/elasticloadbalancing.amazonaws.com/AWSServiceRoleForElasticLoadBalancing", + "arn:aws:iam::*:role/aws-service-role/ecs.application-autoscaling.amazonaws.com/AWSServiceRoleForApplicationAutoScaling_ECSService" + ] + Condition = { + StringLike = { + "iam:AWSServiceName" = [ + "elasticloadbalancing.amazonaws.com", + "ecs.application-autoscaling.amazonaws.com" + ] + } + } + }, + { + Effect = "Allow" + Action = [ + "acm:RequestCertificate", + "acm:DescribeCertificate", + "acm:DeleteCertificate", + "acm:AddTagsToCertificate", + "acm:ListTagsForCertificate" + ] + Resource = "*" + }, + { + Effect = "Allow" + Action = [ + "route53:ChangeResourceRecordSets", + "route53:GetChange", + "route53:ListHostedZones", + "route53:ListResourceRecordSets" + ] + Resource = "*" + } + ] + }) +} diff --git a/infrastructure/terraform/components/cms/iam_role_ecs_task.tf b/infrastructure/terraform/components/cms/iam_role_ecs_task.tf new file mode 100644 index 00000000..85f510af --- /dev/null +++ b/infrastructure/terraform/components/cms/iam_role_ecs_task.tf @@ -0,0 +1,47 @@ +resource "aws_iam_role" "ecs_task" { + name_prefix = "${local.csi}-ecs-task-" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Principal = { + Service = "ecs-tasks.amazonaws.com" + } + Action = "sts:AssumeRole" + } + ] + }) + + tags = merge( + local.default_tags, + { + Name = "${local.csi}-ecs-task" + } + ) +} + +resource "aws_iam_role_policy" "ecs_task_s3" { + name_prefix = "${local.csi}-s3-" + role = aws_iam_role.ecs_task.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "s3:PutObject", + "s3:GetObject", + "s3:DeleteObject", + "s3:ListBucket" + ] + Resource = [ + module.s3bucket_media.arn, + "${module.s3bucket_media.arn}/*" + ] + } + ] + }) +} diff --git a/infrastructure/terraform/components/cms/iam_role_ecs_task_execution.tf b/infrastructure/terraform/components/cms/iam_role_ecs_task_execution.tf new file mode 100644 index 00000000..36569d9a --- /dev/null +++ b/infrastructure/terraform/components/cms/iam_role_ecs_task_execution.tf @@ -0,0 +1,82 @@ +resource "aws_iam_role" "ecs_task_execution" { + name_prefix = "${local.csi}-ecs-exec-" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Principal = { + Service = "ecs-tasks.amazonaws.com" + } + Action = "sts:AssumeRole" + } + ] + }) + + tags = merge( + local.default_tags, + { + Name = "${local.csi}-ecs-task-execution" + } + ) +} + +# AWS managed policy for ECS Task Execution Role (inline replica) +# Source: AmazonECSTaskExecutionRolePolicy +resource "aws_iam_role_policy" "ecs_task_execution_base" { + name_prefix = "${local.csi}-ecs-exec-base-" + role = aws_iam_role.ecs_task_execution.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "ecr:GetAuthorizationToken", + "ecr:BatchCheckLayerAvailability", + "ecr:GetDownloadUrlForLayer", + "ecr:BatchGetImage", + "logs:CreateLogStream", + "logs:PutLogEvents" + ] + Resource = "*" + } + ] + }) +} + +resource "aws_iam_role_policy" "ecs_task_execution_secrets" { + name_prefix = "${local.csi}-secrets-" + role = aws_iam_role.ecs_task_execution.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "ssm:GetParameter", + "ssm:GetParameters" + ] + Resource = [ + aws_ssm_parameter.db_password.arn, + aws_ssm_parameter.redis_auth_token.arn, + aws_ssm_parameter.django_secret_key.arn + ] + }, + { + Effect = "Allow" + Action = [ + "kms:Decrypt", + "kms:DescribeKey" + ] + Resource = [ + module.kms.key_arn, + # local.acct.kms_key.arn # Account KMS key for ECR images + ] + } + ] + }) +} diff --git a/infrastructure/terraform/components/cms/iam_role_rds_monitoring.tf b/infrastructure/terraform/components/cms/iam_role_rds_monitoring.tf new file mode 100644 index 00000000..fae87bae --- /dev/null +++ b/infrastructure/terraform/components/cms/iam_role_rds_monitoring.tf @@ -0,0 +1,28 @@ +resource "aws_iam_role" "rds_monitoring" { + name_prefix = "${local.csi}-rds-mon-" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Principal = { + Service = "monitoring.rds.amazonaws.com" + } + Action = "sts:AssumeRole" + } + ] + }) + + tags = merge( + local.default_tags, + { + Name = "${local.csi}-rds-monitoring" + } + ) +} + +resource "aws_iam_role_policy_attachment" "rds_monitoring" { + role = aws_iam_role.rds_monitoring.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonRDSEnhancedMonitoringRole" +} diff --git a/infrastructure/terraform/components/cms/locals.tf b/infrastructure/terraform/components/cms/locals.tf new file mode 100644 index 00000000..14c0c541 --- /dev/null +++ b/infrastructure/terraform/components/cms/locals.tf @@ -0,0 +1,17 @@ +locals { + aws_lambda_functions_dir_path = "../../../../lambdas" + + root_domain_name = "${var.environment}.${local.acct.route53_zone_names[local.bc_name]}" # e.g. [main|dev|abxy0].cms.[dev|nonprod|prod].nhsnotify.national.nhs.uk + root_domain_id = local.acct.route53_zone_ids[local.bc_name] + root_domain_nameservers = local.acct.route53_zone_nameservers[local.bc_name] + + log_destination_arn = "arn:aws:logs:${var.region}:${var.shared_infra_account_id}:destination:nhs-${var.parent_acct_environment}-obs-firehose-logs" + log_destination_arn_us = "arn:aws:logs:us-east-1:${var.shared_infra_account_id}:destination:nhs-${var.parent_acct_environment}-obs-us-east-1-firehose-logs" + + ecr_repository_url = "${local.acct.ecr_repositories[local.bc_name].repository_url}-test" # TODO + ecr_repository_name = local.acct.ecr_repositories[local.bc_name].name + + vpc_id = local.acct.vpc_ids[local.bc_name] + private_subnets = try(local.acct.private_subnets[local.bc_name], []) + public_subnets = try(local.acct.public_subnets[local.bc_name], []) +} diff --git a/infrastructure/terraform/components/cms/locals_remote_state.tf b/infrastructure/terraform/components/cms/locals_remote_state.tf new file mode 100644 index 00000000..3d88800c --- /dev/null +++ b/infrastructure/terraform/components/cms/locals_remote_state.tf @@ -0,0 +1,21 @@ +locals { + acct = data.terraform_remote_state.acct.outputs +} + +data "terraform_remote_state" "acct" { + backend = "s3" + + config = { + bucket = local.terraform_state_bucket + + key = format( + "%s/%s/%s/%s/acct.tfstate", + var.project, + var.aws_account_id, + "eu-west-2", + var.parent_acct_environment + ) + + region = "eu-west-2" + } +} diff --git a/infrastructure/terraform/components/cms/locals_tfscaffold.tf b/infrastructure/terraform/components/cms/locals_tfscaffold.tf new file mode 100644 index 00000000..c5c04803 --- /dev/null +++ b/infrastructure/terraform/components/cms/locals_tfscaffold.tf @@ -0,0 +1,47 @@ +locals { + component = "cms" + bc_name = "web-cms" + + terraform_state_bucket = format( + "%s-tfscaffold-%s-%s", + var.project, + var.aws_account_id, + var.region, + ) + + csi = replace( + format( + "%s-%s-%s", + var.project, + var.environment, + local.component, + ), + "_", + "", + ) + + # CSI for use in resources with a global namespace, i.e. S3 Buckets + csi_global = replace( + format( + "%s-%s-%s-%s-%s", + var.project, + var.aws_account_id, + var.region, + var.environment, + local.component, + ), + "_", + "", + ) + + default_tags = merge( + var.default_tags, + { + Project = var.project + Environment = var.environment + Component = local.component + Group = var.group + Name = local.csi + }, + ) +} diff --git a/infrastructure/terraform/components/cms/module_kms.tf b/infrastructure/terraform/components/cms/module_kms.tf new file mode 100644 index 00000000..50b75bce --- /dev/null +++ b/infrastructure/terraform/components/cms/module_kms.tf @@ -0,0 +1,117 @@ +module "kms" { + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-kms.zip" + + aws_account_id = var.aws_account_id + component = local.component + environment = var.environment + project = var.project + region = var.region + + name = "main" + deletion_window = var.kms_deletion_window + alias = "alias/${local.csi}" + key_policy_documents = [data.aws_iam_policy_document.kms.json] + iam_delegation = true +} + +data "aws_iam_policy_document" "kms" { + # '*' resource scope is permitted in access policies as as the resource is itself + # https://docs.aws.amazon.com/kms/latest/developerguide/key-policy-services.html + + statement { + sid = "AllowCloudWatchLogsEncrypt" + effect = "Allow" + + principals { + type = "Service" + + identifiers = [ + "sns.amazonaws.com", + "logs.${var.region}.amazonaws.com", + ] + } + + actions = [ + "kms:Encrypt", + "kms:Decrypt", + "kms:GenerateDataKey", + "kms:DescribeKey" + ] + + resources = [ + "*", + ] + } + + statement { + sid = "AllowEventsFromSharedInfraAccount" + effect = "Allow" + + principals { + type = "AWS" + identifiers = ["arn:aws:iam::${var.shared_infra_account_id}:root"] + } + + actions = [ + "kms:Encrypt", + "kms:Decrypt", + "kms:GenerateDataKey" + ] + + resources = [ + "*", + ] + } + + statement { + sid = "AllowRDSToUseKey" + effect = "Allow" + + principals { + type = "Service" + identifiers = ["rds.amazonaws.com"] + } + + actions = [ + "kms:Encrypt", + "kms:Decrypt", + "kms:GenerateDataKey", + "kms:CreateGrant", + "kms:DescribeKey" + ] + + resources = ["*"] + + condition { + test = "StringEquals" + variable = "kms:ViaService" + values = ["rds.${var.region}.amazonaws.com"] + } + } + + statement { + sid = "AllowElastiCacheToUseKey" + effect = "Allow" + + principals { + type = "Service" + identifiers = ["elasticache.amazonaws.com"] + } + + actions = [ + "kms:Encrypt", + "kms:Decrypt", + "kms:GenerateDataKey", + "kms:CreateGrant", + "kms:DescribeKey" + ] + + resources = ["*"] + + condition { + test = "StringEquals" + variable = "kms:ViaService" + values = ["elasticache.${var.region}.amazonaws.com"] + } + } +} diff --git a/infrastructure/terraform/components/cms/module_s3_bucket_media.tf b/infrastructure/terraform/components/cms/module_s3_bucket_media.tf new file mode 100644 index 00000000..e9db2f7d --- /dev/null +++ b/infrastructure/terraform/components/cms/module_s3_bucket_media.tf @@ -0,0 +1,28 @@ +module "s3bucket_media" { + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-s3bucket.zip" + + name = "media" + + aws_account_id = var.aws_account_id + region = var.region + project = var.project + environment = var.environment + component = local.component + + acl = "private" + force_destroy = var.force_destroy + versioning = true + + bucket_logging_target = { + bucket = local.acct.s3_buckets["access_logs"]["id"] + } + + public_access = { + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true + } + + default_tags = local.default_tags +} diff --git a/infrastructure/terraform/components/cms/outputs.tf b/infrastructure/terraform/components/cms/outputs.tf new file mode 100644 index 00000000..38ea559a --- /dev/null +++ b/infrastructure/terraform/components/cms/outputs.tf @@ -0,0 +1,99 @@ +## +# ECS Express Gateway Service Outputs +## + +output "ecs_service_arn" { + description = "ARN of the ECS Express Gateway Service" + value = aws_ecs_express_gateway_service.main.service_arn +} + +output "ecs_service_name" { + description = "Name of the ECS Express Gateway Service" + value = aws_ecs_express_gateway_service.main.service_name +} + +output "ecs_cluster_name" { + description = "Name of the ECS Cluster" + value = aws_ecs_cluster.main.name +} + +output "ecs_ingress_endpoints" { + description = "Ingress endpoint URLs from the ECS Express Gateway Service" + value = { + for path in aws_ecs_express_gateway_service.main.ingress_paths : + path.access_type => path.endpoint + } +} + +## +# RDS Outputs +## + +output "db_instance_endpoint" { + description = "RDS instance endpoint" + value = aws_db_instance.main.endpoint + sensitive = true +} + +output "db_instance_address" { + description = "RDS instance address" + value = aws_db_instance.main.address +} + +output "db_instance_name" { + description = "RDS database name" + value = aws_db_instance.main.db_name +} + +output "db_password_parameter_arn" { + description = "ARN of the SSM Parameter containing the database password" + value = aws_ssm_parameter.db_password.arn +} + +## +# ElastiCache Outputs +## + +output "redis_endpoint" { + description = "ElastiCache Valkey Serverless endpoint" + value = aws_elasticache_serverless_cache.main.endpoint[0].address + sensitive = true +} + +output "redis_port" { + description = "ElastiCache Valkey Serverless port" + value = aws_elasticache_serverless_cache.main.endpoint[0].port +} + +output "redis_auth_token_parameter_arn" { + description = "ARN of the SSM Parameter containing the Redis auth token" + value = aws_ssm_parameter.redis_auth_token.arn +} + +## +# S3 Outputs +## + +output "media_bucket_name" { + description = "S3 bucket name for media files" + value = module.s3bucket_media.bucket +} + +output "media_bucket_arn" { + description = "S3 bucket ARN for media files" + value = module.s3bucket_media.arn +} + +## +# DNS Outputs +## + +output "cms_dns_name" { + description = "DNS name for the Wagtail CMS application" + value = aws_route53_record.main.fqdn +} + +output "cms_url" { + description = "Full HTTPS URL for the Wagtail CMS application" + value = "https://${aws_route53_record.main.fqdn}" +} diff --git a/infrastructure/terraform/components/cms/pre.sh b/infrastructure/terraform/components/cms/pre.sh new file mode 100644 index 00000000..24c791d6 --- /dev/null +++ b/infrastructure/terraform/components/cms/pre.sh @@ -0,0 +1,74 @@ +#!/bin/bash + +# This script is run before Terraform executable commands. +# It ensures all Node.js dependencies are installed, generates any required dependencies, +# and builds all Lambda functions in the workspace before Terraform provisions infrastructure. +# pre.sh runs in the same shell as terraform.sh, not in a subshell + +: "${PROJECT:?PROJECT is required}" +: "${REGION:?REGION is required}" +: "${COMPONENT:?COMPONENT is required}" +: "${ENVIRONMENT:?ENVIRONMENT is required}" +: "${AWS_ACCOUNT_ID:?AWS_ACCOUNT_ID is required}" +: "${ACTION:?ACTION is required}" + +echo "Running app pre.sh" +echo "ENVIRONMENT=$ENVIRONMENT" +echo "ACTION=$ACTION" +echo "PROJECT=$PROJECT" +echo "COMPONENT=$COMPONENT" +echo "AWS_REGION=$REGION" +echo "AWS_ACCOUNT_ID=$AWS_ACCOUNT_ID" + +# Calculate container image prefix from PROJECT, ENVIRONMENT, COMPONENT +CONTAINER_IMAGE_PREFIX="${PROJECT}-${ENVIRONMENT}-${COMPONENT}" +echo "CONTAINER_IMAGE_PREFIX: ${CONTAINER_IMAGE_PREFIX}" + +# Translate ACTION to PUBLISH_CONTAINER_IMAGE (build) +if [ "${ACTION}" = "plan" ]; then + PUBLISH_CONTAINER_IMAGE="false" +else + PUBLISH_CONTAINER_IMAGE="true" +fi + +# Helper function for error handling +run_or_fail() { + "$@" + if [ $? -ne 0 ]; then + echo "$* failed!" >&2 + exit 1 + fi +} + +# Switch to repo root +pushd "$(git rev-parse --show-toplevel)" || exit 1 + +# Calculate git-based version suffix +SHORT_SHA="$(git rev-parse --short HEAD)" +GIT_TAG="$(git describe --tags --exact-match 2>/dev/null || true)" + +if [ -n "${GIT_TAG}" ]; then + RELEASE_VERSION="${GIT_TAG#v}" + CONTAINER_IMAGE_SUFFIX="release-${RELEASE_VERSION}-${SHORT_SHA}" + echo "On tag: $GIT_TAG, image suffix: ${CONTAINER_IMAGE_SUFFIX}" +else + CONTAINER_IMAGE_SUFFIX="sha-${SHORT_SHA}" + echo "Not on a tag, image suffix: ${CONTAINER_IMAGE_SUFFIX}" +fi + +# Export for Terraform +export TF_VAR_container_image_tag_suffix="${CONTAINER_IMAGE_SUFFIX}" +export ECR_REPO="${PROJECT}-main-acct-web-cms-test" # TODO + +run_or_fail npm ci +run_or_fail npm run generate-dependencies --workspaces --if-present +# run_or_fail npm run build:archive --workspaces --if-present +run_or_fail env \ + CONTAINER_IMAGE_PREFIX="${CONTAINER_IMAGE_PREFIX}" \ + CONTAINER_IMAGE_SUFFIX="${CONTAINER_IMAGE_SUFFIX}" \ + AWS_ACCOUNT_ID="${AWS_ACCOUNT_ID}" \ + AWS_REGION="${REGION}" \ + PUBLISH_CONTAINER_IMAGE="${PUBLISH_CONTAINER_IMAGE}" \ + npm run build:container --workspaces --if-present + +popd || exit 1 # Return to working directory diff --git a/infrastructure/terraform/components/cms/provider_aws.tf b/infrastructure/terraform/components/cms/provider_aws.tf new file mode 100644 index 00000000..d694811e --- /dev/null +++ b/infrastructure/terraform/components/cms/provider_aws.tf @@ -0,0 +1,24 @@ +provider "aws" { + region = var.region + + allowed_account_ids = [ + var.aws_account_id, + ] + + default_tags { + tags = local.default_tags + } +} + +provider "aws" { + alias = "us-east-1" + region = "us-east-1" + + default_tags { + tags = local.default_tags + } + + allowed_account_ids = [ + var.aws_account_id, + ] +} diff --git a/infrastructure/terraform/components/cms/route53_record.tf b/infrastructure/terraform/components/cms/route53_record.tf new file mode 100644 index 00000000..801fa853 --- /dev/null +++ b/infrastructure/terraform/components/cms/route53_record.tf @@ -0,0 +1,9 @@ +resource "aws_route53_record" "main" { + zone_id = local.root_domain_id + name = local.root_domain_name + type = "CNAME" + ttl = 300 + records = [aws_ecs_express_gateway_service.main.ingress_paths[0].endpoint] + + depends_on = [aws_ecs_express_gateway_service.main] +} diff --git a/infrastructure/terraform/components/cms/security_group_ecs_tasks.tf b/infrastructure/terraform/components/cms/security_group_ecs_tasks.tf new file mode 100644 index 00000000..4aabf206 --- /dev/null +++ b/infrastructure/terraform/components/cms/security_group_ecs_tasks.tf @@ -0,0 +1,24 @@ +resource "aws_security_group" "ecs_tasks" { + name_prefix = "${local.csi}-ecs-tasks-" + description = "Security group for Wagtail CMS ECS tasks" + vpc_id = local.vpc_id + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + description = "Allow all outbound traffic" + } + + tags = merge( + local.default_tags, + { + Name = "${local.csi}-ecs-tasks" + } + ) + + lifecycle { + create_before_destroy = true + } +} diff --git a/infrastructure/terraform/components/cms/security_group_elasticache.tf b/infrastructure/terraform/components/cms/security_group_elasticache.tf new file mode 100644 index 00000000..62b59a80 --- /dev/null +++ b/infrastructure/terraform/components/cms/security_group_elasticache.tf @@ -0,0 +1,32 @@ +resource "aws_security_group" "elasticache" { + name_prefix = "${local.csi}-elasticache-" + description = "Security group for Wagtail CMS ElastiCache Redis" + vpc_id = local.vpc_id + + ingress { + from_port = 6379 + to_port = 6379 + protocol = "tcp" + security_groups = [aws_security_group.ecs_tasks.id] + description = "Redis from ECS tasks" + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + description = "Allow all outbound traffic" + } + + tags = merge( + local.default_tags, + { + Name = "${local.csi}-elasticache" + } + ) + + lifecycle { + create_before_destroy = true + } +} diff --git a/infrastructure/terraform/components/cms/security_group_rds.tf b/infrastructure/terraform/components/cms/security_group_rds.tf new file mode 100644 index 00000000..05944f48 --- /dev/null +++ b/infrastructure/terraform/components/cms/security_group_rds.tf @@ -0,0 +1,32 @@ +resource "aws_security_group" "rds" { + name_prefix = "${local.csi}-rds-" + description = "Security group for Wagtail CMS RDS PostgreSQL database" + vpc_id = local.vpc_id + + ingress { + from_port = 5432 + to_port = 5432 + protocol = "tcp" + security_groups = [aws_security_group.ecs_tasks.id] + description = "PostgreSQL from ECS tasks" + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + description = "Allow all outbound traffic" + } + + tags = merge( + local.default_tags, + { + Name = "${local.csi}-rds" + } + ) + + lifecycle { + create_before_destroy = true + } +} diff --git a/infrastructure/terraform/components/cms/ssm_parameter_db_password.tf b/infrastructure/terraform/components/cms/ssm_parameter_db_password.tf new file mode 100644 index 00000000..20edbc2e --- /dev/null +++ b/infrastructure/terraform/components/cms/ssm_parameter_db_password.tf @@ -0,0 +1,25 @@ +resource "random_password" "db_password" { + length = 32 + special = true + # Exclude characters that might cause issues in connection strings + override_special = "!#$%&*()-_=+[]{}<>:?" +} + +resource "aws_ssm_parameter" "db_password" { + name = "/${var.project}/${var.environment}/${local.component}/db/password" + description = "RDS PostgreSQL password for Wagtail CMS" + type = "SecureString" + value = random_password.db_password.result + key_id = module.kms.key_id + + tags = merge( + local.default_tags, + { + Name = "${local.csi}-db-password" + } + ) + + lifecycle { + ignore_changes = [value] + } +} diff --git a/infrastructure/terraform/components/cms/ssm_parameter_django_secret_key.tf b/infrastructure/terraform/components/cms/ssm_parameter_django_secret_key.tf new file mode 100644 index 00000000..c631c6a1 --- /dev/null +++ b/infrastructure/terraform/components/cms/ssm_parameter_django_secret_key.tf @@ -0,0 +1,26 @@ +resource "random_password" "django_secret_key" { + length = 50 + special = true + + # Django secret key has stricter requirements + override_special = "!@#$%^&*()-_=+[]{}|;:,.<>?" +} + +resource "aws_ssm_parameter" "django_secret_key" { + name = "/${var.project}/${var.environment}/${local.component}/django/secret-key" + description = "Django secret key for Wagtail CMS" + type = "SecureString" + value = random_password.django_secret_key.result + key_id = module.kms.key_id + + tags = merge( + local.default_tags, + { + Name = "${local.csi}-django-secret-key" + } + ) + + lifecycle { + ignore_changes = [value] + } +} diff --git a/infrastructure/terraform/components/cms/ssm_parameter_redis_auth_token.tf b/infrastructure/terraform/components/cms/ssm_parameter_redis_auth_token.tf new file mode 100644 index 00000000..b0673c0a --- /dev/null +++ b/infrastructure/terraform/components/cms/ssm_parameter_redis_auth_token.tf @@ -0,0 +1,26 @@ +resource "random_password" "redis_auth_token" { + length = 32 + special = true + + # Redis auth token has stricter requirements + override_special = "!&#$^<>-" +} + +resource "aws_ssm_parameter" "redis_auth_token" { + name = "/${var.project}/${var.environment}/${local.component}/redis/auth-token" + description = "Valkey auth token for Wagtail CMS cache (Redis-compatible)" + type = "SecureString" + value = random_password.redis_auth_token.result + key_id = module.kms.key_id + + tags = merge( + local.default_tags, + { + Name = "${local.csi}-redis-token" + } + ) + + lifecycle { + ignore_changes = [value] + } +} diff --git a/infrastructure/terraform/components/cms/variables.tf b/infrastructure/terraform/components/cms/variables.tf new file mode 100644 index 00000000..f4cd82af --- /dev/null +++ b/infrastructure/terraform/components/cms/variables.tf @@ -0,0 +1,164 @@ +## +# Basic Required Variables for tfscaffold Components +## + +variable "project" { + type = string + description = "The name of the tfscaffold project" +} + +variable "environment" { + type = string + description = "The name of the tfscaffold environment" +} + +variable "aws_account_id" { + type = string + description = "The AWS Account ID (numeric)" +} + +variable "region" { + type = string + description = "The AWS Region" +} + +variable "group" { + type = string + description = "The group variables are being inherited from (often synonmous with account short-name)" +} + +## +# tfscaffold variables specific to this component +## + +# This is the only primary variable to have its value defined as +# a default within its declaration in this file, because the variables +# purpose is as an identifier unique to this component, rather +# then to the environment from where all other variables come. + +variable "default_tags" { + type = map(string) + description = "A map of default tags to apply to all taggable resources within the component" + default = {} +} + +## +# Variables specific to the component +## + +variable "force_destroy" { + type = bool + description = "Flag to force deletion of S3 buckets" + default = false +} + +variable "force_lambda_code_deploy" { + type = bool + description = "If the lambda package in s3 has the same commit id tag as the terraform build branch, the lambda will not update automatically. Set to True if making changes to Lambda code from on the same commit for example during development" + default = false +} + +variable "kms_deletion_window" { + type = string + description = "When a kms key is deleted, how long should it wait in the pending deletion state?" + default = "30" +} + +variable "log_level" { + type = string + description = "The log level to be used in lambda functions within the component. Any log with a lower severity than the configured value will not be logged: https://docs.python.org/3/library/logging.html#levels" + default = "INFO" +} + +variable "log_retention_in_days" { + type = number + description = "The retention period in days for the Cloudwatch Logs events to be retained, default of 0 is indefinite" + default = 0 +} + +variable "parent_acct_environment" { + type = string + description = "Name of the environment responsible for the acct resources used, affects things like DNS zone. Useful for named dev environments" + default = "main" +} + +variable "shared_infra_account_id" { + type = string + description = "The AWS Account ID of the shared infrastructure account" + default = "000000000000" +} + +## +# CMS Infrastructure Variables +## + +# RDS PostgreSQL Variables +variable "db_instance_class" { + type = string + description = "RDS instance class for PostgreSQL database" + default = "db.t4g.micro" +} + +variable "db_allocated_storage" { + type = number + description = "Allocated storage for RDS in GB" + default = 20 +} + +variable "db_multi_az" { + type = bool + description = "Enable Multi-AZ deployment for RDS" + default = false +} + +variable "db_backup_retention_period" { + type = number + description = "Backup retention period in days for RDS" + default = 7 +} + +variable "db_skip_final_snapshot" { + type = bool + description = "Skip final snapshot when deleting RDS instance" + default = false +} + +# ElastiCache Valkey Serverless Variables +# Note: cache_node_type and cache_num_nodes not needed for serverless + +variable "cache_snapshot_retention_limit" { + type = number + description = "Number of days to retain ElastiCache snapshots" + default = 5 +} + +# ECS Variables +variable "ecs_task_cpu" { + type = string + description = "CPU units for ECS task (256, 512, 1024, 2048, 4096)" + default = "512" +} + +variable "ecs_task_memory" { + type = string + description = "Memory for ECS task in MiB (512, 1024, 2048, 3072, 4096, 5120, 6144, 7168, 8192)" + default = "1024" +} + +variable "ecs_min_capacity" { + type = number + description = "Minimum number of ECS tasks" + default = 2 +} + +variable "ecs_max_capacity" { + type = number + description = "Maximum number of ECS tasks for auto-scaling" + default = 10 +} + +variable "container_image_tag_suffix" { + type = string + description = "Suffix used for container/image based Lambda image tags" + default = "latest" +} diff --git a/infrastructure/terraform/components/cms/versions.tf b/infrastructure/terraform/components/cms/versions.tf new file mode 100644 index 00000000..f043e108 --- /dev/null +++ b/infrastructure/terraform/components/cms/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.23.0" + } + } + + required_version = ">= 1.14.0" +} diff --git a/scripts/docker/docker.lib.sh b/scripts/docker/docker.lib.sh index fb27fcbc..f2e1ce87 100644 --- a/scripts/docker/docker.lib.sh +++ b/scripts/docker/docker.lib.sh @@ -52,6 +52,7 @@ function docker-build() { for version in $(_get-all-effective-versions) latest; do docker tag "${DOCKER_IMAGE}:$(_get-effective-version)" "${DOCKER_IMAGE}:${version}" done + docker rmi --force "$(docker images | grep "" | awk '{print $3}')" 2> /dev/null ||: } From 3e872784b20d6747584cab7d5ae9c181c1e28618 Mon Sep 17 00:00:00 2001 From: sidnhs Date: Wed, 15 Apr 2026 13:21:23 +0100 Subject: [PATCH 04/10] CCM-16446: Update IAM Permissions for infra role in ecs express --- .../cms/iam_role_ecs_infrastructure.tf | 38 +++++++++++++++++-- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/infrastructure/terraform/components/cms/iam_role_ecs_infrastructure.tf b/infrastructure/terraform/components/cms/iam_role_ecs_infrastructure.tf index 45dcf972..2aab5356 100644 --- a/infrastructure/terraform/components/cms/iam_role_ecs_infrastructure.tf +++ b/infrastructure/terraform/components/cms/iam_role_ecs_infrastructure.tf @@ -46,13 +46,30 @@ resource "aws_iam_role_policy" "ecs_infrastructure_express_gateway_replica" { } }, { - Sid = "ELBOperations" + Sid = "ELBCreateOperations" Effect = "Allow" Action = [ "elasticloadbalancing:CreateListener", "elasticloadbalancing:CreateLoadBalancer", "elasticloadbalancing:CreateRule", - "elasticloadbalancing:CreateTargetGroup", + "elasticloadbalancing:CreateTargetGroup" + ] + Resource = [ + "arn:aws:elasticloadbalancing:*:*:loadbalancer/app/*/*", + "arn:aws:elasticloadbalancing:*:*:listener/app/*/*/*", + "arn:aws:elasticloadbalancing:*:*:listener-rule/app/*/*/*/*", + "arn:aws:elasticloadbalancing:*:*:targetgroup/*/*" + ] + Condition = { + StringEquals = { + "aws:RequestTag/AmazonECSManaged" = "true" + } + } + }, + { + Sid = "ELBModifyDeleteOperations" + Effect = "Allow" + Action = [ "elasticloadbalancing:ModifyListener", "elasticloadbalancing:ModifyRule", "elasticloadbalancing:AddListenerCertificates", @@ -174,11 +191,23 @@ resource "aws_iam_role_policy" "ecs_infrastructure_express_gateway_replica" { } }, { - Sid = "CertificateOperations" + Sid = "CertificateCreateOperations" Effect = "Allow" Action = [ "acm:RequestCertificate", - "acm:AddTagsToCertificate", + "acm:AddTagsToCertificate" + ] + Resource = "arn:aws:acm:*:*:certificate/*" + Condition = { + StringEquals = { + "aws:RequestTag/AmazonECSManaged" = "true" + } + } + }, + { + Sid = "CertificateDeleteOperations" + Effect = "Allow" + Action = [ "acm:DeleteCertificate", "acm:DescribeCertificate" ] @@ -335,6 +364,7 @@ resource "aws_iam_role_policy" "ecs_infrastructure_express" { "ec2:DescribeVpcs", "ec2:CreateSecurityGroup", "ec2:DeleteSecurityGroup", + "ec2:DescribeAccountAttributes", "ec2:AuthorizeSecurityGroupIngress", "ec2:AuthorizeSecurityGroupEgress", "ec2:RevokeSecurityGroupIngress", From 9720cd04a7414fbb967147b776dcabc40747bb6a Mon Sep 17 00:00:00 2001 From: sidnhs Date: Thu, 16 Apr 2026 11:21:49 +0100 Subject: [PATCH 05/10] CCM-16446: Create ecs/alb infra manually --- .../components/cms/acm_certificate_main.tf | 15 + .../cms/acm_certificate_validation_main.tf | 4 + .../cms/appautoscaling_policy_ecs_cpu.tf | 17 + .../cms/appautoscaling_target_ecs.tf | 7 + .../cms/ecs_express_gateway_service.tf | 114 ----- .../components/cms/ecs_service_main.tf | 36 ++ .../cms/ecs_task_definition_main.tf | 102 ++++ .../cms/elasticache_serverless_cache.tf | 2 +- .../cms/iam_role_ecs_infrastructure.tf | 461 ------------------ .../components/cms/lb_listener_http.tf | 16 + .../components/cms/lb_listener_https.tf | 14 + .../terraform/components/cms/lb_main.tf | 17 + .../components/cms/lb_target_group_main.tf | 30 ++ .../terraform/components/cms/outputs.tf | 24 +- .../cms/route53_record_acm_validation.tf | 15 + ...ute53_record.tf => route53_record_main.tf} | 4 +- .../components/cms/security_group_alb.tf | 46 ++ .../cms/security_group_ecs_tasks.tf | 28 +- .../cms/ssm_parameter_django_secret_key.tf | 4 +- 19 files changed, 357 insertions(+), 599 deletions(-) create mode 100644 infrastructure/terraform/components/cms/acm_certificate_main.tf create mode 100644 infrastructure/terraform/components/cms/acm_certificate_validation_main.tf create mode 100644 infrastructure/terraform/components/cms/appautoscaling_policy_ecs_cpu.tf create mode 100644 infrastructure/terraform/components/cms/appautoscaling_target_ecs.tf delete mode 100644 infrastructure/terraform/components/cms/ecs_express_gateway_service.tf create mode 100644 infrastructure/terraform/components/cms/ecs_service_main.tf create mode 100644 infrastructure/terraform/components/cms/ecs_task_definition_main.tf delete mode 100644 infrastructure/terraform/components/cms/iam_role_ecs_infrastructure.tf create mode 100644 infrastructure/terraform/components/cms/lb_listener_http.tf create mode 100644 infrastructure/terraform/components/cms/lb_listener_https.tf create mode 100644 infrastructure/terraform/components/cms/lb_main.tf create mode 100644 infrastructure/terraform/components/cms/lb_target_group_main.tf create mode 100644 infrastructure/terraform/components/cms/route53_record_acm_validation.tf rename infrastructure/terraform/components/cms/{route53_record.tf => route53_record_main.tf} (52%) create mode 100644 infrastructure/terraform/components/cms/security_group_alb.tf diff --git a/infrastructure/terraform/components/cms/acm_certificate_main.tf b/infrastructure/terraform/components/cms/acm_certificate_main.tf new file mode 100644 index 00000000..912b9c33 --- /dev/null +++ b/infrastructure/terraform/components/cms/acm_certificate_main.tf @@ -0,0 +1,15 @@ +resource "aws_acm_certificate" "main" { + domain_name = local.root_domain_name + validation_method = "DNS" + + lifecycle { + create_before_destroy = true + } + + tags = merge( + local.default_tags, + { + Name = local.csi + } + ) +} diff --git a/infrastructure/terraform/components/cms/acm_certificate_validation_main.tf b/infrastructure/terraform/components/cms/acm_certificate_validation_main.tf new file mode 100644 index 00000000..72a448c9 --- /dev/null +++ b/infrastructure/terraform/components/cms/acm_certificate_validation_main.tf @@ -0,0 +1,4 @@ +resource "aws_acm_certificate_validation" "main" { + certificate_arn = aws_acm_certificate.main.arn + validation_record_fqdns = [for r in aws_route53_record.acm_validation : r.fqdn] +} diff --git a/infrastructure/terraform/components/cms/appautoscaling_policy_ecs_cpu.tf b/infrastructure/terraform/components/cms/appautoscaling_policy_ecs_cpu.tf new file mode 100644 index 00000000..2b7f9bdf --- /dev/null +++ b/infrastructure/terraform/components/cms/appautoscaling_policy_ecs_cpu.tf @@ -0,0 +1,17 @@ +resource "aws_appautoscaling_policy" "ecs_cpu" { + name = "${local.csi}-cpu-scaling" + service_namespace = aws_appautoscaling_target.ecs.service_namespace + resource_id = aws_appautoscaling_target.ecs.resource_id + scalable_dimension = aws_appautoscaling_target.ecs.scalable_dimension + policy_type = "TargetTrackingScaling" + + target_tracking_scaling_policy_configuration { + target_value = 70 + scale_in_cooldown = 300 + scale_out_cooldown = 60 + + predefined_metric_specification { + predefined_metric_type = "ECSServiceAverageCPUUtilization" + } + } +} diff --git a/infrastructure/terraform/components/cms/appautoscaling_target_ecs.tf b/infrastructure/terraform/components/cms/appautoscaling_target_ecs.tf new file mode 100644 index 00000000..47833fbd --- /dev/null +++ b/infrastructure/terraform/components/cms/appautoscaling_target_ecs.tf @@ -0,0 +1,7 @@ +resource "aws_appautoscaling_target" "ecs" { + service_namespace = "ecs" + resource_id = "service/${aws_ecs_cluster.main.name}/${aws_ecs_service.main.name}" + scalable_dimension = "ecs:service:DesiredCount" + min_capacity = var.ecs_min_capacity + max_capacity = var.ecs_max_capacity +} diff --git a/infrastructure/terraform/components/cms/ecs_express_gateway_service.tf b/infrastructure/terraform/components/cms/ecs_express_gateway_service.tf deleted file mode 100644 index d36a1e38..00000000 --- a/infrastructure/terraform/components/cms/ecs_express_gateway_service.tf +++ /dev/null @@ -1,114 +0,0 @@ -resource "aws_ecs_express_gateway_service" "main" { - service_name = local.csi - cluster = aws_ecs_cluster.main.name - - execution_role_arn = aws_iam_role.ecs_task_execution.arn - infrastructure_role_arn = aws_iam_role.ecs_infrastructure.arn - task_role_arn = aws_iam_role.ecs_task.arn - - cpu = var.ecs_task_cpu - memory = var.ecs_task_memory - health_check_path = "/health/" - - primary_container { - image = "${local.ecr_repository_url}:${var.project}-${var.environment}-${local.component}-wagtail-${var.container_image_tag_suffix}" - container_port = 8080 - - aws_logs_configuration { - log_group = aws_cloudwatch_log_group.ecs.name - log_stream_prefix = "wagtail" - } - - environment { - name = "DJANGO_SETTINGS_MODULE" - value = "config.settings.production" - } - - environment { - name = "DATABASE_HOST" - value = aws_db_instance.main.address - } - - environment { - name = "DATABASE_PORT" - value = tostring(aws_db_instance.main.port) - } - - environment { - name = "DATABASE_NAME" - value = aws_db_instance.main.db_name - } - - environment { - name = "DATABASE_USER" - value = aws_db_instance.main.username - } - - environment { - name = "REDIS_HOST" - value = aws_elasticache_serverless_cache.main.endpoint[0].address - } - - environment { - name = "REDIS_PORT" - value = tostring(aws_elasticache_serverless_cache.main.endpoint[0].port) - } - - environment { - name = "REDIS_SSL" - value = "true" - } - - environment { - name = "AWS_STORAGE_BUCKET_NAME" - value = module.s3bucket_media.bucket - } - - environment { - name = "AWS_S3_REGION_NAME" - value = var.region - } - - environment { - name = "ALLOWED_HOSTS" - value = local.root_domain_name - } - - # Secrets from SSM Parameter Store - secret { - name = "DATABASE_PASSWORD" - value_from = aws_ssm_parameter.db_password.arn - } - - secret { - name = "REDIS_AUTH_TOKEN" - value_from = aws_ssm_parameter.redis_auth_token.arn - } - - secret { - name = "DJANGO_SECRET_KEY" - value_from = aws_ssm_parameter.django_secret_key.arn - } - } - - network_configuration { - subnets = local.private_subnets - security_groups = [aws_security_group.ecs_tasks.id] - } - - scaling_target { - min_task_count = var.ecs_min_capacity - max_task_count = var.ecs_max_capacity - auto_scaling_metric = "AVERAGE_CPU" - auto_scaling_target_value = 70 - } - - wait_for_steady_state = false - - tags = merge( - local.default_tags, - { - Name = local.csi - } - ) -} diff --git a/infrastructure/terraform/components/cms/ecs_service_main.tf b/infrastructure/terraform/components/cms/ecs_service_main.tf new file mode 100644 index 00000000..bb83e728 --- /dev/null +++ b/infrastructure/terraform/components/cms/ecs_service_main.tf @@ -0,0 +1,36 @@ +resource "aws_ecs_service" "main" { + name = "${local.csi}-s" + cluster = aws_ecs_cluster.main.id + task_definition = aws_ecs_task_definition.main.arn + launch_type = "FARGATE" + desired_count = var.ecs_min_capacity + + network_configuration { + subnets = local.private_subnets + security_groups = [aws_security_group.ecs_tasks.id] + assign_public_ip = false + } + + load_balancer { + target_group_arn = aws_lb_target_group.main.arn + container_name = "wagtail" + container_port = 8080 + } + + deployment_minimum_healthy_percent = 50 + deployment_maximum_percent = 200 + + wait_for_steady_state = false + + depends_on = [ + aws_lb_listener.https, + aws_iam_role_policy.ecs_task_execution_base + ] + + tags = merge( + local.default_tags, + { + Name = local.csi + } + ) +} diff --git a/infrastructure/terraform/components/cms/ecs_task_definition_main.tf b/infrastructure/terraform/components/cms/ecs_task_definition_main.tf new file mode 100644 index 00000000..0f14c190 --- /dev/null +++ b/infrastructure/terraform/components/cms/ecs_task_definition_main.tf @@ -0,0 +1,102 @@ +resource "aws_ecs_task_definition" "main" { + family = local.csi + network_mode = "awsvpc" + requires_compatibilities = ["FARGATE"] + cpu = var.ecs_task_cpu + memory = var.ecs_task_memory + execution_role_arn = aws_iam_role.ecs_task_execution.arn + task_role_arn = aws_iam_role.ecs_task.arn + + container_definitions = jsonencode([ + { + name = "wagtail" + image = "${local.ecr_repository_url}:${var.project}-${var.environment}-${local.component}-wagtail-${var.container_image_tag_suffix}" + essential = true + + portMappings = [ + { + containerPort = 8080 + protocol = "tcp" + } + ] + + environment = [ + { + name = "DJANGO_SETTINGS_MODULE" + value = "config.settings.production" + }, + { + name = "DATABASE_HOST" + value = aws_db_instance.main.address + }, + { + name = "DATABASE_PORT" + value = tostring(aws_db_instance.main.port) + }, + { + name = "DATABASE_NAME" + value = aws_db_instance.main.db_name + }, + { + name = "DATABASE_USER" + value = aws_db_instance.main.username + }, + { + name = "REDIS_HOST" + value = aws_elasticache_serverless_cache.main.endpoint[0].address + }, + { + name = "REDIS_PORT" + value = tostring(aws_elasticache_serverless_cache.main.endpoint[0].port) + }, + { + name = "REDIS_SSL" + value = "true" + }, + { + name = "AWS_STORAGE_BUCKET_NAME" + value = module.s3bucket_media.bucket + }, + { + name = "AWS_S3_REGION_NAME" + value = var.region + }, + { + name = "ALLOWED_HOSTS" + value = local.root_domain_name + } + ] + + secrets = [ + { + name = "DATABASE_PASSWORD" + valueFrom = aws_ssm_parameter.db_password.arn + }, + { + name = "REDIS_AUTH_TOKEN" + valueFrom = aws_ssm_parameter.redis_auth_token.arn + }, + { + name = "DJANGO_SECRET_KEY" + valueFrom = aws_ssm_parameter.django_secret_key.arn + } + ] + + logConfiguration = { + logDriver = "awslogs" + options = { + "awslogs-group" = aws_cloudwatch_log_group.ecs.name + "awslogs-region" = var.region + "awslogs-stream-prefix" = "wagtail" + } + } + } + ]) + + tags = merge( + local.default_tags, + { + Name = local.csi + } + ) +} diff --git a/infrastructure/terraform/components/cms/elasticache_serverless_cache.tf b/infrastructure/terraform/components/cms/elasticache_serverless_cache.tf index 8c087510..12f09ff1 100644 --- a/infrastructure/terraform/components/cms/elasticache_serverless_cache.tf +++ b/infrastructure/terraform/components/cms/elasticache_serverless_cache.tf @@ -29,7 +29,7 @@ resource "aws_elasticache_serverless_cache" "main" { Name = "${local.csi}-valkey-serverless" } ) -} +} # Create Valkey user for authentication resource "aws_elasticache_user" "main" { diff --git a/infrastructure/terraform/components/cms/iam_role_ecs_infrastructure.tf b/infrastructure/terraform/components/cms/iam_role_ecs_infrastructure.tf deleted file mode 100644 index 2aab5356..00000000 --- a/infrastructure/terraform/components/cms/iam_role_ecs_infrastructure.tf +++ /dev/null @@ -1,461 +0,0 @@ -resource "aws_iam_role" "ecs_infrastructure" { - name_prefix = "${local.csi}-ecs-infra-" - - assume_role_policy = jsonencode({ - Version = "2012-10-17" - Statement = [ - { - Effect = "Allow" - Principal = { - Service = "ecs.amazonaws.com" - } - Action = "sts:AssumeRole" - } - ] - }) - - tags = merge( - local.default_tags, - { - Name = "${local.csi}-ecs-infrastructure" - } - ) -} - -# AWS managed policy for Express Gateway Services (inline replica) -# Source: AmazonECSInfrastructureRoleforExpressGatewayServices -resource "aws_iam_role_policy" "ecs_infrastructure_express_gateway_replica" { - name_prefix = "${local.csi}-express-gateway-" - role = aws_iam_role.ecs_infrastructure.id - - policy = jsonencode({ - Version = "2012-10-17" - Statement = [ - { - Sid = "ServiceLinkedRoleCreateOperations" - Effect = "Allow" - Action = "iam:CreateServiceLinkedRole" - Resource = "*" - Condition = { - StringEquals = { - "iam:AWSServiceName" = [ - "ecs.application-autoscaling.amazonaws.com", - "elasticloadbalancing.amazonaws.com" - ] - } - } - }, - { - Sid = "ELBCreateOperations" - Effect = "Allow" - Action = [ - "elasticloadbalancing:CreateListener", - "elasticloadbalancing:CreateLoadBalancer", - "elasticloadbalancing:CreateRule", - "elasticloadbalancing:CreateTargetGroup" - ] - Resource = [ - "arn:aws:elasticloadbalancing:*:*:loadbalancer/app/*/*", - "arn:aws:elasticloadbalancing:*:*:listener/app/*/*/*", - "arn:aws:elasticloadbalancing:*:*:listener-rule/app/*/*/*/*", - "arn:aws:elasticloadbalancing:*:*:targetgroup/*/*" - ] - Condition = { - StringEquals = { - "aws:RequestTag/AmazonECSManaged" = "true" - } - } - }, - { - Sid = "ELBModifyDeleteOperations" - Effect = "Allow" - Action = [ - "elasticloadbalancing:ModifyListener", - "elasticloadbalancing:ModifyRule", - "elasticloadbalancing:AddListenerCertificates", - "elasticloadbalancing:RemoveListenerCertificates", - "elasticloadbalancing:RegisterTargets", - "elasticloadbalancing:DeregisterTargets", - "elasticloadbalancing:DeleteTargetGroup", - "elasticloadbalancing:DeleteLoadBalancer", - "elasticloadbalancing:DeleteRule", - "elasticloadbalancing:DeleteListener" - ] - Resource = [ - "arn:aws:elasticloadbalancing:*:*:loadbalancer/app/*/*", - "arn:aws:elasticloadbalancing:*:*:listener/app/*/*/*", - "arn:aws:elasticloadbalancing:*:*:listener-rule/app/*/*/*/*", - "arn:aws:elasticloadbalancing:*:*:targetgroup/*/*" - ] - Condition = { - StringEquals = { - "aws:ResourceTag/AmazonECSManaged" = "true" - } - } - }, - { - Sid = "TagOnCreateELBResources" - Effect = "Allow" - Action = "elasticloadbalancing:AddTags" - Resource = [ - "arn:aws:elasticloadbalancing:*:*:loadbalancer/app/*/*", - "arn:aws:elasticloadbalancing:*:*:listener/app/*/*/*", - "arn:aws:elasticloadbalancing:*:*:listener-rule/app/*/*/*/*", - "arn:aws:elasticloadbalancing:*:*:targetgroup/*/*" - ] - Condition = { - StringEquals = { - "elasticloadbalancing:CreateAction" = [ - "CreateLoadBalancer", - "CreateListener", - "CreateRule", - "CreateTargetGroup" - ] - } - } - }, - { - Sid = "BlanketAllowCreateSecurityGroupsInVPCs" - Effect = "Allow" - Action = "ec2:CreateSecurityGroup" - Resource = "arn:aws:ec2:*:*:vpc/*" - }, - { - Sid = "CreateSecurityGroupResourcesWithTags" - Effect = "Allow" - Action = [ - "ec2:CreateSecurityGroup", - "ec2:AuthorizeSecurityGroupEgress", - "ec2:AuthorizeSecurityGroupIngress" - ] - Resource = [ - "arn:aws:ec2:*:*:security-group/*", - "arn:aws:ec2:*:*:security-group-rule/*", - "arn:aws:ec2:*:*:vpc/*" - ] - Condition = { - StringEquals = { - "aws:RequestTag/AmazonECSManaged" = "true" - } - } - }, - { - Sid = "ModifySecurityGroupRules" - Effect = "Allow" - Action = [ - "ec2:AuthorizeSecurityGroupEgress", - "ec2:AuthorizeSecurityGroupIngress", - "ec2:RevokeSecurityGroupEgress", - "ec2:RevokeSecurityGroupIngress" - ] - Resource = "arn:aws:ec2:*:*:security-group/*" - }, - { - Sid = "DeleteManagedSecurityGroups" - Effect = "Allow" - Action = "ec2:DeleteSecurityGroup" - Resource = "arn:aws:ec2:*:*:security-group/*" - Condition = { - StringEquals = { - "aws:ResourceTag/AmazonECSManaged" = "true" - } - } - }, - { - Sid = "ModifyVPCForSecurityGroups" - Effect = "Allow" - Action = [ - "ec2:AuthorizeSecurityGroupEgress", - "ec2:AuthorizeSecurityGroupIngress", - "ec2:RevokeSecurityGroupEgress", - "ec2:RevokeSecurityGroupIngress" - ] - Resource = "arn:aws:ec2:*:*:vpc/*" - }, - { - Sid = "TagOnCreateEC2Resources" - Effect = "Allow" - Action = "ec2:CreateTags" - Resource = [ - "arn:aws:ec2:*:*:security-group/*", - "arn:aws:ec2:*:*:security-group-rule/*" - ] - Condition = { - StringEquals = { - "ec2:CreateAction" = [ - "CreateSecurityGroup", - "AuthorizeSecurityGroupIngress", - "AuthorizeSecurityGroupEgress" - ] - } - } - }, - { - Sid = "CertificateCreateOperations" - Effect = "Allow" - Action = [ - "acm:RequestCertificate", - "acm:AddTagsToCertificate" - ] - Resource = "arn:aws:acm:*:*:certificate/*" - Condition = { - StringEquals = { - "aws:RequestTag/AmazonECSManaged" = "true" - } - } - }, - { - Sid = "CertificateDeleteOperations" - Effect = "Allow" - Action = [ - "acm:DeleteCertificate", - "acm:DescribeCertificate" - ] - Resource = "arn:aws:acm:*:*:certificate/*" - Condition = { - StringEquals = { - "aws:ResourceTag/AmazonECSManaged" = "true" - } - } - }, - { - Sid = "ApplicationAutoscalingCreateOperations" - Effect = "Allow" - Action = [ - "application-autoscaling:RegisterScalableTarget", - "application-autoscaling:TagResource" - ] - Resource = "arn:aws:application-autoscaling:*:*:scalable-target/*" - Condition = { - StringEquals = { - "aws:RequestTag/AmazonECSManaged" = "true" - } - } - }, - { - Sid = "ApplicationAutoscalingDeregisterOperations" - Effect = "Allow" - Action = "application-autoscaling:DeregisterScalableTarget" - Resource = "arn:aws:application-autoscaling:*:*:scalable-target/*" - }, - { - Sid = "ApplicationAutoscalingPolicyOperations" - Effect = "Allow" - Action = [ - "application-autoscaling:PutScalingPolicy", - "application-autoscaling:DeleteScalingPolicy" - ] - Resource = "arn:aws:application-autoscaling:*:*:scalable-target/*" - Condition = { - StringEquals = { - "application-autoscaling:service-namespace" = "ecs" - } - } - }, - { - Sid = "ApplicationAutoscalingReadOperations" - Effect = "Allow" - Action = [ - "application-autoscaling:DescribeScalableTargets", - "application-autoscaling:DescribeScalingPolicies", - "application-autoscaling:DescribeScalingActivities" - ] - Resource = "arn:aws:application-autoscaling:*:*:scalable-target/*" - }, - { - Sid = "ECSServiceOperations" - Effect = "Allow" - Action = [ - "ecs:DescribeServices", - "ecs:UpdateService" - ] - Resource = "arn:aws:ecs:*:*:service/*/*" - }, - { - Sid = "CloudWatchAlarmCreateOperations" - Effect = "Allow" - Action = [ - "cloudwatch:PutMetricAlarm", - "cloudwatch:TagResource" - ] - Resource = "arn:aws:cloudwatch:*:*:alarm:*" - Condition = { - StringEquals = { - "aws:RequestTag/AmazonECSManaged" = "true" - } - } - }, - { - Sid = "CloudWatchAlarmDeleteOperations" - Effect = "Allow" - Action = "cloudwatch:DeleteAlarms" - Resource = "arn:aws:cloudwatch:*:*:alarm:*" - }, - { - Sid = "CloudWatchAlarmReadOperations" - Effect = "Allow" - Action = "cloudwatch:DescribeAlarms" - Resource = "*" - }, - { - Sid = "ELBReadOperations" - Effect = "Allow" - Action = [ - "elasticloadbalancing:DescribeLoadBalancers", - "elasticloadbalancing:DescribeTargetGroups", - "elasticloadbalancing:DescribeTargetHealth", - "elasticloadbalancing:DescribeListeners", - "elasticloadbalancing:DescribeRules" - ] - Resource = "*" - }, - { - Sid = "VPCReadOperations" - Effect = "Allow" - Action = [ - "ec2:DescribeSecurityGroups", - "ec2:DescribeSubnets", - "ec2:DescribeRouteTables", - "ec2:DescribeVpcs" - ] - Resource = "*" - }, - { - Sid = "CloudWatchLogsCreateOperations" - Effect = "Allow" - Action = [ - "logs:CreateLogGroup", - "logs:TagResource" - ] - Resource = "arn:aws:logs:*:*:log-group:*" - Condition = { - StringEquals = { - "aws:RequestTag/AmazonECSManaged" = "true" - } - } - }, - { - Sid = "CloudWatchLogsReadOperations" - Effect = "Allow" - Action = "logs:DescribeLogGroups" - Resource = "*" - } - ] - }) -} - -# TODO: Remove once Express Gateway is working - kept for reference -resource "aws_iam_role_policy" "ecs_infrastructure_express" { - name_prefix = "${local.csi}-express-" - role = aws_iam_role.ecs_infrastructure.id - - policy = jsonencode({ - Version = "2012-10-17" - Statement = [ - { - Effect = "Allow" - Action = [ - "ec2:CreateNetworkInterface", - "ec2:DeleteNetworkInterface", - "ec2:DescribeNetworkInterfaces", - "ec2:DescribeRouteTables", - "ec2:DescribeSubnets", - "ec2:DescribeSecurityGroups", - "ec2:DescribeVpcs", - "ec2:CreateSecurityGroup", - "ec2:DeleteSecurityGroup", - "ec2:DescribeAccountAttributes", - "ec2:AuthorizeSecurityGroupIngress", - "ec2:AuthorizeSecurityGroupEgress", - "ec2:RevokeSecurityGroupIngress", - "ec2:RevokeSecurityGroupEgress", - "ec2:CreateTags", - "elasticloadbalancing:CreateLoadBalancer", - "elasticloadbalancing:CreateTargetGroup", - "elasticloadbalancing:CreateListener", - "elasticloadbalancing:DeleteLoadBalancer", - "elasticloadbalancing:DeleteTargetGroup", - "elasticloadbalancing:DeleteListener", - "elasticloadbalancing:Describe*", - "elasticloadbalancing:ModifyLoadBalancerAttributes", - "elasticloadbalancing:ModifyTargetGroup", - "elasticloadbalancing:ModifyTargetGroupAttributes", - "elasticloadbalancing:RegisterTargets", - "elasticloadbalancing:DeregisterTargets", - "elasticloadbalancing:AddTags" - ] - Resource = "*" - }, - { - Effect = "Allow" - Action = [ - "logs:CreateLogGroup", - "logs:DescribeLogGroups", - "logs:PutRetentionPolicy", - "logs:TagLogGroup" - ] - Resource = "*" - }, - { - Effect = "Allow" - Action = [ - "application-autoscaling:RegisterScalableTarget", - "application-autoscaling:DeregisterScalableTarget", - "application-autoscaling:DescribeScalableTargets", - "application-autoscaling:PutScalingPolicy", - "application-autoscaling:DeleteScalingPolicy", - "application-autoscaling:DescribeScalingPolicies", - "application-autoscaling:DescribeScalingActivities", - "application-autoscaling:TagResource" - ] - Resource = "*" - }, - { - Effect = "Allow" - Action = [ - "cloudwatch:PutMetricAlarm", - "cloudwatch:DeleteAlarms", - "cloudwatch:DescribeAlarms" - ] - Resource = "*" - }, - { - Effect = "Allow" - Action = "iam:CreateServiceLinkedRole" - Resource = [ - "arn:aws:iam::*:role/aws-service-role/elasticloadbalancing.amazonaws.com/AWSServiceRoleForElasticLoadBalancing", - "arn:aws:iam::*:role/aws-service-role/ecs.application-autoscaling.amazonaws.com/AWSServiceRoleForApplicationAutoScaling_ECSService" - ] - Condition = { - StringLike = { - "iam:AWSServiceName" = [ - "elasticloadbalancing.amazonaws.com", - "ecs.application-autoscaling.amazonaws.com" - ] - } - } - }, - { - Effect = "Allow" - Action = [ - "acm:RequestCertificate", - "acm:DescribeCertificate", - "acm:DeleteCertificate", - "acm:AddTagsToCertificate", - "acm:ListTagsForCertificate" - ] - Resource = "*" - }, - { - Effect = "Allow" - Action = [ - "route53:ChangeResourceRecordSets", - "route53:GetChange", - "route53:ListHostedZones", - "route53:ListResourceRecordSets" - ] - Resource = "*" - } - ] - }) -} diff --git a/infrastructure/terraform/components/cms/lb_listener_http.tf b/infrastructure/terraform/components/cms/lb_listener_http.tf new file mode 100644 index 00000000..6e111e0d --- /dev/null +++ b/infrastructure/terraform/components/cms/lb_listener_http.tf @@ -0,0 +1,16 @@ +resource "aws_lb_listener" "http" { + load_balancer_arn = aws_lb.main.arn + port = 80 + protocol = "HTTP" + + default_action { + type = "redirect" + redirect { + port = "443" + protocol = "HTTPS" + status_code = "HTTP_301" + } + } + + tags = local.default_tags +} diff --git a/infrastructure/terraform/components/cms/lb_listener_https.tf b/infrastructure/terraform/components/cms/lb_listener_https.tf new file mode 100644 index 00000000..a10dc37a --- /dev/null +++ b/infrastructure/terraform/components/cms/lb_listener_https.tf @@ -0,0 +1,14 @@ +resource "aws_lb_listener" "https" { + load_balancer_arn = aws_lb.main.arn + port = 443 + protocol = "HTTPS" + ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06" + certificate_arn = aws_acm_certificate_validation.main.certificate_arn + + default_action { + type = "forward" + target_group_arn = aws_lb_target_group.main.arn + } + + tags = local.default_tags +} diff --git a/infrastructure/terraform/components/cms/lb_main.tf b/infrastructure/terraform/components/cms/lb_main.tf new file mode 100644 index 00000000..d83f39bc --- /dev/null +++ b/infrastructure/terraform/components/cms/lb_main.tf @@ -0,0 +1,17 @@ +resource "aws_lb" "main" { + name_prefix = "cms-" + internal = true + load_balancer_type = "application" + + subnets = local.private_subnets + security_groups = [aws_security_group.alb.id] + + drop_invalid_header_fields = true + + tags = merge( + local.default_tags, + { + Name = "${local.csi}-alb" + } + ) +} diff --git a/infrastructure/terraform/components/cms/lb_target_group_main.tf b/infrastructure/terraform/components/cms/lb_target_group_main.tf new file mode 100644 index 00000000..eb7ee538 --- /dev/null +++ b/infrastructure/terraform/components/cms/lb_target_group_main.tf @@ -0,0 +1,30 @@ +resource "aws_lb_target_group" "main" { + name_prefix = "cms-" + port = 8080 + protocol = "HTTP" + target_type = "ip" + vpc_id = local.vpc_id + + health_check { + path = "/health/" + protocol = "HTTP" + healthy_threshold = 3 + unhealthy_threshold = 3 + timeout = 5 + interval = 30 + matcher = "200" + } + + deregistration_delay = 30 + + tags = merge( + local.default_tags, + { + Name = "${local.csi}-tg" + } + ) + + lifecycle { + create_before_destroy = true + } +} diff --git a/infrastructure/terraform/components/cms/outputs.tf b/infrastructure/terraform/components/cms/outputs.tf index 38ea559a..d835085c 100644 --- a/infrastructure/terraform/components/cms/outputs.tf +++ b/infrastructure/terraform/components/cms/outputs.tf @@ -1,15 +1,15 @@ ## -# ECS Express Gateway Service Outputs +# ECS Outputs ## output "ecs_service_arn" { - description = "ARN of the ECS Express Gateway Service" - value = aws_ecs_express_gateway_service.main.service_arn + description = "ARN of the ECS Service" + value = aws_ecs_service.main.id } output "ecs_service_name" { - description = "Name of the ECS Express Gateway Service" - value = aws_ecs_express_gateway_service.main.service_name + description = "Name of the ECS Service" + value = aws_ecs_service.main.name } output "ecs_cluster_name" { @@ -17,12 +17,14 @@ output "ecs_cluster_name" { value = aws_ecs_cluster.main.name } -output "ecs_ingress_endpoints" { - description = "Ingress endpoint URLs from the ECS Express Gateway Service" - value = { - for path in aws_ecs_express_gateway_service.main.ingress_paths : - path.access_type => path.endpoint - } +output "alb_dns_name" { + description = "DNS name of the Application Load Balancer" + value = aws_lb.main.dns_name +} + +output "service_url" { + description = "Service URL" + value = "https://${local.root_domain_name}" } ## diff --git a/infrastructure/terraform/components/cms/route53_record_acm_validation.tf b/infrastructure/terraform/components/cms/route53_record_acm_validation.tf new file mode 100644 index 00000000..e71bbcfa --- /dev/null +++ b/infrastructure/terraform/components/cms/route53_record_acm_validation.tf @@ -0,0 +1,15 @@ +resource "aws_route53_record" "acm_validation" { + for_each = { + for dvo in aws_acm_certificate.main.domain_validation_options : dvo.domain_name => { + name = dvo.resource_record_name + type = dvo.resource_record_type + record = dvo.resource_record_value + } + } + + zone_id = local.root_domain_id + name = each.value.name + type = each.value.type + ttl = 60 + records = [each.value.record] +} diff --git a/infrastructure/terraform/components/cms/route53_record.tf b/infrastructure/terraform/components/cms/route53_record_main.tf similarity index 52% rename from infrastructure/terraform/components/cms/route53_record.tf rename to infrastructure/terraform/components/cms/route53_record_main.tf index 801fa853..e5bba26a 100644 --- a/infrastructure/terraform/components/cms/route53_record.tf +++ b/infrastructure/terraform/components/cms/route53_record_main.tf @@ -3,7 +3,7 @@ resource "aws_route53_record" "main" { name = local.root_domain_name type = "CNAME" ttl = 300 - records = [aws_ecs_express_gateway_service.main.ingress_paths[0].endpoint] + records = [aws_lb.main.dns_name] - depends_on = [aws_ecs_express_gateway_service.main] + depends_on = [aws_lb.main] } diff --git a/infrastructure/terraform/components/cms/security_group_alb.tf b/infrastructure/terraform/components/cms/security_group_alb.tf new file mode 100644 index 00000000..aad0a36c --- /dev/null +++ b/infrastructure/terraform/components/cms/security_group_alb.tf @@ -0,0 +1,46 @@ +resource "aws_security_group" "alb" { + name_prefix = "${local.csi}-alb-" + description = "Security group for the Wagtail CMS ALB" + vpc_id = local.vpc_id + + tags = merge( + local.default_tags, + { + Name = "${local.csi}-alb" + } + ) + + lifecycle { + create_before_destroy = true + } +} + +resource "aws_security_group_rule" "alb_ingress_https" { + security_group_id = aws_security_group.alb.id + type = "ingress" + description = "Allow HTTPS from internet" + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] +} + +resource "aws_security_group_rule" "alb_ingress_http" { + security_group_id = aws_security_group.alb.id + type = "ingress" + description = "Allow HTTP from internet (redirected to HTTPS by listener)" + from_port = 80 + to_port = 80 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] +} + +resource "aws_security_group_rule" "alb_egress_ecs" { + security_group_id = aws_security_group.alb.id + type = "egress" + description = "Allow outbound to ECS tasks on container port" + from_port = 8080 + to_port = 8080 + protocol = "tcp" + source_security_group_id = aws_security_group.ecs_tasks.id +} diff --git a/infrastructure/terraform/components/cms/security_group_ecs_tasks.tf b/infrastructure/terraform/components/cms/security_group_ecs_tasks.tf index 4aabf206..3f9bfc25 100644 --- a/infrastructure/terraform/components/cms/security_group_ecs_tasks.tf +++ b/infrastructure/terraform/components/cms/security_group_ecs_tasks.tf @@ -3,14 +3,6 @@ resource "aws_security_group" "ecs_tasks" { description = "Security group for Wagtail CMS ECS tasks" vpc_id = local.vpc_id - egress { - from_port = 0 - to_port = 0 - protocol = "-1" - cidr_blocks = ["0.0.0.0/0"] - description = "Allow all outbound traffic" - } - tags = merge( local.default_tags, { @@ -22,3 +14,23 @@ resource "aws_security_group" "ecs_tasks" { create_before_destroy = true } } + +resource "aws_security_group_rule" "ecs_tasks_ingress_alb" { + security_group_id = aws_security_group.ecs_tasks.id + type = "ingress" + description = "Allow inbound traffic from ALB on container port" + from_port = 8080 + to_port = 8080 + protocol = "tcp" + source_security_group_id = aws_security_group.alb.id +} + +resource "aws_security_group_rule" "ecs_tasks_egress_all" { + security_group_id = aws_security_group.ecs_tasks.id + type = "egress" + description = "Allow all outbound traffic" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] +} diff --git a/infrastructure/terraform/components/cms/ssm_parameter_django_secret_key.tf b/infrastructure/terraform/components/cms/ssm_parameter_django_secret_key.tf index c631c6a1..e085173c 100644 --- a/infrastructure/terraform/components/cms/ssm_parameter_django_secret_key.tf +++ b/infrastructure/terraform/components/cms/ssm_parameter_django_secret_key.tf @@ -1,6 +1,6 @@ resource "random_password" "django_secret_key" { - length = 50 - special = true + length = 50 + special = true # Django secret key has stricter requirements override_special = "!@#$%^&*()-_=+[]{}|;:,.<>?" From 8dbb996d1e9fddb334eaeef0c4291e4d9d9dc7fe Mon Sep 17 00:00:00 2001 From: Aiden Vaines <54067008+aidenvaines-cgi@users.noreply.github.com> Date: Thu, 16 Apr 2026 12:03:23 +0000 Subject: [PATCH 06/10] CCM-16446 Adding Jekyll-Admin for Dev --- .tool-versions | 18 +++++-- docs/Gemfile | 2 + docs/Gemfile.lock | 33 ++++++++++++ docs/Makefile | 2 +- docs/assets/js/nhs-frontend-js-check.js | 1 - docs/assets/js/nhsuk-frontend.min.js | 2 +- docs/assets/js/nhsuk.min.js | 1 - .../terraform/components/cms/README.md | 43 ++++++++++----- scripts/config/check-todos-ignore.conf | 21 ++++++++ scripts/config/pre-commit.yaml | 6 ++- scripts/config/terraform-docs.yml | 53 +++++++++++++++++++ 11 files changed, 161 insertions(+), 21 deletions(-) delete mode 100644 docs/assets/js/nhs-frontend-js-check.js delete mode 100644 docs/assets/js/nhsuk.min.js create mode 100644 scripts/config/check-todos-ignore.conf create mode 100644 scripts/config/terraform-docs.yml diff --git a/.tool-versions b/.tool-versions index 09a61cc3..d3a08651 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,5 +1,5 @@ gitleaks 8.18.4 -nodejs 18.18.2 +nodejs 22.11.0 jq 1.8.1 pre-commit 3.6.0 terraform 1.14.8 @@ -7,20 +7,32 @@ terraform-docs 0.19.0 vale 3.6.0 python 3.13.2 - # ============================================================================== + # The section below is reserved for Docker image versions. # TODO: Move this section - consider using a different file for the repository template dependencies. -# docker/ghcr.io/anchore/grype v0.104.3@sha256:d340f4f8b3b7e6e72a6c9c0152f25402ed8a2d7375dba1dfce4e53115242feb6 # SEE: https://github.com/anchore/grype/pkgs/container/grype + +# docker/ghcr.io/anchore/grype v0.104.3@sha256:d340f4f8b3b7e6e72a6c9c0152f25402ed8a2d7375dba1dfce4e53115242feb6 # SEE: https://github.com/anchore/grype/pkgs/container/grype + # docker/ghcr.io/anchore/syft v1.39.0@sha256:6f13bb010923c33fb197047c8f88888e77071bd32596b3f605d62a133e493ce4 # SEE: https://github.com/anchore/syft/pkgs/container/syft + # docker/ghcr.io/gitleaks/gitleaks v8.24.0@sha256:2bcceac45179b3a91bff11a824d0fb952585b429e54fc928728b1d4d5c3e5176 # SEE: https://github.com/gitleaks/gitleaks/pkgs/container/gitleaks + # docker/ghcr.io/igorshubovych/markdownlint-cli v0.37.0@sha256:fb3e79946fce78e1cde84d6798c6c2a55f2de11fc16606a40d49411e281d950d # SEE: https://github.com/igorshubovych/markdownlint-cli/pkgs/container/markdownlint-cli + # docker/ghcr.io/make-ops-tools/gocloc latest@sha256:6888e62e9ae693c4ebcfed9f1d86c70fd083868acb8815fe44b561b9a73b5032 # SEE: https://github.com/make-ops-tools/gocloc/pkgs/container/gocloc + # docker/ghcr.io/nhs-england-tools/github-runner-image 20230909-321fd1e-rt@sha256:ce4fd6035dc450a50d3cbafb4986d60e77cb49a71ab60a053bb1b9518139a646 # SEE: https://github.com/nhs-england-tools/github-runner-image/pkgs/container/github-runner-image + # docker/hadolint/hadolint 2.12.0-alpine@sha256:7dba9a9f1a0350f6d021fb2f6f88900998a4fb0aaf8e4330aa8c38544f04db42 # SEE: https://hub.docker.com/r/hadolint/hadolint/tags + # docker/hashicorp/terraform 1.5.6@sha256:180a7efa983386a27b43657ed610e9deed9e6c3848d54f9ea9b6cb8a5c8c25f5 # SEE: https://hub.docker.com/r/hashicorp/terraform/tags + # docker/jdkato/vale v3.6.0@sha256:0ef22c8d537f079633cfff69fc46f69a2196072f69cab1ab232e8a79a388e425 # SEE: https://hub.docker.com/r/jdkato/vale/tags + # docker/koalaman/shellcheck latest@sha256:e40388688bae0fcffdddb7e4dea49b900c18933b452add0930654b2dea3e7d5c # SEE: https://hub.docker.com/r/koalaman/shellcheck/tags + # docker/mstruebing/editorconfig-checker 2.7.1@sha256:dd3ca9ea50ef4518efe9be018d669ef9cf937f6bb5cfe2ef84ff2a620b5ddc24 # SEE: https://hub.docker.com/r/mstruebing/editorconfig-checker/tags + # docker/sonarsource/sonar-scanner-cli 11.3@sha256:7462f132388135e32b948f8f18ff0db9ae28a87c6777f1df5b2207e04a6d7c5c # SEE: https://hub.docker.com/r/sonarsource/sonar-scanner-cli/tags diff --git a/docs/Gemfile b/docs/Gemfile index 4a9a2b20..03ece28d 100644 --- a/docs/Gemfile +++ b/docs/Gemfile @@ -19,6 +19,8 @@ group :jekyll_plugins do gem "jekyll-feed", "~> 0.12" gem "jekyll-drawio" gem "jekyll-redirect-from" + # Only enable the admin UI for local development serve. + gem "jekyll-admin" unless ENV["JEKYLL_ENV"] == "production" end # Windows and JRuby does not include zoneinfo files, so bundle the tzinfo-data gem diff --git a/docs/Gemfile.lock b/docs/Gemfile.lock index 5ca7270b..297f4e66 100644 --- a/docs/Gemfile.lock +++ b/docs/Gemfile.lock @@ -3,6 +3,7 @@ GEM specs: addressable (2.8.6) public_suffix (>= 2.0.2, < 6.0) + base64 (0.3.0) cgi (0.4.1) colorator (1.1.0) concurrent-ruby (1.2.3) @@ -36,6 +37,11 @@ GEM safe_yaml (~> 1.0) terminal-table (>= 1.8, < 4.0) webrick (~> 1.7) + jekyll-admin (0.12.0) + jekyll (>= 3.7, < 5.0) + rackup (~> 2.0) + sinatra (~> 4.0) + sinatra-contrib (~> 4.0) jekyll-content-security-policy-generator (1.6.15) digest jekyll @@ -78,6 +84,8 @@ GEM jekyll (>= 3.5, < 5.0) jekyll-feed (~> 0.9) jekyll-seo-tag (~> 2.1) + multi_json (1.20.1) + mustermann (3.1.1) nokogiri (1.19.1-arm64-darwin) racc (~> 1.4) nokogiri (1.19.1-x86_64-linux-gnu) @@ -87,6 +95,16 @@ GEM prism (0.30.0) public_suffix (5.0.5) racc (1.8.1) + rack (3.2.6) + rack-protection (4.2.1) + base64 (>= 0.1.0) + logger (>= 1.6.0) + rack (>= 3.0.0, < 4) + rack-session (2.1.2) + base64 (>= 0.1.0) + rack (>= 3.0.0) + rackup (2.3.1) + rack (>= 3) rake (13.2.1) rb-fsevent (0.11.2) rb-inotify (0.11.1) @@ -106,10 +124,24 @@ GEM google-protobuf (~> 3.23) sass-embedded (1.69.5-x86_64-linux-gnu) google-protobuf (~> 3.23) + sinatra (4.2.1) + logger (>= 1.6.0) + mustermann (~> 3.0) + rack (>= 3.0.0, < 4) + rack-protection (= 4.2.1) + rack-session (>= 2.0.0, < 3) + tilt (~> 2.0) + sinatra-contrib (4.2.1) + multi_json (>= 0.0.2) + mustermann (~> 3.0) + rack-protection (= 4.2.1) + sinatra (= 4.2.1) + tilt (~> 2.0) sorbet-runtime (0.5.11466) strscan (3.1.0) terminal-table (3.0.2) unicode-display_width (>= 1.1.1, < 3) + tilt (2.7.0) unicode-display_width (2.5.0) webrick (1.8.1) @@ -120,6 +152,7 @@ PLATFORMS DEPENDENCIES http_parser.rb (~> 0.6.0) jekyll (~> 4.3.3) + jekyll-admin jekyll-content-security-policy-generator jekyll-drawio jekyll-feed (~> 0.12) diff --git a/docs/Makefile b/docs/Makefile index 5c7680c9..3c77598e 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -8,7 +8,7 @@ h help: @egrep '^\S|^$$' Makefile include-npm-deps: - @cp node_modules/nhsuk-frontend/dist/nhsuk.min.js assets/js/nhsuk.min.js + @cp node_modules/nhsuk-frontend/dist/nhsuk/nhsuk-frontend.min.js assets/js/nhsuk-frontend.min.js config: install diff --git a/docs/assets/js/nhs-frontend-js-check.js b/docs/assets/js/nhs-frontend-js-check.js deleted file mode 100644 index 572d0dbe..00000000 --- a/docs/assets/js/nhs-frontend-js-check.js +++ /dev/null @@ -1 +0,0 @@ -document.body.className = ((document.body.className) ? document.body.className + " js-enabled" : "js-enabled"); diff --git a/docs/assets/js/nhsuk-frontend.min.js b/docs/assets/js/nhsuk-frontend.min.js index 8783c15b..a5e0e319 100644 --- a/docs/assets/js/nhsuk-frontend.min.js +++ b/docs/assets/js/nhsuk-frontend.min.js @@ -1 +1 @@ -const version="10.0.0";function toggleConditionalInput(e,t){if(!(e&&e instanceof HTMLInputElement&&t))return;const n=e.getAttribute("aria-controls");if(!n)return;const i=document.getElementById(n);i&&(e.setAttribute("aria-expanded",e.checked.toString()),i.classList.toggle(t,!e.checked))}function setFocus(e,t={}){const n=e.getAttribute("tabindex");function onBlur(){e.removeEventListener("blur",onBlur),t.onBlur&&t.onBlur.call(e),n||e.removeAttribute("tabindex")}n||e.setAttribute("tabindex","-1"),e.addEventListener("focus",(function onFocus(){e.removeEventListener("focus",onFocus),e.addEventListener("blur",onBlur)})),t.onBeforeFocus&&t.onBeforeFocus.call(e),e.focus()}function isSupported(e=document.body){return!!e&&e.classList.contains("nhsuk-frontend-supported")}function isObject(e){return!!e&&"object"==typeof e&&!Array.isArray(e)}function isScope(e){return!!e&&(e instanceof Element||e instanceof Document)}function formatErrorMessage(Component,e){return`${Component.moduleName}: ${e}`}function normaliseString(e,t){const n=e?e.trim():"";let i,s=null==t?void 0:t.type;switch(s||(["true","false"].includes(n)&&(s="boolean"),n.length>0&&isFinite(Number(n))&&(s="number")),s){case"boolean":i="true"===n;break;case"number":i=Number(n);break;default:i=e}return i}function extractConfigByNamespace(e,t,n){const i=e.properties[n];if("object"!==(null==i?void 0:i.type))return;const s={[n]:{}};for(const[o,r]of Object.entries(t)){let e=s;const t=o.split(".");for(const[i,s]of t.entries())isObject(e)&&(i` from template ` + + + {% block extra_js %}{% endblock %} + + diff --git a/containers/wagtail/home/templates/home/content_page.html b/containers/wagtail/home/templates/home/content_page.html new file mode 100644 index 00000000..1ea119cf --- /dev/null +++ b/containers/wagtail/home/templates/home/content_page.html @@ -0,0 +1,20 @@ +{% extends "base.html" %} +{% load wagtailmarkdown wagtailcore_tags %} + +{% block content %} +
+
+

{{ page.title }}

+ + {% if page.introduction %} +
{{ page.introduction|markdown }}
+ {% endif %} + + {% if page.body %} +
+ {{ page.body|richtext }} +
+ {% endif %} +
+
+{% endblock %} diff --git a/containers/wagtail/home/templates/home/home_page.html b/containers/wagtail/home/templates/home/home_page.html new file mode 100644 index 00000000..6b55dde3 --- /dev/null +++ b/containers/wagtail/home/templates/home/home_page.html @@ -0,0 +1,107 @@ +{% extends "base.html" %} +{% load wagtailcore_tags %} + +{% block content %} + +
+
+
+
+
+

{{ page.hero_heading }}

+

{{ page.hero_description }}

+
+
+
+
+
+ + + {% if page.benefits %} +
+

Benefits

+
+ {% for block in page.benefits %} +
+
+
+

{{ block.value.heading }}

+

{{ block.value.description }}

+
+
+
+ {% endfor %} +
+
+ {% endif %} + + + {% if page.how_it_works %} +
+

How it works

+
    + {% for block in page.how_it_works %} +
  1. +

    {{ block.value.heading }}

    +

    {{ block.value.description|linebreaks }}

    +
  2. + {% endfor %} +
+
+ {% endif %} + + + {% if page.pricing %} +
+

Pricing

+
+ {% for block in page.pricing %} +
+
+
+

{{ block.value.heading }}

+

{{ block.value.description }}

+

{{ block.value.pricing }}

+
+
+
+ {% endfor %} +
+
+ {% endif %} + + + {% if page.cta_heading %} +
+ +

{{ page.cta_heading }}

+

{{ page.cta_description }}

+
+ {% endif %} + + +
+

Explore NHS Notify

+
    + {% for child in page.get_children.live.in_menu %} +
  • +
    + +
    +
  • + {% endfor %} +
+
+{% endblock %} diff --git a/containers/wagtail/home/templates/home/section_index_page.html b/containers/wagtail/home/templates/home/section_index_page.html new file mode 100644 index 00000000..00e76c76 --- /dev/null +++ b/containers/wagtail/home/templates/home/section_index_page.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} +{% load wagtailmarkdown wagtailcore_tags %} + +{% block content %} +
+
+

{{ page.title }}

+ + {% if page.introduction %} +
{{ page.introduction|markdown }}
+ {% endif %} + + {% if page.body %} +
+ {{ page.body|markdown }} +
+ {% endif %} + + + {% with children=page.get_children.live %} + {% if children %} +

In this section

+ + {% endif %} + {% endwith %} +
+
+{% endblock %} diff --git a/containers/wagtail/manage.py b/containers/wagtail/manage.py new file mode 100644 index 00000000..733b38f5 --- /dev/null +++ b/containers/wagtail/manage.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python +import os +import sys + +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "cms.settings.dev") + + from django.core.management import execute_from_command_line + + execute_from_command_line(sys.argv) diff --git a/containers/wagtail/package.json b/containers/wagtail/package.json index 36ef2b66..55a979e1 100644 --- a/containers/wagtail/package.json +++ b/containers/wagtail/package.json @@ -1,9 +1,12 @@ { - "name": "nhs-notify-web-cms-wagtail", - "version": "0.0.1", + "dependencies": { + "nhsuk-frontend": "^10.1.0" + }, + "description": "NHS Notify Wagtail CMS frontend dependencies", + "name": "nhs-notify-cms-wagtail", "private": true, - "description": "Wagtail CMS for NHS Notify", "scripts": { - "build:container": "cd ../.. && make docker-build-and-push base_image=python:3.12-slim dir=containers/wagtail" - } + "build:static": "mkdir -p static/dist && cp -r node_modules/nhsuk-frontend/dist/nhsuk/* static/dist/" + }, + "version": "1.0.0" } diff --git a/containers/wagtail/requirements.txt b/containers/wagtail/requirements.txt index c41747c6..717bc825 100644 --- a/containers/wagtail/requirements.txt +++ b/containers/wagtail/requirements.txt @@ -1,3 +1,8 @@ -# Minimal Flask app for testing -Flask>=3.0,<4.0 -gunicorn>=22.0,<23.0 +# Django and Wagtail CMS +django>=4.2,<5.2 +wagtail>=7.3,<7.4 +wagtail-markdown>=0.10 +dj-database-url +psycopg[binary] +whitenoise +gunicorn==23.0.0 diff --git a/docs/adr/ADR-001_Using_NPM_for_nhsuk_styling.md b/docs/adr/ADR-001_Using_NPM_for_nhsuk_styling.md deleted file mode 100644 index 90842487..00000000 --- a/docs/adr/ADR-001_Using_NPM_for_nhsuk_styling.md +++ /dev/null @@ -1,78 +0,0 @@ -# **ADR**-001: Using npm for nhsuk styling - ->| | | ->| ------------ | --------------------------------- | ->| Date | `12/06/2024` | ->| Status | `Accepted` | ->| Deciders | `Engineering` | ->| Significance | `Dependencies` | ->| Owners | `bhansell1 (BEHA6)` | - ---- - -- [**ADR**-001: Using npm for nhsuk styling](#adr-001-using-npm-for-nhsuk-styling) - - [Context](#context) - - [Decision](#decision) - - [Assumptions](#assumptions) - - [Drivers](#drivers) - - [Options](#options) - - [Outcome](#outcome) - - [Rationale](#rationale) - - [Consequences](#consequences) - - [Actions](#actions) - - [Tags](#tags) - -## Context - -The project requires an NHS theme. The nhsuk-frontend-prototyping kit offers multiple ways on acquiring the styles and Javascript; - -- Using `npm` to install `nhsuk-frontend` package -- Downloading the css and Javascript - -## Decision - -Use `npm` to install `nhsuk-frontend` package. - -In `/docs/assets/css/main.scss` we import `nhsuk.scss` from `node_modules/nhsuk-frontend`. - -We copy out the required Javascript from `node_modules/nhsuk-frontend/dist/nhsuk.min.js` and put this file in `/assets/js/nhsuk.min.js`. This happens when `npm install` is run. - -### Assumptions - -Using `npm` should make upgrading to newer versions simpler. - -### Drivers - -- Easier upgrades -- Faster upgrades -- Non technical people upgrading nhsuk styling - -### Options - -- Using `npm` to install `nhsuk-frontend` package -- Downloading the css and Javascript from the prototyping kit - -### Outcome - -- Decided to use `npm` to install `nhsuk-frontend` package. - -Is it a reversible decision. - -### Rationale - -Makes upgrading easier - -## Consequences - -We have to copy out the `nhsuk.min.js` file into our `assets/js` folder and have the file checked in. - -## Actions - -- [x] bhansell1, 14/06/2024, use npm - -## Tags - -`#reliability` -`#usability` -`#maintainability` -`#simplicity` diff --git a/docs/adr/ADR-nnn_Any_Decision_Record.md b/docs/adr/ADR-nnn_Any_Decision_Record.md deleted file mode 100644 index dcca708b..00000000 --- a/docs/adr/ADR-nnn_Any_Decision_Record.md +++ /dev/null @@ -1,78 +0,0 @@ -# ADR-nnn: Any Decision Record Template - ->| | | ->| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ->| Date | `dd/mm/YYYY` _when the decision was last updated_ | ->| Status | `RFC by dd/mm/YYYY, Proposed, In Discussion, Pending Approval, Withdrawn, Rejected, Accepted, Deprecated, ..., Superseded by ADR-XXX or Supersedes ADR-XXX` | ->| Deciders | `Tech Radar, Engineering, Architecture, Solution Assurance, Clinical Assurance, Technical Review and Governance, Information Governance, Cyber Security, Live Services Board,` ... | ->| Significance | `Structure, Nonfunctional characteristics, Dependencies, Interfaces, Construction techniques,` ... | ->| Owners | | - ---- - -- [ADR-nnn: Any Decision Record Template](#adr-nnn-any-decision-record-template) - - [Context](#context) - - [Decision](#decision) - - [Assumptions](#assumptions) - - [Drivers](#drivers) - - [Options](#options) - - [Outcome](#outcome) - - [Rationale](#rationale) - - [Consequences](#consequences) - - [Compliance](#compliance) - - [Notes](#notes) - - [Actions](#actions) - - [Tags](#tags) - -## Context - -Describe the context and the problem statement. Is there a relationship to other decisions previously made? Are there any dependencies and/or constraints within which the decision will be made? Do these need to be reviewed or validated? Please note that environmental limitations or restrictions such as accepted technology standards, commonly recognised and used patterns, engineering and architecture principles, organisation policies, governance and so on, may as an effect narrow down the choices. This should also be explicitly documented, as this is a point-in-time decision with the intention of being able to articulate it clearly and justify it later. - -## Decision - -### Assumptions - -Summarise the underlying assumptions in the environment in which you make the decision. This could be related to technology changes, forecast of the monetary and non-monetary costs, further delivery commitments, impact from external drivers etc., and any known unknowns that translate to risks. - -### Drivers - -List the decision drivers that motivate this change or course of action. This may include any identified risks and residual risks after applying the decision. - -### Options - -Consider a comprehensive set of alternative options; provide weighting if applicable. - -### Outcome - -State the decision outcome as a result of taking into account all of the above. Is it a reversible or irreversible decision? - -### Rationale - -Provide a rationale for the decision that is based on weighing the options to ensure that the same questions are not going to be asked again and again unless the decision needs to be superseded. - -For non-trivial decisions a comparison table can be useful for the reviewer. Decision criteria down one side, options across the top. You'll likely find decision criteria come from the Drivers section above. Effort can be an important driving factor. You may have an intuitive feel for this, but reviewers will not. T-shirt sizing the effort for each option may help communicate. - -## Consequences - -Describe the resulting context, after applying the decision. All the identified consequences should be listed here, not just the positive ones. Any decision comes with many implications. For example, it may introduce a need to make other decisions as an effect of cross-cutting concerns; it may impact structural or operational characteristics of the software, and influence non-functional requirements; as a result, some things may become easier or more difficult to do because of this change. What are the trade-offs? - -What are the conditions under which this decision no longer applies or becomes irrelevant? - -## Compliance - -Establish how the success is going to be measured. Once implemented, the effect might lend itself to be measured, therefore if appropriate a set of criteria for success could be established. Compliance checks of the decision can be manual or automated using a fitness function. If it is the latter this section can then specify how that fitness function would be implemented and whether there are any other changes to the codebase needed to measure this decision for compliance. - -## Notes - -Include any links to existing epics, decisions, dependencies, risks, and policies related to this decision record. This section could also include any further links to configuration items within the project or the codebase, signposting to the areas of change. - -It is important that if the decision is sub-optimal or the choice is tactical or misaligned with the strategic directions the risk related to it is identified and clearly articulated. As a result of that, the expectation is that a [Tech Debt](./tech-debt.md) record is going to be created on the backlog. - -## Actions - -- [x] name, date by, action -- [ ] name, date by, action - -## Tags - -`#availability|#scalability|#elasticity|#performance|#reliability|#resilience|#maintainability|#testability|#deployability|#modularity|#simplicity|#security|#data|#cost|#usability|#accessibility|…` these tags are intended to be operational, structural or cross-cutting architecture characteristics to link to related decisions. diff --git a/docs/assets/images/icon-chevron-left__back_link.svg b/docs/assets/images/icon-chevron-left__back_link.svg index df4c127b..8ac4e0e1 100644 --- a/docs/assets/images/icon-chevron-left__back_link.svg +++ b/docs/assets/images/icon-chevron-left__back_link.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/docs/assets/images/icon-chevron-right__breadcrumb.svg b/docs/assets/images/icon-chevron-right__breadcrumb.svg index 7a659856..317985f0 100644 --- a/docs/assets/images/icon-chevron-right__breadcrumb.svg +++ b/docs/assets/images/icon-chevron-right__breadcrumb.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/package-lock.json b/package-lock.json index b3acdf93..b4245602 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,12 +10,48 @@ ] }, "containers/wagtail": { - "name": "nhs-notify-web-cms-wagtail", - "version": "0.0.1" + "name": "nhs-notify-cms-wagtail", + "version": "1.0.0", + "dependencies": { + "nhsuk-frontend": "^10.1.0" + } }, - "node_modules/nhs-notify-web-cms-wagtail": { + "node_modules/nhs-notify-cms-wagtail": { "resolved": "containers/wagtail", "link": true + }, + "node_modules/nhsuk-frontend": { + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/nhsuk-frontend/-/nhsuk-frontend-10.4.2.tgz", + "integrity": "sha512-DYa7E/jwWtQPKqzeF9eB9nVcTKHpjMYf+SydKao379qQapIkblfS2BNvKsVKuWpI0w+QgI8XSDNNOUTQEGRb1w==", + "license": "MIT", + "engines": { + "node": "^20.9.0 || ^22.11.0 || >= 24.11.0" + }, + "peerDependencies": { + "@prettier/sync": "^0.6.0", + "highlight.js": "^11.0.0", + "nunjucks": "^3.0.0", + "outdent": "^0.8.0", + "slug": "^9.0.0 || ^11.0.0" + }, + "peerDependenciesMeta": { + "@prettier/sync": { + "optional": true + }, + "highlight.js": { + "optional": true + }, + "nunjucks": { + "optional": true + }, + "outdent": { + "optional": true + }, + "slug": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 9aede47f..5aca10bb 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "nhs-notify-web-cms", "scripts": { - "build:container": "npm run build:container --workspaces --if-present", "build:archive": "npm run build:archive --workspaces --if-present", + "build:container": "npm run build:container --workspaces --if-present", "generate-dependencies": "npm run generate-dependencies --workspaces --if-present", "lint": "npm run lint --workspaces", "lint:fix": "npm run lint:fix --workspaces", diff --git a/scripts/config/vale/styles/config/vocabularies/words/accept.txt b/scripts/config/vale/styles/config/vocabularies/words/accept.txt index 353872c5..d8ba9ffa 100644 --- a/scripts/config/vale/styles/config/vocabularies/words/accept.txt +++ b/scripts/config/vale/styles/config/vocabularies/words/accept.txt @@ -1,6 +1,5 @@ : APIM -[A-Z]+s Bitwarden bot bundler From e802b043cbe59e5f8a834704975b9bf5be360d78 Mon Sep 17 00:00:00 2001 From: aidenvaines-cgi Date: Thu, 16 Apr 2026 14:39:38 +0100 Subject: [PATCH 08/10] CCM-16446 Adding base wagtail config --- containers/wagtail/README.md | 89 ++++++++----------- containers/wagtail/cms/settings/production.py | 3 + containers/wagtail/package.json | 1 + .../components/cms/ecs_service_main.tf | 2 +- .../cms/ecs_task_definition_main.tf | 8 +- .../terraform/components/cms/variables.tf | 4 +- .../config/vocabularies/words/accept.txt | 16 +++- 7 files changed, 63 insertions(+), 60 deletions(-) diff --git a/containers/wagtail/README.md b/containers/wagtail/README.md index cc714be0..fcbd9573 100644 --- a/containers/wagtail/README.md +++ b/containers/wagtail/README.md @@ -18,11 +18,13 @@ docker-compose exec wagtail python manage.py createsuperuser ``` To stop: + ```bash docker-compose down ``` To reset database (removes all data): + ```bash docker-compose down -v ``` @@ -30,27 +32,31 @@ docker-compose down -v ## Features - **Wagtail 7.3+**: Latest stable Wagtail CMS -- **Django 4.2+**: Modern Django framework +- **Django 5.1**: Modern Django framework - **Markdown support**: Easy content editing with wagtail-markdown -- **NHS.UK Frontend**: Official NHS design system (via CDN) +- **NHS.UK Frontend 10.1.0**: Official NHS design system - **Custom NHS Notify branding**: Matching the Jekyll site styling - **Single command startup**: `docker-compose up --build` +- **PostgreSQL 16**: Production database backend +- **Gunicorn**: Production WSGI server +- **Health checks**: `/health/` endpoint for monitoring +- **Whitenoise**: Efficient static file serving ## Styling and Templates The CMS uses the official NHS.UK Frontend design system with custom NHS Notify branding: - **Base template**: [home/templates/base.html](home/templates/base.html) -- **Custom CSS**: [static/css/nhsnotify.css](static/css/nhsnotify.css) -- **NHS.UK Frontend**: Loaded from CDN (v10.1.0) -- **Fonts**: Self-hosted Frutiger fonts in [static/fonts/](static/fonts/) -- **Favicons**: NHS-branded icons in [static/favicons/](static/favicons/) +- **NHS.UK Frontend**: Built via NPM during Docker build (v10.1.0) +- **Static assets**: Generated in `static/dist/` from `node_modules` Templates include: -- Side navigation for section pages -- Breadcrumb navigation + +- NHS header with navigation +- NEWS banner +- NHS footer with links +- Responsive NHS.UK grid layout - Child page listings for section index pages -- Responsive layout matching the Jekyll site - **PostgreSQL**: Production database backend - **Gunicorn**: Production WSGI server - **Health checks**: `/health/` endpoint for ALB @@ -63,82 +69,63 @@ Templates include: - `manage.py` - Django management script - `docker/` - Docker configuration - `docker-compose.yml` - Local development environment -- `CONTENT_MIGRATION.md` - Guide for porting Jekyll content to Wagtail +- `package.json` - NPM dependencies for NHS.UK Frontend ## Page Types -The CMS includes three page types to mirror the Jekyll site structure: +The CMS includes three page types: 1. **HomePage** - Site root (only one allowed) - - Fields: introduction, body + - StreamField sections: hero, benefits, how_it_works, pricing, CTA + - Auto-created by migration -2. **SectionIndexPage** - Container pages that list child pages (e.g., "Get Started", "Pricing") - - Fields: introduction, body, show_children - - Automatically displays child pages if enabled +2. **SectionIndexPage** - Container pages that list child pages + - Fields: introduction (markdown), body (markdown) + - Automatically displays child pages + - Can be nested 3. **ContentPage** - Standard content pages - - Fields: introduction, body, show_in_navigation - - Can be nested under other pages + - Fields: introduction (markdown), body (markdown) + - Cannot have children ## Migrating Content from Jekyll The Jekyll site content is in the `docs/` directory. To migrate it to Wagtail: -1. Read [CONTENT_MIGRATION.md](CONTENT_MIGRATION.md) for detailed instructions -2. Start with key pages (Home, Get Started, Pricing) -3. Create pages through the Wagtail admin at http://localhost:8080/admin/ -4. Copy content from Jekyll markdown files, converting as needed +1. Create pages through the Wagtail admin at http://localhost:8080/admin/ +2. Start with key section pages (About, Get Started, Pricing, Using NHS Notify, Support) +3. Copy markdown content from `tmp/` folder files +4. Use the markdown editor to paste content directly For the PoC, manual migration through the admin is recommended. A bulk import tool can be added later. -## Features - -- **Wagtail 6.3**: Latest stable Wagtail CMS -- **Django 5.0**: Modern Django framework -- **PostgreSQL**: Production database backend -- **Redis**: Caching and session storage -- **S3 Storage**: Media files stored in AWS S3 -- **Gunicorn**: Production WSGI server -- **Health checks**: `/health/` endpoint for ALB -- **Prometheus metrics**: `/metrics` endpoint for monitoring -- **Security hardened**: CSP, HSTS, secure cookies - ## Environment Variables -The container expects these environment variables (provided by ECS): +The container expects these environment variables: ### Django Settings -- `DJANGO_SECRET_KEY`: Django secret key (from SSM Parameter Store) -- `DJANGO_SETTINGS_MODULE`: `config.settings.production` (default) -- `ALLOWED_HOSTS`: Comma-separated list of allowed hostnames + +- `DJANGO_SECRET_KEY`: Django secret key (generate with `python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"`) +- `DJANGO_SETTINGS_MODULE`: `cms.settings.production` (default) - `DEBUG`: Set to `false` in production ### Database -- `DATABASE_HOST`: RDS PostgreSQL endpoint + +- `DATABASE_HOST`: PostgreSQL endpoint - `DATABASE_PORT`: Database port (default: `5432`) - `DATABASE_NAME`: Database name (default: `wagtail`) - `DATABASE_USER`: Database username -- `DATABASE_PASSWORD`: Database password (from SSM Parameter Store) -- `DATABASE_SSLMODE`: SSL mode for database connection (default: `prefer`) - -### Redis Cache -- `REDIS_HOST`: ElastiCache Redis endpoint -- `REDIS_PORT`: Redis port (default: `6379`) -- `REDIS_AUTH_TOKEN`: Redis authentication token (from SSM Parameter Store) -- `REDIS_SSL`: Enable SSL for Redis (default: `true`) - -### S3 Storage -- `AWS_STORAGE_BUCKET_NAME`: S3 bucket for media files -- `AWS_S3_REGION_NAME`: AWS region (default: `eu-west-2`) +- `DATABASE_PASSWORD`: Database password ### Wagtail + - `WAGTAILADMIN_BASE_URL`: Full URL to the Wagtail admin (e.g., `https://cms.example.com`) ## Local Development ### Using Docker Compose (Recommended) -1. **Start all services** (PostgreSQL, Redis, Wagtail): +1. **Start all services** (PostgreSQL and Wagtail): ```bash docker-compose up -d ``` diff --git a/containers/wagtail/cms/settings/production.py b/containers/wagtail/cms/settings/production.py index debc42e7..6c10f775 100644 --- a/containers/wagtail/cms/settings/production.py +++ b/containers/wagtail/cms/settings/production.py @@ -20,5 +20,8 @@ # Force HTTPS redirect SECURE_SSL_REDIRECT = True +# Exempt health check from HTTPS redirect (ALB uses HTTP for health checks) +SECURE_REDIRECT_EXEMPT = [r"^health/$"] + SECURE_CONTENT_TYPE_NOSNIFF = True SECURE_REFERRER_POLICY = "no-referrer-when-downgrade" diff --git a/containers/wagtail/package.json b/containers/wagtail/package.json index 55a979e1..1dc7f132 100644 --- a/containers/wagtail/package.json +++ b/containers/wagtail/package.json @@ -6,6 +6,7 @@ "name": "nhs-notify-cms-wagtail", "private": true, "scripts": { + "build:container": "cd ../.. && make docker-build-and-push base_image=python:3.12-slim dir=containers/wagtail", "build:static": "mkdir -p static/dist && cp -r node_modules/nhsuk-frontend/dist/nhsuk/* static/dist/" }, "version": "1.0.0" diff --git a/infrastructure/terraform/components/cms/ecs_service_main.tf b/infrastructure/terraform/components/cms/ecs_service_main.tf index bb83e728..0433c9a1 100644 --- a/infrastructure/terraform/components/cms/ecs_service_main.tf +++ b/infrastructure/terraform/components/cms/ecs_service_main.tf @@ -1,5 +1,5 @@ resource "aws_ecs_service" "main" { - name = "${local.csi}-s" + name = "${local.csi}-a" cluster = aws_ecs_cluster.main.id task_definition = aws_ecs_task_definition.main.arn launch_type = "FARGATE" diff --git a/infrastructure/terraform/components/cms/ecs_task_definition_main.tf b/infrastructure/terraform/components/cms/ecs_task_definition_main.tf index 0f14c190..b80cf7a5 100644 --- a/infrastructure/terraform/components/cms/ecs_task_definition_main.tf +++ b/infrastructure/terraform/components/cms/ecs_task_definition_main.tf @@ -23,7 +23,7 @@ resource "aws_ecs_task_definition" "main" { environment = [ { name = "DJANGO_SETTINGS_MODULE" - value = "config.settings.production" + value = "cms.settings.production" }, { name = "DATABASE_HOST" @@ -63,7 +63,11 @@ resource "aws_ecs_task_definition" "main" { }, { name = "ALLOWED_HOSTS" - value = local.root_domain_name + value = "${local.root_domain_name},*" + }, + { + name = "CSRF_TRUSTED_ORIGINS" + value = "https://${local.root_domain_name}" } ] diff --git a/infrastructure/terraform/components/cms/variables.tf b/infrastructure/terraform/components/cms/variables.tf index f4cd82af..3750dc37 100644 --- a/infrastructure/terraform/components/cms/variables.tf +++ b/infrastructure/terraform/components/cms/variables.tf @@ -136,13 +136,13 @@ variable "cache_snapshot_retention_limit" { variable "ecs_task_cpu" { type = string description = "CPU units for ECS task (256, 512, 1024, 2048, 4096)" - default = "512" + default = "1024" } variable "ecs_task_memory" { type = string description = "Memory for ECS task in MiB (512, 1024, 2048, 3072, 4096, 5120, 6144, 7168, 8192)" - default = "1024" + default = "2048" } variable "ecs_min_capacity" { diff --git a/scripts/config/vale/styles/config/vocabularies/words/accept.txt b/scripts/config/vale/styles/config/vocabularies/words/accept.txt index d8ba9ffa..995aa05d 100644 --- a/scripts/config/vale/styles/config/vocabularies/words/accept.txt +++ b/scripts/config/vale/styles/config/vocabularies/words/accept.txt @@ -1,27 +1,33 @@ : +[cC]yber +[iI]nset +[Nn][Pp][Mm] +[Uu][Rr][Ll] APIM Bitwarden bot bundler Burkina -[cC]yber clientRef Cohorting ctrl Dependabot endfor +Favicons +Frutiger fullName Futuna Gitleaks Grype +Gunicorn +hostnames +how_it_works idempotence -[iI]nset Maarten Marino namePrefix Notify's Noto -npm OAuth Octokit onboarding @@ -36,6 +42,8 @@ Rica rollout Sao SCAL +show_children +show_in_navigation Sint src Syft @@ -47,6 +55,6 @@ toolchain Trufflehog unnotified urlset -[Uu][Rr][Ll] validation_failed Wayfinder +Whitenoise From 04cc7a14161d53a2de7defb505c056940d137cbc Mon Sep 17 00:00:00 2001 From: aidenvaines-cgi Date: Thu, 16 Apr 2026 15:38:47 +0100 Subject: [PATCH 09/10] CCM-16446 Adding base wagtail config --- containers/wagtail/cms/settings/base.py | 10 +++++++++ containers/wagtail/cms/settings/production.py | 13 ++++++++---- .../wagtail/home/migrations/0001_initial.py | 4 ++++ .../components/cms/ecs_service_main.tf | 2 ++ .../cms/ecs_task_definition_main.tf | 10 ++++++++- .../components/cms/iam_role_ecs_task.tf | 21 +++++++++++++++++++ .../terraform/components/cms/lb_main.tf | 6 +++--- .../components/cms/lb_target_group_main.tf | 8 ++++++- 8 files changed, 65 insertions(+), 9 deletions(-) diff --git a/containers/wagtail/cms/settings/base.py b/containers/wagtail/cms/settings/base.py index fc35f153..0e1f0080 100644 --- a/containers/wagtail/cms/settings/base.py +++ b/containers/wagtail/cms/settings/base.py @@ -89,6 +89,16 @@ # Database # https://docs.djangoproject.com/en/4.2/ref/settings/#databases +# Construct DATABASE_URL from individual components if not provided +# This allows password rotation via SSM parameter +if "DATABASE_URL" not in os.environ and all( + key in os.environ for key in ["DATABASE_HOST", "DATABASE_PORT", "DATABASE_NAME", "DATABASE_USER", "DATABASE_PASSWORD"] +): + os.environ["DATABASE_URL"] = ( + f"postgres://{os.environ['DATABASE_USER']}:{os.environ['DATABASE_PASSWORD']}" + f"@{os.environ['DATABASE_HOST']}:{os.environ['DATABASE_PORT']}/{os.environ['DATABASE_NAME']}" + ) + DATABASES = { "default": dj_database_url.config( default="sqlite:///" + os.path.join(BASE_DIR, "db.sqlite3"), diff --git a/containers/wagtail/cms/settings/production.py b/containers/wagtail/cms/settings/production.py index 6c10f775..65690fa6 100644 --- a/containers/wagtail/cms/settings/production.py +++ b/containers/wagtail/cms/settings/production.py @@ -8,20 +8,25 @@ # Ensure that the session cookie is only sent by browsers under an HTTPS connection. # https://docs.djangoproject.com/en/stable/ref/settings/#session-cookie-secure SESSION_COOKIE_SECURE = True +SESSION_COOKIE_HTTPONLY = True +SESSION_COOKIE_SAMESITE = 'Lax' # Ensure that the CSRF cookie is only sent by browsers under an HTTPS connection. # https://docs.djangoproject.com/en/stable/ref/settings/#csrf-cookie-secure CSRF_COOKIE_SECURE = True +CSRF_COOKIE_HTTPONLY = False # Allow JavaScript to read for AJAX requests +CSRF_COOKIE_SAMESITE = 'Lax' # Allow the redirect importer to work in load-balanced / cloud environments. # https://docs.wagtail.io/en/stable/reference/settings.html#redirects WAGTAIL_REDIRECTS_FILE_STORAGE = "cache" -# Force HTTPS redirect -SECURE_SSL_REDIRECT = True +# Trust X-Forwarded-Proto header from ALB (ALB terminates SSL) +# https://docs.djangoproject.com/en/stable/ref/settings/#secure-proxy-ssl-header +SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') -# Exempt health check from HTTPS redirect (ALB uses HTTP for health checks) -SECURE_REDIRECT_EXEMPT = [r"^health/$"] +# ALB handles HTTP→HTTPS redirect, so disable Django's redirect +# SECURE_SSL_REDIRECT = False # Explicitly disabled - ALB handles this SECURE_CONTENT_TYPE_NOSNIFF = True SECURE_REFERRER_POLICY = "no-referrer-when-downgrade" diff --git a/containers/wagtail/home/migrations/0001_initial.py b/containers/wagtail/home/migrations/0001_initial.py index 49619f07..a8e6c0c2 100644 --- a/containers/wagtail/home/migrations/0001_initial.py +++ b/containers/wagtail/home/migrations/0001_initial.py @@ -1,6 +1,7 @@ # Squashed migration - represents the complete initial state of the home app # Combines: 0001_initial, 0002_create_homepage, 0003_contentpage_sectionindexpage... +import os from django.db import migrations, models import django.db.models.deletion import wagtail.fields @@ -47,6 +48,9 @@ def create_homepage(apps, schema_editor): site = Site.objects.filter(is_default_site=True).first() if site: site.root_page = homepage + # Update hostname and port from environment variables + site.hostname = os.environ.get('WAGTAIL_SITE_HOSTNAME', 'localhost') + site.port = int(os.environ.get('WAGTAIL_SITE_PORT', '80')) site.save() # Handle locales if they exist (Wagtail 2.11+) diff --git a/infrastructure/terraform/components/cms/ecs_service_main.tf b/infrastructure/terraform/components/cms/ecs_service_main.tf index 0433c9a1..b91551c0 100644 --- a/infrastructure/terraform/components/cms/ecs_service_main.tf +++ b/infrastructure/terraform/components/cms/ecs_service_main.tf @@ -5,6 +5,8 @@ resource "aws_ecs_service" "main" { launch_type = "FARGATE" desired_count = var.ecs_min_capacity + enable_execute_command = true + network_configuration { subnets = local.private_subnets security_groups = [aws_security_group.ecs_tasks.id] diff --git a/infrastructure/terraform/components/cms/ecs_task_definition_main.tf b/infrastructure/terraform/components/cms/ecs_task_definition_main.tf index b80cf7a5..fa3cdf94 100644 --- a/infrastructure/terraform/components/cms/ecs_task_definition_main.tf +++ b/infrastructure/terraform/components/cms/ecs_task_definition_main.tf @@ -68,6 +68,14 @@ resource "aws_ecs_task_definition" "main" { { name = "CSRF_TRUSTED_ORIGINS" value = "https://${local.root_domain_name}" + }, + { + name = "WAGTAIL_SITE_HOSTNAME" + value = local.root_domain_name + }, + { + name = "WAGTAIL_SITE_PORT" + value = "443" } ] @@ -81,7 +89,7 @@ resource "aws_ecs_task_definition" "main" { valueFrom = aws_ssm_parameter.redis_auth_token.arn }, { - name = "DJANGO_SECRET_KEY" + name = "SECRET_KEY" valueFrom = aws_ssm_parameter.django_secret_key.arn } ] diff --git a/infrastructure/terraform/components/cms/iam_role_ecs_task.tf b/infrastructure/terraform/components/cms/iam_role_ecs_task.tf index 85f510af..ec3b63a9 100644 --- a/infrastructure/terraform/components/cms/iam_role_ecs_task.tf +++ b/infrastructure/terraform/components/cms/iam_role_ecs_task.tf @@ -45,3 +45,24 @@ resource "aws_iam_role_policy" "ecs_task_s3" { ] }) } + +resource "aws_iam_role_policy" "ecs_task_exec_command" { + name_prefix = "${local.csi}-exec-" + role = aws_iam_role.ecs_task.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "ssmmessages:CreateControlChannel", + "ssmmessages:CreateDataChannel", + "ssmmessages:OpenControlChannel", + "ssmmessages:OpenDataChannel" + ] + Resource = "*" + } + ] + }) +} diff --git a/infrastructure/terraform/components/cms/lb_main.tf b/infrastructure/terraform/components/cms/lb_main.tf index d83f39bc..775e153f 100644 --- a/infrastructure/terraform/components/cms/lb_main.tf +++ b/infrastructure/terraform/components/cms/lb_main.tf @@ -1,9 +1,9 @@ resource "aws_lb" "main" { - name_prefix = "cms-" - internal = true + name = local.csi + internal = false load_balancer_type = "application" - subnets = local.private_subnets + subnets = local.public_subnets security_groups = [aws_security_group.alb.id] drop_invalid_header_fields = true diff --git a/infrastructure/terraform/components/cms/lb_target_group_main.tf b/infrastructure/terraform/components/cms/lb_target_group_main.tf index eb7ee538..c4c7f021 100644 --- a/infrastructure/terraform/components/cms/lb_target_group_main.tf +++ b/infrastructure/terraform/components/cms/lb_target_group_main.tf @@ -1,5 +1,5 @@ resource "aws_lb_target_group" "main" { - name_prefix = "cms-" + name = local.csi port = 8080 protocol = "HTTP" target_type = "ip" @@ -15,6 +15,12 @@ resource "aws_lb_target_group" "main" { matcher = "200" } + stickiness { + type = "lb_cookie" + cookie_duration = 86400 # 24 hours + enabled = true + } + deregistration_delay = 30 tags = merge( From 6e7a862f1a5e1c8b7a4ad7aaad0949f8fa17316e Mon Sep 17 00:00:00 2001 From: aidenvaines-cgi Date: Thu, 16 Apr 2026 17:00:50 +0100 Subject: [PATCH 10/10] CCM-16446 Adding base wagtail config --- containers/wagtail/{docker => }/Dockerfile | 1 + containers/wagtail/cms/settings/base.py | 13 ++-- .../wagtail/home/management/__init__.py | 0 .../home/management/commands/__init__.py | 0 .../management/commands/setup_homepage.py | 59 +++++++++++++++++++ .../wagtail/home/migrations/0001_initial.py | 3 - 6 files changed, 69 insertions(+), 7 deletions(-) rename containers/wagtail/{docker => }/Dockerfile (97%) create mode 100644 containers/wagtail/home/management/__init__.py create mode 100644 containers/wagtail/home/management/commands/__init__.py create mode 100644 containers/wagtail/home/management/commands/setup_homepage.py diff --git a/containers/wagtail/docker/Dockerfile b/containers/wagtail/Dockerfile similarity index 97% rename from containers/wagtail/docker/Dockerfile rename to containers/wagtail/Dockerfile index 0b01e30b..eab4b471 100644 --- a/containers/wagtail/docker/Dockerfile +++ b/containers/wagtail/Dockerfile @@ -57,4 +57,5 @@ EXPOSE 8080 CMD set -xe; \ python manage.py collectstatic --noinput; \ python manage.py migrate --noinput; \ + python manage.py setup_homepage; \ gunicorn cms.wsgi:application --bind 0.0.0.0:8080 --workers 2 --timeout 60 --access-logfile - --error-logfile - diff --git a/containers/wagtail/cms/settings/base.py b/containers/wagtail/cms/settings/base.py index 0e1f0080..90491e3d 100644 --- a/containers/wagtail/cms/settings/base.py +++ b/containers/wagtail/cms/settings/base.py @@ -9,6 +9,7 @@ """ import os +from urllib.parse import quote import dj_database_url @@ -94,10 +95,14 @@ if "DATABASE_URL" not in os.environ and all( key in os.environ for key in ["DATABASE_HOST", "DATABASE_PORT", "DATABASE_NAME", "DATABASE_USER", "DATABASE_PASSWORD"] ): - os.environ["DATABASE_URL"] = ( - f"postgres://{os.environ['DATABASE_USER']}:{os.environ['DATABASE_PASSWORD']}" - f"@{os.environ['DATABASE_HOST']}:{os.environ['DATABASE_PORT']}/{os.environ['DATABASE_NAME']}" - ) + # URL-encode username and password to handle special characters + user = quote(os.environ['DATABASE_USER'], safe='') + password = quote(os.environ['DATABASE_PASSWORD'], safe='') + host = os.environ['DATABASE_HOST'] + port = os.environ['DATABASE_PORT'] + name = os.environ['DATABASE_NAME'] + + os.environ["DATABASE_URL"] = f"postgres://{user}:{password}@{host}:{port}/{name}" DATABASES = { "default": dj_database_url.config( diff --git a/containers/wagtail/home/management/__init__.py b/containers/wagtail/home/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/containers/wagtail/home/management/commands/__init__.py b/containers/wagtail/home/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/containers/wagtail/home/management/commands/setup_homepage.py b/containers/wagtail/home/management/commands/setup_homepage.py new file mode 100644 index 00000000..e9ab3297 --- /dev/null +++ b/containers/wagtail/home/management/commands/setup_homepage.py @@ -0,0 +1,59 @@ +import os +from django.core.management.base import BaseCommand +from django.contrib.contenttypes.models import ContentType +from wagtail.models import Page, Site, Locale + +from home.models import HomePage + + +class Command(BaseCommand): + help = 'Creates the homepage and sets it as the site root (idempotent)' + + def handle(self, *args, **options): + # Check if homepage already exists + if HomePage.objects.filter(slug='home').exists(): + self.stdout.write(self.style.SUCCESS('Homepage already exists, skipping creation')) + return + + # Delete the default "Welcome to Wagtail" page if it exists + Page.objects.filter(slug='home', depth=2).exclude(content_type=ContentType.objects.get_for_model(HomePage)).delete() + + # Get the root page + try: + root_page = Page.objects.get(slug='root', depth=1) + except Page.DoesNotExist: + self.stdout.write(self.style.ERROR('Root page not found. Run migrations first.')) + return + + # Create the homepage + homepage = HomePage( + title="Home", + slug="home", + hero_heading="Send NHS App messages, emails, texts and letters to patients and the public", + hero_description="You can use NHS Notify if you work in or with NHS England to support patient care.", + cta_heading="Find out how you can start using NHS Notify", + cta_description="NHS England organisations and services that support direct care can register their interest and get started with NHS Notify.", + cta_button_text="Get started", + cta_button_url="/get-started/", + ) + + # Add as child of root + root_page.add_child(instance=homepage) + + # Handle locales if they exist + if Locale.objects.exists(): + default_locale = Locale.objects.get(language_code="en") + homepage.locale = default_locale + homepage.save() + + # Set as the root page for the default site + site = Site.objects.filter(is_default_site=True).first() + if site: + site.root_page = homepage + # Update hostname and port from environment variables + site.hostname = os.environ.get('WAGTAIL_SITE_HOSTNAME', 'localhost') + site.port = int(os.environ.get('WAGTAIL_SITE_PORT', '80')) + site.save() + self.stdout.write(self.style.SUCCESS(f'Homepage created and set as site root for {site.hostname}:{site.port}')) + else: + self.stdout.write(self.style.WARNING('Homepage created but no default site found')) diff --git a/containers/wagtail/home/migrations/0001_initial.py b/containers/wagtail/home/migrations/0001_initial.py index a8e6c0c2..f9843328 100644 --- a/containers/wagtail/home/migrations/0001_initial.py +++ b/containers/wagtail/home/migrations/0001_initial.py @@ -208,7 +208,4 @@ class Migration(migrations.Migration): }, bases=('wagtailcore.page',), ), - - # Create the homepage instance and set as site root - migrations.RunPython(create_homepage, remove_homepage), ]