diff options
author | Joseph Hunkeler <jhunkeler@users.noreply.github.com> | 2024-07-15 10:07:25 -0400 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-07-15 10:07:25 -0400 |
commit | 07dc44efdc5c2fbc2b34c969e623d3b0bc0df15a (patch) | |
tree | 1f41c27e50baeee149b59b8c3d37a9c72cbd0ded | |
parent | 70cd78cdef69237ba3c511b9e091715ec6d093e5 (diff) | |
download | stasis-07dc44efdc5c2fbc2b34c969e623d3b0bc0df15a.tar.gz |
Unit tests (#12)
* Change return value of conda_setup_headless() from void to int
* Replace exit() with return;
* Return early if unpacking the micromamba binary fails
* Exit program when pointer to INIFILE is NULL.
* Validation function cannot otherwise proceed
* The way the logic is set up I've decided to duplicate the installation code for now until I find time to revise it
* The only meaningful difference between a "fresh start" and reusing the conda installation is a rmtree().
* Exposes STASIS_DOWNLOAD_TIMEOUT environment variable
* Sets the connection timeout for libcurl to 30, instead of 300.
* Export ini_section_create() function
* Add download() tests
* Add conda_*() tests
* Add boilerplate source file for test framework
* Fixes segfault reported by @GeorgeJCleary (#10)
* The key is now an array index. When key is -1, the env variable is not defined.
* Free resources only when continue on error is disabled (#11)
* Fix segfault due to premature shutdown/cleanup
* If conda_setup_headless cannot succeed, die
* Set STASIS_SYSCONFDIR for tests
-rw-r--r-- | .github/workflows/cmake-multi-platform.yml | 2 | ||||
-rw-r--r-- | include/conda.h | 2 | ||||
-rw-r--r-- | include/ini.h | 8 | ||||
-rw-r--r-- | src/conda.c | 17 | ||||
-rw-r--r-- | src/delivery.c | 21 | ||||
-rw-r--r-- | src/download.c | 10 | ||||
-rw-r--r-- | tests/_test_boilerplate.c | 17 | ||||
-rw-r--r-- | tests/test_conda.c | 181 | ||||
-rw-r--r-- | tests/test_download.c | 50 |
9 files changed, 300 insertions, 8 deletions
diff --git a/.github/workflows/cmake-multi-platform.yml b/.github/workflows/cmake-multi-platform.yml index 6d4709a..e7d4b33 100644 --- a/.github/workflows/cmake-multi-platform.yml +++ b/.github/workflows/cmake-multi-platform.yml @@ -56,3 +56,5 @@ jobs: - name: Test working-directory: ${{ steps.strings.outputs.build-output-dir }} run: ctest -V --build-config ${{ matrix.build_type }} --output-junit results.xml --test-output-size-passed 65536 --test-output-size-failed 65536 + env: + STASIS_SYSCONFDIR: ../../.. diff --git a/include/conda.h b/include/conda.h index cea3f02..d439371 100644 --- a/include/conda.h +++ b/include/conda.h @@ -89,7 +89,7 @@ int conda_activate(const char *root, const char *env_name); /** * Configure the active conda installation for headless operation */ -void conda_setup_headless(); +int conda_setup_headless(); /** * Creates a Conda environment from a YAML config diff --git a/include/ini.h b/include/ini.h index af2639b..7167cad 100644 --- a/include/ini.h +++ b/include/ini.h @@ -101,6 +101,14 @@ struct INIFILE *ini_open(const char *filename); struct INISection *ini_section_search(struct INIFILE **ini, unsigned mode, const char *value); /** + * + * @param ini + * @param key + * @return + */ +int ini_section_create(struct INIFILE **ini, char *key); + +/** * * @param ini * @param section diff --git a/src/conda.c b/src/conda.c index 976bbbc..7aaec77 100644 --- a/src/conda.c +++ b/src/conda.c @@ -35,7 +35,10 @@ int micromamba(struct MicromambaInfo *info, char *command, ...) { char untarcmd[PATH_MAX * 2]; mkdirs(info->micromamba_prefix, 0755); sprintf(untarcmd, "tar -xvf %s -C %s --strip-components=1 bin/micromamba 1>/dev/null", installer_path, info->micromamba_prefix); - system(untarcmd); + int untarcmd_status = system(untarcmd); + if (untarcmd_status) { + return -1; + } } char cmd[STASIS_BUFSIZ]; @@ -249,7 +252,7 @@ int conda_check_required() { return 0; } -void conda_setup_headless() { +int conda_setup_headless() { if (globals.verbose) { conda_exec("config --system --set quiet false"); } else { @@ -285,7 +288,7 @@ void conda_setup_headless() { if (conda_exec(cmd)) { msg(STASIS_MSG_ERROR | STASIS_MSG_L2, "Unable to install user-defined base packages (conda)\n"); - exit(1); + return 1; } } @@ -307,7 +310,7 @@ void conda_setup_headless() { if (pip_exec(cmd)) { msg(STASIS_MSG_ERROR | STASIS_MSG_L2, "Unable to install user-defined base packages (pip)\n"); - exit(1); + return 1; } } @@ -315,15 +318,17 @@ void conda_setup_headless() { msg(STASIS_MSG_ERROR | STASIS_MSG_L2, "Your STASIS configuration lacks the bare" " minimum software required to build conda packages." " Please fix it.\n"); - exit(1); + return 1; } if (globals.always_update_base_environment) { if (conda_exec("update --all")) { fprintf(stderr, "conda update was unsuccessful\n"); - exit(1); + return 1; } } + + return 0; } int conda_env_create_from_uri(char *name, char *uri) { diff --git a/src/delivery.c b/src/delivery.c index 0c20550..b27ab08 100644 --- a/src/delivery.c +++ b/src/delivery.c @@ -420,6 +420,10 @@ static int populate_mission_ini(struct Delivery **ctx) { } void validate_delivery_ini(struct INIFILE *ini) { + if (!ini) { + SYSERROR("%s", "INIFILE is NULL!"); + exit(1); + } if (ini_section_search(&ini, INI_SEARCH_EXACT, "meta")) { ini_has_key_required(ini, "meta", "name"); ini_has_key_required(ini, "meta", "version"); @@ -1428,6 +1432,18 @@ void delivery_install_conda(char *install_script, char *conda_install_dir) { fprintf(stderr, "conda installation failed\n"); exit(1); } + } else { + // Proceed with the installation + // -b = batch mode (non-interactive) + char cmd[PATH_MAX] = {0}; + snprintf(cmd, sizeof(cmd) - 1, "%s %s -b -p %s", + find_program("bash"), + install_script, + conda_install_dir); + if (shell_safe(&proc, cmd)) { + fprintf(stderr, "conda installation failed\n"); + exit(1); + } } } else { msg(STASIS_MSG_L3, "Conda removal disabled by configuration\n"); @@ -1451,7 +1467,10 @@ void delivery_conda_enable(struct Delivery *ctx, char *conda_install_dir) { exit(1); } - conda_setup_headless(); + if (conda_setup_headless()) { + // no COE check. this call must succeed. + exit(1); + } } void delivery_defer_packages(struct Delivery *ctx, int type) { diff --git a/src/download.c b/src/download.c index 1623560..f83adda 100644 --- a/src/download.c +++ b/src/download.c @@ -3,6 +3,7 @@ // #include <string.h> +#include <stdlib.h> #include "download.h" size_t download_writer(void *fp, size_t size, size_t nmemb, void *stream) { @@ -18,6 +19,8 @@ long download(char *url, const char *filename, char **errmsg) { FILE *fp; char user_agent[20]; sprintf(user_agent, "stasis/%s", VERSION); + long timeout = 30L; + char *timeout_str = getenv("STASIS_DOWNLOAD_TIMEOUT"); curl_global_init(CURL_GLOBAL_ALL); c = curl_easy_init(); @@ -27,11 +30,18 @@ long download(char *url, const char *filename, char **errmsg) { if (!fp) { return -1; } + curl_easy_setopt(c, CURLOPT_VERBOSE, 0L); curl_easy_setopt(c, CURLOPT_FOLLOWLOCATION, 1L); curl_easy_setopt(c, CURLOPT_USERAGENT, user_agent); curl_easy_setopt(c, CURLOPT_NOPROGRESS, 0L); curl_easy_setopt(c, CURLOPT_WRITEDATA, fp); + + if (timeout_str) { + timeout = strtol(timeout_str, NULL, 10); + } + curl_easy_setopt(c, CURLOPT_CONNECTTIMEOUT, timeout); + curl_code = curl_easy_perform(c); if (curl_code != CURLE_OK) { if (errmsg) { diff --git a/tests/_test_boilerplate.c b/tests/_test_boilerplate.c new file mode 100644 index 0000000..1d7734b --- /dev/null +++ b/tests/_test_boilerplate.c @@ -0,0 +1,17 @@ +#include "testing.h" + +void test_NAME() { + struct testcase { + + }; + STASIS_ASSERT(); +} + +int main(int argc, char *argv[]) { + STASIS_TEST_BEGIN_MAIN(); + STASIS_TEST_FUNC *tests[] = { + test_NAME, + }; + STASIS_TEST_RUN(tests); + STASIS_TEST_END_MAIN(); +}
\ No newline at end of file diff --git a/tests/test_conda.c b/tests/test_conda.c new file mode 100644 index 0000000..49cf48b --- /dev/null +++ b/tests/test_conda.c @@ -0,0 +1,181 @@ +#include "testing.h" + +char cwd_start[PATH_MAX]; +char cwd_workspace[PATH_MAX]; +int conda_is_installed = 0; + +void test_micromamba() { + char mm_prefix[PATH_MAX] = {0}; + char c_prefix[PATH_MAX] = {0}; + snprintf(mm_prefix, strlen(cwd_workspace) + strlen("micromamba") + 2, "%s/%s", cwd_workspace, "micromamba"); + snprintf(c_prefix, strlen(mm_prefix) + strlen("conda") + 2, "%s/%s", mm_prefix, "conda"); + + struct testcase { + struct MicromambaInfo mminfo; + const char *cmd; + int result; + }; + struct testcase tc[] = { + {.mminfo = {.micromamba_prefix = mm_prefix, .conda_prefix = c_prefix}, .cmd = "info", .result = 0}, + {.mminfo = {.micromamba_prefix = mm_prefix, .conda_prefix = c_prefix}, .cmd = "env list", .result = 0}, + {.mminfo = {.micromamba_prefix = mm_prefix, .conda_prefix = c_prefix}, .cmd = "run python -V", .result = 0}, + {.mminfo = {.micromamba_prefix = mm_prefix, .conda_prefix = c_prefix}, .cmd = "no_such_option", .result = 109}, + }; + + for (size_t i = 0; i < sizeof(tc) / sizeof(*tc); i++) { + struct testcase *item = &tc[i]; + int result = micromamba(&item->mminfo, (char *) item->cmd); + if (result > 0) { + result = result >> 8; + } + STASIS_ASSERT(result == item->result, "unexpected exit value"); + SYSERROR("micromamba command: '%s' (returned: %d)", item->cmd, result); + } +} + +static char conda_prefix[PATH_MAX] = {0}; +struct Delivery ctx; + +void test_conda_installation() { + char *install_url = calloc(255, sizeof(install_url)); + delivery_get_installer_url(&ctx, install_url); + delivery_get_installer(&ctx, install_url); + delivery_install_conda(ctx.conda.installer_path, ctx.storage.conda_install_prefix); + STASIS_ASSERT_FATAL(access(ctx.storage.conda_install_prefix, F_OK) == 0, "conda was not installed correctly"); + STASIS_ASSERT_FATAL(conda_activate(ctx.storage.conda_install_prefix, "base") == 0, "unable to activate base environment"); + STASIS_ASSERT_FATAL(conda_exec("info") == 0, "conda was not installed correctly"); + conda_is_installed = 1; +} + +void test_conda_activate() { + struct testcase { + const char *key; + char *value; + }; + struct testcase tc[] = { + {.key = "CONDA_DEFAULT_ENV", .value = "base"}, + {.key = "CONDA_SHLVL", .value = "1"}, + {.key = "_CE_CONDA", ""}, + {.key = "_CE_M", ""}, + {.key = "CONDA_PREFIX", ctx.storage.conda_install_prefix}, + }; + + STASIS_SKIP_IF(!conda_is_installed, "cannot run without conda"); + STASIS_ASSERT_FATAL(conda_activate(ctx.storage.conda_install_prefix, "base") == 0, "unable to activate base environment"); + + for (size_t i = 0; i < sizeof(tc) / sizeof(*tc); i++) { + struct testcase *item = &tc[i]; + char *value = getenv(item->key); + STASIS_ASSERT(value != NULL, "expected key to exist, but doesn't. conda is not activated."); + if (value) { + STASIS_ASSERT(strcmp(value, item->value) == 0, "conda variable value mismatch"); + } + } +} + +void test_conda_exec() { + STASIS_ASSERT(conda_exec("--version") == 0, "conda is broken"); + STASIS_ASSERT(conda_exec("info") == 0, "conda is broken"); + STASIS_ASSERT(conda_exec("search fitsverify") == 0, "conda is broken"); + STASIS_ASSERT(conda_exec("create -n testenv") == 0, "conda is broken"); + STASIS_ASSERT(conda_exec("install -n testenv fitsverify") == 0, "conda is broken"); + STASIS_ASSERT(conda_exec("update -n testenv fitsverify") == 0, "conda is broken"); + STASIS_ASSERT(conda_exec("list -n testenv") == 0, "conda is broken"); + STASIS_ASSERT(conda_exec("run -n testenv fitsverify") == 0, "conda is broken"); + STASIS_ASSERT(conda_exec("notasubcommand --help") != 0, "conda is broken"); +} + +void test_python_exec() { + const char *python_system_path = find_program("python3"); + char python_path[PATH_MAX]; + sprintf(python_path, "%s/bin/python3", ctx.storage.conda_install_prefix); + + STASIS_ASSERT(strcmp(python_path, python_system_path ? python_system_path : "/not/found") == 0, "conda is not configured correctly."); + STASIS_ASSERT(python_exec("-V") == 0, "python is broken"); +} + +void test_conda_setup_headless() { + globals.conda_packages = strlist_init(); + globals.pip_packages = strlist_init(); + strlist_append(&globals.conda_packages, "boa"); + strlist_append(&globals.conda_packages, "conda-build"); + strlist_append(&globals.conda_packages, "conda-verify"); + strlist_append(&globals.pip_packages, "pytest"); + STASIS_ASSERT(conda_setup_headless() == 0, "headless configuration failed"); +} + +void test_conda_env_create_from_uri() { + const char *url = "https://ssb.stsci.edu/jhunk/stasis_test/test_conda_env_create_from_uri.yml"; + char *name = strdup(__FUNCTION__); + STASIS_ASSERT(conda_env_create_from_uri(name, (char *) url) == 0, "creating an environment from a remote source failed"); + free(name); +} + +void test_conda_env_create_export_remove() { + char *name = strdup(__FUNCTION__); + STASIS_ASSERT(conda_env_create(name, "3", "fitsverify") == 0, "unable to create a simple environment"); + STASIS_ASSERT(conda_env_export(name, ".", name) == 0, "unable to export an environment"); + STASIS_ASSERT(conda_env_remove(name) == 0, "unable to remove an environment"); + free(name); +} + +void test_conda_index() { + mkdirs("channel/noarch", 0755); + STASIS_ASSERT(conda_index("channel") == 0, "cannot index a simple conda channel"); +} + +int main(int argc, char *argv[]) { + STASIS_TEST_BEGIN_MAIN(); + STASIS_TEST_FUNC *tests[] = { + test_micromamba, + test_conda_installation, + test_conda_activate, + test_conda_setup_headless, + test_conda_exec, + test_python_exec, + test_conda_env_create_from_uri, + test_conda_env_create_export_remove, + test_conda_index, + }; + + const char *ws = "workspace"; + getcwd(cwd_start, sizeof(cwd_start) - 1); + mkdir(ws, 0755); + chdir(ws); + getcwd(cwd_workspace, sizeof(cwd_workspace) - 1); + + snprintf(conda_prefix, strlen(cwd_workspace) + strlen("conda") + 2, "%s/conda", cwd_workspace); + + const char *mockinidata = "[meta]\n" + "name = mock\n" + "version = 1.0.0\n" + "rc = 1\n" + "mission = generic\n" + "python = 3.11\n" + "[conda]\n" + "installer_name = Miniforge3\n" + "installer_version = 24.3.0-0\n" + "installer_platform = {{env:STASIS_CONDA_PLATFORM}}\n" + "installer_arch = {{env:STASIS_CONDA_ARCH}}\n" + "installer_baseurl = https://github.com/conda-forge/miniforge/releases/download/24.3.0-0\n"; + stasis_testing_write_ascii("mock.ini", mockinidata); + struct INIFILE *ini = ini_open("mock.ini"); + ctx._stasis_ini_fp.delivery = ini; + ctx._stasis_ini_fp.delivery_path = realpath("mock.ini", NULL); + + setenv("TMPDIR", cwd_workspace, 1); + globals.sysconfdir = getenv("STASIS_SYSCONFDIR"); + ctx.storage.root = strdup(cwd_workspace); + + setenv("LANG", "C", 1); + bootstrap_build_info(&ctx); + delivery_init(&ctx); + + STASIS_TEST_RUN(tests); + + chdir(cwd_start); + if (rmtree(cwd_workspace)) { + perror(cwd_workspace); + } + STASIS_TEST_END_MAIN(); +}
\ No newline at end of file diff --git a/tests/test_download.c b/tests/test_download.c new file mode 100644 index 0000000..cee7683 --- /dev/null +++ b/tests/test_download.c @@ -0,0 +1,50 @@ +#include "testing.h" + +void test_download() { + struct testcase { + const char *url; + long http_code; + const char *data; + const char *errmsg; + }; + struct testcase tc[] = { + {.url = "https://ssb.stsci.edu/jhunk/stasis_test/test_download.txt", .http_code = 200L, .data = "It works!\n", .errmsg = NULL}, + {.url = "https://ssb.stsci.edu/jhunk/stasis_test/test_download.broken", .http_code = 404L, .data = "<html", .errmsg = NULL}, + {.url = "https://example.tld", .http_code = -1L, .data = NULL, .errmsg = "Couldn't resolve host name"}, + }; + + for (size_t i = 0; i < sizeof(tc) / sizeof(*tc); i++) { + const char *filename = "output.txt"; + char errmsg[BUFSIZ] = {0}; + char *errmsg_p = errmsg; + long http_code = download((char *) tc[i].url, filename, &errmsg_p); + if (tc[i].errmsg) { + STASIS_ASSERT(strlen(errmsg_p), "an error should have been thrown by curl, but wasn't"); + fflush(stderr); + SYSERROR("curl error message: %s", errmsg_p); + } else { + STASIS_ASSERT(!strlen(errmsg_p), "unexpected error thrown by curl"); + } + STASIS_ASSERT(http_code == tc[i].http_code, "expecting non-error HTTP code"); + + char **data = file_readlines(filename, 0, 100, NULL); + if (http_code >= 0) { + STASIS_ASSERT(data != NULL, "data should not be null"); + STASIS_ASSERT(strncmp(data[0], tc[i].data, strlen(tc[i].data)) == 0, "data file does not match the expected contents"); + } else { + STASIS_ASSERT(http_code == -1, "http_code should be -1 on fatal curl error"); + STASIS_ASSERT(data == NULL, "data should be NULL on fatal curl error"); + } + guard_free(data); + remove(filename); + } +} + +int main(int argc, char *argv[]) { + STASIS_TEST_BEGIN_MAIN(); + STASIS_TEST_FUNC *tests[] = { + test_download, + }; + STASIS_TEST_RUN(tests); + STASIS_TEST_END_MAIN(); +}
\ No newline at end of file |