diff --git a/.clang-format b/.clang-format index 593a5ea..089c354 100644 --- a/.clang-format +++ b/.clang-format @@ -31,7 +31,7 @@ BinPackParameters: true BraceWrapping: AfterCaseLabel: false AfterClass: false - AfterControlStatement: Never + AfterControlStatement: MultiLine AfterEnum: false AfterFunction: false AfterNamespace: false diff --git a/.clangd b/.clangd index 3036582..359a391 100644 --- a/.clangd +++ b/.clangd @@ -1,2 +1,2 @@ CompileFlags: - Add: [-std=c++17, -Iinclude] + Add: [-std=c++20] diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index 75ee195..0baaa09 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -16,6 +16,8 @@ jobs: - uses: actions/checkout@v4 - name: Update apt repositories for ccache run: sudo apt update + - name: Install dependencies + run: sudo apt-get install util-linux libmount-dev slurm-wlm libslurm-dev slurmd slurmctld slurm-client - name: Set up ccache uses: hendrikmuhs/ccache-action@v1.2 with: diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0c6131e --- /dev/null +++ b/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2023-2024, ETH Zürich +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/meson.build b/meson.build index 1657030..d1d6e20 100644 --- a/meson.build +++ b/meson.build @@ -1,38 +1,41 @@ project('uenv', ['cpp'], default_options : [ - 'cpp_std=c++17', + 'cpp_std=c++20', 'default_library=static', ], version: files('VERSION'), meson_version: '>=0.57') version = meson.project_version() -#default_mount_point = get_option('default_mount_point') +uenv_slurm_plugin = get_option('slurm_plugin') +uenv_cli = get_option('cli') conf_data = configuration_data() # configure the dependencies -# + # use meson wrap to provide the dependencies, because the 'default_library=static' option # will be propogated to each dependency, for a statically linked executable. -catch_dep = subproject('catch2', default_options: 'werror=false').get_variable('catch2_with_main_dep') -cli11_dep = subproject('cli11', default_options: 'werror=false').get_variable('CLI11_dep') -fmt_dep = subproject('fmt', default_options: 'werror=false').get_variable('fmt_dep') -json_dep = subproject('nlohmann_json', default_options: 'werror=false').get_variable('nlohmann_json_dep') -spdlog_dep = subproject('spdlog', default_options: 'werror=false').get_variable('spdlog_dep') -sqlite3_dep = subproject('sqlite3', default_options: 'werror=false').get_variable('sqlite3_dep') +catch_dep = subproject('catch2', default_options: ['werror=false', 'warning_level=0']).get_variable('catch2_with_main_dep') +cli11_dep = subproject('cli11', default_options: ['werror=false', 'warning_level=0']).get_variable('CLI11_dep') +fmt_dep = subproject('fmt', default_options: ['werror=false', 'warning_level=0']).get_variable('fmt_dep') +json_dep = subproject('nlohmann_json', default_options: ['werror=false', 'warning_level=0']).get_variable('nlohmann_json_dep') +spdlog_dep = subproject('spdlog', default_options: ['werror=false', 'warning_level=0','std_format=disabled','external_fmt=enabled']).get_variable('spdlog_dep') +sqlite3_dep = subproject('sqlite3', default_options: ['werror=false', 'warning_level=0']).get_variable('sqlite3_dep') # the lib dependency is all of the common funtionality shared between the CLI # and the slurm plugin. lib_src = [ + 'src/uenv/cscs.cpp', 'src/uenv/env.cpp', 'src/uenv/envvars.cpp', 'src/uenv/lex.cpp', 'src/uenv/log.cpp', 'src/uenv/meta.cpp', 'src/uenv/parse.cpp', - 'src/util/strings.cpp', + 'src/uenv/repository.cpp', 'src/util/shell.cpp', + 'src/util/strings.cpp', 'src/uenv/uenv.cpp', ] lib_inc = include_directories('src') @@ -44,25 +47,30 @@ lib_dep = declare_dependency( ) # the uenv executable -uenv_src = [ - 'src/cli/env.cpp', - 'src/cli/start.cpp', - 'src/cli/uenv.cpp', -] -uenv_dep = [sqlite3_dep] +if uenv_cli + uenv_src = [ + 'src/cli/image.cpp', + 'src/cli/ls.cpp', + 'src/cli/run.cpp', + 'src/cli/start.cpp', + 'src/cli/uenv.cpp', + ] + uenv_dep = [sqlite3_dep] -uenv = executable('uenv', - sources: uenv_src, - dependencies: [uenv_dep, lib_dep, fmt_dep, spdlog_dep, cli11_dep], - c_args: ['-DVERSION="@0@"'.format(version)], - install: true) + cli = executable('uenv', + sources: uenv_src, + dependencies: [uenv_dep, lib_dep, fmt_dep, spdlog_dep, cli11_dep], + c_args: ['-DVERSION="@0@"'.format(version)], + install: true) +endif unit_src = [ 'test/unit/env.cpp', 'test/unit/envvars.cpp', - 'test/unit/parse.cpp', 'test/unit/lex.cpp', 'test/unit/main.cpp', + 'test/unit/parse.cpp', + 'test/unit/repository.cpp', ] unit = executable('unit', @@ -71,3 +79,9 @@ unit = executable('unit', build_by_default: true, install: false) +if uenv_slurm_plugin + subdir('src/slurm') +endif + +# install the license +#install_data('LICENSE', install_mode : 'rw-r--r--', install_dir : 'license') diff --git a/meson_options.txt b/meson_options.txt new file mode 100644 index 0000000..4ed7e38 --- /dev/null +++ b/meson_options.txt @@ -0,0 +1,2 @@ +option('slurm_plugin', type: 'boolean', value: true) +option('cli', type: 'boolean', value: true) diff --git a/rpm/macros.meson b/rpm/macros.meson new file mode 100644 index 0000000..21dd77a --- /dev/null +++ b/rpm/macros.meson @@ -0,0 +1,34 @@ +%__sourcedir . +%__builddir %{_target_platform} +%__meson_wrap_mode nodownload + +%meson_setup \ + mkdir -p %{__builddir} \ + CFLAGS="${CFLAGS:-%optflags}"; export CFLAGS; \ + CXXFLAGS="${CXXFLAGS:-%optflags}"; export CXXFLAGS; \ + FFLAGS="${FFLAGS:-%optflags}"; export FFLAGS; \ + FCFLAGS="${FCFLAGS:-%optflags}"; export FCFLAGS; \ + meson setup %{__sourcedir} %{__builddir} \\\ + %{?_enable_debug:-Ddebug=true} \\\ + --prefix=%{_prefix} \\\ + --bindir=%{_bindir} \\\ + --sbindir=%{_sbindir} \\\ + --libexecdir=%{_libexecdir} \\\ + --libdir=%{_libdir} \\\ + --localstatedir=%{_var} \\\ + --sharedstatedir=%{_sharedstatedir} \\\ + --includedir=%{_includedir} \\\ + --datadir=%{_datadir} \\\ + --sysconfdir=%{_sysconfdir} \\\ + --mandir=%{_mandir} \\\ + --infodir=%{_infodir} \\\ + --localedir=%{_datadir}/locale \\\ + -Dcli=false \\\ + -Dslurm_plugin=true \\\ + %{nil} + +%meson_build \ +meson compile %_smp_mflags -C %{__builddir} + +%meson_install \ +DESTDIR=%buildroot meson install --no-rebuild --skip-subprojects -C %{__builddir} diff --git a/rpm/make-rpm.sh b/rpm/make-rpm.sh new file mode 100755 index 0000000..390d6fb --- /dev/null +++ b/rpm/make-rpm.sh @@ -0,0 +1,117 @@ +#!/bin/bash + +usage="$(basename "$0") [-h] [-s,--skip-binary] [--slurm-version] dstdir + +Helper script to create a source and binary rpm of the project. + +Options: +-h,--help show this help text +-s,--skip-bin skip creation of binary package +--slurm-version slurm version to specify in RPM dependency, defaults to 20.11.9 +" + +# Default argument +RPM_SLURM_VERSION=20.11.9 + +# Initialize our own variables +skip_bin=0 + +# A temporary variable to hold the output of `getopt` +TEMP=$(getopt -o s,h --long skip-bin,help,slurm-version: -- "$@") + +# If getopt has reported an error, exit script with an error +if [ $? != 0 ]; then + # echo 'Error parsing options' >&2 + echo "${usage}" >&2 + exit 1 +fi + +eval set -- "$TEMP" + +# Now go through all the options +while true; do + case "$1" in + -s | --skip-bin) + skip_bin=1 + shift + ;; + --slurm-version) + shift + RPM_SLURM_VERSION="$1" + shift + ;; + -h | --help) + shift + echo "${usage}" + exit 1 + ;; + --) + shift + break + ;; + *) + echo "Internal error! $1" + exit 1 + ;; + esac +done + +# Remaining dstdir is in $1 +dstdir=$(realpath $1) + +# Check if the positional argument was provided +if [ -z "$dstdir" ]; then + echo "${usage}" >&2 + exit 1 +fi + +# Print the parsed arguments +echo "skip_bin=$skip_bin" +echo "dstdir=$dstdir" +echo "RPM_SLURM_VERSION=$RPM_SLURM_VERSION" + +set -euo pipefail + +# absolute path to this script (where the spec file is located) +_scriptdir=$(realpath "$(dirname "${BASH_SOURCE[0]}")") + +# the project root directory +_projectdir=$(realpath "${_scriptdir}/../") + +SLURM_UENV_MOUNT_VERSION=$(sed 's/-.*//' "${_scriptdir}/../VERSION") + +rm -rf "${dstdir}" +mkdir -p "${dstdir}" + +echo SLURM_UENV_MOUNT_VERSION=$SLURM_UENV_MOUNT_VERSION +echo _scriptdir=$_scriptdir +echo _projectdir=$_projectdir +tarball=slurm-uenv-mount-"${SLURM_UENV_MOUNT_VERSION}".tar.gz + +( + cd "${dstdir}" + + mkdir -p BUILD BUILDROOT RPMS SOURCES SPECS SRPMS + + source_prefix="slurm-uenv-mount-${SLURM_UENV_MOUNT_VERSION}" + + ( + cd "${_projectdir}" + git archive --format=tar.gz --output="${dstdir}/SOURCES/${tarball}" HEAD + ) + + cp "${_scriptdir}/slurm-uenv-mount.spec" SPECS/ + sed -i "s|UENVMNT_VERSION|${SLURM_UENV_MOUNT_VERSION}|g" SPECS/slurm-uenv-mount.spec + sed -i "s|RPM_SLURM_VERSION|${RPM_SLURM_VERSION}|g" SPECS/slurm-uenv-mount.spec + + # create src rpm + rpmbuild -bs --define "_topdir ." SPECS/slurm-uenv-mount.spec + + if [ "${skip_bin}" -eq "0" ]; then + # create binary rpm + rpmbuild --nodeps --define "_topdir $(pwd)" \ + --define "set_build_flags CXXFLAGS=\"-O2 -Wall -Wpedantic\"" \ + --define "_vpath_srcdir ${source_prefix}" \ + --rebuild SRPMS/slurm-uenv-mount-*.src.rpm + fi +) diff --git a/rpm/readme.md b/rpm/readme.md new file mode 100644 index 0000000..efc2290 --- /dev/null +++ b/rpm/readme.md @@ -0,0 +1,31 @@ +# For building RPMs. + +RPM packaging requires that it performs the `meson setup ...`, `meson compile ...` +and `meson install ...` itself, in a controlled environment. The script provided +here drives that process, after making suitable sacrifices to the RPM packaging gods. + +**WARNING**: the tools here build the RPM based on the state of the source code at `HEAD` +in the git repository. If you have uncommitted changes, they will not be reflected +in the generated RPM. + +**NOTE**: building RPMs is fiddly business - it isn't you, it is `rpmbuild`. Contact +Ben or Simon for help instead of trying to find good docs on RPMs (they don't exist). + +## make-rpm.sh + +A script that will generate both source and binary RPMs for the project. + +It requires a destination path where the RPM build will occur, and should be run in this path. + +``` +./rpm-build.sh $HOME/rpm/uenv +``` + +## macros.meson + +The spec file `slurm-uenv-mount.spec` uses macros like `%meson_setup`, which parameterise +calls to meson. Macros for meson are not usually available, so we provide a definition of +the macros in `macros.meson`. They have been modified from the ones provided in the +following RPM: + +https://packages.altlinux.org/en/sisyphus/binary/rpm-macros-meson/noarch/2908824343041241330 diff --git a/rpm/slurm-uenv-mount.spec b/rpm/slurm-uenv-mount.spec new file mode 100644 index 0000000..4596ae5 --- /dev/null +++ b/rpm/slurm-uenv-mount.spec @@ -0,0 +1,37 @@ +Name: slurm-uenv-mount +Version: UENVMNT_VERSION +Release: RPM_SLURM_VERSION +Summary: SLURM spank plugin to mount squashfs images. +Prefix: /usr +Requires: slurm = RPM_SLURM_VERSION + +License: BSD3 +URL: https://github.com/eth-cscs/slurm-uenv-mount +Source0: %{name}-%{version}.tar.gz + +BuildRequires: meson gcc slurm-devel + +%define _build_id_links none + +%description +A SLURM spank plugin to mount squashfs images. + +%prep +%autosetup -c + +%build +%meson_setup +%meson_build + +%install +%meson_install + +%post +REQ="required /usr/lib64/libslurm-uenv-mount.so" +CNF=/etc/plugstack.conf.d/99-slurm-uenv-mount.conf +mkdir -p /etc/plugstack.conf.d +echo "$REQ" > "$CNF" + +%files +%license LICENSE +%{_libdir}/lib%{name}.so diff --git a/src/cli/env.cpp b/src/cli/env.cpp deleted file mode 100644 index 2fd4ef3..0000000 --- a/src/cli/env.cpp +++ /dev/null @@ -1,61 +0,0 @@ -#include -#include - -#include -#include - -namespace uenv { - -std::unordered_map getenv(const env& environment) { - // accumulator for the environment variables that will be set. - // (key, value) -> (environment variable name, value) - std::unordered_map env_vars; - - // returns the value of an environment variable. - // if the variable has been recorded in env_vars, that value is returned - // else the cstdlib getenv function is called to get the currently set value - // returns nullptr if the variable is not set anywhere - auto ge = [&env_vars](const std::string& name) -> const char* { - if (env_vars.count(name)) { - return env_vars[name].c_str(); - } - return ::getenv(name.c_str()); - }; - - // iterate over each view in order, and set the environment variables that - // each view configures. - // the variables are not set directly, instead they are accumulated in - // env_vars. - for (auto& view : environment.views) { - auto result = environment.uenvs.at(view.uenv) - .views.at(view.name) - .environment.get_values(ge); - for (const auto& v : result) { - env_vars[v.name] = v.value; - } - } - - return env_vars; -} - -util::expected -setenv(const std::unordered_map& variables) { - for (auto var : variables) { - std::string fwd_name = "SQFSMNT_FWD_" + var.first; - if (auto rcode = ::setenv(fwd_name.c_str(), var.second.c_str(), true)) { - switch (rcode) { - case EINVAL: - return util::unexpected( - fmt::format("invalid variable name {}", fwd_name)); - case ENOMEM: - return util::unexpected("out of memory"); - default: - return util::unexpected( - fmt::format("unknown error setting {}", fwd_name)); - } - } - } - return 0; -} - -} // namespace uenv diff --git a/src/cli/env.h b/src/cli/env.h deleted file mode 100644 index 5db45dd..0000000 --- a/src/cli/env.h +++ /dev/null @@ -1,19 +0,0 @@ -#pragma once - -#include -#include - -#include -#include - -// common code for working with environment variables used by different CLI -// modes - -namespace uenv { - -std::unordered_map getenv(const env&); - -util::expected -setenv(const std::unordered_map& variables); - -} // namespace uenv diff --git a/src/cli/image.cpp b/src/cli/image.cpp new file mode 100644 index 0000000..39272d7 --- /dev/null +++ b/src/cli/image.cpp @@ -0,0 +1,32 @@ +// vim: ts=4 sts=4 sw=4 et +#include + +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "image.h" +#include "ls.h" + +namespace uenv { + +void image_help() { + fmt::println("image help"); +} + +void image_args::add_cli(CLI::App& cli, + [[maybe_unused]] global_settings& settings) { + auto* image_cli = + cli.add_subcommand("image", "manage and query uenv images"); + + // add the `uenv image ls` command + ls_args.add_cli(*image_cli, settings); +} + +} // namespace uenv diff --git a/src/cli/image.h b/src/cli/image.h new file mode 100644 index 0000000..f2c90cb --- /dev/null +++ b/src/cli/image.h @@ -0,0 +1,19 @@ +// vim: ts=4 sts=4 sw=4 et +#pragma once + +#include +#include + +#include "ls.h" +#include "uenv.h" + +namespace uenv { + +void image_help(); + +struct image_args { + image_ls_args ls_args; + void add_cli(CLI::App&, global_settings& settings); +}; + +} // namespace uenv diff --git a/src/cli/ls.cpp b/src/cli/ls.cpp new file mode 100644 index 0000000..a059525 --- /dev/null +++ b/src/cli/ls.cpp @@ -0,0 +1,107 @@ +// vim: ts=4 sts=4 sw=4 et + +// #include + +#include +#include +#include + +#include +#include +#include +#include + +#include "ls.h" + +namespace uenv { + +void image_ls_help() { + fmt::println("image ls help"); +} + +void image_ls_args::add_cli(CLI::App& cli, + [[maybe_unused]] global_settings& settings) { + auto* ls_cli = cli.add_subcommand("ls", "manage and query uenv images"); + ls_cli->add_option("uenv", uenv_description, + "comma separated list of uenv to mount."); + ls_cli->add_flag("--no-header", no_header, + "print only the matching records, with no header."); + ls_cli->callback([&settings]() { settings.mode = uenv::mode_image_ls; }); +} + +int image_ls(const image_ls_args& args, const global_settings& settings) { + spdlog::info("image ls {}", + args.uenv_description ? *args.uenv_description : "none"); + + // get the repo and handle errors if it does not exist + if (!settings.repo) { + spdlog::error( + "a repo needs to be provided either using the --repo flag " + "or by setting the UENV_REPO_PATH environment variable"); + return 1; + } + + // open the repo + auto store = uenv::open_repository(*settings.repo); + if (!store) { + spdlog::error("unable to open repo: {}", store.error()); + return 1; + } + + // find the search term that was provided by the user + uenv_label label{}; + if (args.uenv_description) { + if (const auto parse = parse_uenv_label(*args.uenv_description)) { + label = *parse; + } else { + spdlog::error("invalid search term: {}", parse.error().message()); + return 1; + } + } + + // set label->system to the current cluster name if it has not + // already been set. + label.system = cscs::get_system_name(label.system); + + // query the repo + const auto result = store->query(label); + if (!result) { + spdlog::error("invalid search term: {}", store.error()); + return 1; + } + + if (result->empty()) { + if (!args.no_header) { + fmt::println("no matching uenv"); + } + return 0; + } + + // print the results + std::size_t w_name = std::string_view("uenv").size(); + std::size_t w_sys = std::string_view("system").size(); + std::size_t w_arch = std::string_view("arch").size(); + + for (auto& r : *result) { + w_name = std::max( + w_name, fmt::format("{}/{}:{}", r.name, r.version, r.tag).size()); + w_sys = std::max(w_sys, r.system.size()); + w_arch = std::max(w_arch, r.uarch.size()); + } + ++w_name; + ++w_sys; + ++w_arch; + if (!args.no_header) { + fmt::println("{:<{}}{:<{}}{:<{}}{:<18}", "uenv", w_name, "arch", w_arch, + "system", w_sys, "id"); + } + for (auto& r : *result) { + auto name = fmt::format("{}/{}:{}", r.name, r.version, r.tag); + fmt::println("{:<{}}{:<{}}{:<{}}{:<18}", name, w_name, r.uarch, w_arch, + r.system, w_sys, r.id.string()); + } + + return 0; +} + +} // namespace uenv diff --git a/src/cli/ls.h b/src/cli/ls.h new file mode 100644 index 0000000..a327db6 --- /dev/null +++ b/src/cli/ls.h @@ -0,0 +1,40 @@ +#pragma once +// vim: ts=4 sts=4 sw=4 et + +#include + +#include +#include + +#include + +#include "uenv.h" + +namespace uenv { + +void image_ls_help(); + +struct image_ls_args { + std::optional uenv_description; + bool no_header = false; + void add_cli(CLI::App&, global_settings& settings); +}; + +int image_ls(const image_ls_args& args, const global_settings& settings); + +} // namespace uenv + +template <> class fmt::formatter { + public: + // parse format specification and store it: + constexpr auto parse(format_parse_context& ctx) { + return ctx.end(); + } + // format a value using stored specification: + template + constexpr auto format(uenv::image_ls_args const& opts, + FmtContext& ctx) const { + return fmt::format_to(ctx.out(), "{{uenv: '{}'}}", + opts.uenv_description); + } +}; diff --git a/src/cli/run.cpp b/src/cli/run.cpp new file mode 100644 index 0000000..2b3fcf0 --- /dev/null +++ b/src/cli/run.cpp @@ -0,0 +1,71 @@ +// vim: ts=4 sts=4 sw=4 et + +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "run.h" +#include "uenv.h" + +namespace uenv { + +void run_help() { + fmt::println("run help"); +} + +void run_args::add_cli(CLI::App& cli, global_settings& settings) { + auto* run_cli = cli.add_subcommand("run", "run a uenv session"); + run_cli->add_option("-v,--view", view_description, + "comma separated list of views to load"); + run_cli + ->add_option("uenv", uenv_description, + "comma separated list of uenv to mount") + ->required(); + run_cli + ->add_option("commands", commands, + "the command to run, including with arguments") + ->required(); + run_cli->callback([&settings]() { settings.mode = uenv::mode_run; }); +} + +int run(const run_args& args, const global_settings& globals) { + spdlog::info("run with options {}", args); + const auto env = concretise_env(args.uenv_description, + args.view_description, globals.repo); + + if (!env) { + spdlog::error("{}", env.error()); + return 1; + } + + // generate the environment variables to set + auto env_vars = uenv::getenv(*env); + + if (auto rval = uenv::setenv(env_vars, "SQFSMNT_FWD_"); !rval) { + spdlog::error("setting environment variables {}", rval.error()); + return 1; + } + + // generate the mount list + std::vector commands = {"squashfs-mount"}; + for (auto e : env->uenvs) { + commands.push_back(fmt::format("{}:{}", e.second.sqfs_path.string(), + e.second.mount_path)); + } + + commands.push_back("--"); + commands.insert(commands.end(), args.commands.begin(), args.commands.end()); + + return util::exec(commands); +} + +} // namespace uenv diff --git a/src/cli/run.h b/src/cli/run.h new file mode 100644 index 0000000..fd354e0 --- /dev/null +++ b/src/cli/run.h @@ -0,0 +1,50 @@ +// vim: ts=4 sts=4 sw=4 et + +#include +#include +#include + +#include +#include +#include + +#include + +#include "uenv.h" + +namespace uenv { + +void run_help(); + +struct run_args { + std::string uenv_description; + std::optional view_description; + std::vector commands; + void add_cli(CLI::App&, global_settings& settings); +}; + +int run(const run_args& args, const global_settings& settings); + +} // namespace uenv + +template <> class fmt::formatter { + public: + // parse format specification and store it: + constexpr auto parse(format_parse_context& ctx) { + return ctx.end(); + } + // format a value using stored specification: + template + constexpr auto format(uenv::run_args const& opts, FmtContext& ctx) const { + auto tmp = fmt::format_to( + ctx.out(), "{{uenv: '{}', view: ", opts.uenv_description); + if (!opts.view_description) { + return fmt::format_to(tmp, "'', commands: {}}}", + opts.uenv_description, + fmt::join(opts.commands, " ")); + } + return fmt::format_to(tmp, "'{}', commands: {}}}", + *opts.view_description, + fmt::join(opts.commands, " ")); + } +}; diff --git a/src/cli/start.cpp b/src/cli/start.cpp index 55cc35b..31130d3 100644 --- a/src/cli/start.cpp +++ b/src/cli/start.cpp @@ -13,7 +13,6 @@ #include #include -#include "env.h" #include "start.h" #include "uenv.h" @@ -37,10 +36,9 @@ void start_args::add_cli(CLI::App& cli, int start(const start_args& args, [[maybe_unused]] const global_settings& globals) { - spdlog::debug("running start with options {}", args); - - const auto env = - concretise_env(args.uenv_description, args.view_description); + spdlog::info("start with options {}", args); + const auto env = concretise_env(args.uenv_description, + args.view_description, globals.repo); if (!env) { spdlog::error("{}", env.error()); @@ -50,7 +48,7 @@ int start(const start_args& args, // generate the environment variables to set auto env_vars = uenv::getenv(*env); - if (auto rval = uenv::setenv(env_vars); !rval) { + if (auto rval = uenv::setenv(env_vars, "SQFSMNT_FWD_"); !rval) { spdlog::error("setting environment variables {}", rval.error()); return 1; } @@ -74,7 +72,6 @@ int start(const start_args& args, commands.push_back("--"); commands.push_back(shell->string()); - spdlog::info("exec {}", fmt::join(commands, " ")); return util::exec(commands); } diff --git a/src/cli/uenv.cpp b/src/cli/uenv.cpp index 4963025..38bbda9 100644 --- a/src/cli/uenv.cpp +++ b/src/cli/uenv.cpp @@ -7,6 +7,12 @@ #include +#include +#include +#include + +#include "image.h" +#include "run.h" #include "start.h" #include "uenv.h" @@ -20,9 +26,15 @@ int main(int argc, char** argv) { CLI::App cli("uenv"); cli.add_flag("-v,--verbose", settings.verbose, "enable verbose output"); cli.add_flag("--no-color", settings.no_color, "disable color output"); + cli.add_flag("--repo", settings.repo_, "the uenv repository"); + + uenv::start_args start; + uenv::run_args run; + uenv::image_args image; - uenv::start_args start_args; - start_args.add_cli(cli, settings); + start.add_cli(cli, settings); + run.add_cli(cli, settings); + image.add_cli(cli, settings); CLI11_PARSE(cli, argc, argv); @@ -38,11 +50,41 @@ int main(int argc, char** argv) { } uenv::init_log(console_log_level, spdlog::level::trace); - spdlog::debug("{}", settings); + // if a repo was not provided as a flag, look at environment variables + if (!settings.repo_) { + if (const auto p = uenv::default_repo_path()) { + settings.repo_ = *uenv::default_repo_path(); + } else { + spdlog::warn("ignoring the default repo path: {}", p.error()); + } + } + + // post-process settings after the CLI arguments have been parsed + if (settings.repo_) { + if (const auto rpath = + uenv::validate_repo_path(*settings.repo_, false, false)) { + settings.repo = *rpath; + } else { + spdlog::warn("ignoring repo path due to an error, {}", + rpath.error()); + settings.repo = std::nullopt; + settings.repo_ = std::nullopt; + } + } + + if (settings.repo) { + spdlog::info("repo is set {}", *settings.repo); + } + + spdlog::info("{}", settings); switch (settings.mode) { case uenv::mode_start: - return uenv::start(start_args, settings); + return uenv::start(start, settings); + case uenv::mode_run: + return uenv::run(run, settings); + case uenv::mode_image_ls: + return uenv::image_ls(image.ls_args, settings); case uenv::mode_none: default: help(); diff --git a/src/cli/uenv.h b/src/cli/uenv.h index ae39398..5af1e9e 100644 --- a/src/cli/uenv.h +++ b/src/cli/uenv.h @@ -1,27 +1,34 @@ // vim: ts=4 sts=4 sw=4 et #pragma once +#include +#include +#include + #include +#include + namespace uenv { extern int mode; -// extern bool verbose; constexpr int mode_none = 0; constexpr int mode_start = 1; -// constexpr int mode_status = 1; -// constexpr int mode_image = 2; -// constexpr int mode_run = 3; +constexpr int mode_run = 2; +constexpr int mode_image_ls = 3; struct global_settings { int verbose = 0; bool no_color = false; int mode = mode_none; - // bool color = true; - // std::string repo; - // std::string const to_string() + // repo_ is the unverified string description of the repo path that is + // either read from an environment variable or as a --repo CLI argument. the + // value should be validated using uenv::validate_repo_path before use. + std::optional repo_; + + std::optional repo; }; } // namespace uenv diff --git a/src/slurm/config.hpp.in b/src/slurm/config.hpp.in new file mode 100644 index 0000000..ba1e1f5 --- /dev/null +++ b/src/slurm/config.hpp.in @@ -0,0 +1,5 @@ +#pragma once + +#define DEFAULT_MOUNT_POINT "@default_mount_point@" +// the name of the env variable `UENV_REPO_PATH` +#define UENV_REPO_PATH_VARNAME "@uenv_repo_path_varname@" diff --git a/src/slurm/meson.build b/src/slurm/meson.build new file mode 100644 index 0000000..3875c85 --- /dev/null +++ b/src/slurm/meson.build @@ -0,0 +1,29 @@ +conf_data = configuration_data() +conf_data.set('default_mount_point', '/user-environment') +conf_data.set('uenv_repo_path_varname', '$SCRATCH/.uenv-images') +configure_file(input : 'config.hpp.in', + output : 'config.hpp', + configuration : conf_data) + +libmount_dep = dependency('mount') + +#lib_src = ['./lib/parse_args.cpp', + #'./lib/database.cpp', + #'./lib/filesystem.cpp', + #'./lib/mount.cpp', + #'./lib/sqlite.cpp', + #'./lib/strings.cpp'] + +module_src = ['plugin.cpp', 'mount.cpp'] + +#module_inc = include_directories('src') + +module_dep = [libmount_dep, sqlite3_dep, lib_dep] + +shared_module('slurm-uenv-mount', + sources: module_src, + dependencies: module_dep, + #include_directories: module_inc, + cpp_args: ['-Wall', '-Wpedantic', '-Wextra'], + install: true) + diff --git a/src/slurm/mount.cpp b/src/slurm/mount.cpp new file mode 100644 index 0000000..5fc24f3 --- /dev/null +++ b/src/slurm/mount.cpp @@ -0,0 +1,142 @@ +#include +#include +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include + +namespace uenv { + +util::expected mount_entry::validate() const { + namespace fs = std::filesystem; + + auto mount = fs::path(mount_path); + + // does the mount point exist? + if (!fs::exists(mount)) { + return util::unexpected( + fmt::format("the mount point '{}' does not exist", mount_path)); + } + + mount = fs::canonical(mount); + + // is the mount point a directory? + if (!fs::is_directory(mount)) { + return util::unexpected( + fmt::format("the mount point '{}' is not a directory", mount_path)); + } + + auto sqfs = fs::path(sqfs_path); + + // does the squashfs path exist? + if (!fs::exists(sqfs)) { + return util::unexpected( + fmt::format("the squashfs file '{}' does not exist", sqfs_path)); + } + + // remove symlink etc, so that we can test file and permissions + sqfs = fs::canonical(sqfs); + + // is the squashfs path a file ? + if (!fs::is_regular_file(sqfs)) { + return util::unexpected( + fmt::format("the squashfs file '{}' is not a file", sqfs_path)); + } + + // do we have read access to the squashfs file + const fs::perms sqfs_perm = fs::status(sqfs).permissions(); + auto satisfies = [&sqfs_perm](fs::perms c) { + return fs::perms::none != (sqfs_perm & c); + }; + if (!(satisfies(fs::perms::owner_read) || + satisfies(fs::perms::group_read))) { + return util::unexpected( + fmt::format("you do not have read access to the squashfs file '{}'", + sqfs_path)); + } + + return {}; +} + +util::expected +do_mount(const std::vector& mount_entries) { + if (mount_entries.size() == 0) { + return {}; + } + if (unshare(CLONE_NEWNS) != 0) { + return util::unexpected("Failed to unshare the mount namespace"); + } + // make all mounts in new namespace slave mounts, changes in the original + // namesapce won't propagate into current namespace + if (mount(NULL, "/", NULL, MS_SLAVE | MS_REC, NULL) != 0) { + return util::unexpected( + "mount: unable to change `/` to MS_SLAVE | MS_REC"); + } + + for (auto& entry : mount_entries) { + std::string mount_point = entry.mount_path; + std::string squashfs_file = entry.sqfs_path; + + if (!std::filesystem::is_regular_file(squashfs_file)) { + return util::unexpected("the uenv squashfs file does not exist: " + + squashfs_file); + } + if (!std::filesystem::is_directory(mount_point)) { + return util::unexpected("the mount point is not a valide path: " + + mount_point); + } + + auto cxt = mnt_new_context(); + + if (mnt_context_disable_mtab(cxt, 1) != 0) { + return util::unexpected("Failed to disable mtab"); + } + + if (mnt_context_set_fstype(cxt, "squashfs") != 0) { + return util::unexpected("Failed to set fstype to squashfs"); + } + + if (mnt_context_append_options(cxt, "loop,nosuid,nodev,ro") != 0) { + return util::unexpected("Failed to set mount options"); + } + + if (mnt_context_set_source(cxt, squashfs_file.c_str()) != 0) { + return util::unexpected("Failed to set source"); + } + + if (mnt_context_set_target(cxt, mount_point.c_str()) != 0) { + return util::unexpected("Failed to set target"); + } + + // https://ftp.ntu.edu.tw/pub/linux/utils/util-linux/v2.38/libmount-docs/libmount-Mount-context.html#mnt-context-mount + const int rc = mnt_context_mount(cxt); + const bool success = rc == 0 && mnt_context_get_status(cxt) == 1; + if (!success) { + char code_buf[256]; + mnt_context_get_excode(cxt, rc, code_buf, sizeof(code_buf)); + const char* target_buf = mnt_context_get_target(cxt); + // careful: mnt_context_get_target can return NULL + std::string target = (target_buf == nullptr) ? "?" : target_buf; + + return util::unexpected(target + ": " + code_buf); + } + } + + return {}; +} + +} // namespace uenv diff --git a/src/slurm/plugin.cpp b/src/slurm/plugin.cpp new file mode 100644 index 0000000..252daa1 --- /dev/null +++ b/src/slurm/plugin.cpp @@ -0,0 +1,364 @@ +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "config.hpp" + +extern "C" { +#include +#include +} + +namespace uenv { + +util::expected +do_mount(const std::vector& mount_entries); + +void set_log_level() { + // use warn as the default log level + auto log_level = spdlog::level::warn; + bool invalid_env = false; + + // check the environment variable UENV_LOG_LEVEL + auto log_level_str = std::getenv("UENV_LOG_LEVEL"); + if (log_level_str != nullptr) { + int lvl; + auto [ptr, ec] = std::from_chars( + log_level_str, log_level_str + std::strlen(log_level_str), lvl); + + if (ec == std::errc()) { + if (lvl == 1) { + log_level = spdlog::level::info; + } else if (lvl == 2) { + log_level = spdlog::level::debug; + } else if (lvl > 2) { + log_level = spdlog::level::trace; + } + } else { + invalid_env = true; + } + } + uenv::init_log(log_level, spdlog::level::info); + if (invalid_env) { + spdlog::warn(fmt::format("UENV_LOG_LEVEL invalid value '{}' -- " + "expected a value between 0 and 3", + log_level_str)); + } +} + +} // namespace uenv + +// +// Forward declare the implementation of the plugin callbacks. +// + +namespace impl { +int slurm_spank_init(spank_t sp, int ac, char** av); +int slurm_spank_init_post_opt(spank_t sp, int ac, char** av); +} // namespace impl + +// +// Implement the SPANK plugin C interface. +// + +extern "C" { + +extern const char plugin_name[] = "uenv-mount"; +extern const char plugin_type[] = "spank"; +#ifdef SLURM_VERSION_NUMBER +extern const unsigned int plugin_version = SLURM_VERSION_NUMBER; +#endif +extern const unsigned int spank_plugin_version = 1; + +// Called from both srun and slurmd. +int slurm_spank_init(spank_t sp, int ac, char** av) { + return impl::slurm_spank_init(sp, ac, av); +} + +int slurm_spank_init_post_opt(spank_t sp, int ac, char** av) { + return impl::slurm_spank_init_post_opt(sp, ac, av); +} + +} // extern "C" + +// +// Implementation +// +namespace impl { +struct arg_pack { + std::optional uenv_description; + std::optional view_description; + std::optional repo_description; +}; + +static arg_pack args{}; + +/// wrapper for getenv - uses std::getenv or spank_getenv depending +/// on the slurm context (local or remote) +std::optional getenv_wrapper(spank_t sp, const char* var) { + const int len = 1024; + char buf[len]; + + if (spank_context() == spank_context_t::S_CTX_LOCAL || + spank_context() == spank_context_t::S_CTX_ALLOCATOR) { + if (const auto ret = std::getenv(var); ret != nullptr) { + return ret; + } + return std::nullopt; + } + + const auto ret = spank_getenv(sp, var, buf, len); + + if (ret == ESPANK_ENV_NOEXIST) { + return std::nullopt; + } + + if (ret == ESPANK_SUCCESS) { + return std::string{buf}; + } + + slurm_spank_log("getenv failed"); + throw ret; +} + +util::expected, std::string> +validate_uenv_mount_list(std::string mount_var) { + auto mount_list = uenv::parse_mount_list(mount_var); + if (!mount_list) { + return util::unexpected(mount_list.error().message()); + } + + for (auto& entry : *mount_list) { + if (auto valid = entry.validate(); !valid) { + return util::unexpected(valid.error()); + } + } + + return *mount_list; +} + +static spank_option uenv_arg{ + (char*)"uenv", + (char*)"[:mount-point][,]*", + (char*)"A comma seprated list of file and mountpoint, default mount-point " + "is " DEFAULT_MOUNT_POINT, + 1, // requires an argument + 0, // plugin specific value to pass to the callback (unnused) + [](int val, const char* optarg, int remote) -> int { + slurm_verbose("uenv: val:%d optarg:%s remote:%d", val, optarg, remote); + args.uenv_description = optarg; + return ESPANK_SUCCESS; + }}; + +static spank_option view_arg{ + (char*)"view", + (char*)"[uenv:]view-name[,]*", + (char*)"A comma separated list of uenv views", + 1, // requires an argument + 0, // plugin specific value to pass to the callback (unnused) + [](int val, const char* optarg, int remote) -> int { + slurm_verbose("uenv: val:%d optarg:%s remote:%d", val, optarg, remote); + args.view_description = optarg; + return ESPANK_SUCCESS; + }}; + +static spank_option repo_arg{ + (char*)"repo", + (char*)"path*", + (char*)"the absolute path of a uenv repository used to look up uenv images", + 1, // requires an argument + 0, // plugin specific value to pass to the callback (unnused) + [](int val, const char* optarg, int remote) -> int { + slurm_verbose("uenv: val:%d optarg:%s remote:%d", val, optarg, remote); + args.repo_description = optarg; + return ESPANK_SUCCESS; + }}; + +int slurm_spank_init(spank_t sp, int ac [[maybe_unused]], + char** av [[maybe_unused]]) { + + for (auto arg : {&uenv_arg, &view_arg, &repo_arg}) { + if (auto status = spank_option_register(sp, arg)) { + return status; + } + } + + return ESPANK_SUCCESS; +} + +/// check if image, mountpoint is valid +int init_post_opt_remote(spank_t sp) { + // initialise logging + // level warning to console + // level info to syslog + uenv::init_log(spdlog::level::warn, spdlog::level::info); + + // parse environment variables to test whether there is anything to mount + auto mount_var = getenv_wrapper(sp, "UENV_MOUNT_LIST"); + + // variable is not set - nothing to do here + if (!mount_var) { + return ESPANK_SUCCESS; + } + + auto mount_list = validate_uenv_mount_list(*mount_var); + if (!mount_list) { + slurm_error("internal error parsing the mount list: %s", + mount_list.error().c_str()); + return -ESPANK_ERROR; + } + + auto result = do_mount(*mount_list); + if (!result) { + slurm_error("error mounting the requested uenv image: %s", + result.error().c_str()); + return -ESPANK_ERROR; + } + + return ESPANK_SUCCESS; +} + +/// parse and validate the CLI arguments +/// set environment variables that are used in the remote context to mount the +/// image set environment variables for all requested views +int init_post_opt_local_allocator(spank_t sp [[maybe_unused]]) { + // initialise logging + uenv::set_log_level(); + + if (!args.uenv_description) { + // it is an error if the view argument was passed without the uenv + // argument + if (args.view_description) { + slurm_error("the uenv --view=%s argument is set, but the --uenv " + "argument was not", + args.view_description->c_str()); + return -ESPANK_ERROR; + } + + // check whether a uenv has been mounted in the calling environment. + // this will be mounted in the remote context, so check that: + // * the squashfs image exists + // * the user has read access to the squashfs image + // * the mount point exists + if (auto mount_var = getenv_wrapper(sp, "UENV_MOUNT_LIST")) { + if (auto mount_list = validate_uenv_mount_list(*mount_var); + !mount_list) { + slurm_error("invalid UENV_MOUNT_LIST: %s", + mount_list.error().c_str()); + return -ESPANK_ERROR; + } + } + + return ESPANK_SUCCESS; + } + + // if no repository was explicitly set using the --repo argument, check + // UENV_REPO_PATH environment variable, before using default in SCRATCH or + // HOME. + if (!args.repo_description) { + if (const auto r = uenv::default_repo_path()) { + args.repo_description = *r; + } else { + slurm_error("unable to find a valid repo path: %s", + r.error().c_str()); + return -ESPANK_ERROR; + } + } + + // parse and validate the repo path if one is set + // - it is a valid path description + // - it is an absolute path + // - it exists + std::optional repo_path; + if (args.repo_description) { + const auto r = + uenv::validate_repo_path(*args.repo_description, false, false); + if (!r) { + slurm_error("unable to find a valid repo path: %s", + r.error().c_str()); + return -ESPANK_ERROR; + } + repo_path = *r; + } + + const auto env = uenv::concretise_env(*args.uenv_description, + args.view_description, repo_path); + + if (!env) { + slurm_error("%s", env.error().c_str()); + return -ESPANK_ERROR; + } + + // generate the environment variables to set + auto env_vars = uenv::getenv(*env); + + if (auto rval = uenv::setenv(env_vars, ""); !rval) { + slurm_error("setting environment variables: %s", rval.error().c_str()); + return -ESPANK_ERROR; + } + + // set additional environment variables that are required to communicate + // with the remote plugin. + std::unordered_map uenv_vars; + + std::vector uenv_mount_list; + for (auto& e : env->uenvs) { + auto& u = e.second; + uenv_mount_list.push_back( + fmt::format("{}:{}", u.sqfs_path.string(), u.mount_path.string())); + } + uenv_vars["UENV_MOUNT_LIST"] = + fmt::format("{}", fmt::join(uenv_mount_list, ",")); + if (!env->views.empty()) { + std::vector view_list; + for (auto& v : env->views) { + auto& u = env->uenvs.at(v.uenv); + view_list.push_back( + fmt::format("{}:{}:{}", u.mount_path.string(), v.uenv, v.name)); + } + uenv_vars["UENV_VIEW"] = fmt::format("{}", fmt::join(view_list, ",")); + } + + if (auto rval = uenv::setenv(uenv_vars, ""); !rval) { + slurm_error("setting uenv environment variables %s", + rval.error().c_str()); + return -ESPANK_ERROR; + } + + return ESPANK_SUCCESS; +} + +int slurm_spank_init_post_opt(spank_t sp, int ac [[maybe_unused]], + char** av [[maybe_unused]]) { + switch (spank_context()) { + case spank_context_t::S_CTX_REMOTE: { + return init_post_opt_remote(sp); + } + case spank_context_t::S_CTX_LOCAL: + case spank_context_t::S_CTX_ALLOCATOR: { + return init_post_opt_local_allocator(sp); + } + default: + break; + } + + return ESPANK_SUCCESS; +} + +} // namespace impl diff --git a/src/uenv/cscs.cpp b/src/uenv/cscs.cpp new file mode 100644 index 0000000..a9db9cb --- /dev/null +++ b/src/uenv/cscs.cpp @@ -0,0 +1,27 @@ +#include +#include + +#include + +#include "cscs.h" + +namespace cscs { + +std::optional get_system_name(std::optional value) { + if (value) { + if (value == "*") { + return std::nullopt; + } + return value; + } + + if (auto system_name = std::getenv("CLUSTER_NAME")) { + spdlog::debug("cluster name is '{}'", system_name); + return system_name; + } + + spdlog::debug("cluster name is undefined"); + return std::nullopt; +} + +} // namespace cscs diff --git a/src/uenv/cscs.h b/src/uenv/cscs.h new file mode 100644 index 0000000..212e825 --- /dev/null +++ b/src/uenv/cscs.h @@ -0,0 +1,8 @@ +#pragma once + +#include +#include + +namespace cscs { +std::optional get_system_name(std::optional); +} diff --git a/src/uenv/env.cpp b/src/uenv/env.cpp index 196b731..2901617 100644 --- a/src/uenv/env.cpp +++ b/src/uenv/env.cpp @@ -1,33 +1,39 @@ #include #include #include +#include #include #include #include #include +#include #include +#include #include +#include namespace uenv { +using util::unexpected; + util::expected concretise_env(const std::string& uenv_args, - std::optional view_args) { + std::optional view_args, + std::optional repo_arg) { namespace fs = std::filesystem; // parse the uenv description that was provided as a command line argument. // the command line argument is a comma-separated list of uenvs, where each - // uenc is either + // uenv is either // - the path of a squashfs image; or - // - a uenv description of the form name/version:tag + // - a uenv description of the form name[/version][:tag][@system][%uarch] // with an optional mount point. const auto uenv_descriptions = uenv::parse_uenv_args(uenv_args); if (!uenv_descriptions) { - return util::unexpected( - fmt::format("unable to read the uenv argument\n {}", - uenv_descriptions.error().msg)); + return unexpected(fmt::format("invalid uenv description: {}", + uenv_descriptions.error().message())); } // concretise the uenv descriptions by looking for the squashfs file, or @@ -35,67 +41,86 @@ concretise_env(const std::string& uenv_args, // after this loop, we have fully validated list of uenvs, mount points and // meta data (if they have meta data). - // the following are for assigning default mount points when no explicit - // mount point is provided. - std::vector> default_mounts{ - "/user-environment", "/user-tools", {}}; - auto default_mount = default_mounts.begin(); - std::unordered_map uenvs; + std::set used_mounts; + std::set used_sqfs; for (auto& desc : *uenv_descriptions) { - // determine the mount point of the uenv, then validate that it exists. - fs::path mount; - if (auto m = desc.mount()) { - mount = *m; - // once an explicit mount point has been set defaults are no longer - // applied. set default mount to the last entry, which is a nullopt - // std::optional. - default_mount = std::prev(default_mounts.end()); - } else { - // no mount point was provided for this uenv, so use the default if - // it is available. - if (*default_mount) { - mount = **default_mount; - ++default_mount; - } else { - return util::unexpected( - fmt::format("no mount point provided for {}", desc)); + // determine the sqfs_path + fs::path sqfs_path; + + // if a label was used to describe the uenv (e.g. "prgenv-gnu/24.7" + // it has to be looked up in a repo. + if (auto label = desc.label()) { + if (!repo_arg) { + return unexpected( + "a repo needs to be provided either using the --repo flag " + "or by setting the UENV_REPO_PATH environment variable"); + } + auto store = uenv::open_repository(*repo_arg); + if (!store) { + return unexpected( + fmt::format("unable to open repo: {}", store.error())); } - } - spdlog::info("{} will be mounted at {}", desc, mount); - // check that the mount point exists - if (!fs::exists(mount)) { - return util::unexpected( - fmt::format("the mount point '{}' does not exist", mount)); - } + // set label->system to the current cluster name if it has not + // already been set. + label->system = cscs::get_system_name(label->system); - if (desc.label()) { - return util::unexpected( - fmt::format("support for mounting uenv from labels is not " - "supported yet: '{}'", - *desc.label())); - } + // search for label in the repo + const auto result = store->query(*label); + if (!result) { + return unexpected(fmt::format("{}", store.error())); + } + std::vector results = *result; - // get the sqfs_path - // desc.filename - check that it exists - // desc.label - perform database lookup (TODO) + if (results.size() == 0u) { + return unexpected(fmt::format("no uenv matches '{}'", *label)); + } - // TODO parameterise whether an absolute path is a hard requirement (for - // the SLURM plugin it is) - auto sqfs_path = fs::path(*desc.filename()); - if (!fs::exists(sqfs_path)) { - return util::unexpected( - fmt::format("{} does not exist", sqfs_path)); + // ensure that all results share a unique sha + if (results.size() > 1u) { + std::stable_sort(results.begin(), results.end(), + [](const auto& lhs, const auto& rhs) -> bool { + return lhs.sha < rhs.sha; + }); + auto e = + std::unique(results.begin(), results.end(), + [](const auto& lhs, const auto& rhs) -> bool { + return lhs.sha == rhs.sha; + }); + if (std::distance(results.begin(), e) > 1u) { + auto errmsg = fmt::format( + "more than one uenv matches the uenv description " + "'{}':", + desc.label().value()); + for (auto r : results) { + errmsg += fmt::format("\n {}", r); + } + return unexpected(errmsg); + } + } + + // set sqfs_path + const auto& r = results[0]; + sqfs_path = + *repo_arg / "images" / r.sha.string() / "store.squashfs"; + } + // otherwise an explicit filename was provided, e.g. + // "/scratch/myimages/develp/store.squashfs" + else { + sqfs_path = fs::path(*desc.filename()); } + sqfs_path = fs::absolute(sqfs_path); - if (!fs::is_regular_file(sqfs_path)) { - return util::unexpected(fmt::format("{} is not a file", sqfs_path)); + if (!fs::exists(sqfs_path) || !fs::is_regular_file(sqfs_path)) { + return unexpected( + fmt::format("the uenv image {} does not exist or is not a file", + sqfs_path)); } + spdlog::info("{} squashfs image {}", desc, sqfs_path.string()); // set the meta data path and env.json path if they exist - const auto img_root_path = sqfs_path.parent_path(); - const auto meta_p = img_root_path / "meta"; + const auto meta_p = sqfs_path.parent_path() / "meta"; const auto env_meta_p = meta_p / "env.json"; const std::optional meta_path = fs::is_directory(meta_p) ? meta_p : std::optional{}; @@ -106,19 +131,23 @@ concretise_env(const std::string& uenv_args, // if meta/env.json exists, parse the json therein std::string name; std::string description; + std::optional mount_meta; std::unordered_map views; if (env_meta_path) { if (const auto result = uenv::load_meta(*env_meta_path)) { name = std::move(result->name); - description = std::move(result->description); + description = result->description.value_or(""); + mount_meta = result->mount; views = std::move(result->views); - spdlog::debug("loaded meta with name {}", name); + spdlog::info("{}: loaded meta (name {}, mount {})", desc, name, + mount_meta); } else { - spdlog::error("error loading the uenv meta data in {}: {}", + spdlog::error("{} opening the uenv meta data {}: {}", desc, *env_meta_path, result.error()); } } else { - spdlog::debug("the meta data file {} does not exist", meta_path); + spdlog::debug("{} no meta file found at expected location {}", desc, + meta_path); description = ""; // generate a unique name for the uenv name = "anonymous"; @@ -129,6 +158,59 @@ concretise_env(const std::string& uenv_args, } } + // if an explicit mount point was provided, use that + // otherwise use the mount point provided in the meta data + auto mount_string = desc.mount() ? desc.mount() : mount_meta; + + // handle the case where no mount point was provided by the CLI or meta + // data + if (!mount_string) { + return unexpected( + fmt::format("no mount point provided for {}", desc)); + } + + fs::path mount; + if (auto p = parse_path(*mount_string)) { + mount = *p; + if (!fs::exists(mount)) { + return unexpected(fmt::format( + "the mount point {} for {} does not exist", desc, mount)); + } + if (!fs::is_directory(mount)) { + return unexpected( + fmt::format("the mount point {} for {} is not a directory", + desc, mount)); + } + if (!mount.is_absolute()) { + return unexpected(fmt::format( + "the mount point {} for {} must be an absolute path", desc, + mount)); + } + } else { + return unexpected( + fmt::format("invalid mount point provided for {}: {}", desc, + p.error().message())); + } + spdlog::info("{} will be mounted at {}", desc, mount); + + // check for unique mount points and squashfs images + { + mount = fs::canonical(mount); + if (used_mounts.count(mount)) { + return unexpected(fmt::format("more than one image mounted " + "at the mount point '{}'", + mount)); + } + used_mounts.insert(mount); + + sqfs_path = fs::canonical(sqfs_path); + if (used_sqfs.count(sqfs_path)) { + return unexpected(fmt::format( + "the '{}' uenv is mounted more than once", sqfs_path)); + } + used_sqfs.insert(sqfs_path); + } + uenvs[name] = concrete_uenv{name, mount, sqfs_path, meta_path, description, std::move(views)}; } @@ -154,11 +236,13 @@ concretise_env(const std::string& uenv_args, if (view_args) { const auto view_descriptions = uenv::parse_view_args(*view_args); if (!view_descriptions) { - return util::unexpected(fmt::format("invalid view description: {}", - view_descriptions.error().msg)); + return unexpected(fmt::format("invalid view description: {}", + view_descriptions.error().message())); } for (auto& view : *view_descriptions) { + spdlog::debug("analysing view {}", view); + // check whether the view name matches the name of any views // provided by uenv if (view2uenv.count(view.name)) { @@ -169,7 +253,13 @@ concretise_env(const std::string& uenv_args, if (!view.uenv) { // it is ambiguous if more than one option is available if (matching_uenvs.size() > 1) { - return util::unexpected("ambiguous view name"); + auto errstr = fmt::format( + "there is more than one view named '{}':", + view.name); + for (auto m : matching_uenvs) { + errstr += fmt::format("\n {}:{}", m, view.name); + } + return unexpected(errstr); } views.push_back({matching_uenvs[0], view.name}); } @@ -183,17 +273,17 @@ concretise_env(const std::string& uenv_args, }); // no uenv matches if (it == matching_uenvs.end()) { - return util::unexpected(""); + return unexpected( + fmt::format("the view '{}:{}' does not exist", + *view.uenv, view.name)); } views.push_back({*it, view.name}); } } // no view that matches the view is available else { - return util::unexpected( - fmt::format("the requested view '{}' is not " - "provided by any of the uenv", - view.name)); + return unexpected( + fmt::format("the view '{}' does not exist", view.name)); } } } @@ -201,4 +291,69 @@ concretise_env(const std::string& uenv_args, return env{uenvs, views}; } +std::unordered_map getenv(const env& environment) { + // accumulator for the environment variables that will be set. + // (key, value) -> (environment variable name, value) + std::unordered_map env_vars; + + // returns the value of an environment variable. + // if the variable has been recorded in env_vars, that value is returned + // else the cstdlib getenv function is called to get the currently set value + // returns nullptr if the variable is not set anywhere + auto ge = [&env_vars](const std::string& name) -> const char* { + if (env_vars.count(name)) { + return env_vars[name].c_str(); + } + return ::getenv(name.c_str()); + }; + + // iterate over each view in order, and set the environment variables that + // each view configures. + // the variables are not set directly, instead they are accumulated in + // env_vars. + for (auto& view : environment.views) { + auto result = environment.uenvs.at(view.uenv) + .views.at(view.name) + .environment.get_values(ge); + for (const auto& v : result) { + env_vars[v.name] = v.value; + } + } + + return env_vars; +} + +util::expected +setenv(const std::unordered_map& variables, + const std::string& prefix) { + for (auto var : variables) { + std::string fwd_name = prefix + var.first; + if (auto rcode = ::setenv(fwd_name.c_str(), var.second.c_str(), true)) { + switch (rcode) { + case EINVAL: + return unexpected( + fmt::format("invalid variable name {}", fwd_name)); + case ENOMEM: + return unexpected("out of memory"); + default: + return unexpected( + fmt::format("unknown error setting {}", fwd_name)); + } + } + } + return 0; +} + +bool operator==(const uenv_record& lhs, const uenv_record& rhs) { + return std::tie(lhs.name, lhs.version, lhs.tag, lhs.system, lhs.uarch, + lhs.sha) == std::tie(rhs.name, rhs.version, rhs.tag, + rhs.system, rhs.uarch, rhs.sha); +} + +bool operator<(const uenv_record& lhs, const uenv_record& rhs) { + return std::tie(lhs.name, lhs.version, lhs.tag, lhs.system, lhs.uarch, + lhs.sha) < std::tie(rhs.name, rhs.version, rhs.tag, + rhs.system, rhs.uarch, rhs.sha); +} + } // namespace uenv diff --git a/src/uenv/env.h b/src/uenv/env.h index c7b2a8d..9474628 100644 --- a/src/uenv/env.h +++ b/src/uenv/env.h @@ -1,15 +1,9 @@ #pragma once -// #include -// #include -// #include -// #include #include #include #include -// #include - #include #include @@ -26,6 +20,13 @@ struct env { util::expected concretise_env(const std::string& uenv_args, - std::optional view_args); + std::optional view_args, + std::optional repo_arg); + +std::unordered_map getenv(const env&); + +util::expected +setenv(const std::unordered_map& variables, + const std::string& prefix); } // namespace uenv diff --git a/src/uenv/lex.cpp b/src/uenv/lex.cpp index d97e8ec..266c29f 100644 --- a/src/uenv/lex.cpp +++ b/src/uenv/lex.cpp @@ -36,6 +36,10 @@ class lexer_impl { parse(); } + std::string string() const { + return std::string(input_); + } + token next() { auto t = token_; parse(); @@ -109,6 +113,18 @@ class lexer_impl { character_token(tok::at); ++stream_; return; + case '!': + character_token(tok::bang); + ++stream_; + return; + case '%': + character_token(tok::percent); + ++stream_; + return; + case '*': + character_token(tok::star); + ++stream_; + return; #pragma GCC diagnostic push #pragma GCC diagnostic ignored "-Wpedantic" @@ -136,7 +152,11 @@ class lexer_impl { } void character_token(tok kind) { - token_ = {loc(), kind, std::string_view(&*stream_, 1)}; + if (kind != tok::end) { + token_ = {loc(), kind, std::string_view(&*stream_, 1)}; + } else { + token_ = {loc(), kind, std::string_view("end", 3)}; + } } char character() { @@ -185,6 +205,10 @@ tok lexer::current_kind() const { return impl_->current_kind(); } +std::string lexer::string() const { + return impl_->string(); +} + lexer::~lexer() = default; } // namespace uenv diff --git a/src/uenv/lex.h b/src/uenv/lex.h index 4ee6dd8..178f38b 100644 --- a/src/uenv/lex.h +++ b/src/uenv/lex.h @@ -1,7 +1,7 @@ #pragma once #include -#include +#include #include @@ -15,8 +15,11 @@ enum class tok { symbol, // string, e.g. prgenv-gnu dash, // comma ',' dot, // comma ',' - end, // end of input whitespace, // sequence of spaces + bang, // exclamation mark '!' + star, // '*' + percent, // percentage symbol '%' + end, // end of input error, // invalid input encountered in stream }; @@ -44,6 +47,9 @@ class lexer { // a convenience helper for checking the kind of the current token tok current_kind() const; + // return a string view of the full input + std::string string() const; + ~lexer(); private: @@ -64,6 +70,8 @@ template <> class fmt::formatter { switch (t) { case uenv::tok::colon: return fmt::format_to(ctx.out(), "colon"); + case uenv::tok::star: + return fmt::format_to(ctx.out(), "star"); case uenv::tok::comma: return fmt::format_to(ctx.out(), "comma"); case uenv::tok::slash: @@ -78,6 +86,10 @@ template <> class fmt::formatter { return fmt::format_to(ctx.out(), "whitespace"); case uenv::tok::at: return fmt::format_to(ctx.out(), "at"); + case uenv::tok::bang: + return fmt::format_to(ctx.out(), "bang"); + case uenv::tok::percent: + return fmt::format_to(ctx.out(), "percent"); case uenv::tok::end: return fmt::format_to(ctx.out(), "end"); case uenv::tok::error: diff --git a/src/uenv/log.cpp b/src/uenv/log.cpp index a9d599d..3493906 100644 --- a/src/uenv/log.cpp +++ b/src/uenv/log.cpp @@ -11,10 +11,16 @@ void init_log(spdlog::level::level_enum console_log_level, spdlog::level::level_enum syslog_log_level) { auto console_sink = std::make_shared(); console_sink->set_level(console_log_level); + if (console_log_level >= spdlog::level::level_enum::info) { + console_sink->set_pattern("[%^%l%$] %v"); + } else { + console_sink->set_pattern("[%Y-%m-%d %H:%M:%S.%e] [%n] [%^%l%$] %v"); + } auto syslog_sink = std::make_shared( "uenv", LOG_PID, 0, false); syslog_sink->set_level(syslog_log_level); + syslog_sink->set_pattern("[%Y-%m-%d %H:%M:%S.%e] [%n] [%^%l%$] %v"); // The default logger is a combined logger that dispatches to the console // and syslog. We explicitly set the level to trace to allow all messages to @@ -23,6 +29,5 @@ void init_log(spdlog::level::level_enum console_log_level, spdlog::set_default_logger(std::make_shared( "uenv", spdlog::sinks_init_list({console_sink, syslog_sink}))); spdlog::set_level(spdlog::level::trace); - spdlog::set_pattern("[%Y-%m-%d %H:%M:%S.%e] [%n] [%^%l%$] %v"); } } // namespace uenv diff --git a/src/uenv/meta.cpp b/src/uenv/meta.cpp index 94b2d2e..c674daf 100644 --- a/src/uenv/meta.cpp +++ b/src/uenv/meta.cpp @@ -1,5 +1,6 @@ #include #include +#include #include #include @@ -19,17 +20,26 @@ util::expected load_meta(const std::filesystem::path& file) { if (!std::filesystem::is_regular_file(file)) { return util::unexpected(fmt::format( - "unable to read meta data file {}: it is not a file.", file)); + "the uenv meta data file {} does not exist", file.string())); } auto fid = std::ifstream(file); - // TODO: check parse - const auto raw = json::parse(fid); + nlohmann::json raw; + try { + raw = json::parse(fid); + } catch (std::exception& e) { + return util::unexpected( + fmt::format("error parsing meta data file for uenv {}: {}", + file.string(), e.what())); + } const std::string name = raw.contains("name") ? raw["name"] : "unnamed"; - const std::string description = - raw.contains("description") ? raw["description"] : ""; + using ostring = std::optional; + const ostring description = + raw.contains("description") ? ostring(raw["description"]) : ostring{}; + const ostring mount = + raw.contains("mount") ? ostring(raw["mount"]) : ostring{}; std::unordered_map views; if (auto& jviews = raw["views"]; jviews.is_object()) { @@ -60,7 +70,7 @@ util::expected load_meta(const std::filesystem::path& file) { } } - return meta{name, description, views}; + return meta{name, description, mount, views}; } } // namespace uenv diff --git a/src/uenv/meta.h b/src/uenv/meta.h index fe1011d..f15898d 100644 --- a/src/uenv/meta.h +++ b/src/uenv/meta.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include @@ -13,7 +14,8 @@ namespace uenv { struct meta { // construct meta data from an input file std::string name; - std::string description; + std::optional description; + std::optional mount; std::unordered_map views; }; diff --git a/src/uenv/mount.h b/src/uenv/mount.h new file mode 100644 index 0000000..a069554 --- /dev/null +++ b/src/uenv/mount.h @@ -0,0 +1,16 @@ +#pragma once + +#include + +#include + +namespace uenv { + +struct mount_entry { + std::string sqfs_path; + std::string mount_path; + + util::expected validate() const; +}; + +} // namespace uenv diff --git a/src/uenv/parse.cpp b/src/uenv/parse.cpp index bfceedc..ab83af0 100644 --- a/src/uenv/parse.cpp +++ b/src/uenv/parse.cpp @@ -6,10 +6,16 @@ #include #include +#include #include namespace uenv { +std::string parse_error::message() const { + return fmt::format("{}\n {}\n {}{}", detail, input, std::string(loc, ' '), + std::string(std::max(1u, width), '^')); +} + // some pre-processor gubbins that generates code to attempt // parsing a value using a parse_x method. unwraps and // forwards the error if there was an error. @@ -35,9 +41,7 @@ parse_string(lexer& L, std::string_view type, Test&& test) { if (result.empty()) { const auto t = L.peek(); return util::unexpected(parse_error{ - fmt::format("internal error parsing a {}, unexpected '{}'", type, - t.spelling), - t.loc}); + L.string(), fmt::format("unexpected '{}'", type, t.spelling), t}); } return result; @@ -57,20 +61,62 @@ std::string sanitise_input(std::string_view input) { return sanitised; } +// tokens that can appear in names +// names aer used for uenv names, versions, tags bool is_name_tok(tok t) { return t == tok::symbol || t == tok::dash || t == tok::dot; }; + +// tokens that can be the first token in a name +// don't allow leading dashes and periods +bool is_name_start_tok(tok t) { + return t == tok::symbol; +}; + util::expected parse_name(lexer& L) { + if (!is_name_start_tok(L.current_kind())) { + const auto t = L.peek(); + return util::unexpected(parse_error{ + L.string(), fmt::format("found unexpected '{}'", t.spelling), t}); + } return parse_string(L, "name", is_name_tok); } +// all of the symbols that can occur in a path. +// this is a subset of the characters that posix allows (which is effectively +// every character). But it is a sane subset. If the user somehow has spaces or +// colons in their file names, we are doing them a favor. bool is_path_tok(tok t) { return t == tok::slash || t == tok::symbol || t == tok::dash || t == tok::dot; }; +// require that all paths start with a dot or / +bool is_path_start_tok(tok t) { + return t == tok::slash || t == tok::dot; +}; util::expected parse_path(lexer& L) { + if (!is_path_start_tok(L.current_kind())) { + const auto t = L.peek(); + return util::unexpected(parse_error{ + L.string(), + fmt::format("expected a path which must start with a '/' or '.'"), + t}); + } return parse_string(L, "path", is_path_tok); } +util::expected parse_path(const std::string& in) { + auto L = lexer(in); + auto result = parse_path(L); + if (!result) { + return result; + } + + if (const auto t = L.peek(); t.kind != tok::end) { + return util::unexpected(parse_error{ + L.string(), fmt::format("unexpected symbol '{}'", t.spelling), t}); + } + return result; +} util::expected parse_view_description(lexer& L) { // there are two valid inputs to parse @@ -92,19 +138,69 @@ util::expected parse_view_description(lexer& L) { util::expected parse_uenv_label(lexer& L) { uenv_label result; + // labels are of the form: + // name[/version][:tag][!uarch][@system] + // - name is required + // - the other fields are optional + // - version and tag are required to be in order (tag after version and + // before uarch or system) + // - uarch and system come after tag, and can be in any order + PARSE(L, name, result.name); + // process version and tag in order, if they are present if (L.current_kind() == tok::slash) { L.next(); PARSE(L, name, result.version); } if (L.current_kind() == tok::colon) { + // the ':' character is also used to set the mount point. + // do not continue parsing if a path follows the ':'. + if (is_path_start_tok(L.peek(1).kind)) { + return result; + } + L.next(); PARSE(L, name, result.tag); } + // process version and system in any order + bool system = false; + bool uarch = false; + while ((L.current_kind() == tok::at && !system) || + (L.current_kind() == tok::percent && !uarch)) { + if (L.current_kind() == tok::at) { + L.next(); + if (L.current_kind() == tok::star) { + result.system = "*"; + L.next(); + } else { + PARSE(L, name, result.system); + } + system = true; + } else if (L.current_kind() == tok::percent) { + L.next(); + PARSE(L, name, result.uarch); + uarch = true; + } + } return result; } +util::expected +parse_uenv_label(const std::string& in) { + auto L = lexer(in); + auto result = parse_uenv_label(L); + if (!result) { + return result; + } + + if (const auto t = L.peek(); t.kind != tok::end) { + return util::unexpected(parse_error{ + L.string(), fmt::format("unexpected symbol '{}'", t.spelling), t}); + } + return result; +} + // parse an individual uenv description util::expected parse_uenv_description(lexer& L) { const auto k = L.current_kind(); @@ -113,7 +209,7 @@ util::expected parse_uenv_description(lexer& L) { if (k == tok::slash) { std::string path; PARSE(L, path, path); - if (L.current_kind() == tok::at) { + if (L.current_kind() == tok::colon) { L.next(); std::string mount; PARSE(L, path, mount); @@ -127,7 +223,7 @@ util::expected parse_uenv_description(lexer& L) { if (is_name_tok(k)) { uenv_label label; PARSE(L, uenv_label, label); - if (L.current_kind() == tok::at) { + if (L.current_kind() == tok::colon) { L.next(); std::string mount; PARSE(L, path, mount); @@ -140,9 +236,7 @@ util::expected parse_uenv_description(lexer& L) { // neither path nor name label - oops const auto t = L.peek(); return util::unexpected(parse_error{ - fmt::format("not a valid uenv description, unexpected symbol '{}'", - t.spelling), - t.loc}); + L.string(), fmt::format("unexpected symbol '{}'", t.spelling), t}); } /* Public interface. @@ -178,7 +272,7 @@ parse_view_args(const std::string& arg) { // consumed, and invalid token was encountered if (const auto t = L.peek(); t.kind != tok::end) { return util::unexpected(parse_error{ - fmt::format("unexpected symbol {}", t.spelling), t.loc}); + L.string(), fmt::format("unexpected symbol '{}'", t.spelling), t}); } return views; @@ -210,9 +304,61 @@ parse_uenv_args(const std::string& arg) { // and invalid token was encountered if (const auto t = L.peek(); t.kind != tok::end) { return util::unexpected(parse_error{ - fmt::format("unexpected symbol {}", t.spelling), t.loc}); + L.string(), fmt::format("unexpected symbol '{}'", t.spelling), t}); } return uenvs; } +util::expected parse_mount_entry(lexer& L) { + mount_entry result; + + PARSE(L, path, result.sqfs_path); + if (L.current_kind() != tok::colon) { + const auto t = L.peek(); + return util::unexpected(parse_error( + L.string(), + fmt::format("expected a ':' separating the squashfs image and " + "mount path, found '{}'", + t.spelling), + t)); + } + // eat the colon + L.next(); + PARSE(L, path, result.mount_path); + + return result; +} + +util::expected, parse_error> +parse_mount_list(const std::string& arg) { + const std::string sanitised = sanitise_input(arg); + auto L = lexer(sanitised); + std::vector mounts; + + spdlog::debug("parsing uenv description {}", arg); + while (true) { + mount_entry mnt; + PARSE(L, mount_entry, mnt); + mounts.push_back(std::move(mnt)); + + if (L.peek().kind != tok::comma) { + break; + } + // eat the comma + L.next(); + + // handle trailing comma elegantly + if (L.peek().kind == tok::end) { + break; + } + } + // if parsing finished and the string has not been consumed, + // and invalid token was encountered + if (const auto t = L.peek(); t.kind != tok::end) { + return util::unexpected(parse_error{ + L.string(), fmt::format("unexpected symbol {}", t.spelling), t}); + } + return mounts; +} + } // namespace uenv diff --git a/src/uenv/parse.h b/src/uenv/parse.h index 2c6d4b6..8d3deb6 100644 --- a/src/uenv/parse.h +++ b/src/uenv/parse.h @@ -6,16 +6,40 @@ #include +#include +#include #include #include namespace uenv { +/// represents an error generated when parsing a string. +/// +/// stores the input string and the location (loc) in the string where the error +/// was encountered +/// +/// there are two levels of error message: +/// - detail: a detailed low level description (e.g. "unexpected symbol ?") that +/// correlates to the loc in input +/// - description: a high level description, usually added at a higher level +/// (e.g. "invalid --uenv argument") struct parse_error { - std::string msg; + std::string input; + std::string description; + std::string detail; unsigned loc; - parse_error(std::string msg, unsigned loc) : msg(std::move(msg)), loc(loc) { + unsigned width; + parse_error(std::string input, std::string detail, const token& tok) + : input(std::move(input)), detail(std::move(detail)), loc(tok.loc), + width(tok.spelling.length()) { } + parse_error(std::string input, std::string description, std::string detail, + const token& tok) + : input(std::move(input)), description(std::move(description)), + detail(std::move(detail)), loc(tok.loc), + width(tok.spelling.length()) { + } + std::string message() const; }; // apply to strings before parsing them. @@ -28,4 +52,11 @@ parse_view_args(const std::string& arg); util::expected, parse_error> parse_uenv_args(const std::string& arg); +util::expected, parse_error> +parse_mount_list(const std::string& arg); + +util::expected parse_path(const std::string& in); + +util::expected parse_uenv_label(const std::string& in); + } // namespace uenv diff --git a/src/uenv/repository.cpp b/src/uenv/repository.cpp new file mode 100644 index 0000000..062c4fe --- /dev/null +++ b/src/uenv/repository.cpp @@ -0,0 +1,344 @@ +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include +#include +#include + +// the C API for sqlite3 +#include + +namespace fs = std::filesystem; + +namespace uenv { + +template using hopefully = util::expected; + +using util::unexpected; + +/// get the default location for the user's repository. +/// - use the environment variable UENV_REPO_PATH if it is set +/// - use $SCRATCH/.uenv/repo if $SCRATCH is set +/// - use $HOME/.uenv/repo if $HOME is set +/// +/// returns nullopt if no environment variables were set. +/// returns error if +/// - the provided path is not absolute +/// - the path string was not valid +util::expected, std::string> default_repo_path() { + std::string path_string; + if (auto p = std::getenv("UENV_REPO_PATH")) { + return p; + } else if (auto p = std::getenv("SCRATCH")) { + return std::string(p) + "/.uenv-images"; + } else if (auto p = std::getenv("HOME")) { + return std::string(p) + "/.uenv/repo"; + } + return std::nullopt; +} + +util::expected +validate_repo_path(const std::string& path, bool is_absolute, bool exists) { + auto parsed_path_string = parse_path(path); + if (!parsed_path_string) { + return util::unexpected( + fmt::format("{} is an invalid uenv repository path: {}", path, + parsed_path_string.error().message())); + } + try { + const auto p = std::filesystem::path(*parsed_path_string); + } catch (...) { + return util::unexpected( + fmt::format("{} is an invalid uenv repository path", path)); + } + + const auto p = fs::path(path); + if (is_absolute && !p.is_absolute()) { + return unexpected(fmt::format("'{}' is not an absolute path.", path)); + } + if (exists && !fs::exists(p)) { + return unexpected(fmt::format("'{}' does not exist.", path)); + } + return fs::absolute(p); +} + +// A thin wrapper around sqlite3* +// A shared pointer with a custom destructor that calls the sqlite3 C API +// descructor is used to manage the lifetime of the sqlite3* object. The shared +// pointer is used because the sqlite_statement type needs to hold a re +struct sqlite_database { + // type definitions + using db_ptr_type = std::shared_ptr; + + // constructors + sqlite_database(sqlite3* db) : data(db, &sqlite3_close) { + } + sqlite_database(sqlite_database&&) = default; + + // state + db_ptr_type data; +}; + +hopefully open_sqlite_db(const fs::path path) { + sqlite3* db; + if (sqlite3_open_v2(path.string().c_str(), &db, SQLITE_OPEN_READONLY, + NULL) != SQLITE_OK) { + return unexpected(fmt::format( + "internal sqlite3 error opening database file {}", path.string())); + } + + return sqlite_database(db); +} + +struct sqlite_statement { + // type definitions + using db_ptr_type = sqlite_database::db_ptr_type; + using stmt_ptr_type = + std::unique_ptr; + + struct column { + int index; + std::string name; + std::any value; + + operator std::int64_t() const { + return std::any_cast(value); + } + operator std::size_t() const { + return std::any_cast(value); + } + operator std::string() const { + return std::any_cast(value); + } + }; + + // constructors + sqlite_statement(std::string query, sqlite_database& db, + sqlite3_stmt* statement) + : query(std::move(query)), db(db.data), + statement(statement, &sqlite3_finalize) { + } + + // functions + bool step() { + return SQLITE_ROW == sqlite3_step(statement.get()); + } + + hopefully operator[](int i) const { + if (i >= sqlite3_column_count(statement.get()) || i < 0) { + return unexpected(fmt::format("sqlite3_column_count out of range")); + } + + column result; + result.index = i; + result.name = sqlite3_column_name(statement.get(), i); + std::string_view type = sqlite3_column_decltype(statement.get(), i); + if (type == "TEXT") { + result.value = std::string(reinterpret_cast( + sqlite3_column_text(statement.get(), i))); + } else if (type == "INTEGER") { + // sqlite3 stores integers in the database using between 1-8 bytes + // (according to the size of the value) but when they are loaded + // into memory, it always stores them as a signed 8 byte value. + // So always convert from int64. + result.value = static_cast( + sqlite3_column_int64(statement.get(), i)); + } else { + return unexpected(fmt::format("unknown column type {}", type)); + } + + return result; + } + + hopefully operator[](const std::string& name) const { + int num_cols = sqlite3_column_count(statement.get()); + + for (int col = 0; col < num_cols; ++col) { + if (sqlite3_column_name(statement.get(), col) == name) { + return operator[](col); + } + } + + return unexpected(fmt::format( + "sqlite3 statement does not have a column named '{}'", name)); + } + + // state + const std::string query; + db_ptr_type db; + stmt_ptr_type statement; +}; + +hopefully create_sqlite_statement(const std::string& query, + sqlite_database& db) { + sqlite3_stmt* statement; + sqlite3* D = db.data.get(); + int rc = sqlite3_prepare_v2(D, query.c_str(), query.size() + 1, &statement, + nullptr); + if (SQLITE_OK != rc) { + return unexpected( + fmt::format("unable to create prepared statement:\n{}\n\n{}", + sqlite3_errstr(rc), sqlite3_errmsg(D))); + } + + return sqlite_statement{query, db, statement}; +} + +struct repository_impl { + repository_impl(sqlite_database db, fs::path path, fs::path db_path) + : db(std::move(db)), path(std::move(path)), + db_path(std::move(db_path)) { + } + repository_impl(repository_impl&&) = default; + sqlite_database db; + fs::path path; + fs::path db_path; + + util::expected, std::string> + query(const uenv_label&); +}; + +repository::repository(repository&&) = default; +repository::repository(std::unique_ptr impl) + : impl_(std::move(impl)) { +} + +util::expected +open_repository(const fs::path& repo_path) { + auto db_path = repo_path / "index.db"; + if (!fs::is_regular_file(db_path)) { + return unexpected(fmt::format("the repository is invalid - the index " + "database {} does not exist", + db_path.string())); + } + + // open the sqlite database + auto db = open_sqlite_db(db_path); + if (!db) { + return unexpected(db.error()); + } + + return repository( + std::make_unique(std::move(*db), repo_path, db_path)); +} + +util::expected, std::string> +repository_impl::query(const uenv_label& label) { + std::vector results; + + std::string query = fmt::format("SELECT * FROM records"); + std::vector query_terms; + if (label.name) { + query_terms.push_back(fmt::format("name = '{}'", *label.name)); + } + if (label.tag) { + query_terms.push_back(fmt::format("tag = '{}'", *label.tag)); + } + if (label.version) { + query_terms.push_back(fmt::format("version = '{}'", *label.version)); + } + if (label.uarch) { + query_terms.push_back(fmt::format("uarch = '{}'", *label.uarch)); + } + if (label.system) { + query_terms.push_back(fmt::format("system = '{}'", *label.system)); + } + + if (!query_terms.empty()) { + query += fmt::format(" WHERE {}", fmt::join(query_terms, " AND ")); + } + + // spdlog::info("running database query\n{}", query); + auto s = create_sqlite_statement(query, db); + if (!s) { + return unexpected( + fmt::format("creating database query: {}", s.error())); + } + while (s->step()) { + // unsafe: unwrap using .value() without checking for errors. + // the best way to do this would be to "validate" the database + // beforehand by checking the columns exist. Even better, validate that + // column 0 -> 'system', etc, and use integer indexes to look up more + // effiently. + results.push_back({(*s)["system"].value(), (*s)["uarch"].value(), + (*s)["name"].value(), (*s)["version"].value(), + (*s)["tag"].value(), (*s)["date"].value(), + (*s)["size"].value(), sha256((*s)["sha256"].value()), + uenv_id((*s)["id"].value())}); + } + + // now check for id and sha search terms + if (label.only_name()) { + // search for an if name could also be an id + if (is_sha(*label.name, 16)) { + auto result = create_sqlite_statement( + fmt::format("SELECT * FROM records WHERE id = '{}'", + uenv_id(*label.name).string()), + db); + if (result) { + while (result->step()) { + results.push_back( + {(*result)["system"].value(), + (*result)["uarch"].value(), (*result)["name"].value(), + (*result)["version"].value(), (*result)["tag"].value(), + (*result)["date"].value(), (*result)["size"].value(), + sha256((*result)["sha256"].value()), + uenv_id((*result)["id"].value())}); + } + } + } + // search for a sha if name could also be a sha256 + else if (is_sha(*label.name, 64)) { + auto result = create_sqlite_statement( + fmt::format("SELECT * FROM records WHERE sha256 = '{}'", + sha256(*label.name).string()), + db); + if (result) { + while (result->step()) { + results.push_back( + {(*result)["system"].value(), + (*result)["uarch"].value(), (*result)["name"].value(), + (*result)["version"].value(), (*result)["tag"].value(), + (*result)["date"].value(), (*result)["size"].value(), + sha256((*result)["sha256"].value()), + uenv_id((*result)["id"].value())}); + } + } + } + } + + // sort the results + std::sort(results.begin(), results.end()); + // remove duplicates + results.erase(std::unique(results.begin(), results.end()), results.end()); + + return results; +} + +// wrapping the pimpled implementation + +repository::~repository() = default; + +const fs::path& repository::path() const { + return impl_->path; +} + +const fs::path& repository::db_path() const { + return impl_->db_path; +} + +util::expected, std::string> +repository::query(const uenv_label& label) { + return impl_->query(label); +} + +} // namespace uenv diff --git a/src/uenv/repository.h b/src/uenv/repository.h new file mode 100644 index 0000000..3807173 --- /dev/null +++ b/src/uenv/repository.h @@ -0,0 +1,53 @@ +#pragma once + +#include +#include +#include + +#include +#include + +namespace uenv { + +// PIMPL forward declaration +struct repository; + +/// get the default location for the user's repository. +/// - use the environment variable UENV_REPO_PATH if it is set +/// - use $SCRATCH/.uenv-images if $SCRATCH is set +/// - use $HOME/.uenv/images +util::expected, std::string> default_repo_path(); + +util::expected +validate_repo_path(const std::string& path, bool is_absolute = true, + bool exists = true); + +util::expected +open_repository(const std::filesystem::path&); + +struct repository_impl; +struct repository { + private: + std::unique_ptr impl_; + + public: + repository(repository&&); + repository(std::unique_ptr); + + // copying a repository is not permitted + repository() = delete; + repository(const repository&) = delete; + + const std::filesystem::path& path() const; + const std::filesystem::path& db_path() const; + + util::expected, std::string> + query(const uenv_label& label); + + ~repository(); + + friend util::expected + open_repository(const std::filesystem::path&); +}; + +} // namespace uenv diff --git a/src/uenv/sqlite.cpp b/src/uenv/sqlite.cpp new file mode 100644 index 0000000..c0c7b3d --- /dev/null +++ b/src/uenv/sqlite.cpp @@ -0,0 +1,106 @@ +#include +#include + +#include "sqlite.h" + +std::map sqlite_oflag = { + {sqlite_open::readonly, SQLITE_OPEN_READONLY}}; + +SQLiteDB::SQLiteDB(const std::string& fname, sqlite_open flag) { + int rc = + sqlite3_open_v2(fname.c_str(), &this->db, sqlite_oflag.at(flag), NULL); + if (rc != SQLITE_OK) { + throw SQLiteError("Couldn't open database"); + } +} + +SQLiteDB::~SQLiteDB() { + sqlite3_close(this->db); +} + +/// SQLiteColumn +SQLiteColumn::SQLiteColumn(const SQLiteStatement& statement, int index) + : statement(statement), index(index) { +} + +std::string SQLiteColumn::name() const { + return sqlite3_column_name(this->statement.stmt, this->index); +} + +SQLiteColumn::operator int() const { + auto res = sqlite3_column_type(this->statement.stmt, this->index); + if (res != SQLITE_INTEGER) { + throw SQLiteError("Wrong column type requested"); + } + return sqlite3_column_int(this->statement.stmt, this->index); +} + +SQLiteColumn::operator std::string() const { + const unsigned char* txt = + sqlite3_column_text(this->statement.stmt, this->index); + return reinterpret_cast(txt); +} + +/// SQLiteStatement +SQLiteStatement::SQLiteStatement(SQLiteDB& db, const std::string& query) + : db(db) { + const char* tail; + int rc = + sqlite3_prepare_v2(db.get(), query.c_str(), -1, &this->stmt, &tail); + if (rc != SQLITE_OK) { + throw SQLiteError(sqlite3_errmsg(db.get())); + } + column_count = sqlite3_column_count(this->stmt); +} + +int SQLiteStatement::getColumnIndex(const std::string& name) const { + for (int i = 0; i < this->column_count; ++i) { + if (this->getColumn(i).name() == name) + return i; + } + return -1; +} + +void SQLiteStatement::bind(const std::string& name, const std::string& value) { + int i = sqlite3_bind_parameter_index(this->stmt, name.c_str()); + if (sqlite3_bind_text(this->stmt, i, value.c_str(), -1, SQLITE_STATIC) != + SQLITE_OK) { + throw SQLiteError(std::string("Failed to bind parameter: ") + + sqlite3_errmsg(this->db.get())); + } +} + +void SQLiteStatement::checkIndex(int i) const { + if (i >= this->column_count) { + throw SQLiteError("Column out of range"); + } +} + +std::string SQLiteStatement::getColumnType(int i) const { + checkIndex(i); + const char* result = sqlite3_column_decltype(this->stmt, i); + if (!result) { + throw SQLiteError("Could not determine declared column type."); + } else { + return result; + } +} + +SQLiteColumn SQLiteStatement::getColumn(int i) const { + checkIndex(i); + if (this->rc != SQLITE_ROW) { + throw SQLiteError("Statement invalid"); + } + return SQLiteColumn(*this, i); +} + +bool SQLiteStatement::execute() { + if ((this->rc = sqlite3_step(this->stmt)) == SQLITE_ROW) { + return true; + } + return false; +} + +SQLiteStatement::~SQLiteStatement() { + sqlite3_finalize(this->stmt); +} diff --git a/src/uenv/sqlite.h b/src/uenv/sqlite.h new file mode 100644 index 0000000..baee6bc --- /dev/null +++ b/src/uenv/sqlite.h @@ -0,0 +1,79 @@ +#pragma once + +#include +#include + +struct sqlite3_stmt; +struct sqlite3; + +enum class sqlite_open : int { readonly }; + +class SQLiteError : public std::exception { + public: + SQLiteError(const std::string& msg) : msg(msg) { + } + const char* what() const noexcept override { + return msg.c_str(); + } + + private: + std::string msg; +}; + +class SQLiteStatement; + +class SQLiteDB { + public: + SQLiteDB(const std::string& fname, sqlite_open flag); + SQLiteDB(const SQLiteDB&) = delete; + SQLiteDB operator=(const SQLiteDB&) = delete; + virtual ~SQLiteDB(); + + private: + sqlite3* db{nullptr}; + + protected: + sqlite3* get() { + return db; + } + + friend SQLiteStatement; +}; + +class SQLiteColumn; + +class SQLiteStatement { + public: + SQLiteStatement(SQLiteDB& db, const std::string& query); + SQLiteStatement(const SQLiteStatement&) = delete; + SQLiteStatement operator=(const SQLiteStatement&) = delete; + std::string getColumnType(int i) const; + SQLiteColumn getColumn(int i) const; + int getColumnIndex(const std::string& name) const; + void bind(const std::string& name, const std::string& value); + bool execute(); + + virtual ~SQLiteStatement(); + + private: + void checkIndex(int i) const; + + private: + SQLiteDB& db; + sqlite3_stmt* stmt; + int column_count{-1}; + int rc; + friend SQLiteColumn; +}; + +class SQLiteColumn { + public: + SQLiteColumn(const SQLiteStatement& statement, int index); + std::string name() const; + operator int() const; + operator std::string() const; + + private: + const SQLiteStatement& statement; + const int index; +}; diff --git a/src/uenv/uenv.cpp b/src/uenv/uenv.cpp index 9ba5aa4..6d547e7 100644 --- a/src/uenv/uenv.cpp +++ b/src/uenv/uenv.cpp @@ -4,6 +4,23 @@ namespace uenv { +bool is_sha(std::string_view v, std::size_t n) { + if (n > 0) { + if (n != v.size()) { + return false; + } + } + auto is_sha_value = [](char c) -> bool { + return (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9'); + }; + for (auto& c : v) { + if (!is_sha_value(c)) { + return false; + } + } + return true; +} + uenv_description::uenv_description(std::string file) : value_(std::move(file)) { } diff --git a/src/uenv/uenv.h b/src/uenv/uenv.h index 66375eb..cdfe51e 100644 --- a/src/uenv/uenv.h +++ b/src/uenv/uenv.h @@ -2,23 +2,81 @@ #include #include +#include #include #include #include +#include #include -#include "meta.h" - namespace uenv { struct uenv_label { - std::string name; + std::optional name; std::optional version; std::optional tag; + std::optional system; + std::optional uarch; + bool only_name() const { + return name && !version && !tag && !system && !uarch; + } +}; + +bool is_sha(std::string_view v, std::size_t n = 0); + +template struct sha_type { + std::array value; + + std::string string() const { + return std::string(value.begin(), value.end()); + } + + sha_type() { + value.fill('0'); + } + + sha_type(const std::string& input) { + // assert input.size() == N + // assert input values in correct range a...z,0..9 + if (!is_sha(input, N)) { + throw std::range_error( + fmt::format("'{}' is not a valid sha of length {}", input, N)); + } + + std::copy(input.begin(), input.end(), value.begin()); + } + + sha_type& operator=(const sha_type& other) = default; + + friend bool operator==(const sha_type& lhs, const sha_type& rhs) { + return lhs.value == rhs.value; + } + + friend bool operator<(const sha_type& lhs, const sha_type& rhs) { + return lhs.value < rhs.value; + } +}; + +using sha256 = sha_type<64>; +using uenv_id = sha_type<16>; + +struct uenv_record { + std::string system; + std::string uarch; + std::string name; + std::string version; + std::string tag; + std::string date; + std::size_t size_byte; + sha256 sha; + uenv_id id; }; +bool operator==(const uenv_record& lhs, const uenv_record& rhs); +bool operator<(const uenv_record& lhs, const uenv_record& rhs); + struct uenv_description { uenv_description(uenv_label label); uenv_description(uenv_label label, std::string mount); @@ -64,11 +122,19 @@ template <> class fmt::formatter { // format a value using stored specification: template constexpr auto format(uenv::uenv_label const& d, FmtContext& ctx) const { - auto ctx_ = fmt::format_to(ctx.out(), "{}", d.name); + auto ctx_ = ctx.out(); + if (d.name) + ctx_ = fmt::format_to(ctx.out(), "{}", *d.name); + else + ctx_ = fmt::format_to(ctx.out(), ""); if (d.version) ctx_ = fmt::format_to(ctx_, "/{}", *d.version); if (d.tag) ctx_ = fmt::format_to(ctx_, ":{}", *d.tag); + if (d.system) + ctx_ = fmt::format_to(ctx_, "@{}", *d.system); + if (d.uarch) + ctx_ = fmt::format_to(ctx_, "%{}", *d.uarch); return ctx_; } }; @@ -110,3 +176,28 @@ template <> class fmt::formatter { return fmt::format_to(ctx_, "meta=none)"); } }; + +template <> class fmt::formatter { + public: + // parse format specification and store it: + constexpr auto parse(format_parse_context& ctx) { + return ctx.end(); + } + // format a value using stored specification: + template + constexpr auto format(uenv::uenv_record const& r, FmtContext& ctx) const { + return fmt::format_to(ctx.out(), "{}/{}:{}@{}%{}", r.name, r.version, + r.tag, r.system, r.uarch); + /* + std::string system; + std::string uarch; + std::string name; + std::string version; + std::string tag; + std::string date; + std::size_t size_byte; + std::string sha256; + std::string id; + */ + } +}; diff --git a/src/util/expected.h b/src/util/expected.h index 7f77ce0..bb58617 100644 --- a/src/util/expected.h +++ b/src/util/expected.h @@ -190,7 +190,7 @@ struct expected { using unexpected_type = unexpected; using data_type = std::variant; - expected() = default; + expected() requires std::is_default_constructible_v: data_(T()) {}; expected(const expected&) = default; expected(expected&&) = default; diff --git a/src/util/shell.cpp b/src/util/shell.cpp index d35165f..a7ddf51 100644 --- a/src/util/shell.cpp +++ b/src/util/shell.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include @@ -42,7 +43,7 @@ int exec(const std::vector& args) { } argv.push_back(nullptr); - spdlog::info("running {}", argv[0]); + spdlog::info("exec: {}", fmt::join(args, " ")); int r = execvp(argv[0], argv.data()); // } // end unsafe diff --git a/test/.gitignore b/test/.gitignore new file mode 100644 index 0000000..1fa32e1 --- /dev/null +++ b/test/.gitignore @@ -0,0 +1,3 @@ +# ignore the scratch path used for temporary / mock data for testing +# the data in this path are generated by the test workflow +scratch diff --git a/test/integration/.gitignore b/test/integration/.gitignore new file mode 100644 index 0000000..687e4a1 --- /dev/null +++ b/test/integration/.gitignore @@ -0,0 +1,2 @@ +install +bats diff --git a/test/integration/common.bash b/test/integration/common.bash new file mode 100755 index 0000000..9dd466b --- /dev/null +++ b/test/integration/common.bash @@ -0,0 +1,35 @@ +#!/bin/bash + +function log() { + echo "${@}" >&2 +} + +function logf() { + printf "${@}\n" >&2 +} + +function run_srun_unchecked() { + log "+ srun $@" + run srun -N1 --oversubscribe "$@" + + log "${output}" + + echo "+ exit status: ${status}" +} + +function run_srun() { + run_srun_unchecked "$@" + [ "${status}" -eq 0 ] +} + +function run_sbatch_unchecked() { + slurm_log=$(mktemp) + run sbatch --wait -o "${slurm_log}" "$@" + log "${output}" + logf "+ job log (${slurm_log}):\n$(cat ${slurm_log})" +} + +function run_sbatch() { + run_sbatch_unchecked "$@" + [ "${status}" -eq 0 ] +} diff --git a/test/integration/install-bats b/test/integration/install-bats new file mode 100755 index 0000000..6f0c5f6 --- /dev/null +++ b/test/integration/install-bats @@ -0,0 +1,26 @@ +#!/bin/bash + +install_path=$(realpath ./install) + +# clean up old installation +rm -rf $install_path +rm -rf ./bats + +echo "installation path: $install_path" +mkdir -p $install_path + +curl -L https://github.com/bats-core/bats-core/archive/refs/tags/v1.9.0.tar.gz 2> /dev/null | tar xz +mv bats-core-1.9.0 $install_path/bats-core +ln -s $install_path/bats-core/bin/bats ./bats + +echo "installed bats-core: $install_path/bats-core" + +git clone --depth 1 --quiet https://github.com/bats-core/bats-assert.git $install_path/bats-helpers/bats-assert +echo "installed bats-assert: $install_path/bats-assert" + +git clone --depth 1 --quiet https://github.com/bats-core/bats-support.git $install_path/bats-helpers/bats-support +echo "installed bats-support: $install_path/bats-support" + +export BATS_LIB_PATH=$install_path/bats-helpers + +echo BATS_LIB_PATH=$BATS_LIB_PATH diff --git a/test/integration/readme.md b/test/integration/readme.md new file mode 100644 index 0000000..a2990c7 --- /dev/null +++ b/test/integration/readme.md @@ -0,0 +1,26 @@ +# Integration Tests + +Integration test for the CLI and SLURM plugin, using the [bats](https://github.com/bats-core/bats-core) testing framework. + +## getting started + +Before running tests, first download bats: + +```console +./install-bats +``` + +## running the tests + +There are two separate sets of tests. + +- `slurm.bats`: test the slurm plugin + - ensure that you have built the plugin and configured slurm in your environment to use the plugin. +- `cli.bats`: test the uenv cli + - ensure that you have built the cli and that it is in your `PATH`. + +To run the respective tests, use the copy of bats installed in this path using the `./install-bats` script above: +```console +./bats ./slurm.bats +./bats ./cli.bats +``` diff --git a/test/integration/setup_suite.bash b/test/integration/setup_suite.bash new file mode 100644 index 0000000..06ee153 --- /dev/null +++ b/test/integration/setup_suite.bash @@ -0,0 +1,18 @@ +# this file is automatically detected by bats +# it performs setup and teardown to run once, before and after respectively, all of the tests are run. + +# create the input data if it has not already been created. +function setup_suite() { + DIR="$( cd "$( dirname "$BATS_TEST_FILENAME" )" >/dev/null 2>&1 && pwd )" + scratch=$DIR/../scratch + + if [ ! -d $scratch ]; then + $DIR/../setup/setup $scratch + fi + export REPOS=$(realpath ${scratch}/repos) +} + +function teardown_suite() { + : +} + diff --git a/test/integration/slurm.bats b/test/integration/slurm.bats new file mode 100644 index 0000000..810af28 --- /dev/null +++ b/test/integration/slurm.bats @@ -0,0 +1,195 @@ +function setup() { + bats_install_path=$(realpath ./install) + export BATS_LIB_PATH=$bats_install_path/bats-helpers + + bats_load_library bats-support + bats_load_library bats-assert + load ./common + + export REPOS=$(realpath ../scratch/repos) + export SQFS_LIB=$(realpath ../scratch/sqfs) + + unset UENV_MOUNT_LIST +} + +function teardown() { + : +} + +@test "noargs" { + # nothing is done if no --uenv is present and UENV_MOUNT_LIST is empty + unset UENV_MOUNT_LIST + unset UENV_REPO_PATH + run_srun_unchecked bash -c 'findmnt -r | grep /user-environment' + refute_output --partial '/user-environment' +} + +@test "mount uenv" { + export UENV_REPO_PATH=$REPOS/apptool + + # if no mount point is provided the default provided by the uenv's meta + # data should be used. + + # app has default mount /user-environment + run_srun_unchecked --uenv=app/42.0 bash -c 'findmnt -r | grep /user-environment' + assert_output --partial '/user-environment' + + # tool has default mount /user-tools + run_srun_unchecked --uenv=tool bash -c 'findmnt -r | grep /user-tools' + assert_output --partial '/user-tools' + + # if the view is mounted, the app should be visible + run_srun_unchecked --uenv=app/42.0 --view=app app + assert_output --partial 'hello app' + + # if the view is mounted, the app should be visible + run_srun_unchecked --uenv=tool --view=tool tool + assert_output --partial 'hello tool' + + # check that the correct uenv with name app is chosen + run_srun_unchecked --uenv=app/43.0 --view=app app --version + assert_output --partial '43.0' + run_srun_unchecked --uenv=app/42.0 --view=app app --version + assert_output --partial '42.0' + + # an error should be generated if an ambiguous uenv is requested + run_srun_unchecked --uenv=app --view=app app --version + assert_output --partial "error: more than one uenv matches the uenv description 'app'" + + unset UENV_REPO_PATH + run_srun_unchecked --uenv=app/43.0 --repo=$REPOS/apptool --view=app app + assert_output --partial 'hello app' + run_srun_unchecked --uenv=app/42.0:v1@arapiles%zen3,tool --repo=$REPOS/apptool --view=app,tool tool + assert_output --partial 'hello tool' +} + +@test "views" { + export UENV_REPO_PATH=$REPOS/apptool + + # if the view is mounted, the app should be visible + run_srun_unchecked --uenv=app/42.0 --view=app app + assert_output --partial 'hello app' + + # if the view is mounted, the app should be visible + run_srun_unchecked --uenv=tool --view=tool tool + assert_output --partial 'hello tool' + + # check multiple views + uenv + run_srun_unchecked --uenv=app/42.0,tool --view=app,tool bash -c "tool; app" + assert_output --partial 'hello tool' + assert_output --partial 'hello app' + run_srun_unchecked --uenv=app/42.0,tool --view=app:app,tool:tool bash -c "tool; app" + assert_output --partial 'hello tool' + assert_output --partial 'hello app' + + # check that invalid view names are caught + run_srun_unchecked --uenv=tool --view=tools true + assert_output --partial "the view 'tools' does not exist" + run_srun_unchecked --uenv=app/42.0,tool --view=app:app,tool:tools true + assert_output --partial "the view 'tools' does not exist" + run_srun_unchecked --uenv=app/42.0,tool --view=app:app,wombat:tool true + assert_output --partial "the view 'wombat:tool' does not exist" +} + +# check for invalid arguments passed to --uenv +@test "faulty --uenv argument" { + export UENV_REPO_PATH=$REPOS/apptool + + run_srun_unchecked --uenv=a:b:c true + assert_output --partial 'expected a path' + + run_srun_unchecked --uenv=a? true + assert_output --partial "invalid uenv description: unexpected symbol '?'" + + run_srun_unchecked --uenv=app: true + assert_output --partial 'invalid uenv description:' + + run_srun_unchecked --uenv=app/42.0:v1@arapiles%zen3+ ls /user-environment + assert_output --partial "invalid uenv description: unexpected symbol '+'" +} + +@test "custom mount point" { + export UENV_REPO_PATH=$REPOS/apptool + + run_srun_unchecked --uenv=app/42.0:/user-environment bash -c 'findmnt -r | grep /user-environment' + assert_output --partial "/user-environment" +} + +@test "duplicate mount fails" { + export UENV_REPO_PATH=$REPOS/apptool + + # duplicate images fail + run_srun_unchecked --uenv=tool:/user-environment,app/42.0:/user-environment true + assert_output --partial "more than one image mounted at the mount point '/user-environment'" +} + +@test "duplicate image fails" { + export UENV_REPO_PATH=$REPOS/apptool + + # duplicate images fail + run_srun_unchecked --uenv=tool:/user-environment,tool true + assert_output --partial "uenv is mounted more than once" +} + +@test "empty --uenv argument" { + export UENV_REPO_PATH=$REPOS/apptool + run_srun_unchecked --uenv='' true + assert_output --partial 'invalid uenv description' +} + +@test "sbatch" { + export UENV_REPO_PATH=$REPOS/apptool + run_sbatch < /dev/null && pwd) + +source $DIR/setup_repos.bash + +echo "setup_repos $DIR/../scratch" +setup_repos $DIR/../scratch diff --git a/test/setup/setup_repos.bash b/test/setup/setup_repos.bash new file mode 100755 index 0000000..08f6091 --- /dev/null +++ b/test/setup/setup_repos.bash @@ -0,0 +1,67 @@ +#!/bin/bash + +function setup_repo_apptool() { + scratch=$1 + working=$(cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd) + + repo=${scratch}/repos/apptool + sqfs_path=${scratch}/sqfs/apptool + sources=${working}/apptool + + echo "repo path ${repo}" + echo "working path $working" + echo "source path $sources" + + # clean up previous builds before starting + rm -rf ${repo} + rm -rf ${sqfs_path} + + # create an empty repo + mkdir -p ${repo} + mkdir -p ${sqfs_path} + + cp ${sources}/schema.sql.template schema.sql + + # create a squashfs image for each uenv + # and copy into the repo + for name in app42 app43 tool + do + sqfs=${working}/${name}.squashfs + mksquashfs ${sources}/${name} ${sqfs} > /dev/null + sha=$(sha256sum ${sqfs} | awk '{print $1}') + id=${sha:0:16} + + echo ${sha} ${name} + + for img_path in "$repo/images/${sha}" "$sqfs_path/$name" + do + mkdir -p $img_path + cp ${sqfs} $img_path/store.squashfs + cp -R ${sources}/${name}/meta $img_path + done + rm ${sqfs} + + sed -i "s|{${name}-sha}|${sha}|g" schema.sql + sed -i "s|{${name}-id}|${id}|g" schema.sql + done + + # create the database + sqlite3 ${repo}/index.db < schema.sql + + rm -rf store.squashfs + rm schema.sql +} + +function setup_repos() { + echo "= Setup Repos" + scratch=$1 + echo "scratch path $scratch" + + rm -rf $scratch + mkdir -p $scratch + scratch=$(realpath $scratch) + + echo + echo "== apptool repo" + setup_repo_apptool $scratch +} diff --git a/test/unit/lex.cpp b/test/unit/lex.cpp index e68cce4..b4576aa 100644 --- a/test/unit/lex.cpp +++ b/test/unit/lex.cpp @@ -13,15 +13,18 @@ TEST_CASE("error characters", "[lex]") { } TEST_CASE("punctuation", "[lex]") { - uenv::lexer L(":,:/@"); + uenv::lexer L(":,:/@!*!"); REQUIRE(L.next() == uenv::token{0, uenv::tok::colon, ":"}); REQUIRE(L.next() == uenv::token{1, uenv::tok::comma, ","}); REQUIRE(L.next() == uenv::token{2, uenv::tok::colon, ":"}); REQUIRE(L.next() == uenv::token{3, uenv::tok::slash, "/"}); REQUIRE(L.next() == uenv::token{4, uenv::tok::at, "@"}); + REQUIRE(L.next() == uenv::token{5, uenv::tok::bang, "!"}); + REQUIRE(L.next() == uenv::token{6, uenv::tok::star, "*"}); + REQUIRE(L.next() == uenv::token{7, uenv::tok::bang, "!"}); // pop the end token twice to check that it does not run off the end - REQUIRE(L.next() == uenv::token{5, uenv::tok::end, ""}); - REQUIRE(L.next() == uenv::token{5, uenv::tok::end, ""}); + REQUIRE(L.next() == uenv::token{8, uenv::tok::end, ""}); + REQUIRE(L.next() == uenv::token{8, uenv::tok::end, ""}); } TEST_CASE("peek", "[lex]") { diff --git a/test/unit/parse.cpp b/test/unit/parse.cpp index e727deb..c782c12 100644 --- a/test/unit/parse.cpp +++ b/test/unit/parse.cpp @@ -2,6 +2,7 @@ #include #include +#include #include // forward declare private parsers @@ -11,6 +12,7 @@ util::expected parse_path(lexer&); util::expected parse_uenv_label(lexer&); util::expected parse_uenv_description(lexer&); util::expected parse_view_description(lexer& L); +util::expected parse_mount_entry(lexer& L); } // namespace uenv TEST_CASE("sanitise inputs", "[parse]") { @@ -33,8 +35,8 @@ TEST_CASE("parse names", "[parse]") { TEST_CASE("parse path", "[parse]") { for (const auto& in : - {"etc", "/etc", "/etc.", "/etc/usr/file.txt", "/etc-car/hole_s/_.", - ".", "./.ssh/config", ".bashrc", "age.txt"}) { + {"./etc", "/etc", "/etc.", "/etc/usr/file.txt", "/etc-car/hole_s/_.", + ".", "./.ssh/config", ".bashrc"}) { auto L = uenv::lexer(in); auto result = uenv::parse_path(L); REQUIRE(result); @@ -50,6 +52,8 @@ TEST_CASE("parse uenv label", "[parse]") { REQUIRE(result->name == "prgenv-gnu"); REQUIRE(!result->version); REQUIRE(!result->tag); + REQUIRE(!result->uarch); + REQUIRE(!result->system); } { auto L = uenv::lexer("prgenv-gnu/24.7"); @@ -58,6 +62,8 @@ TEST_CASE("parse uenv label", "[parse]") { REQUIRE(result->name == "prgenv-gnu"); REQUIRE(result->version == "24.7"); REQUIRE(!result->tag); + REQUIRE(!result->uarch); + REQUIRE(!result->system); } { auto L = uenv::lexer("prgenv-gnu/24.7:v1"); @@ -66,6 +72,8 @@ TEST_CASE("parse uenv label", "[parse]") { REQUIRE(result->name == "prgenv-gnu"); REQUIRE(result->version == "24.7"); REQUIRE(result->tag == "v1"); + REQUIRE(!result->uarch); + REQUIRE(!result->system); } { auto L = uenv::lexer("prgenv-gnu:v1"); @@ -74,8 +82,51 @@ TEST_CASE("parse uenv label", "[parse]") { REQUIRE(result->name == "prgenv-gnu"); REQUIRE(!result->version); REQUIRE(result->tag == "v1"); + REQUIRE(!result->uarch); + REQUIRE(!result->system); } - for (auto defectiv_label : {"prgenv-gnu/:v1", "prgenv-gnu/wombat:"}) { + { + auto L = uenv::lexer("prgenv-gnu/24.7:v1@santis%a100"); + auto result = uenv::parse_uenv_label(L); + REQUIRE(result); + REQUIRE(result->name == "prgenv-gnu"); + REQUIRE(result->version == "24.7"); + REQUIRE(result->tag == "v1"); + REQUIRE(result->uarch == "a100"); + REQUIRE(result->system == "santis"); + } + { + auto L = uenv::lexer("prgenv-gnu%a100"); + auto result = uenv::parse_uenv_label(L); + REQUIRE(result); + REQUIRE(result->name == "prgenv-gnu"); + REQUIRE(result->uarch == "a100"); + } + { + auto L = uenv::lexer("prgenv-gnu/24.7:v1%a100@santis"); + auto result = uenv::parse_uenv_label(L); + REQUIRE(result); + REQUIRE(result->name == "prgenv-gnu"); + REQUIRE(result->version == "24.7"); + REQUIRE(result->tag == "v1"); + REQUIRE(result->uarch == "a100"); + REQUIRE(result->system == "santis"); + } + { + auto L = uenv::lexer("prgenv-gnu/24.7:v1%a100"); + auto result = uenv::parse_uenv_label(L); + REQUIRE(result); + REQUIRE(result->name == "prgenv-gnu"); + REQUIRE(result->version == "24.7"); + REQUIRE(result->tag == "v1"); + REQUIRE(result->uarch == "a100"); + REQUIRE(!result->system); + } + for (auto defectiv_label : { + "prgenv-gnu/:v1", + "prgenv-gnu/wombat:", + ".wombat", + }) { auto L = uenv::lexer(defectiv_label); REQUIRE(!uenv::parse_uenv_label(L)); } @@ -114,7 +165,7 @@ TEST_CASE("parse view list", "[parse]") { TEST_CASE("parse uenv list", "[parse]") { { - auto in = "prgenv-gnu/24.7:rc1@/user-environment"; + auto in = "prgenv-gnu/24.7:rc1:/user-environment"; auto result = uenv::parse_uenv_args(in); REQUIRE(result); REQUIRE(result->size() == 1); @@ -125,9 +176,37 @@ TEST_CASE("parse uenv list", "[parse]") { REQUIRE(l.tag == "rc1"); REQUIRE(*d.mount() == "/user-environment"); } + { + // test case where no tag is provide - ensure that the mount point after + // the : character is read correctly. + auto in = "prgenv-gnu/24.7:/user-environment"; + auto result = uenv::parse_uenv_args(in); + if (!result) + fmt::println("ERROR {}", result.error().message()); + REQUIRE(result); + REQUIRE(result->size() == 1); + auto d = (*result)[0]; + auto l = *d.label(); + REQUIRE(l.name == "prgenv-gnu"); + REQUIRE(l.version == "24.7"); + REQUIRE(*d.mount() == "/user-environment"); + } + { + // test that no mount point is handled correctly + auto in = "prgenv-gnu/24.7:rc1"; + auto result = uenv::parse_uenv_args(in); + REQUIRE(result); + REQUIRE(result->size() == 1); + auto d = (*result)[0]; + auto l = *d.label(); + REQUIRE(l.name == "prgenv-gnu"); + REQUIRE(l.version == "24.7"); + REQUIRE(l.tag == "rc1"); + REQUIRE(!d.mount()); + } { auto in = - "/scratch/.uenv-images/sdfklsdf890df9a87sdf/store.squashfs@/" + "/scratch/.uenv-images/sdfklsdf890df9a87sdf/store.squashfs:/" "user-environment/store-asdf/my-image_mnt_point3//,prgenv-nvidia"; auto result = uenv::parse_uenv_args(in); REQUIRE(result); @@ -145,3 +224,33 @@ TEST_CASE("parse uenv list", "[parse]") { REQUIRE(!l.tag); } } + +TEST_CASE("parse mount", "[parse]") { + { + auto in = "/images/store.squashfs:/user-environment"; + auto result = uenv::parse_mount_list(in); + REQUIRE(result); + REQUIRE(result->size() == 1); + auto m = (*result)[0]; + REQUIRE(m.sqfs_path == "/images/store.squashfs"); + REQUIRE(m.mount_path == "/user-environment"); + } + { + auto in = "/images/store.squashfs:/user-environment,/images/" + "wombat.squashfs:/user-tools"; + auto result = uenv::parse_mount_list(in); + REQUIRE(result); + REQUIRE(result->size() == 2); + auto m = (*result)[0]; + REQUIRE(m.sqfs_path == "/images/store.squashfs"); + REQUIRE(m.mount_path == "/user-environment"); + m = (*result)[1]; + REQUIRE(m.sqfs_path == "/images/wombat.squashfs"); + REQUIRE(m.mount_path == "/user-tools"); + } + { + auto in = ""; + auto result = uenv::parse_mount_list(in); + REQUIRE(!result); + } +} diff --git a/test/unit/readme.md b/test/unit/readme.md new file mode 100644 index 0000000..99e78ad --- /dev/null +++ b/test/unit/readme.md @@ -0,0 +1,5 @@ +# Unit Tests + +Unit tests for the uenv C++ library. + +Use the Catch2 C++ unit testing library. diff --git a/test/unit/repository.cpp b/test/unit/repository.cpp new file mode 100644 index 0000000..74b5d0b --- /dev/null +++ b/test/unit/repository.cpp @@ -0,0 +1,84 @@ +#include + +#include +#include + +#include +#include + +namespace fs = std::filesystem; + +TEST_CASE("read-only", "[repository]") { + fs::path repo_path{"../test/scratch/repo"}; + auto store = uenv::open_repository(repo_path); + + if (!store) { + SKIP(fmt::format("{}", store.error())); + } + + fmt::println("db path: {}", store->db_path().string()); + { + auto results = store->query({{}, {}, {}, {}, {}}); + if (!results) { + fmt::println("ERROR: {}", results.error()); + } + REQUIRE(results); + + for (auto& r : *results) { + fmt::println("{}", r); + } + } + + fmt::println(""); + { + auto results = store->query({"mch", {}, {}, {}, {}}); + if (!results) { + fmt::println("ERROR: {}", results.error()); + } + REQUIRE(results); + + for (auto& r : *results) { + fmt::println("{}", r); + } + } + + fmt::println(""); + { + auto results = store->query({{}, "v7", {}, {}, {}}); + if (!results) { + fmt::println("ERROR: {}", results.error()); + } + REQUIRE(results); + + for (auto& r : *results) { + fmt::println("{}", r); + } + } + + fmt::println(""); + { + auto results = store->query({{}, "24.7", "v1-rc1", {}, "a100"}); + if (!results) { + fmt::println("ERROR: {}", results.error()); + } + REQUIRE(results); + + for (auto& r : *results) { + fmt::println("{}", r); + } + } + + fmt::println(""); + { + auto results = store->query({"wombat", {}, {}, {}, {}}); + if (!results) { + fmt::println("ERROR: {}", results.error()); + } + REQUIRE(results); + REQUIRE(results->empty()); + + for (auto& r : *results) { + fmt::println("{}", r); + } + } +}