diff --git a/data_safe_haven/infrastructure/components/dynamic/file_upload.py b/data_safe_haven/infrastructure/components/dynamic/file_upload.py index cb9f5152f1..731a662899 100644 --- a/data_safe_haven/infrastructure/components/dynamic/file_upload.py +++ b/data_safe_haven/infrastructure/components/dynamic/file_upload.py @@ -29,7 +29,7 @@ def __init__( self.file_hash = file_hash self.file_target = file_target self.file_permissions = file_permissions - self.force_refresh = force_refresh + self.force_refresh = Output.from_input(force_refresh).apply(lambda force: force if force else False) self.subscription_name = subscription_name self.vm_name = vm_name self.vm_resource_group_name = vm_resource_group_name diff --git a/data_safe_haven/infrastructure/stacks/sre/workspaces.py b/data_safe_haven/infrastructure/stacks/sre/workspaces.py index 64e0c6d4f3..fa854168f1 100644 --- a/data_safe_haven/infrastructure/stacks/sre/workspaces.py +++ b/data_safe_haven/infrastructure/stacks/sre/workspaces.py @@ -1,3 +1,4 @@ +import pathlib from collections.abc import Mapping from typing import Any @@ -166,7 +167,7 @@ def __init__( ] # Get details for each deployed VM - vm_outputs = [ + vm_outputs: list[dict[str, Any]] = [ { "ip_address": vm.ip_address_private, "name": vm.vm_name, @@ -176,23 +177,30 @@ def __init__( ] # Upload smoke tests - run_all_tests = FileReader(resources_path / "workspace" / "run_all_tests.bats") + mustache_values={ + "check_uninstallable_packages": "0", + } + file_uploads = [(FileReader(resources_path / "workspace" / "run_all_tests.bats"), "0444")] + for test_file in pathlib.Path(resources_path / "workspace").glob("test*"): + file_uploads.append((FileReader(test_file), "0444")) for vm, vm_output in zip(vms, vm_outputs, strict=True): - file_run_all_tests = FileUpload( - f"{self._name}_file_run_all_tests", - FileUploadProps( - file_contents=run_all_tests.file_contents(), - file_hash=run_all_tests.sha256(), - file_permissions="0444", - file_target=f"/opt/tests/{run_all_tests.name}", - force_refresh=True, - subscription_name=props.subscription_name, - vm_name=vm.vm_name, - vm_resource_group_name=resource_group.name, - ), - opts=child_opts, - ) - vm_output["run_all_tests"] = file_run_all_tests.script_output + outputs: dict[str, Output[str]] = {} + for file_upload, file_permissions in file_uploads: + file_smoke_test = FileUpload( + replace_separators(f"{self._name}_file_{file_upload.name}", "_"), + FileUploadProps( + file_contents=file_upload.file_contents(mustache_values=mustache_values), + file_hash=file_upload.sha256(), + file_permissions=file_permissions, + file_target=f"/opt/tests/{file_upload.name}", + subscription_name=props.subscription_name, + vm_name=vm.vm_name, + vm_resource_group_name=resource_group.name, + ), + opts=child_opts, + ) + outputs[file_upload.name] = file_smoke_test.script_output + vm_output["file_uploads"] = outputs # Register outputs self.resource_group = resource_group diff --git a/data_safe_haven/resources/workspace/run_all_tests.bats b/data_safe_haven/resources/workspace/run_all_tests.bats index b843d046f3..800a55cd3d 100644 --- a/data_safe_haven/resources/workspace/run_all_tests.bats +++ b/data_safe_haven/resources/workspace/run_all_tests.bats @@ -1,47 +1,92 @@ #! /usr/bin/env bats -load "../bats/bats-assert/load" -load "../bats/bats-file/load" -load "../bats/bats-support/load" # Helper functions # ---------------- -install_requirements_python() { - pip install pandas psycopg pymssql +initialise_python_environment() { + ENV_PATH="${HOME}/.local/bats-python-environment" + rm -rf "$ENV_PATH" + python -m venv "$ENV_PATH" + source "${ENV_PATH}/bin/activate" + pip install --upgrade pip --quiet } -install_requirements_R() { - Rscript -e "install.packages(c('DBI', 'odbc', 'RPostgres'))" +initialise_r_environment() { + ENV_PATH="${HOME}/.local/bats-r-environment" + rm -rf "$ENV_PATH" + mkdir -p "$ENV_PATH" } +install_r_package() { + PACKAGE_NAME="$1" + ENV_PATH="${HOME}/.local/bats-r-environment" + Rscript -e "install.packages('$PACKAGE_NAME', lib='$ENV_PATH');" +} -# Python -# ------ -# Test Python functionality -@test "Python functionality" { - run python tests/test_functionality_python.py 2>&1 - assert_output --partial 'All functionality tests passed' +install_r_package_version() { + PACKAGE_NAME="$1" + PACKAGE_VERSION="$2" + ENV_PATH="${HOME}/.local/bats-r-environment" + Rscript -e "install.packages('remotes', lib='$ENV_PATH');" + Rscript -e "library('remotes', lib='$ENV_PATH'); remotes::install_version(package='$PACKAGE_NAME', version='$PACKAGE_VERSION', lib='$ENV_PATH');" } -# Test Python package repository -@test "Python package repository" { - run bash tests/test_repository_python.sh 2>&1 - assert_output --partial 'All package installations behaved as expected' + +check_db_credentials() { + db_credentials="${HOME}/.local/db.dsh" + if [ -f "$db_credentials" ]; then + return 0 + fi + return 1 } -# R -# - -# Test R packages -# Test R functionality -@test "R functionality" { - run Rscript tests/test_functionality_R.R - assert_output --partial 'All functionality tests passed' +# Mounted drives +# -------------- +@test "Mounted drives (/data)" { + run bash test_mounted_drives.sh -d data + [ "$status" -eq 0 ] +} +@test "Mounted drives (/home)" { + run bash test_mounted_drives.sh -d home + [ "$status" -eq 0 ] +} +@test "Mounted drives (/output)" { + run bash test_mounted_drives.sh -d output + [ "$status" -eq 0 ] +} +@test "Mounted drives (/shared)" { + run bash test_mounted_drives.sh -d shared + [ "$status" -eq 0 ] } -# Test R package repository + +# Package repositories +# -------------------- +@test "Python package repository" { + initialise_python_environment + run bash test_repository_python.sh 2>&1 + [ "$status" -eq 0 ] +} @test "R package repository" { - run bash tests/test_repository_R.sh - assert_output --partial 'All package installations behaved as expected' + initialise_r_environment + run bash test_repository_R.sh + [ "$status" -eq 0 ] +} + + +# Language functionality +# ---------------------- +@test "Python functionality" { + initialise_python_environment + pip install numpy pandas scikit-learn --quiet + run python test_functionality_python.py 2>&1 + [ "$status" -eq 0 ] +} +@test "R functionality" { + initialise_r_environment + install_r_package_version "MASS" "7.3-52" + run Rscript test_functionality_R.R + [ "$status" -eq 0 ] } @@ -49,44 +94,35 @@ install_requirements_R() { # --------- # Test MS SQL database @test "MS SQL database (Python)" { - install_requirements_python - run bash tests/test_databases.sh -d mssql -l python - assert_output --partial 'All database tests passed' + check_db_credentials || skip "No database credentials available" + initialise_python_environment + pip install pandas psycopg pymssql --quiet + run bash test_databases.sh -d mssql -l python + [ "$status" -eq 0 ] } @test "MS SQL database (R)" { - install_requirements_R - run bash tests/test_databases.sh -d mssql -l R - assert_output --partial 'All database tests passed' + check_db_credentials || skip "No database credentials available" + initialise_r_environment + install_r_package "DBI" + install_r_package "odbc" + install_r_package "RPostgres" + run bash test_databases.sh -d mssql -l R + [ "$status" -eq 0 ] } - # Test Postgres database @test "Postgres database (Python)" { - install_requirements_python - run bash tests/test_databases.sh -d postgresql -l python - assert_output --partial 'All database tests passed' + check_db_credentials || skip "No database credentials available" + initialise_python_environment + pip install pandas psycopg pymssql --quiet + run bash test_databases.sh -d postgresql -l python + [ "$status" -eq 0 ] } @test "Postgres database (R)" { - install_requirements_R - run bash tests/test_databases.sh -d postgresql -l R - assert_output --partial 'All database tests passed' -} - - -# Mounted drives -# -------------- -@test "Mounted drives (/data)" { - run bash tests/test_mounted_drives.sh -d data - assert_output --partial 'All tests passed' -} -@test "Mounted drives (/home)" { - run bash tests/test_mounted_drives.sh -d home - assert_output --partial 'All tests passed' -} -@test "Mounted drives (/output)" { - run bash tests/test_mounted_drives.sh -d output - assert_output --partial 'All tests passed' -} -@test "Mounted drives (/shared)" { - run bash tests/test_mounted_drives.sh -d shared - assert_output --partial 'All tests passed' + check_db_credentials || skip "No database credentials available" + initialise_r_environment + install_r_package "DBI" + install_r_package "odbc" + install_r_package "RPostgres" + run bash test_databases.sh -d postgresql -l R + [ "$status" -eq 0 ] } diff --git a/data_safe_haven/resources/workspace/test_databases.sh b/data_safe_haven/resources/workspace/test_databases.sh new file mode 100644 index 0000000000..69fd7a456c --- /dev/null +++ b/data_safe_haven/resources/workspace/test_databases.sh @@ -0,0 +1,51 @@ +#! /bin/bash +db_type="" +language="" +while getopts d:l: flag; do + case "${flag}" in + d) db_type=${OPTARG} ;; + l) language=${OPTARG} ;; + *) + echo "Invalid option ${OPTARG}" + exit 1 + ;; + esac +done + +db_credentials="${HOME}/.local/db.dsh" +if [ -f "$db_credentials" ]; then + username="databaseadmin" + password="$(cat "$db_credentials")" +else + echo "Credentials file ($db_credentials) not found." + exit 1 +fi + +sre_fqdn="$(grep trusted /etc/pip.conf | cut -d "." -f 2-99)" +sre_prefix="$(hostname | cut -d "-" -f 1-4)" +if [ "$db_type" == "mssql" ]; then + db_name="master" + port="1433" + server_name="mssql.${sre_fqdn}" + hostname="${sre_prefix}-db-server-mssql" +elif [ "$db_type" == "postgresql" ]; then + db_name="postgres" + port="5432" + server_name="postgresql.${sre_fqdn}" + hostname="${sre_prefix}-db-server-postgresql" +else + echo "Did not recognise database type '$db_type'" + exit 1 +fi + +if [ "$port" == "" ]; then + echo "Database type '$db_type' is not part of this SRE" + exit 1 +else + script_path=$(dirname "$(readlink -f "$0")") + if [ "$language" == "python" ]; then + python "${script_path}"/test_databases_python.py --db-type "$db_type" --db-name "$db_name" --port "$port" --server-name "$server_name" --hostname "$hostname" --username "$username" --password "$password" || exit 1 + elif [ "$language" == "R" ]; then + Rscript "${script_path}"/test_databases_R.R "$db_type" "$db_name" "$port" "$server_name" "$hostname" "$username" "$password" || exit 1 + fi +fi diff --git a/data_safe_haven/resources/workspace/test_databases_R.R b/data_safe_haven/resources/workspace/test_databases_R.R new file mode 100644 index 0000000000..a261f21532 --- /dev/null +++ b/data_safe_haven/resources/workspace/test_databases_R.R @@ -0,0 +1,51 @@ +#!/usr/bin/env Rscript +library(DBI, lib.loc='~/.local/bats-r-environment') +library(odbc, lib.loc='~/.local/bats-r-environment') +library(RPostgres, lib.loc='~/.local/bats-r-environment') + +# Parse command line arguments +args = commandArgs(trailingOnly=TRUE) +if (length(args)!=7) { + stop("Exactly seven arguments are required: db_type, db_name, port, server_name, hostname, username and password") +} +db_type = args[1] +db_name = args[2] +port = args[3] +server_name = args[4] +hostname = args[5] +username = args[6] +password = args[7] + +# Connect to the database +print(paste("Attempting to connect to '", db_name, "' on '", server_name, "' via port '", port, sep="")) +if (db_type == "mssql") { + cnxn <- DBI::dbConnect( + odbc::odbc(), + Driver = "ODBC Driver 17 for SQL Server", + Server = paste(server_name, port, sep=","), + Database = db_name, + # Trusted_Connection = "yes", + UID = paste(username, "@", hostname, sep=""), + PWD = password + ) +} else if (db_type == "postgresql") { + cnxn <- DBI::dbConnect( + RPostgres::Postgres(), + host = server_name, + port = port, + dbname = db_name, + user = paste(username, "@", hostname, sep=""), + password = password + ) +} else { + stop(paste("Database type '", db_type, "' was not recognised", sep="")) +} + +# Run a query and save the output into a dataframe +df <- dbGetQuery(cnxn, "SELECT * FROM information_schema.tables;") +if (dim(df)[1] > 0) { + print(head(df, 5)) + print("All database tests passed") +} else { + stop(paste("Reading from database '", db_name, "' failed", sep="")) +} diff --git a/data_safe_haven/resources/workspace/test_databases_python.py b/data_safe_haven/resources/workspace/test_databases_python.py new file mode 100644 index 0000000000..37a37acb91 --- /dev/null +++ b/data_safe_haven/resources/workspace/test_databases_python.py @@ -0,0 +1,55 @@ +#! /usr/bin/env python +import argparse + +import pandas as pd +import psycopg +import pymssql + + +def test_database(server_name, hostname, port, db_type, db_name, username, password): + print(f"Attempting to connect to '{db_name}' on '{server_name}' via port {port}") + username_full = f"{username}@{hostname}" + cnxn = None + if db_type == "mssql": + cnxn = pymssql.connect( + server=server_name, user=username_full, password=password, database=db_name + ) + elif db_type == "postgresql": + connection_string = f"host={server_name} port={port} dbname={db_name} user={username_full} password={password}" + cnxn = psycopg.connect(connection_string) + else: + raise ValueError(f"Database type '{db_type}' was not recognised") + df = pd.read_sql("SELECT * FROM information_schema.tables;", cnxn) + if df.size: + print(df.head(5)) + print("All database tests passed") + else: + raise ValueError(f"Reading from database '{db_name}' failed.") + + +# Parse command line arguments +parser = argparse.ArgumentParser() +parser.add_argument( + "--db-type", + type=str, + choices=["mssql", "postgresql"], + help="Which database type to use", +) +parser.add_argument("--db-name", type=str, help="Which database to connect to") +parser.add_argument("--port", type=str, help="Which port to connect to") +parser.add_argument("--server-name", type=str, help="Which server to connect to") +parser.add_argument("--username", type=str, help="Database username") +parser.add_argument("--hostname", type=str, help="Azure hostname of the server") +parser.add_argument("--password", type=str, help="Database user password") +args = parser.parse_args() + +# Run database test +test_database( + args.server_name, + args.hostname, + args.port, + args.db_type, + args.db_name, + args.username, + args.password, +) diff --git a/data_safe_haven/resources/workspace/test_functionality_R.R b/data_safe_haven/resources/workspace/test_functionality_R.R new file mode 100644 index 0000000000..94c351e7c3 --- /dev/null +++ b/data_safe_haven/resources/workspace/test_functionality_R.R @@ -0,0 +1,39 @@ +# Test logistic regression using R +library('MASS', lib.loc='~/.local/bats-r-environment') +library('stats') + +gen_data <- function(n = 100, p = 3) { + set.seed(1) + weights <- stats::rgamma(n = n, shape = rep(1, n), rate = rep(1, n)) + y <- stats::rbinom(n = n, size = 1, prob = 0.5) + theta <- stats::rnorm(n = p, mean = 0, sd = 1) + means <- colMeans(as.matrix(y) %*% theta) + x <- MASS::mvrnorm(n = n, means, diag(1, p, p)) + return(list(x = x, y = y, weights = weights, theta = theta)) +} + +run_logistic_regression <- function(data) { + fit <- stats::glm.fit(x = data$x, + y = data$y, + weights = data$weights, + family = stats::quasibinomial(link = "logit")) + return(fit$coefficients) +} + +data <- gen_data() +theta <- run_logistic_regression(data) +print("Logistic regression ran OK") + + +# Test clustering of random data using R +num_clusters <- 5 +N <- 10 +set.seed(0, kind = "Mersenne-Twister") +cluster_means <- runif(num_clusters, 0, 10) +means_selector <- as.integer(runif(N, 1, num_clusters + 1)) +data_means <- cluster_means[means_selector] +data <- rnorm(n = N, mean = data_means, sd = 0.5) +hc <- hclust(dist(data)) +print("Clustering ran OK") + +print("All functionality tests passed") diff --git a/data_safe_haven/resources/workspace/test_functionality_python.py b/data_safe_haven/resources/workspace/test_functionality_python.py new file mode 100644 index 0000000000..9ca9662d98 --- /dev/null +++ b/data_safe_haven/resources/workspace/test_functionality_python.py @@ -0,0 +1,37 @@ +"""Test logistic regression using python""" +import numpy as np +import pandas as pd +from sklearn.linear_model import LogisticRegression + + +def gen_data(n_samples, n_points): + """Generate data for fitting""" + target = np.random.binomial(n=1, p=0.5, size=(n_samples, 1)) + theta = np.random.normal(loc=0.0, scale=1.0, size=(1, n_points)) + means = np.mean(np.multiply(target, theta), axis=0) + values = np.random.multivariate_normal( + means, np.diag([1] * n_points), size=n_samples + ).T + data = dict(("x{}".format(n), values[n]) for n in range(n_points)) + data["y"] = target.reshape((n_samples,)) + data["weights"] = np.random.gamma(shape=1, scale=1.0, size=n_samples) + return pd.DataFrame(data=data) + + +def main(): + """Logistic regression""" + data = gen_data(100, 3) + input_data = data.iloc[:, :-2] + output_data = data["y"] + weights = data["weights"] + + logit = LogisticRegression(solver="liblinear") + logit.fit(input_data, output_data, sample_weight=weights) + logit.score(input_data, output_data, sample_weight=weights) + + print("Logistic model ran OK") + print("All functionality tests passed") + + +if __name__ == "__main__": + main() diff --git a/data_safe_haven/resources/workspace/test_mounted_drives.sh b/data_safe_haven/resources/workspace/test_mounted_drives.sh new file mode 100644 index 0000000000..a1812934b9 --- /dev/null +++ b/data_safe_haven/resources/workspace/test_mounted_drives.sh @@ -0,0 +1,66 @@ +#! /bin/bash +while getopts d: flag +do + case "${flag}" in + d) directory=${OPTARG};; + *) + echo "Usage: $0 -d [directory]" + exit 1 + esac +done + +nfailed=0 +if [[ "$directory" = "home" ]]; then directory_path=$(echo ~); else directory_path="/${directory}"; fi +testfile="$(tr -dc 'a-zA-Z0-9' < /dev/urandom | fold -w 32 | head -n 1)" + +# Check that directory exists +if [ "$(ls "${directory_path}" 2>&1 1>/dev/null)" ]; then + echo "Could not find mount '${directory_path}'" + nfailed=$((nfailed + 1)) +fi + +# Test operations +CAN_CREATE="$([[ "$(touch "${directory_path}/${testfile}" 2>&1 1>/dev/null)" = "" ]] && echo '1' || echo '0')" +CAN_WRITE="$([[ -w "${directory_path}/${testfile}" ]] && echo '1' || echo '0')" +CAN_DELETE="$([[ "$(touch "${directory_path}/${testfile}" 2>&1 1>/dev/null && rm "${directory_path}/${testfile}" 2>&1)" ]] && echo '0' || echo '1')" + +# Check that permissions are as expected for each directory +case "$directory" in + data) + if [ "$CAN_CREATE" = 1 ]; then echo "Able to create files in ${directory_path}!"; nfailed=$((nfailed + 1)); fi + if [ "$CAN_WRITE" = 1 ]; then echo "Able to write files in ${directory_path}!"; nfailed=$((nfailed + 1)); fi + if [ "$CAN_DELETE" = 1 ]; then echo "Able to delete files in ${directory_path}!"; nfailed=$((nfailed + 1)); fi + ;; + + home) + if [ "$CAN_CREATE" = 0 ]; then echo "Unable to create files in ${directory_path}!"; nfailed=$((nfailed + 1)); fi + if [ "$CAN_WRITE" = 0 ]; then echo "Unable to write files in ${directory_path}!"; nfailed=$((nfailed + 1)); fi + if [ "$CAN_DELETE" = 0 ]; then echo "Unable to delete files in ${directory_path}!"; nfailed=$((nfailed + 1)); fi + ;; + + output) + if [ "$CAN_CREATE" = 0 ]; then echo "Unable to create files in ${directory_path}!"; nfailed=$((nfailed + 1)); fi + if [ "$CAN_WRITE" = 0 ]; then echo "Unable to write files in ${directory_path}!"; nfailed=$((nfailed + 1)); fi + if [ "$CAN_DELETE" = 0 ]; then echo "Unable to delete files in ${directory_path}!"; nfailed=$((nfailed + 1)); fi + ;; + + shared) + if [ "$CAN_CREATE" = 0 ]; then echo "Unable to create files in ${directory_path}!"; nfailed=$((nfailed + 1)); fi + if [ "$CAN_WRITE" = 0 ]; then echo "Unable to write files in ${directory_path}!"; nfailed=$((nfailed + 1)); fi + if [ "$CAN_DELETE" = 0 ]; then echo "Unable to delete files in ${directory_path}!"; nfailed=$((nfailed + 1)); fi + ;; + + *) + echo "Usage: $0 -d [directory]" + exit 1 +esac + +# Cleanup and print output +rm -f "${directory_path}/${testfile}" 2> /dev/null +if [ $nfailed = 0 ]; then + echo "All tests passed for '${directory_path}'" + exit 0 +else + echo "$nfailed tests failed for '${directory_path}'!" + exit $nfailed +fi diff --git a/data_safe_haven/resources/workspace/test_repository_R.mustache.sh b/data_safe_haven/resources/workspace/test_repository_R.mustache.sh new file mode 100644 index 0000000000..03568b1e62 --- /dev/null +++ b/data_safe_haven/resources/workspace/test_repository_R.mustache.sh @@ -0,0 +1,49 @@ +#! /bin/bash +# We need to test packages that are: +# - *not* pre-installed +# - on the tier-3 list (so we can test all tiers) +# - alphabetically early and late (so we can test the progress of the mirror synchronisation) +packages=("askpass" "zeallot") +uninstallable_packages=("aws.s3") + +# Create a temporary library directory +TEST_INSTALL_PATH="${HOME}/.local/bats-r-environment" +# TEST_INSTALL_PATH="${HOME}/test-repository-R" +# rm -rf "$TEST_INSTALL_PATH" +# mkdir -p "$TEST_INSTALL_PATH" + +# Install sample packages to local user library +N_FAILURES=0 +for package in "${packages[@]}"; do + echo "Attempting to install ${package}..." + Rscript -e "options(warn=-1); install.packages('${package}', lib='${TEST_INSTALL_PATH}', quiet=TRUE)" + if (Rscript -e "library('${package}', lib.loc='${TEST_INSTALL_PATH}')"); then + echo "... $package installation succeeded" + else + echo "... $package installation failed" + N_FAILURES=$((N_FAILURES + 1)) + fi +done +# If requested, demonstrate that installation fails for packages *not* on the approved list +TEST_FAILURE="{{check_uninstallable_packages}}" +if [ $TEST_FAILURE -eq 1 ]; then + for package in "${uninstallable_packages[@]}"; do + echo "Attempting to install ${package}..." + Rscript -e "options(warn=-1); install.packages('${package}', lib='${TEST_INSTALL_PATH}', quiet=TRUE)" + if (Rscript -e "library('${package}', lib.loc='${TEST_INSTALL_PATH}')"); then + echo "... $package installation unexpectedly succeeded!" + N_FAILURES=$((N_FAILURES + 1)) + else + echo "... $package installation failed as expected" + fi + done +fi +rm -rf "$TEST_INSTALL_PATH" + +if [ $N_FAILURES -eq 0 ]; then + echo "All package installations behaved as expected" + exit 0 +else + echo "One or more package installations did not behave as expected!" + exit $N_FAILURES +fi diff --git a/data_safe_haven/resources/workspace/test_repository_python.mustache.sh b/data_safe_haven/resources/workspace/test_repository_python.mustache.sh new file mode 100644 index 0000000000..28e46a23e1 --- /dev/null +++ b/data_safe_haven/resources/workspace/test_repository_python.mustache.sh @@ -0,0 +1,42 @@ +#! /bin/bash + +# We need to test packages that are: +# - *not* pre-installed +# - on the allowlist (so we can test this is working) +# - alphabetically early and late (so we can test the progress of the mirror synchronisation) +installable_packages=("contourpy" "tzdata") +uninstallable_packages=("awscli") + +# Install sample packages to local user library +N_FAILURES=0 +for package in "${installable_packages[@]}"; do + echo "Attempting to install ${package}..." + if (pip install "$package" --quiet); then + echo "... $package installation succeeded" + else + echo "... $package installation failed" + N_FAILURES=$((N_FAILURES + 1)) + fi +done +# If requested, demonstrate that installation fails for packages *not* on the approved list +TEST_FAILURE="{{check_uninstallable_packages}}" +if [ $TEST_FAILURE -eq 1 ]; then + for package in "${uninstallable_packages[@]}"; do + echo "Attempting to install ${package}..." + if (pip install "$package" --quiet); then + echo "... $package installation unexpectedly succeeded!" + N_FAILURES=$((N_FAILURES + 1)) + else + echo "... $package installation failed as expected" + fi + done +fi +rm -rf "$TEST_INSTALL_PATH" + +if [ $N_FAILURES -eq 0 ]; then + echo "All package installations behaved as expected" + exit 0 +else + echo "One or more package installations did not behave as expected!" + exit $N_FAILURES +fi diff --git a/data_safe_haven/resources/workspace/workspace.cloud_init.mustache.yaml b/data_safe_haven/resources/workspace/workspace.cloud_init.mustache.yaml index b387f6a1d6..c4216adb76 100644 --- a/data_safe_haven/resources/workspace/workspace.cloud_init.mustache.yaml +++ b/data_safe_haven/resources/workspace/workspace.cloud_init.mustache.yaml @@ -104,6 +104,8 @@ packages: - libpq-dev # interact with PostgreSQL databases - msodbcsql17 # interact with Microsoft SQL databases - unixodbc-dev # interact with Microsoft SQL databases + # Bash testing + - bats package_update: true package_upgrade: true @@ -145,25 +147,3 @@ runcmd: - while (! mountpoint -q /output); do sleep 5; mount /output; done - while (! mountpoint -q /shared); do sleep 5; mount /shared; done - findmnt - - # Install bats - # ------------ - - echo ">=== Installing bats... ===<" - - git clone https://github.com/bats-core/bats-core /opt/bats/bats-core - - git clone https://github.com/bats-core/bats-support /opt/bats/bats-support - - git clone https://github.com/bats-core/bats-assert /opt/bats/bats-assert - - git clone https://github.com/bats-core/bats-file /opt/bats/bats-file - - /opt/bats/bats-core/install.sh /usr/local - - if [ "$(which bats)" = "" ]; then echo "Could not install bats!"; exit 1; else echo "... successfully installed bats"; fi - - # Update PATH - # ----------- - - echo ">=== Updating PATH... ===<" - # Set PATH to the current version which contains all installed packages - # Append ~/.local/bin and ~/bin so that any executables that are installed there (eg. by pip) can be used - # We do this at the end of the script so that - # - we know this is the PATH that worked when we checked for each package - # - we only get one entry in /etc/bash.bashrc rather than several with "last-one-wins" - - PATH="$PATH:\$HOME/.local/bin:\$HOME/bin" - - echo "Setting PATH to '${PATH}'" - - sed -i "s|^export PATH=.*|export PATH=${PATH}|" /etc/bash.bashrc