1
0
mirror of https://github.com/ohwgiles/laminar.git synced 2026-10-18 09:04:42 +00:00

3 Commits

Author SHA1 Message Date
Oliver Giles
101e90f9e1 remove reference to helper script
more trouble than it's worth
2019-12-13 10:32:09 +02:00
Oliver Giles
b433fd16d2 Update UserManual.md
Co-Authored-By: Starbeamrainbowlabs <sbrl@starbeamrainbowlabs.com>
2019-11-14 21:16:57 +02:00
Oliver Giles
7c99eae2bd Replace nodes/tags with contexts 2019-11-09 21:59:37 +02:00
40 changed files with 1065 additions and 1441 deletions

View File

@@ -1,5 +1,5 @@
### ###
### Copyright 2015-2020 Oliver Giles ### Copyright 2015-2019 Oliver Giles
### ###
### This file is part of Laminar ### This file is part of Laminar
### ###
@@ -22,22 +22,9 @@ cmake_policy(SET CMP0058 NEW)
set(CMAKE_INCLUDE_CURRENT_DIR ON) set(CMAKE_INCLUDE_CURRENT_DIR ON)
add_definitions("-std=c++17 -Wall -Wextra -Wno-unused-parameter -Wno-sign-compare") add_definitions("-std=c++14 -Wall -Wextra -Wno-unused-parameter -Wno-sign-compare")
set(CMAKE_CXX_FLAGS_DEBUG "${CMAKE_CXX_FLAGS_DEBUG} -Werror -DDEBUG") set(CMAKE_CXX_FLAGS_DEBUG "${CMAKE_CXX_FLAGS_DEBUG} -Werror -DDEBUG")
# Allow passing in the version string, for e.g. patched/packaged versions
if(NOT LAMINAR_VERSION AND EXISTS ${CMAKE_SOURCE_DIR}/.git)
execute_process(COMMAND git describe --tags --abbrev=8 --dirty
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
OUTPUT_VARIABLE LAMINAR_VERSION
OUTPUT_STRIP_TRAILING_WHITESPACE)
endif()
if(NOT LAMINAR_VERSION)
set(LAMINAR_VERSION xx-unversioned)
endif()
set_source_files_properties(src/version.cpp PROPERTIES COMPILE_DEFINITIONS
LAMINAR_VERSION=${LAMINAR_VERSION})
# This macro takes a list of files, gzips them and converts the output into # This macro takes a list of files, gzips them and converts the output into
# object files so they can be linked directly into the application. # object files so they can be linked directly into the application.
# ld generates symbols based on the string argument given to its executable, # ld generates symbols based on the string argument given to its executable,
@@ -75,7 +62,7 @@ add_custom_command(OUTPUT laminar.capnp.c++ laminar.capnp.h
# Zip and compile statically served resources # Zip and compile statically served resources
generate_compressed_bins(${CMAKE_SOURCE_DIR}/src/resources index.html js/app.js generate_compressed_bins(${CMAKE_SOURCE_DIR}/src/resources index.html js/app.js
style.css manifest.webmanifest favicon.ico favicon-152.png icon.png) favicon.ico favicon-152.png icon.png)
# The code that allows dynamic modifying of index.html requires knowing its original size # The code that allows dynamic modifying of index.html requires knowing its original size
add_custom_command(OUTPUT index_html_size.h add_custom_command(OUTPUT index_html_size.h
@@ -91,22 +78,22 @@ file(DOWNLOAD https://raw.githubusercontent.com/drudru/ansi_up/v1.3.0/ansi_up.js
js/ansi_up.js EXPECTED_MD5 158566dc1ff8f2804de972f7e841e2f6) js/ansi_up.js EXPECTED_MD5 158566dc1ff8f2804de972f7e841e2f6)
file(DOWNLOAD https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.7.2/Chart.min.js file(DOWNLOAD https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.7.2/Chart.min.js
js/Chart.min.js EXPECTED_MD5 f6c8efa65711e0cbbc99ba72997ecd0e) js/Chart.min.js EXPECTED_MD5 f6c8efa65711e0cbbc99ba72997ecd0e)
file(DOWNLOAD https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap.min.css
css/bootstrap.min.css EXPECTED_MD5 5d5357cb3704e1f43a1f5bfed2aebf42)
# ...and compile them # ...and compile them
generate_compressed_bins(${CMAKE_BINARY_DIR} js/vue-router.min.js js/vue.min.js generate_compressed_bins(${CMAKE_BINARY_DIR} js/vue-router.min.js js/vue.min.js
js/ansi_up.js js/Chart.min.js) js/ansi_up.js js/Chart.min.js css/bootstrap.min.css)
# (see resources.cpp where these are fetched) # (see resources.cpp where these are fetched)
set(LAMINARD_CORE_SOURCES set(LAMINARD_CORE_SOURCES
src/conf.cpp
src/database.cpp src/database.cpp
src/server.cpp
src/laminar.cpp src/laminar.cpp
src/leader.cpp src/conf.cpp
src/http.cpp src/http.cpp
src/resources.cpp src/resources.cpp
src/rpc.cpp src/rpc.cpp
src/run.cpp src/run.cpp
src/server.cpp
src/version.cpp
laminar.capnp.c++ laminar.capnp.c++
index_html_size.h index_html_size.h
) )
@@ -116,7 +103,7 @@ add_executable(laminard ${LAMINARD_CORE_SOURCES} src/main.cpp ${COMPRESSED_BINS}
target_link_libraries(laminard capnp-rpc capnp kj-http kj-async kj pthread sqlite3 z) target_link_libraries(laminard capnp-rpc capnp kj-http kj-async kj pthread sqlite3 z)
## Client ## Client
add_executable(laminarc src/client.cpp src/version.cpp laminar.capnp.c++) add_executable(laminarc src/client.cpp laminar.capnp.c++)
target_link_libraries(laminarc capnp-rpc capnp kj-async kj pthread) target_link_libraries(laminarc capnp-rpc capnp kj-async kj pthread)
## Tests ## Tests
@@ -124,18 +111,15 @@ set(BUILD_TESTS FALSE CACHE BOOL "Build tests")
if(BUILD_TESTS) if(BUILD_TESTS)
find_package(GTest REQUIRED) find_package(GTest REQUIRED)
include_directories(${GTEST_INCLUDE_DIRS} src) include_directories(${GTEST_INCLUDE_DIRS} src)
add_executable(laminar-tests ${LAMINARD_CORE_SOURCES} ${COMPRESSED_BINS} test/main.cpp test/laminar-functional.cpp test/unit-conf.cpp test/unit-database.cpp) add_executable(laminar-tests ${LAMINARD_CORE_SOURCES} ${COMPRESSED_BINS} test/main.cpp test/laminar-functional.cpp test/unit-conf.cpp test/unit-database.cpp test/unit-run.cpp)
target_link_libraries(laminar-tests ${GTEST_LIBRARY} capnp-rpc capnp kj-http kj-async kj pthread sqlite3 z) target_link_libraries(laminar-tests ${GTEST_LIBRARY} capnp-rpc capnp kj-http kj-async kj pthread sqlite3 z)
endif() endif()
set(SYSTEMD_UNITDIR /lib/systemd/system CACHE PATH "Path to systemd unit files") set(SYSTEMD_UNITDIR lib/systemd/system CACHE PATH "Path to systemd unit files")
set(BASH_COMPLETIONS_DIR /usr/share/bash-completion/completions CACHE PATH "Path to bash completions directory") set(BASH_COMPLETIONS_DIR usr/share/bash-completion/completions CACHE PATH "Path to bash completions directory")
set(ZSH_COMPLETIONS_DIR /usr/share/zsh/site-functions CACHE PATH "Path to zsh completions directory") set(ZSH_COMPLETIONS_DIR usr/share/zsh/site-functions CACHE PATH "Path to zsh completions directory")
install(TARGETS laminard RUNTIME DESTINATION sbin) install(TARGETS laminard laminarc RUNTIME DESTINATION usr/bin)
install(TARGETS laminarc RUNTIME DESTINATION bin) install(FILES etc/laminar.service DESTINATION ${SYSTEMD_UNITDIR})
install(FILES etc/laminar.conf DESTINATION /etc) install(FILES etc/laminar.conf DESTINATION etc)
install(FILES etc/laminarc-completion.bash DESTINATION ${BASH_COMPLETIONS_DIR} RENAME laminarc) install(FILES etc/laminarc-completion.bash DESTINATION ${BASH_COMPLETIONS_DIR} RENAME laminarc)
install(FILES etc/laminarc-completion.zsh DESTINATION ${ZSH_COMPLETIONS_DIR} RENAME _laminarc) install(FILES etc/laminarc-completion.zsh DESTINATION ${ZSH_COMPLETIONS_DIR} RENAME _laminarc)
configure_file(etc/laminar.service.in laminar.service @ONLY)
install(FILES ${CMAKE_CURRENT_BINARY_DIR}/laminar.service DESTINATION ${SYSTEMD_UNITDIR})

View File

@@ -24,9 +24,8 @@ Then compile and install laminar with:
```bash ```bash
git clone https://github.com/ohwgiles/laminar.git git clone https://github.com/ohwgiles/laminar.git
cd laminar cd laminar
cmake -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr cmake -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/
make -j4 make -j4
# Warning: the following will overwrite an existing /etc/laminar.conf
sudo make install sudo make install
``` ```
@@ -35,7 +34,3 @@ sudo make install
## Packaging for distributions ## Packaging for distributions
The `pkg` directory contains shell scripts which use docker to build native packages (deb,rpm) for common Linux distributions. Note that these are very simple packages which may not completely conform to the distribution's packaging guidelines, however they may serve as a starting point for creating an official package, or may be useful if the official package lags. The `pkg` directory contains shell scripts which use docker to build native packages (deb,rpm) for common Linux distributions. Note that these are very simple packages which may not completely conform to the distribution's packaging guidelines, however they may serve as a starting point for creating an official package, or may be useful if the official package lags.
## Contributing
Issues and pull requests via GitHub are most welcome. All pull requests must adhere to the [Developer Certificate of Origin](https://developercertificate.org/).

View File

@@ -323,6 +323,8 @@ Then in `example.run`
echo $foo # prints "bar" echo $foo # prints "bar"
``` ```
This works because laminarc reads `$JOB` and `$NUM` and passes them to the laminar daemon as part of the `set` request. (It is thus possible to set environment variables on other jobs by overriding these variables, but this is not very useful).
--- ---
# Archiving artefacts # Archiving artefacts
@@ -521,9 +523,15 @@ DUT_IP=192.168.3.2
FOO=bar FOO=bar
``` ```
This environment will then be available the run script of jobs associated with this context. Note that these definitions are not expanded by a shell, so `FOO="bar"` would result in a variable `FOO` whose contents *include* double-quotes. This environment will then be available the run script of jobs associated with this context.
## Grouping jobs with tags
Tags are also used to group jobs in the web UI. Each tag will presented as a tab in the "Jobs" page.
---
# Remote jobs # Remote jobs
@@ -576,20 +584,6 @@ EOF
--- ---
# Colours in log output
Laminar's frontend supports ANSI colours using the [ansi-up library](https://github.com/drudru/ansi_up). Unfortunately, there is no standard way of convincing applications to output colours when not connected to a tty. It is recommended to set [CLICOLOR_FORCE=1](https://bixense.com/clicolors/) in Laminar's [global environment file](#Environment-variables), plus any of the following environment variables that may be relevant (please submit more):
* git: `GIT_CONFIG_PARAMETERS='color.status=always' 'color.ui=always'`
* google test: `GTEST_COLOR=1`
* grep: `GREP_OPTIONS=--color=always`
More intrusive options for other common tools which do not support enabling colours via environment variable:
* gcc and clang: Add `-fdiagnostics-color=always` to compile flags
---
# Customizing the WebUI # Customizing the WebUI
## Organising jobs into groups ## Organising jobs into groups
@@ -604,23 +598,15 @@ All=.*
Changes to this file are detected immediately and will be visible on next page refresh. Changes to this file are detected immediately and will be visible on next page refresh.
## Adding a description to a job
Edit `/var/lib/laminar/cfg/jobs/$JOBNAME.conf`:
```
DESCRIPTION=Anything here will appear on the job page in the frontend <em>unescaped</em>.
```
## Setting the page title ## Setting the page title
Change `LAMINAR_TITLE` in `/etc/laminar.conf` to your preferred page title. Laminar must be restarted for this change to take effect. Change `LAMINAR_TITLE` in `/etc/laminar.conf` to your preferred page title. Laminar must be restarted for this change to take effect.
## Custom HTML template ## Custom stylesheet
If it exists, the file `/var/lib/laminar/custom/index.html` will be served by laminar instead of the default markup that is bundled into the Laminar binary. This file can be used to change any aspect of Laminar's WebUI, for example adding menu links or adding a custom stylesheet. Any required assets will need to be served directly from your [HTTP reverse proxy](#Service-configuration) or other HTTP server. If it exists, the file `/var/lib/laminar/custom/style.css` will be served by laminar and may be used to change the appearance of Laminar's WebUI.
An example customization can be found at [cweagans/semantic-laminar-theme](https://github.com/cweagans/semantic-laminar-theme). This directory is also a good place to add any extra assets needed for this customization, but note that in this case you will need to serve this directory directly from your [HTTP reverse proxy](#Service-configuration) (highly recommended).
--- ---
@@ -680,8 +666,6 @@ Laminar will also export variables in the form `KEY=VALUE` found in these files:
- `contexts/$CONTEXT.env` - `contexts/$CONTEXT.env`
- `jobs/$JOB.env` - `jobs/$JOB.env`
Note that definitions in these files are not expanded by a shell, so `FOO="bar"` would result in a variable `FOO` whose contents *include* double-quotes.
Finally, variables supplied on the command-line call to `laminarc queue`, `laminarc start` or `laminarc run` will be available. See [parameterized runs](#Parameterized-runs) Finally, variables supplied on the command-line call to `laminarc queue`, `laminarc start` or `laminarc run` will be available. See [parameterized runs](#Parameterized-runs)
## laminarc ## laminarc

View File

@@ -28,7 +28,7 @@ RUN apk add --no-cache --virtual .build -X http://dl-3.alpinelinux.org/alpine/ed
zlib-dev \ zlib-dev \
rapidjson-dev && \ rapidjson-dev && \
cd /build/laminar && \ cd /build/laminar && \
cmake -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr && \ cmake -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/ && \
make -j4 && \ make -j4 && \
make install && \ make install && \
apk del .build && \ apk del .build && \

View File

@@ -5,7 +5,7 @@ After=network.target
[Service] [Service]
User=laminar User=laminar
EnvironmentFile=-/etc/laminar.conf EnvironmentFile=-/etc/laminar.conf
ExecStart=@CMAKE_INSTALL_PREFIX@/sbin/laminard ExecStart=/usr/bin/laminard
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target

View File

@@ -54,7 +54,7 @@ Lightweight Continuous Integration Service
%prep %prep
%build %build
cmake3 -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr -DLAMINAR_VERSION=$VERSION -DSYSTEMD_UNITDIR=%{_unitdir} %{_sourcedir}/laminar-$VERSION cmake3 -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/ -DSYSTEMD_UNITDIR=%{_unitdir} %{_sourcedir}/laminar-$VERSION
pwd pwd
make make
@@ -63,7 +63,7 @@ make
%files %files
%{_bindir}/laminarc %{_bindir}/laminarc
%{_sbindir}/laminard %{_bindir}/laminard
%{_unitdir}/laminar.service %{_unitdir}/laminar.service
%config(noreplace) %{_sysconfdir}/laminar.conf %config(noreplace) %{_sysconfdir}/laminar.conf
%{_datarootdir}/bash-completion/completions/laminarc %{_datarootdir}/bash-completion/completions/laminarc

View File

@@ -51,7 +51,7 @@ Lightweight Continuous Integration Service
%prep %prep
%build %build
cmake -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr -DLAMINAR_VERSION=$VERSION -DSYSTEMD_UNITDIR=%{_unitdir} %{_sourcedir}/laminar-$VERSION cmake -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/ -DSYSTEMD_UNITDIR=%{_unitdir} %{_sourcedir}/laminar-$VERSION
pwd pwd
make make
@@ -60,7 +60,7 @@ make
%files %files
%{_bindir}/laminarc %{_bindir}/laminarc
%{_sbindir}/laminard %{_bindir}/laminard
%{_unitdir}/laminar.service %{_unitdir}/laminar.service
%config(noreplace) %{_sysconfdir}/laminar.conf %config(noreplace) %{_sysconfdir}/laminar.conf
%{_datarootdir}/bash-completion/completions/laminarc %{_datarootdir}/bash-completion/completions/laminarc

View File

@@ -17,7 +17,7 @@ docker run --rm -i -v $SOURCE_DIR:/laminar:ro -v $OUTPUT_DIR:/output $DOCKER_TAG
mkdir /build mkdir /build
cd /build cd /build
cmake -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr -DLAMINAR_VERSION=$VERSION -DZSH_COMPLETIONS_DIR=/usr/share/zsh/functions/Completion/Unix /laminar cmake -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/ -DZSH_COMPLETIONS_DIR=/usr/share/zsh/functions/Completion/Unix /laminar
make -j4 make -j4
mkdir laminar mkdir laminar
make DESTDIR=laminar install/strip make DESTDIR=laminar install/strip

View File

@@ -30,8 +30,7 @@ cmake \
-DCMAKE_OBJCOPY=/usr/bin/arm-linux-gnueabihf-objcopy \ -DCMAKE_OBJCOPY=/usr/bin/arm-linux-gnueabihf-objcopy \
-DCMAKE_STRIP=/usr/bin/arm-linux-gnueabihf-strip \ -DCMAKE_STRIP=/usr/bin/arm-linux-gnueabihf-strip \
-DCMAKE_BUILD_TYPE=Release \ -DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX=/usr \ -DCMAKE_INSTALL_PREFIX=/ \
-DLAMINAR_VERSION=$VERSION \
-DZSH_COMPLETIONS_DIR=/usr/share/zsh/functions/Completion/Unix \ -DZSH_COMPLETIONS_DIR=/usr/share/zsh/functions/Completion/Unix \
/laminar /laminar
make -j4 make -j4

68
pkg/debian9-amd64.sh Executable file
View File

@@ -0,0 +1,68 @@
#!/bin/bash -e
OUTPUT_DIR=$PWD
SOURCE_DIR=$(readlink -f $(dirname ${BASH_SOURCE[0]})/..)
VERSION=$(cd "$SOURCE_DIR" && git describe --tags --abbrev=8 --dirty)-1~upstream-debian9
DOCKER_TAG=$(docker build -q - <<EOS
FROM debian:9-slim
RUN apt-get update && apt-get install -y wget cmake g++ libsqlite3-dev libboost-dev zlib1g-dev
EOS
)
docker run --rm -i -v $SOURCE_DIR:/laminar:ro -v $OUTPUT_DIR:/output $DOCKER_TAG bash -xe <<EOS
mkdir /build
cd /build
wget -O capnproto.tar.gz https://github.com/capnproto/capnproto/archive/v0.7.0.tar.gz
wget -O rapidjson.tar.gz https://github.com/miloyip/rapidjson/archive/v1.1.0.tar.gz
md5sum -c <<EOF
a9de5f042f4cf05515c2d7dfc7f5df21 capnproto.tar.gz
badd12c511e081fec6c89c43a7027bce rapidjson.tar.gz
EOF
tar xzf capnproto.tar.gz
tar xzf rapidjson.tar.gz
cd /build/capnproto-0.7.0/c++/
cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=off .
make -j4
make install
cd /build/rapidjson-1.1.0/
cmake -DRAPIDJSON_BUILD_EXAMPLES=off .
make install
cd /build
cmake -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/ -DZSH_COMPLETIONS_DIR=/usr/share/zsh/functions/Completion/Unix /laminar
make -j4
mkdir laminar
make DESTDIR=laminar install/strip
mkdir laminar/DEBIAN
cat <<EOF > laminar/DEBIAN/control
Package: laminar
Version: $VERSION
Section:
Priority: optional
Architecture: amd64
Maintainer: Oliver Giles <web ohwg net>
Depends: libsqlite3-0, zlib1g
Description: Lightweight Continuous Integration Service
EOF
echo /etc/laminar.conf > laminar/DEBIAN/conffiles
cat <<EOF > laminar/DEBIAN/postinst
#!/bin/bash
echo Creating laminar user with home in /var/lib/laminar
useradd -r -d /var/lib/laminar -s /usr/sbin/nologin laminar
mkdir -p /var/lib/laminar/cfg/{jobs,contexts,scripts}
chown -R laminar: /var/lib/laminar
EOF
chmod +x laminar/DEBIAN/postinst
dpkg-deb --build laminar
mv laminar.deb /output/laminar_${VERSION}_amd64.deb
EOS

88
pkg/debian9-armhf.sh Executable file
View File

@@ -0,0 +1,88 @@
#!/bin/bash -e
OUTPUT_DIR=$PWD
SOURCE_DIR=$(readlink -f $(dirname ${BASH_SOURCE[0]})/..)
VERSION=$(cd "$SOURCE_DIR" && git describe --tags --abbrev=8 --dirty)-1~upstream-debian9
DOCKER_TAG=$(docker build -q - <<EOS
FROM debian:9-slim
RUN dpkg --add-architecture armhf && apt-get update && apt-get install -y wget cmake crossbuild-essential-armhf libsqlite3-dev:armhf libboost-dev:armhf zlib1g-dev:armhf
EOS
)
docker run --rm -i -v $SOURCE_DIR:/laminar:ro -v $OUTPUT_DIR:/output $DOCKER_TAG bash -xe <<EOS
mkdir /build
cd /build
cat > toolchain.cmake <<EOF
SET(CMAKE_C_COMPILER arm-linux-gnueabihf-gcc)
SET(CMAKE_CXX_COMPILER arm-linux-gnueabihf-g++)
set(CMAKE_LIBRARY_ARCHITECTURE arm-linux-gnueabihf)
EOF
wget -O capnproto.tar.gz https://github.com/capnproto/capnproto/archive/v0.7.0.tar.gz
wget -O rapidjson.tar.gz https://github.com/miloyip/rapidjson/archive/v1.1.0.tar.gz
md5sum -c <<EOF
a9de5f042f4cf05515c2d7dfc7f5df21 capnproto.tar.gz
badd12c511e081fec6c89c43a7027bce rapidjson.tar.gz
EOF
tar xzf capnproto.tar.gz
tar xzf rapidjson.tar.gz
mkdir capnproto-host
cd capnproto-host
cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=off /build/capnproto-0.7.0/c++/
make -j4
make install
cd /build/capnproto-0.7.0/c++/
cmake -DCMAKE_TOOLCHAIN_FILE=../../toolchain.cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=OFF -DCMAKE_INSTALL_PREFIX=/usr/arm-linux-gnueabihf/ .
make -j4
make install
cd /build/rapidjson-1.1.0/
cmake -DRAPIDJSON_BUILD_EXAMPLES=off -DCMAKE_INSTALL_PREFIX=/usr/arm-linux-gnueabihf/ .
make install
cd /build
cmake \
-DCMAKE_TOOLCHAIN_FILE=toolchain.cmake \
-DCMAKE_LINKER=/usr/bin/arm-linux-gnueabihf-ld \
-DCMAKE_OBJCOPY=/usr/bin/arm-linux-gnueabihf-objcopy \
-DCMAKE_STRIP=/usr/bin/arm-linux-gnueabihf-strip \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX=/ \
-DZSH_COMPLETIONS_DIR=/usr/share/zsh/functions/Completion/Unix \
/laminar
make -j4
mkdir laminar
make DESTDIR=laminar install/strip
mkdir laminar/DEBIAN
cat <<EOF > laminar/DEBIAN/control
Package: laminar
Version: $VERSION
Section:
Priority: optional
Architecture: armhf
Maintainer: Oliver Giles <web ohwg net>
Depends: libsqlite3-0, zlib1g
Description: Lightweight Continuous Integration Service
EOF
echo /etc/laminar.conf > laminar/DEBIAN/conffiles
cat <<EOF > laminar/DEBIAN/postinst
#!/bin/bash
echo Creating laminar user with home in /var/lib/laminar
useradd -r -d /var/lib/laminar -s /usr/sbin/nologin laminar
mkdir -p /var/lib/laminar/cfg/{jobs,contexts,scripts}
chown -R laminar: /var/lib/laminar
EOF
chmod +x laminar/DEBIAN/postinst
dpkg-deb --build laminar
mv laminar.deb /output/laminar_${VERSION}_armhf.deb
EOS

View File

@@ -1,5 +1,5 @@
/// ///
/// Copyright 2015-2020 Oliver Giles /// Copyright 2015-2018 Oliver Giles
/// ///
/// This file is part of Laminar /// This file is part of Laminar
/// ///
@@ -17,15 +17,12 @@
/// along with Laminar. If not, see <http://www.gnu.org/licenses/> /// along with Laminar. If not, see <http://www.gnu.org/licenses/>
/// ///
#include "laminar.capnp.h" #include "laminar.capnp.h"
#include "log.h"
#include <capnp/ez-rpc.h> #include <capnp/ez-rpc.h>
#include <kj/vector.h> #include <kj/vector.h>
#include <iostream>
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
#include <unistd.h>
#define EXIT_BAD_ARGUMENT 1 #define EXIT_BAD_ARGUMENT 1
#define EXIT_OPERATION_FAILED 2 #define EXIT_OPERATION_FAILED 2
@@ -74,57 +71,18 @@ static int setParams(int argc, char** argv, T& request) {
} }
static void printTriggerLink(const char* job, uint run) { static void printTriggerLink(const char* job, uint run) {
if(getenv("__LAMINAR_SETENV_PIPE")) { // use a private ANSI CSI sequence to mark the JOB:NUM so the
// use a private ANSI CSI sequence to mark the JOB:NUM so the // frontend can recognise it and generate a hyperlink.
// frontend can recognise it and generate a hyperlink. printf("\033[{%s:%d\033\\\n", job, run);
printf("\033[{%s:%d\033\\\n", job, run);
} else {
// not called from within a laminar job, let's not confuse
// scripts with ANSI sequences.
printf("%s:%d\n", job, run);
}
}
static void usage(std::ostream& out) {
out << "laminarc version " << laminar_version() << "\n";
out << "Usage: laminarc [-h|--help] COMMAND [PARAMETERS...]]\n";
out << " -h|--help show this help message\n";
out << "where COMMAND is:\n";
out << " queue JOB_LIST... queues one or more jobs for execution and returns immediately.\n";
out << " start JOB_LIST... queues one or more jobs for execution and blocks until it starts.\n";
out << " run JOB_LIST... queues one or more jobs for execution and blocks until it finishes.\n";
out << " set PARAMETER_LIST... sets the given parameters as environment variables in the currently\n";
out << " runing job. Fails if run outside of a job context.\n";
out << " abort NAME NUMBER aborts the run identified by NAME and NUMBER.\n";
out << " show-jobs lists all known jobs.\n";
out << " show-queued lists currently queued jobs.\n";
out << " show-running lists currently running jobs.\n";
out << "JOB_LIST is of the form:\n";
out << " [JOB_NAME [PARAMETER_LIST...]]...\n";
out << "PARAMETER_LIST is of the form:\n";
out << " [KEY=VALUE]...\n";
out << "Example:\n";
out << " laminarc start \\\n";
out << " nightly-build branch=master type=release \\\n";
out << " nightly-build branch=master type=debug\n";
} }
int main(int argc, char** argv) { int main(int argc, char** argv) {
if(argc < 2) if(argc < 2) {
return usage(std::cerr), EXIT_BAD_ARGUMENT; fprintf(stderr, "Usage: %s <command> [parameters...]\n", argv[0]);
else if(strcmp("-h", argv[1]) == 0 || strcmp("--help", argv[1]) == 0) return EXIT_BAD_ARGUMENT;
return usage(std::cout), EXIT_SUCCESS; }
struct: public kj::TaskSet::ErrorHandler {
void taskFailed(kj::Exception&& e) override {
fprintf(stderr, "%s\n", e.getDescription().cStr());
ret = EXIT_OPERATION_FAILED;
}
int ret = 0;
} errorHandler;
kj::TaskSet ts(errorHandler);
int& ret = errorHandler.ret;
int ret = 0;
const char* address = getenv("LAMINAR_HOST") ?: getenv("LAMINAR_BIND_RPC") ?: "unix-abstract:laminar"; const char* address = getenv("LAMINAR_HOST") ?: getenv("LAMINAR_BIND_RPC") ?: "unix-abstract:laminar";
capnp::EzRpcClient client(address); capnp::EzRpcClient client(address);
@@ -137,20 +95,23 @@ int main(int argc, char** argv) {
fprintf(stderr, "Usage %s queue <jobName>\n", argv[0]); fprintf(stderr, "Usage %s queue <jobName>\n", argv[0]);
return EXIT_BAD_ARGUMENT; return EXIT_BAD_ARGUMENT;
} }
kj::Vector<capnp::RemotePromise<LaminarCi::QueueResults>> promises;
int jobNameIndex = 2; int jobNameIndex = 2;
// make a request for each job specified on the commandline // make a request for each job specified on the commandline
do { do {
auto req = laminar.queueRequest(); auto req = laminar.queueRequest();
req.setJobName(argv[jobNameIndex]); req.setJobName(argv[jobNameIndex]);
int n = setParams(argc - jobNameIndex - 1, &argv[jobNameIndex + 1], req); int n = setParams(argc - jobNameIndex - 1, &argv[jobNameIndex + 1], req);
ts.add(req.send().then([&ret,argv,jobNameIndex](capnp::Response<LaminarCi::QueueResults> resp){ promises.add(req.send());
if(resp.getResult() != LaminarCi::MethodResult::SUCCESS) {
fprintf(stderr, "Failed to queue job '%s'\n", argv[jobNameIndex]);
ret = EXIT_OPERATION_FAILED;
}
}));
jobNameIndex += n + 1; jobNameIndex += n + 1;
} while(jobNameIndex < argc); } while(jobNameIndex < argc);
// pend on the promises
for(auto& p : promises) {
if(p.wait(waitScope).getResult() != LaminarCi::MethodResult::SUCCESS) {
fprintf(stderr, "Failed to queue job '%s'\n", argv[2]);
return EXIT_OPERATION_FAILED;
}
}
} else if(strcmp(argv[1], "start") == 0 || strcmp(argv[1], "trigger") == 0) { } else if(strcmp(argv[1], "start") == 0 || strcmp(argv[1], "trigger") == 0) {
if(strcmp(argv[1], "trigger") == 0) if(strcmp(argv[1], "trigger") == 0)
fprintf(stderr, "Warning: 'trigger' is deprecated, use 'queue' for the old behavior\n"); fprintf(stderr, "Warning: 'trigger' is deprecated, use 'queue' for the old behavior\n");
@@ -159,6 +120,10 @@ int main(int argc, char** argv) {
return EXIT_BAD_ARGUMENT; return EXIT_BAD_ARGUMENT;
} }
kj::Vector<capnp::RemotePromise<LaminarCi::StartResults>> promises; kj::Vector<capnp::RemotePromise<LaminarCi::StartResults>> promises;
struct: public kj::TaskSet::ErrorHandler {
void taskFailed(kj::Exception&&) override {}
} ignoreFailed;
kj::TaskSet ts(ignoreFailed);
int jobNameIndex = 2; int jobNameIndex = 2;
// make a request for each job specified on the commandline // make a request for each job specified on the commandline
do { do {
@@ -169,16 +134,21 @@ int main(int argc, char** argv) {
if(resp.getResult() != LaminarCi::MethodResult::SUCCESS) { if(resp.getResult() != LaminarCi::MethodResult::SUCCESS) {
fprintf(stderr, "Failed to start job '%s'\n", argv[2]); fprintf(stderr, "Failed to start job '%s'\n", argv[2]);
ret = EXIT_OPERATION_FAILED; ret = EXIT_OPERATION_FAILED;
} else }
printTriggerLink(argv[jobNameIndex], resp.getBuildNum()); printTriggerLink(argv[jobNameIndex], resp.getBuildNum());
})); }));
jobNameIndex += n + 1; jobNameIndex += n + 1;
} while(jobNameIndex < argc); } while(jobNameIndex < argc);
ts.onEmpty().wait(waitScope);
} else if(strcmp(argv[1], "run") == 0) { } else if(strcmp(argv[1], "run") == 0) {
if(argc < 3) { if(argc < 3) {
fprintf(stderr, "Usage %s run <jobName>\n", argv[0]); fprintf(stderr, "Usage %s run <jobName>\n", argv[0]);
return EXIT_BAD_ARGUMENT; return EXIT_BAD_ARGUMENT;
} }
struct: public kj::TaskSet::ErrorHandler {
void taskFailed(kj::Exception&&) override {}
} ignoreFailed;
kj::TaskSet ts(ignoreFailed);
int jobNameIndex = 2; int jobNameIndex = 2;
// make a request for each job specified on the commandline // make a request for each job specified on the commandline
do { do {
@@ -186,24 +156,34 @@ int main(int argc, char** argv) {
req.setJobName(argv[jobNameIndex]); req.setJobName(argv[jobNameIndex]);
int n = setParams(argc - jobNameIndex - 1, &argv[jobNameIndex + 1], req); int n = setParams(argc - jobNameIndex - 1, &argv[jobNameIndex + 1], req);
ts.add(req.send().then([&ret,argv,jobNameIndex](capnp::Response<LaminarCi::RunResults> resp){ ts.add(req.send().then([&ret,argv,jobNameIndex](capnp::Response<LaminarCi::RunResults> resp){
if(resp.getResult() == LaminarCi::JobResult::UNKNOWN) printTriggerLink(argv[jobNameIndex], resp.getBuildNum());
fprintf(stderr, "Failed to start job '%s'\n", argv[2]); if(resp.getResult() != LaminarCi::JobResult::SUCCESS) {
else
printTriggerLink(argv[jobNameIndex], resp.getBuildNum());
if(resp.getResult() != LaminarCi::JobResult::SUCCESS)
ret = EXIT_RUN_FAILED; ret = EXIT_RUN_FAILED;
}
})); }));
jobNameIndex += n + 1; jobNameIndex += n + 1;
} while(jobNameIndex < argc); } while(jobNameIndex < argc);
ts.onEmpty().wait(waitScope);
} else if(strcmp(argv[1], "set") == 0) { } else if(strcmp(argv[1], "set") == 0) {
if(argc < 3) { if(argc < 3) {
fprintf(stderr, "Usage %s set param=value\n", argv[0]); fprintf(stderr, "Usage %s set param=value\n", argv[0]);
return EXIT_BAD_ARGUMENT; return EXIT_BAD_ARGUMENT;
} }
if(char* pipeNum = getenv("__LAMINAR_SETENV_PIPE")) { auto req = laminar.setRequest();
LSYSCALL(write(atoi(pipeNum), argv[2], strlen(argv[2]))); char* eq = strchr(argv[2], '=');
char* job = getenv("JOB");
char* num = getenv("RUN");
if(job && num && eq) {
char* name = argv[2];
*eq++ = '\0';
char* val = eq;
req.getRun().setJob(job);
req.getRun().setBuildNum(atoi(num));
req.getParam().setName(name);
req.getParam().setValue(val);
req.send().wait(waitScope);
} else { } else {
fprintf(stderr, "Must be run from within a laminar job\n"); fprintf(stderr, "Missing $JOB or $RUN or param is not in the format key=value\n");
return EXIT_BAD_ARGUMENT; return EXIT_BAD_ARGUMENT;
} }
} else if(strcmp(argv[1], "abort") == 0) { } else if(strcmp(argv[1], "abort") == 0) {
@@ -214,17 +194,14 @@ int main(int argc, char** argv) {
auto req = laminar.abortRequest(); auto req = laminar.abortRequest();
req.getRun().setJob(argv[2]); req.getRun().setJob(argv[2]);
req.getRun().setBuildNum(atoi(argv[3])); req.getRun().setBuildNum(atoi(argv[3]));
ts.add(req.send().then([&ret](capnp::Response<LaminarCi::AbortResults> resp){ if(req.send().wait(waitScope).getResult() != LaminarCi::MethodResult::SUCCESS)
if(resp.getResult() != LaminarCi::MethodResult::SUCCESS) ret = EXIT_OPERATION_FAILED;
ret = EXIT_OPERATION_FAILED;
}));
} else if(strcmp(argv[1], "show-jobs") == 0) { } else if(strcmp(argv[1], "show-jobs") == 0) {
if(argc != 2) { if(argc != 2) {
fprintf(stderr, "Usage: %s show-jobs\n", argv[0]); fprintf(stderr, "Usage: %s show-jobs\n", argv[0]);
return EXIT_BAD_ARGUMENT; return EXIT_BAD_ARGUMENT;
} }
auto jobs = laminar.listKnownRequest().send().wait(waitScope); for(auto it : laminar.listKnownRequest().send().wait(waitScope).getResult()) {
for(auto it : jobs.getResult()) {
printf("%s\n", it.cStr()); printf("%s\n", it.cStr());
} }
} else if(strcmp(argv[1], "show-queued") == 0) { } else if(strcmp(argv[1], "show-queued") == 0) {
@@ -232,8 +209,7 @@ int main(int argc, char** argv) {
fprintf(stderr, "Usage: %s show-queued\n", argv[0]); fprintf(stderr, "Usage: %s show-queued\n", argv[0]);
return EXIT_BAD_ARGUMENT; return EXIT_BAD_ARGUMENT;
} }
auto queued = laminar.listQueuedRequest().send().wait(waitScope); for(auto it : laminar.listQueuedRequest().send().wait(waitScope).getResult()) {
for(auto it : queued.getResult()) {
printf("%s\n", it.cStr()); printf("%s\n", it.cStr());
} }
} else if(strcmp(argv[1], "show-running") == 0) { } else if(strcmp(argv[1], "show-running") == 0) {
@@ -241,8 +217,7 @@ int main(int argc, char** argv) {
fprintf(stderr, "Usage: %s show-running\n", argv[0]); fprintf(stderr, "Usage: %s show-running\n", argv[0]);
return EXIT_BAD_ARGUMENT; return EXIT_BAD_ARGUMENT;
} }
auto running = laminar.listRunningRequest().send().wait(waitScope); for(auto it : laminar.listRunningRequest().send().wait(waitScope).getResult()) {
for(auto it : running.getResult()) {
printf("%s:%d\n", it.getJob().cStr(), it.getBuildNum()); printf("%s:%d\n", it.getJob().cStr(), it.getBuildNum());
} }
} else { } else {
@@ -250,7 +225,5 @@ int main(int argc, char** argv) {
return EXIT_BAD_ARGUMENT; return EXIT_BAD_ARGUMENT;
} }
ts.onEmpty().wait(waitScope);
return ret; return ret;
} }

View File

@@ -19,7 +19,6 @@
#ifndef LAMINAR_CONF_H_ #ifndef LAMINAR_CONF_H_
#define LAMINAR_CONF_H_ #define LAMINAR_CONF_H_
#include <string>
#include <unordered_map> #include <unordered_map>
class StringMap : public std::unordered_map<std::string, std::string> { class StringMap : public std::unordered_map<std::string, std::string> {

View File

@@ -24,11 +24,6 @@
#include <set> #include <set>
class Run; class Run;
// FNM_EXTMATCH isn't supported under musl
#if !defined(FNM_EXTMATCH)
#define FNM_EXTMATCH 0
#endif
// Represents a context within which a Run will be executed. Allows applying // Represents a context within which a Run will be executed. Allows applying
// a certain environment to a set of Jobs, or setting a limit on the number // a certain environment to a set of Jobs, or setting a limit on the number
// of parallel Runs // of parallel Runs

View File

@@ -268,8 +268,8 @@ Http::Http(Laminar &li) :
Http::~Http() Http::~Http()
{ {
LASSERT(logWatchers.size() == 0); KJ_ASSERT(logWatchers.size() == 0);
LASSERT(eventPeers.size() == 0); KJ_ASSERT(eventPeers.size() == 0);
} }
kj::Promise<void> Http::startServer(kj::Timer& timer, kj::Own<kj::ConnectionReceiver>&& listener) kj::Promise<void> Http::startServer(kj::Timer& timer, kj::Own<kj::ConnectionReceiver>&& listener)
@@ -297,8 +297,3 @@ void Http::notifyLog(std::string job, uint run, std::string log_chunk, bool eot)
} }
} }
} }
void Http::setHtmlTemplate(std::string tmpl)
{
resources->setHtmlTemplate(tmpl);
}

View File

@@ -43,9 +43,6 @@ public:
void notifyEvent(const char* data, std::string job = nullptr); void notifyEvent(const char* data, std::string job = nullptr);
void notifyLog(std::string job, uint run, std::string log_chunk, bool eot); void notifyLog(std::string job, uint run, std::string log_chunk, bool eot);
// Allows supplying a custom HTML template. Pass an empty string to use the default.
void setHtmlTemplate(std::string tmpl = std::string());
private: private:
virtual kj::Promise<void> request(kj::HttpMethod method, kj::StringPtr url, const kj::HttpHeaders& headers, virtual kj::Promise<void> request(kj::HttpMethod method, kj::StringPtr url, const kj::HttpHeaders& headers,
kj::AsyncInputStream& requestBody, Response& response) override; kj::AsyncInputStream& requestBody, Response& response) override;

View File

@@ -5,10 +5,11 @@ interface LaminarCi {
queue @0 (jobName :Text, params :List(JobParam)) -> (result :MethodResult); queue @0 (jobName :Text, params :List(JobParam)) -> (result :MethodResult);
start @1 (jobName :Text, params :List(JobParam)) -> (result :MethodResult, buildNum :UInt32); start @1 (jobName :Text, params :List(JobParam)) -> (result :MethodResult, buildNum :UInt32);
run @2 (jobName :Text, params :List(JobParam)) -> (result :JobResult, buildNum :UInt32); run @2 (jobName :Text, params :List(JobParam)) -> (result :JobResult, buildNum :UInt32);
listQueued @3 () -> (result :List(Text)); set @3 (run :Run, param :JobParam) -> (result :MethodResult);
listRunning @4 () -> (result :List(Run)); listQueued @4 () -> (result :List(Text));
listKnown @5 () -> (result :List(Text)); listRunning @5 () -> (result :List(Run));
abort @6 (run :Run) -> (result :MethodResult); listKnown @6 () -> (result :List(Text));
abort @7 (run :Run) -> (result :MethodResult);
struct Run { struct Run {
job @0 :Text; job @0 :Text;

View File

@@ -1,5 +1,5 @@
/// ///
/// Copyright 2015-2020 Oliver Giles /// Copyright 2015-2019 Oliver Giles
/// ///
/// This file is part of Laminar /// This file is part of Laminar
/// ///
@@ -113,12 +113,6 @@ Laminar::Laminar(Server &server, Settings settings) :
.addPath((homePath/"cfg"/"jobs").toString(true).cStr()) .addPath((homePath/"cfg"/"jobs").toString(true).cStr())
.addPath((homePath/"cfg").toString(true).cStr()); // for groups.conf .addPath((homePath/"cfg").toString(true).cStr()); // for groups.conf
loadCustomizations();
srv.watchPaths([this]{
LLOG(INFO, "Reloading customizations");
loadCustomizations();
}).addPath((homePath/"custom").toString(true).cStr());
srv.listenRpc(*rpc, settings.bind_rpc); srv.listenRpc(*rpc, settings.bind_rpc);
srv.listenHttp(*http, settings.bind_http); srv.listenHttp(*http, settings.bind_http);
@@ -127,14 +121,6 @@ Laminar::Laminar(Server &server, Settings settings) :
loadConfiguration(); loadConfiguration();
} }
void Laminar::loadCustomizations() {
KJ_IF_MAYBE(templ, fsHome->tryOpenFile(kj::Path{"custom","index.html"})) {
http->setHtmlTemplate((*templ)->readAllText().cStr());
} else {
http->setHtmlTemplate();
}
}
uint Laminar::latestRun(std::string job) { uint Laminar::latestRun(std::string job) {
auto it = activeJobs.byJobName().equal_range(job); auto it = activeJobs.byJobName().equal_range(job);
if(it.first == it.second) { if(it.first == it.second) {
@@ -227,7 +213,6 @@ std::string Laminar::getStatus(MonitorScope scope) {
Json j; Json j;
j.set("type", "status"); j.set("type", "status");
j.set("title", getenv("LAMINAR_TITLE") ?: "Laminar"); j.set("title", getenv("LAMINAR_TITLE") ?: "Laminar");
j.set("version", laminar_version());
j.set("time", time(nullptr)); j.set("time", time(nullptr));
j.startObject("data"); j.startObject("data");
if(scope.type == MonitorScope::RUN) { if(scope.type == MonitorScope::RUN) {
@@ -253,8 +238,7 @@ std::string Laminar::getStatus(MonitorScope scope) {
j.set("etc", run->startedAt + lastRuntime); j.set("etc", run->startedAt + lastRuntime);
}); });
} }
if(auto it = buildNums.find(scope.job); it != buildNums.end()) j.set("latestNum", int(buildNums[scope.job]));
j.set("latestNum", int(it->second));
j.startArray("artifacts"); j.startArray("artifacts");
populateArtifacts(j, scope.job, scope.num); populateArtifacts(j, scope.job, scope.num);
j.EndArray(); j.EndArray();
@@ -333,8 +317,7 @@ std::string Laminar::getStatus(MonitorScope scope) {
j.set("number", build).set("started", started); j.set("number", build).set("started", started);
j.EndObject(); j.EndObject();
}); });
auto desc = jobDescriptions.find(scope.job);
j.set("description", desc == jobDescriptions.end() ? "" : desc->second);
} else if(scope.type == MonitorScope::ALL) { } else if(scope.type == MonitorScope::ALL) {
j.startArray("jobs"); j.startArray("jobs");
db->stmt("SELECT name,number,startedAt,completedAt,result FROM builds b JOIN (SELECT name n,MAX(number) l FROM builds GROUP BY n) q ON b.name = q.n AND b.number = q.l") db->stmt("SELECT name,number,startedAt,completedAt,result FROM builds b JOIN (SELECT name n,MAX(number) l FROM builds GROUP BY n) q ON b.name = q.n AND b.number = q.l")
@@ -560,10 +543,6 @@ bool Laminar::loadConfiguration() {
ctxPtnList.insert(ctx); ctxPtnList.insert(ctx);
jobContexts[jobName].swap(ctxPtnList); jobContexts[jobName].swap(ctxPtnList);
} }
std::string desc = conf.get<std::string>("DESCRIPTION");
if(!desc.empty()) {
jobDescriptions[jobName] = desc;
}
} }
} }
@@ -602,14 +581,16 @@ std::shared_ptr<Run> Laminar::queueJob(std::string name, ParamMap params) {
} }
bool Laminar::abort(std::string job, uint buildNum) { bool Laminar::abort(std::string job, uint buildNum) {
if(Run* run = activeRun(job, buildNum)) if(Run* run = activeRun(job, buildNum)) {
return run->abort(); run->abort(true);
return true;
}
return false; return false;
} }
void Laminar::abortAll() { void Laminar::abortAll() {
for(std::shared_ptr<Run> run : activeJobs) { for(std::shared_ptr<Run> run : activeJobs) {
run->abort(); run->abort(false);
} }
} }
@@ -617,9 +598,7 @@ bool Laminar::tryStartRun(std::shared_ptr<Run> run, int queueIndex) {
for(auto& sc : contexts) { for(auto& sc : contexts) {
std::shared_ptr<Context> ctx = sc.second; std::shared_ptr<Context> ctx = sc.second;
if(ctx->canQueue(jobContexts.at(run->name))) { if(ctx->canQueue(jobContexts.at(run->name)) && run->configure(buildNums[run->name] + 1, ctx, *fsHome)) {
kj::Promise<RunState> onRunFinished = run->start(buildNums[run->name] + 1, ctx, *fsHome,[this](kj::Maybe<pid_t>& pid){return srv.onChildExit(pid);});
ctx->busyExecutors++; ctx->busyExecutors++;
// set the last known result if exists // set the last known result if exists
db->stmt("SELECT result FROM builds WHERE name = ? ORDER BY completedAt DESC LIMIT 1") db->stmt("SELECT result FROM builds WHERE name = ? ORDER BY completedAt DESC LIMIT 1")
@@ -628,20 +607,13 @@ bool Laminar::tryStartRun(std::shared_ptr<Run> run, int queueIndex) {
run->lastResult = RunState(result); run->lastResult = RunState(result);
}); });
kj::Promise<void> exec = srv.readDescriptor(run->output_fd, [this, run](const char*b, size_t n){ // Actually schedules the Run steps
// handle log output kj::Promise<void> exec = handleRunStep(run.get()).then([=]{
std::string s(b, n); runFinished(run.get());
run->log += s;
http->notifyLog(run->name, run->build, s, false);
}).then([run, p = kj::mv(onRunFinished)]() mutable {
// wait until leader reaped
return kj::mv(p);
}).then([this, run](RunState){
handleRunFinished(run.get());
}); });
if(run->timeout > 0) { if(run->timeout > 0) {
exec = exec.attach(srv.addTimeout(run->timeout, [r=run.get()](){ exec = exec.attach(srv.addTimeout(run->timeout, [r=run.get()](){
r->abort(); r->abort(true);
})); }));
} }
srv.addTask(kj::mv(exec)); srv.addTask(kj::mv(exec));
@@ -685,7 +657,31 @@ void Laminar::assignNewJobs() {
} }
} }
void Laminar::handleRunFinished(Run * r) { kj::Promise<void> Laminar::handleRunStep(Run* run) {
if(run->step()) {
// no more steps
return kj::READY_NOW;
}
kj::Promise<int> exited = srv.onChildExit(run->current_pid);
// promise is fulfilled when the process is reaped. But first we wait for all
// output from the pipe (Run::output_fd) to be consumed.
return srv.readDescriptor(run->output_fd, [this,run](const char*b,size_t n){
// handle log output
std::string s(b, n);
run->log += s;
http->notifyLog(run->name, run->build, s, false);
}).then([p = std::move(exited)]() mutable {
// wait until the process is reaped
return kj::mv(p);
}).then([this, run](int status){
run->reaped(status);
// next step in Run
return handleRunStep(run);
});
}
void Laminar::runFinished(Run * r) {
std::shared_ptr<Context> ctx = r->context; std::shared_ptr<Context> ctx = r->context;
ctx->busyExecutors--; ctx->busyExecutors--;
@@ -780,7 +776,7 @@ bool Laminar::handleBadgeRequest(std::string job, std::string &badge) {
const char* gradient1 = (rs == RunState::SUCCESS) ? "#2aff4d" : "#ff2a2a"; const char* gradient1 = (rs == RunState::SUCCESS) ? "#2aff4d" : "#ff2a2a";
const char* gradient2 = (rs == RunState::SUCCESS) ? "#24b43c" : "#b42424"; const char* gradient2 = (rs == RunState::SUCCESS) ? "#24b43c" : "#b42424";
char* svg = NULL; char* svg = NULL;
if(asprintf(&svg, asprintf(&svg,
R"x( R"x(
<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="20"> <svg xmlns="http://www.w3.org/2000/svg" width="%d" height="20">
<clipPath id="clip"> <clipPath id="clip">
@@ -800,21 +796,13 @@ R"x(
<rect x="%d" width="%d" height="20" fill="url(#status)"/> <rect x="%d" width="%d" height="20" fill="url(#status)"/>
<text x="%d" y="14" fill="#000">%s</text> <text x="%d" y="14" fill="#000">%s</text>
</g> </g>
</svg>)x", jobNameWidth+statusWidth, jobNameWidth+statusWidth, gradient1, gradient2, jobNameWidth, jobNameWidth/2+1, job.data(), jobNameWidth, statusWidth, jobNameWidth+statusWidth/2, status.data()) < 0) </svg>)x", jobNameWidth+statusWidth, jobNameWidth+statusWidth, gradient1, gradient2, jobNameWidth, jobNameWidth/2+1, job.data(), jobNameWidth, statusWidth, jobNameWidth+statusWidth/2, status.data());
return false;
badge = svg; badge = svg;
return true; return true;
} }
// TODO: deprecate
std::string Laminar::getCustomCss() { std::string Laminar::getCustomCss() {
KJ_IF_MAYBE(cssFile, fsHome->tryOpenFile(kj::Path{"custom","style.css"})) { KJ_IF_MAYBE(cssFile, fsHome->tryOpenFile(kj::Path{"custom","style.css"})) {
static bool warningShown = false;
if(!warningShown) {
LLOG(WARNING, "Custom CSS has been deprecated and will be removed from a future release. Use a custom HTML template instead.");
warningShown = true;
}
return (*cssFile)->readAllText().cStr(); return (*cssFile)->readAllText().cStr();
} else { } else {
return std::string(); return std::string();

View File

@@ -103,10 +103,10 @@ public:
private: private:
bool loadConfiguration(); bool loadConfiguration();
void loadCustomizations();
void assignNewJobs(); void assignNewJobs();
bool tryStartRun(std::shared_ptr<Run> run, int queueIndex); bool tryStartRun(std::shared_ptr<Run> run, int queueIndex);
void handleRunFinished(Run*); kj::Promise<void> handleRunStep(Run *run);
void runFinished(Run*);
// expects that Json has started an array // expects that Json has started an array
void populateArtifacts(Json& out, std::string job, uint num) const; void populateArtifacts(Json& out, std::string job, uint num) const;
@@ -121,8 +121,6 @@ private:
std::unordered_map<std::string, std::set<std::string>> jobContexts; std::unordered_map<std::string, std::set<std::string>> jobContexts;
std::unordered_map<std::string, std::string> jobDescriptions;
std::unordered_map<std::string, std::string> jobGroups; std::unordered_map<std::string, std::string> jobGroups;
Settings settings; Settings settings;

View File

@@ -1,298 +0,0 @@
///
/// Copyright 2019-2020 Oliver Giles
///
/// This file is part of Laminar
///
/// Laminar is free software: you can redistribute it and/or modify
/// it under the terms of the GNU General Public License as published by
/// the Free Software Foundation, either version 3 of the License, or
/// (at your option) any later version.
///
/// Laminar is distributed in the hope that it will be useful,
/// but WITHOUT ANY WARRANTY; without even the implied warranty of
/// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
/// GNU General Public License for more details.
///
/// You should have received a copy of the GNU General Public License
/// along with Laminar. If not, see <http://www.gnu.org/licenses/>
///
#include "log.h"
#include <string>
#include <unistd.h>
#include <queue>
#include <sys/prctl.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <kj/async-io.h>
#include <kj/async-unix.h>
#include <kj/filesystem.h>
#include "run.h"
// short syntax helper for kj::Path
template<typename T>
inline kj::Path operator/(const kj::Path& p, const T& ext) {
return p.append(ext);
}
template<typename T>
inline kj::Path operator/(const kj::PathPtr& p, const T& ext) {
return p.append(ext);
}
struct Script {
kj::Path path;
kj::Path cwd;
bool runOnAbort;
};
class Leader final : public kj::TaskSet::ErrorHandler {
public:
Leader(kj::AsyncIoContext& ioContext, kj::Filesystem& fs, const char* jobName, uint runNumber);
RunState run();
private:
void taskFailed(kj::Exception&& exception) override;
kj::Promise<void> step(std::queue<Script>& scripts);
kj::Promise<void> reapChildProcesses();
kj::Promise<void> readEnvPipe(kj::AsyncInputStream* stream, char* buffer);
kj::TaskSet tasks;
RunState result;
kj::AsyncIoContext& ioContext;
const kj::Directory& home;
kj::PathPtr rootPath;
std::string jobName;
uint runNumber;
pid_t currentGroupId;
pid_t currentScriptPid;
std::queue<Script> scripts;
int setEnvPipe[2];
};
Leader::Leader(kj::AsyncIoContext &ioContext, kj::Filesystem &fs, const char *jobName, uint runNumber) :
tasks(*this),
result(RunState::SUCCESS),
ioContext(ioContext),
home(fs.getCurrent()),
rootPath(fs.getCurrentPath()),
jobName(jobName),
runNumber(runNumber)
{
tasks.add(ioContext.unixEventPort.onSignal(SIGTERM).then([this](siginfo_t) {
while(scripts.size() && (!scripts.front().runOnAbort))
scripts.pop();
// TODO: probably shouldn't do this if we are already in a runOnAbort script
kill(-currentGroupId, SIGTERM);
return this->ioContext.provider->getTimer().afterDelay(2*kj::SECONDS).then([this]{
fprintf(stderr, "[laminar] sending SIGKILL to process group %d\n", currentGroupId);
kill(-currentGroupId, SIGKILL);
});
}));
LSYSCALL(pipe(setEnvPipe));
auto event = ioContext.lowLevelProvider->wrapInputFd(setEnvPipe[0], kj::LowLevelAsyncIoProvider::TAKE_OWNERSHIP);
auto buffer = kj::heapArrayBuilder<char>(1024);
tasks.add(readEnvPipe(event, buffer.asPtr().begin()).attach(kj::mv(event), kj::mv(buffer)));
}
RunState Leader::run()
{
kj::Path cfgDir{"cfg"};
// create the run directory
kj::Path rd{"run",jobName,std::to_string(runNumber)};
bool createWorkdir = true;
KJ_IF_MAYBE(ls, home.tryLstat(rd)) {
LASSERT(ls->type == kj::FsNode::Type::DIRECTORY);
LLOG(WARNING, "Working directory already exists, removing", rd.toString());
if(home.tryRemove(rd) == false) {
LLOG(WARNING, "Failed to remove working directory");
createWorkdir = false;
}
}
if(createWorkdir && home.tryOpenSubdir(rd, kj::WriteMode::CREATE|kj::WriteMode::CREATE_PARENT) == nullptr) {
LLOG(ERROR, "Could not create working directory", rd.toString());
return RunState::FAILED;
}
// create an archive directory
kj::Path archive = kj::Path{"archive",jobName,std::to_string(runNumber)};
if(home.exists(archive)) {
LLOG(WARNING, "Archive directory already exists", archive.toString());
} else if(home.tryOpenSubdir(archive, kj::WriteMode::CREATE|kj::WriteMode::CREATE_PARENT) == nullptr) {
LLOG(ERROR, "Could not create archive directory", archive.toString());
return RunState::FAILED;
}
// create a workspace for this job if it doesn't exist
kj::Path ws{"run",jobName,"workspace"};
if(!home.exists(ws)) {
home.openSubdir(ws, kj::WriteMode::CREATE|kj::WriteMode::CREATE_PARENT);
// prepend the workspace init script
if(home.exists(cfgDir/"jobs"/(jobName+".init")))
scripts.push({cfgDir/"jobs"/(jobName+".init"), kj::mv(ws), false});
}
// add scripts
// global before-run script
if(home.exists(cfgDir/"before"))
scripts.push({cfgDir/"before", rd.clone(), false});
// job before-run script
if(home.exists(cfgDir/"jobs"/(jobName+".before")))
scripts.push({cfgDir/"jobs"/(jobName+".before"), rd.clone(), false});
// main run script. must exist.
scripts.push({cfgDir/"jobs"/(jobName+".run"), rd.clone(), false});
// job after-run script
if(home.exists(cfgDir/"jobs"/(jobName+".after")))
scripts.push({cfgDir/"jobs"/(jobName+".after"), rd.clone(), true});
// global after-run script
if(home.exists(cfgDir/"after"))
scripts.push({cfgDir/"after", rd.clone(), true});
// Start executing scripts
return step(scripts).then([this](){
return result;
}).wait(ioContext.waitScope);
}
void Leader::taskFailed(kj::Exception &&exception)
{
LLOG(ERROR, exception);
}
kj::Promise<void> Leader::step(std::queue<Script> &scripts)
{
if(scripts.empty())
return kj::READY_NOW;
Script currentScript = kj::mv(scripts.front());
scripts.pop();
pid_t pid = fork();
if(pid == 0) { // child
// unblock all signals
sigset_t mask;
sigfillset(&mask);
sigprocmask(SIG_UNBLOCK, &mask, nullptr);
// create a new process group to help us deal with any wayward forks
setpgid(0, 0);
std::string buildNum = std::to_string(runNumber);
LSYSCALL(chdir(currentScript.cwd.toString(false).cStr()));
setenv("RESULT", to_string(result).c_str(), true);
// pass the pipe through a variable to allow laminarc to send new env back
char pipeNum[4];
sprintf(pipeNum, "%d", setEnvPipe[1]);
setenv("__LAMINAR_SETENV_PIPE", pipeNum, 1);
fprintf(stderr, "[laminar] Executing %s\n", currentScript.path.toString().cStr());
kj::String execPath = (rootPath/currentScript.path).toString(true);
execl(execPath.cStr(), execPath.cStr(), NULL);
fprintf(stderr, "[laminar] Failed to execute %s\n", currentScript.path.toString().cStr());
_exit(1);
}
currentScriptPid = pid;
currentGroupId = pid;
return reapChildProcesses().then([&](){
return step(scripts);
});
}
kj::Promise<void> Leader::reapChildProcesses()
{
return ioContext.unixEventPort.onSignal(SIGCHLD).then([this](siginfo_t) -> kj::Promise<void> {
while(true) {
int status;
errno = 0;
pid_t pid = waitpid(-1, &status, WNOHANG);
if(pid == -1 && errno == ECHILD) {
// all children exited
return kj::READY_NOW;
} else if(pid == 0) {
// child processes are still running
if(currentScriptPid) {
// We could get here if a more deeply nested process was reparented to us
// before the primary script executed. Quietly wait until the process we're
// waiting for is done
return reapChildProcesses();
}
// Otherwise, reparented orphans are on borrowed time
// TODO list wayward processes?
fprintf(stderr, "[laminar] sending SIGHUP to adopted child processes\n");
kill(-currentGroupId, SIGHUP);
return ioContext.provider->getTimer().afterDelay(5*kj::SECONDS).then([this]{
fprintf(stderr, "[laminar] sending SIGKILL to process group %d\n", currentGroupId);
// TODO: should we mark the job as failed if we had to kill reparented processes?
kill(-currentGroupId, SIGKILL);
return reapChildProcesses();
}).exclusiveJoin(reapChildProcesses());
} else if(pid == currentScriptPid) {
// the script we were waiting for is done
// if we already marked as failed, preserve that
if(result == RunState::SUCCESS) {
if(WIFSIGNALED(status) && (WTERMSIG(status) == SIGTERM || WTERMSIG(status) == SIGKILL))
result = RunState::ABORTED;
else if(WEXITSTATUS(status) != 0)
result = RunState::FAILED;
}
currentScriptPid = 0;
} else {
// some reparented process was reaped
}
}
});
}
kj::Promise<void> Leader::readEnvPipe(kj::AsyncInputStream *stream, char *buffer) {
return stream->tryRead(buffer, 1, 1024).then([this,stream,buffer](size_t sz) {
if(sz > 0) {
buffer[sz] = '\0';
if(char* eq = strchr(buffer, '=')) {
*eq++ = '\0';
setenv(buffer, eq, 1);
}
return readEnvPipe(stream, kj::mv(buffer));
}
return kj::Promise<void>(kj::READY_NOW);
});
}
int leader_main(void) {
auto ioContext = kj::setupAsyncIo();
auto fs = kj::newDiskFilesystem();
kj::UnixEventPort::captureSignal(SIGTERM);
// Don't use captureChildExit or onChildExit because they don't provide a way to
// reap orphaned child processes. Stick with the more fundamental onSignal.
kj::UnixEventPort::captureSignal(SIGCHLD);
// Becoming a subreaper means any descendent process whose parent process disappears
// will be reparented to this one instead of init (or higher layer subreaper).
// We do this so that the run will wait until all descedents exit before executing
// the next step.
prctl(PR_SET_CHILD_SUBREAPER, 1, NULL, NULL, NULL);
// Become the leader of a new process group. This is so that all child processes
// will also get a kill signal when the run is aborted
setpgid(0, 0);
// Environment inherited from main laminard process
const char* jobName = getenv("JOB");
std::string name(jobName);
uint runNumber = atoi(getenv("RUN"));
if(!jobName || !runNumber)
return EXIT_FAILURE;
Leader leader(ioContext, *fs, jobName, runNumber);
// Parent process will cast back to RunState
return int(leader.run());
}

View File

@@ -1,36 +0,0 @@
///
/// Copyright 2019 Oliver Giles
///
/// This file is part of Laminar
///
/// Laminar is free software: you can redistribute it and/or modify
/// it under the terms of the GNU General Public License as published by
/// the Free Software Foundation, either version 3 of the License, or
/// (at your option) any later version.
///
/// Laminar is distributed in the hope that it will be useful,
/// but WITHOUT ANY WARRANTY; without even the implied warranty of
/// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
/// GNU General Public License for more details.
///
/// You should have received a copy of the GNU General Public License
/// along with Laminar. If not, see <http://www.gnu.org/licenses/>
///
#ifndef LAMINAR_LEADER_H_
#define LAMINAR_LEADER_H_
// Main function for the leader process which is responsible for
// executing all the scripts which make up a Run. Separating this
// into its own process allows for a cleaner process tree view,
// where it's obvious which script belongs to which run of which
// job, and allows this leader process to act as a subreaper for
// any wayward child processes.
// This could have been implemented as a separate process, but
// instead we just fork & exec /proc/self/exe from the main laminar
// daemon, and distinguish based on argv[0]. This saves installing
// another binary and avoids some associated pitfalls.
int leader_main(void);
#endif // LAMINAR_LEADER_H_

View File

@@ -1,5 +1,5 @@
/// ///
/// Copyright 2015-2020 Oliver Giles /// Copyright 2015-2019 Oliver Giles
/// ///
/// This file is part of Laminar /// This file is part of Laminar
/// ///
@@ -67,7 +67,5 @@ namespace _ {
for (::kj::_::Debug::Fault f(__FILE_BASE__, __LINE__, \ for (::kj::_::Debug::Fault f(__FILE_BASE__, __LINE__, \
_kjSyscallResult.getErrorNumber(), #call, #__VA_ARGS__, ##__VA_ARGS__);; f.fatal()) _kjSyscallResult.getErrorNumber(), #call, #__VA_ARGS__, ##__VA_ARGS__);; f.fatal())
const char* laminar_version();
#endif // LAMINAR_LOG_H_ #endif // LAMINAR_LOG_H_

View File

@@ -1,5 +1,5 @@
/// ///
/// Copyright 2015-2020 Oliver Giles /// Copyright 2015-2016 Oliver Giles
/// ///
/// This file is part of Laminar /// This file is part of Laminar
/// ///
@@ -17,17 +17,11 @@
/// along with Laminar. If not, see <http://www.gnu.org/licenses/> /// along with Laminar. If not, see <http://www.gnu.org/licenses/>
/// ///
#include "laminar.h" #include "laminar.h"
#include "leader.h"
#include "server.h" #include "server.h"
#include "log.h" #include "log.h"
#include <signal.h>
#include <fcntl.h>
#include <iostream>
#include <kj/async-unix.h> #include <kj/async-unix.h>
#include <kj/filesystem.h> #include <kj/filesystem.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/stat.h>
static Laminar* laminar; static Laminar* laminar;
static Server* server; static Server* server;
@@ -46,36 +40,15 @@ constexpr const char* INTADDR_HTTP_DEFAULT = "*:8080";
constexpr const char* ARCHIVE_URL_DEFAULT = "/archive/"; constexpr const char* ARCHIVE_URL_DEFAULT = "/archive/";
} }
static void usage(std::ostream& out) {
out << "laminard version " << laminar_version() << "\n";
out << "Usage:\n";
out << " -h|--help show this help message\n";
out << " -v enable verbose output\n";
}
int main(int argc, char** argv) { int main(int argc, char** argv) {
if(argv[0][0] == '{')
return leader_main();
for(int i = 1; i < argc; ++i) { for(int i = 1; i < argc; ++i) {
if(strcmp(argv[i], "-v") == 0) { if(strcmp(argv[i], "-v") == 0) {
kj::_::Debug::setLogLevel(kj::_::Debug::Severity::INFO); kj::_::Debug::setLogLevel(kj::_::Debug::Severity::INFO);
} else if(strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) {
return usage(std::cout), EXIT_SUCCESS;
} else {
std::cerr << "Unknown argument " << argv[i] << "\n";
return usage(std::cerr), EXIT_FAILURE;
} }
} }
// The parent process hopefully connected stdin to /dev/null, but
// do it again here just in case. This is important because stdin
// is inherited to job runs via the leader process, and some
// processes misbehave if they can successfully block on reading
// from stdin.
close(STDIN_FILENO);
LASSERT(open("/dev/null", O_RDONLY) == STDIN_FILENO);
auto ioContext = kj::setupAsyncIo(); auto ioContext = kj::setupAsyncIo();
Settings settings; Settings settings;
@@ -93,8 +66,6 @@ int main(int argc, char** argv) {
signal(SIGINT, &laminar_quit); signal(SIGINT, &laminar_quit);
signal(SIGTERM, &laminar_quit); signal(SIGTERM, &laminar_quit);
printf("laminard version %s started\n", laminar_version());
server->start(); server->start();
delete laminar; delete laminar;

View File

@@ -1,5 +1,5 @@
/// ///
/// Copyright 2015-2020 Oliver Giles /// Copyright 2015-2019 Oliver Giles
/// ///
/// This file is part of Laminar /// This file is part of Laminar
/// ///
@@ -27,17 +27,17 @@
extern const char _binary_##name##_z_end[]; \ extern const char _binary_##name##_z_end[]; \
resources.emplace(route, Resource{_binary_ ## name ## _z_start, _binary_ ## name ## _z_end, content_type}) resources.emplace(route, Resource{_binary_ ## name ## _z_start, _binary_ ## name ## _z_end, content_type})
#define CONTENT_TYPE_HTML "text/html; charset=utf-8" #define CONTENT_TYPE_HTML "text/html; charset=utf-8"
#define CONTENT_TYPE_ICO "image/x-icon" #define CONTENT_TYPE_ICO "image/x-icon"
#define CONTENT_TYPE_PNG "image/png" #define CONTENT_TYPE_PNG "image/png"
#define CONTENT_TYPE_JS "application/javascript; charset=utf-8" #define CONTENT_TYPE_JS "application/javascript; charset=utf-8"
#define CONTENT_TYPE_CSS "text/css; charset=utf-8" #define CONTENT_TYPE_CSS "text/css; charset=utf-8"
#define CONTENT_TYPE_MANIFEST "application/manifest+json; charset=utf-8"
#define GZIP_FORMAT 16 #define GZIP_FORMAT 16
Resources::Resources() Resources::Resources()
{ {
INIT_RESOURCE("/", index_html, CONTENT_TYPE_HTML);
INIT_RESOURCE("/favicon.ico", favicon_ico, CONTENT_TYPE_ICO); INIT_RESOURCE("/favicon.ico", favicon_ico, CONTENT_TYPE_ICO);
INIT_RESOURCE("/favicon-152.png", favicon_152_png, CONTENT_TYPE_PNG); INIT_RESOURCE("/favicon-152.png", favicon_152_png, CONTENT_TYPE_PNG);
INIT_RESOURCE("/icon.png", icon_png, CONTENT_TYPE_PNG); INIT_RESOURCE("/icon.png", icon_png, CONTENT_TYPE_PNG);
@@ -47,72 +47,47 @@ Resources::Resources()
INIT_RESOURCE("/js/vue-router.min.js", js_vue_router_min_js, CONTENT_TYPE_JS); INIT_RESOURCE("/js/vue-router.min.js", js_vue_router_min_js, CONTENT_TYPE_JS);
INIT_RESOURCE("/js/ansi_up.js", js_ansi_up_js, CONTENT_TYPE_JS); INIT_RESOURCE("/js/ansi_up.js", js_ansi_up_js, CONTENT_TYPE_JS);
INIT_RESOURCE("/js/Chart.min.js", js_Chart_min_js, CONTENT_TYPE_JS); INIT_RESOURCE("/js/Chart.min.js", js_Chart_min_js, CONTENT_TYPE_JS);
INIT_RESOURCE("/style.css", style_css, CONTENT_TYPE_CSS); INIT_RESOURCE("/css/bootstrap.min.css", css_bootstrap_min_css, CONTENT_TYPE_CSS);
INIT_RESOURCE("/manifest.webmanifest", manifest_webmanifest, CONTENT_TYPE_MANIFEST);
// Configure the default template
setHtmlTemplate(std::string());
}
void Resources::setHtmlTemplate(std::string tmpl) { if(const char* baseUrl = getenv("LAMINAR_BASE_URL")) {
extern const char _binary_index_html_z_start[]; // The administrator needs to customize the <base href>. Unfortunately this seems
extern const char _binary_index_html_z_end[]; // to be the only thing that needs to be customizable but cannot be done via dynamic
// DOM manipulation without heavy compromises. So replace the static char array with
z_stream strm; // a modified buffer accordingly.
memset(&strm, 0, sizeof(z_stream)); z_stream strm;
memset(&strm, 0, sizeof(z_stream));
if(!tmpl.empty()) { std::string tmp;
tmp.resize(INDEX_HTML_UNCOMPRESSED_SIZE);
// inflate
inflateInit2(&strm, MAX_WBITS|GZIP_FORMAT);
strm.next_in = (unsigned char*) _binary_index_html_z_start;
strm.avail_in = _binary_index_html_z_end - _binary_index_html_z_start;
strm.next_out = (unsigned char*) tmp.data();
strm.avail_out = INDEX_HTML_UNCOMPRESSED_SIZE;
if(inflate(&strm, Z_FINISH) != Z_STREAM_END) {
LLOG(FATAL, "Failed to uncompress index_html");
}
// replace
// There's no validation on the replacement string, so you can completely mangle
// the html if you like. This isn't really an issue because if you can modify laminar's
// environment you already have elevated permissions
if(auto it = tmp.find("base href=\"/"))
tmp.replace(it+11, 1, baseUrl);
// deflate // deflate
index_html.resize(tmpl.size()); index_html.resize(tmp.size());
deflateInit2(&strm, Z_DEFAULT_COMPRESSION, Z_DEFLATED, MAX_WBITS|GZIP_FORMAT, 8, Z_DEFAULT_STRATEGY); deflateInit2(&strm, Z_DEFAULT_COMPRESSION, Z_DEFLATED, MAX_WBITS|GZIP_FORMAT, 8, Z_DEFAULT_STRATEGY);
strm.next_in = (unsigned char*) tmpl.data(); strm.next_in = (unsigned char*) tmp.data();
strm.avail_in = tmpl.size(); strm.avail_in = tmp.size();
strm.next_out = (unsigned char*) index_html.data(); strm.next_out = (unsigned char*) index_html.data();
strm.avail_out = tmpl.size(); strm.avail_out = tmp.size();
if(deflate(&strm, Z_FINISH) != Z_STREAM_END) { if(deflate(&strm, Z_FINISH) != Z_STREAM_END) {
LLOG(FATAL, "Failed to compress index.html"); LLOG(FATAL, "Failed to compress index.html");
} }
index_html.resize(strm.total_out); index_html.resize(strm.total_out);
} else { // update resource map
// use the default template from compile-time asset resources["/"].start = index_html.data();
if(const char* baseUrl = getenv("LAMINAR_BASE_URL")) { resources["/"].end = index_html.data() + index_html.size();
// The administrator needs to customize the <base href>. Unfortunately this seems
// to be the only thing that needs to be customizable but cannot be done via dynamic
// DOM manipulation without heavy compromises. So replace the static char array with
// a modified buffer accordingly.
std::string tmp;
tmp.resize(INDEX_HTML_UNCOMPRESSED_SIZE);
// inflate
inflateInit2(&strm, MAX_WBITS|GZIP_FORMAT);
strm.next_in = (unsigned char*) _binary_index_html_z_start;
strm.avail_in = _binary_index_html_z_end - _binary_index_html_z_start;
strm.next_out = (unsigned char*) tmp.data();
strm.avail_out = INDEX_HTML_UNCOMPRESSED_SIZE;
if(inflate(&strm, Z_FINISH) != Z_STREAM_END) {
LLOG(FATAL, "Failed to uncompress index_html");
}
// replace
// There's no validation on the replacement string, so you can completely mangle
// the html if you like. This isn't really an issue because if you can modify laminar's
// environment you already have elevated permissions
if(auto it = tmp.find("base href=\"/"))
tmp.replace(it+11, 1, baseUrl);
// deflate
index_html.resize(tmp.size());
deflateInit2(&strm, Z_DEFAULT_COMPRESSION, Z_DEFLATED, MAX_WBITS|GZIP_FORMAT, 8, Z_DEFAULT_STRATEGY);
strm.next_in = (unsigned char*) tmp.data();
strm.avail_in = tmp.size();
strm.next_out = (unsigned char*) index_html.data();
strm.avail_out = tmp.size();
if(deflate(&strm, Z_FINISH) != Z_STREAM_END) {
LLOG(FATAL, "Failed to compress index.html");
}
index_html.resize(strm.total_out);
} else {
index_html = std::string(_binary_index_html_z_start, _binary_index_html_z_end);
}
} }
// update resource map
resources["/"] = Resource{index_html.data(), index_html.data() + index_html.size(), CONTENT_TYPE_HTML};
} }
inline bool beginsWith(std::string haystack, const char* needle) { inline bool beginsWith(std::string haystack, const char* needle) {

View File

@@ -34,9 +34,6 @@ public:
// type. Function returns false if no resource for the given path exists // type. Function returns false if no resource for the given path exists
bool handleRequest(std::string path, const char** start, const char** end, const char** content_type); bool handleRequest(std::string path, const char** start, const char** end, const char** content_type);
// Allows providing a custom HTML template. Pass an empty string to use the default.
void setHtmlTemplate(std::string templ = std::string());
private: private:
struct Resource { struct Resource {
const char* start; const char* start;

View File

@@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html lang="en"> <html>
<head> <head>
<base href="/"> <base href="/">
<meta charset="utf-8"> <meta charset="utf-8">
@@ -8,201 +8,333 @@
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<link rel="apple-touch-icon-precomposed" href="favicon-152.png"> <link rel="apple-touch-icon-precomposed" href="favicon-152.png">
<link rel="icon" href="favicon.ico"> <link rel="icon" href="favicon.ico">
<link rel="manifest" href="/manifest.webmanifest">
<title>Laminar</title> <title>Laminar</title>
<script src="js/vue.min.js"></script> <script src="js/vue.min.js"></script>
<script src="js/vue-router.min.js"></script> <script src="js/vue-router.min.js"></script>
<script src="js/ansi_up.js"></script> <script src="js/ansi_up.js"></script>
<script src="js/Chart.min.js"></script> <script src="js/Chart.min.js"></script>
<script src="js/app.js" defer></script> <link href="css/bootstrap.min.css" rel="stylesheet">
<link href="style.css" rel="stylesheet">
<link href="custom/style.css" rel="stylesheet"> <link href="custom/style.css" rel="stylesheet">
<script src="js/app.js" defer></script>
<style>
body, html { height: 100%; }
.navbar { margin-bottom: 0; border-radius: 0; }
.navbar-brand { margin: 0 -15px; padding: 7px 15px }
.navbar-brand>img { display: inline; }
a.navbar-btn { color: #9d9d9d; }
a.navbar-btn.active { color: #fff; }
a.navbar-btn:hover { color: #fff; text-decoration: none; }
a.navbar-btn:focus { color: #fff; }
.bell { margin: 8px 15px; color: #9d9d9d; }
.bell:hover { text-decoration: none; color: #9d9d9d; cursor: pointer; }
.bell.active { color: #333; }
dt,dd { line-height: 2; }
canvas {
width: 100% !important;
max-width: 800px;
height: auto !important;
}
.progress {
height: 10px;
margin-top: 5px;
margin-bottom: 0;
}
table#joblist tr:first-child td { border-top: 0; }
#popup-connecting {
position: fixed;
background: white;
border: 1px solid #ddd;
bottom: 10px;
right: 10px;
padding: 20px;
}
/* status icons */
span.status {
display: inline-block;
width: 1em;
text-align: center;
font-family: sans-serif;
}
span.success { color: forestgreen; }
span.failed { color: firebrick; }
span.aborted { color: indigo; }
span.spin {
color: steelblue;
animation: 2s linear infinite spin;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* chart overlay */
li.chart-overlay {
position: absolute;
display: inline-block;
background: white;
border: 1px solid lightgray;
padding: 3px;
}
/* sort indicators */
a.sort {
position: relative;
margin-left: 7px;
}
a.sort:before, a.sort:after {
border: 4px solid transparent;
content: "";
position: absolute;
display: block;
height: 0;
width: 0;
right: 0;
top: 50%;
}
a.sort:before {
border-bottom-color: #ccc;
margin-top: -9px;
}
a.sort:after {
border-top-color: #ccc;
margin-top: 1px;
}
a.sort.dsc:after { border-top-color: #000; }
a.sort.asc:before { border-bottom-color: #000; }
a.sort:hover { text-decoration: none; cursor:pointer; }
a.sort:not(.asc):hover:before { border-bottom-color: #777; }
a.sort:not(.dsc):hover:after { border-top-color: #777; }
</style>
</head> </head>
<body> <body>
<template id="home"><div id="page-home-main"> <template id="home"><div>
<nav> <ol class="breadcrumb"><li class="active">Home</li></ol>
<table class="table striped"> <div class="container-fluid"><div class="row">
<tr v-for="job in jobsQueued"> <div class="col-sm-5 col-md-4 col-lg-3 dash">
<td><router-link :to="'/jobs/'+job.name">{{job.name}}</router-link> <i>queued</i></td> <table class="table table-bordered">
</tr> <tr v-for="job in jobsQueued">
<tr v-for="job in jobsRunning"> <td><router-link :to="'/jobs/'+job.name">{{job.name}}</router-link> <i>queued</i></td>
<td> </tr>
<span v-html="runIcon(job.result)"></span> <tr v-for="job in jobsRunning">
<router-link :to="'/jobs/'+job.name">{{job.name}}</router-link> <td><span v-html="runIcon(job.result)"></span> <router-link :to="'/jobs/'+job.name">{{job.name}}</router-link> <router-link :to="'/jobs/'+job.name+'/'+job.number">#{{job.number}}</router-link>
<router-link :to="'/jobs/'+job.name+'/'+job.number">#{{job.number}}</router-link> <small class="pull-right">{{formatDuration(job.started, job.completed)}}</small>
<small style="float:right;">{{formatDuration(job.started, job.completed)}}</small> <div class="progress">
<div class="progress" style="margin-top: 5px;"> <div class="progress-bar progress-bar-striped" :class="'progress-bar-'+(job.overtime?'warning':'info')" :class="job.etc?'':'active'" :style="'width:'+(!job.etc?'100':job.progress)+'%'"></div>
<div class="progress-bar" :class="{overtime:job.overtime,indeterminate:!job.etc}" :style="job.etc && {width:job.progress+'%'}"></div> </div>
</td>
</tr>
<tr v-for="job in jobsRecent">
<td><span v-html="runIcon(job.result)"></span> <router-link :to="'/jobs/'+job.name">{{job.name}}</router-link> <router-link :to="'/jobs/'+job.name+'/'+job.number">#{{job.number}}</router-link><br><small>Took {{formatDuration(job.started, job.completed)}} at {{formatDate(job.started)}}</small></td>
</tr>
</table>
</div>
<div class="col-sm-7 col-md-8 col-lg-9"><div class="row">
<div class="col-md-6">
<div class="panel panel-default">
<div class="panel-heading">Total runs per day this week</div>
<div class="panel-body">
<canvas id="chartBpd"></canvas>
</div> </div>
</td>
</tr>
<tr v-for="job in jobsRecent">
<td>
<span v-html="runIcon(job.result)"></span>
<router-link :to="'/jobs/'+job.name">{{job.name}}</router-link>
<router-link :to="'/jobs/'+job.name+'/'+job.number">#{{job.number}}</router-link><br>
<small>Took {{formatDuration(job.started, job.completed)}} at {{formatDate(job.started)}}</small>
</td>
</tr>
</table>
</nav>
<section style="border-left: 1px solid #d0d0d0;">
<div id="page-home-stats">
<div>
<h3>Recent regressions</h3>
<table>
<tr v-for="job in resultChanged" v-if="job.lastFailure>job.lastSuccess"><td><router-link :to="'/jobs/'+job.name+'/'+job.lastFailure">{{job.name}} #{{job.lastFailure}}</router-link> since <router-link :to="'/jobs/'+job.name+'/'+job.lastSuccess">#{{job.lastSuccess}}</router-link></tr>
</table>
</div>
<div>
<h3>Low pass rates</h3>
<table>
<tr v-for="job in lowPassRates"><td><router-link :to="'/jobs/'+job.name">{{job.name}}</router-link></td><td>{{Math.round(job.passRate*100)}}&nbsp;%</td></tr>
</table>
</div>
<div>
<h3>Utilization</h3>
<div><canvas id="chartUtil"></canvas></div>
</div>
</div>
<div>
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: 5px; padding: 5px;">
<div><canvas id="chartBpd"></canvas></div>
<div><canvas id="chartBpj"></canvas></div>
<div><canvas id="chartTpj"></canvas></div>
<div><canvas id="chartBuildTimeChanges"></canvas></div>
<div><canvas id="chartBuildTimeDist"></canvas></div>
</div>
</div>
</section>
</div></template>
<template id="jobs"><div>
<nav style="display: grid; grid-auto-flow: column; justify-content: space-between; align-items: end; padding: 10px 15px;">
<div style="display:grid; grid-auto-flow: column; grid-gap: 15px; padding: 5px 0;">
<a v-show="ungrouped.length" :class="{'active':group==null}" href v-on:click.prevent="group = null">Ungrouped Jobs</a>
<a v-for="g in Object.keys(groups)" :class="{'active':g==group}" href v-on:click.prevent="group = g">{{g}}</a>
</div>
<input class="form-control" id="jobFilter" v-model="search" placeholder="Filter...">
</nav>
<table class="striped" id="job-list">
<tr v-for="job in filteredJobs()">
<td><router-link :to="'/jobs/'+job.name">{{job.name}}</router-link></td>
<td style="white-space: nowrap;"><span v-html="runIcon(job.result)"></span> <router-link :to="'/jobs/'+job.name+'/'+job.number">#{{job.number}}</router-link></td>
<td>{{formatDate(job.started)}}</td>
<td>{{formatDuration(job.started,job.completed)}}</td>
</tr>
</table>
</div></template>
<template id="job"><div id="page-job-main">
<div style="padding: 15px;">
<h2>{{$route.params.name}}</h2>
<div v-html="description"></div>
<dl>
<dt>Last Successful Run</dt>
<dd><router-link v-if="lastSuccess" :to="'/jobs/'+$route.params.name+'/'+lastSuccess.number">#{{lastSuccess.number}}</router-link> {{lastSuccess?' - at '+formatDate(lastSuccess.started):'never'}}</dd>
<dt>Last Failed Run</dt>
<dd><router-link v-if="lastFailed" :to="'/jobs/'+$route.params.name+'/'+lastFailed.number">#{{lastFailed.number}}</router-link> {{lastFailed?' - at '+formatDate(lastFailed.started):'never'}}</dd>
</dl>
</div>
<div style="display: grid; justify-content: center; padding: 15px;">
<canvas id="chartBt"></canvas>
</div>
<div style="grid-column: 1/-1">
<table class="striped">
<thead><tr>
<th><a class="sort" :class="(sort.field=='result'?sort.order:'')" v-on:click="do_sort('result')">&nbsp;</a></th>
<th>Run <a class="sort" :class="(sort.field=='number'?sort.order:'')" v-on:click="do_sort('number')">&nbsp;</a></th>
<th class="text-center">Started <a class="sort" :class="(sort.field=='started'?sort.order:'')" v-on:click="do_sort('started')">&nbsp;</a></th>
<th class="text-center">Duration <a class="sort" :class="(sort.field=='duration'?sort.order:'')" v-on:click="do_sort('duration')">&nbsp;</a></th>
<th class="text-center vp-sm-hide">Reason <a class="sort" :class="(sort.field=='reason'?sort.order:'')" v-on:click="do_sort('reason')">&nbsp;</a></th>
</tr></thead>
<tr v-show="nQueued">
<td colspan="5"><i>{{nQueued}} run(s) queued</i></td>
</tr>
<tr v-for="job in jobsRunning.concat(jobsRecent)" track-by="$index">
<td style="width:1px"><span v-html="runIcon(job.result)"></span></td>
<td><router-link :to="'/jobs/'+$route.params.name+'/'+job.number">#{{job.number}}</router-link></td>
<td class="text-center">{{formatDate(job.started)}}</td>
<td class="text-center">{{formatDuration(job.started, job.completed)}}</td>
<td class="text-center vp-sm-hide">{{job.reason}}</td>
</tr>
</table>
<div style="float: right; margin: 15px; display: inline-grid; grid-auto-flow: column; gap: 10px; align-items: center">
<button v-on:click="page_prev" :disabled="sort.page==0">&laquo;</button>
<span>Page {{sort.page+1}} of {{pages}}</span>
<button class="btn" v-on:click="page_next" :disabled="sort.page==pages-1">&raquo;</button>
</div>
</div>
</div></template>
<template id="run"><div style="display: grid; grid-template-rows: auto 1fr">
<div style="padding: 15px">
<div style="display: grid; grid-template-columns: auto 1fr 400px auto auto; gap: 5px; align-items: center">
<h2 style="white-space: nowrap"><span v-html="runIcon(job.result)"></span> {{$route.params.name}} #{{$route.params.number}}</h2>
<span></span>
<div><!-- extra div to preserve grid columns when v-show hides the progress bar -->
<div class="progress" v-show="job.result == 'running'">
<div class="progress-bar" :class="{overtime:job.overtime,indeterminate:!job.etc}" :style="job.etc && {width:job.progress+'%'}"></div>
</div> </div>
</div> </div>
<router-link :disabled="$route.params.number == 1" :to="'/jobs/'+$route.params.name+'/'+($route.params.number-1)" tag="button">&laquo;</router-link> <div class="col-md-6">
<router-link :disabled="$route.params.number == latestNum" :to="'/jobs/'+$route.params.name+'/'+(parseInt($route.params.number)+1)" tag="button">&raquo;</router-link> <div class="panel panel-default">
<div class="panel-heading">Most runs per job in the last 24 hours</div>
<div class="panel-body" id="chartStatus">
<canvas id="chartBpj"></canvas>
</div>
</div>
</div></div><div class="row">
<div class="col-md-6">
<div class="panel panel-default">
<div class="panel-heading">Longest average run time per job this week</div>
<div class="panel-body">
<canvas id="chartTpj"></canvas>
</div>
</div>
</div>
<div class="col-md-6">
<div class="panel panel-default">
<div class="panel-heading">Current executor utilization</div>
<div class="panel-body">
<canvas id="chartUtil"></canvas>
</div>
</div>
</div></div><div class="row">
<div class="col-md-6">
<div class="panel panel-default">
<div class="panel-heading">Regressions and recoveries</div>
<div class="panel-body"><div style="position:relative">
<canvas id="chartResultChanges"></canvas>
<ul v-for="job in resultChanged">
<li v-if="job.lastFailure>job.lastSuccess" :id="'rcd_'+job.name" class="chart-overlay"><router-link :to="'/jobs/'+job.name">{{job.name}}</router-link>: <span v-html="runIcon('failed')"></span> <router-link :to="'/jobs/'+job.name+'/'+job.lastFailure">#{{job.lastFailure}}</router-link> since <span v-html="runIcon('success')"></span> <router-link :to="'/jobs/'+job.name+'/'+job.lastSuccess">#{{job.lastSuccess}}</router-link></li>
<li v-if="job.lastFailure<job.lastSuccess" :id="'rcd_'+job.name" class="chart-overlay"><router-link :to="'/jobs/'+job.name">{{job.name}}</router-link>: <span v-html="runIcon('success')"></span> <router-link :to="'/jobs/'+job.name+'/'+job.lastSuccess">#{{job.lastSuccess}}</router-link> since <span v-html="runIcon('failed')"></span> <router-link :to="'/jobs/'+job.name+'/'+job.lastFailure">#{{job.lastFailure}}</router-link></li>
</ul>
</div></div>
</div>
</div>
<div class="col-md-6">
<div class="panel panel-default">
<div class="panel-heading">Low pass rates</div>
<div class="panel-body">
<canvas id="chartPassRates"></canvas>
</div>
</div>
</div></div><div class="row">
<div class="col-md-6">
<div class="panel panel-default">
<div class="panel-heading">Run time changes</div>
<div class="panel-body">
<canvas id="chartBuildTimeChanges"></canvas>
</div>
</div>
</div>
<div class="col-md-6">
<div class="panel panel-default">
<div class="panel-heading">Average run time distribution</div>
<div class="panel-body">
<canvas id="chartBuildTimeDist"></canvas>
</div>
</div>
</div>
</div></div>
</div></div>
</div></template>
<template id="jobs"><div>
<ol class="breadcrumb"><li><router-link to="/">Home</router-link></li><li class="active">Jobs</li></ol>
<div class="container-fluid"><div class="row">
<div class="col-xs-12">
<div class="pull-right">
<input class="form-control" id="jobFilter" v-model="search" placeholder="Filter...">
</div>
<ul class="nav nav-tabs">
<li v-show="ungrouped.length" :class="{'active':group==null}"><a href v-on:click.prevent="group = null">Ungrouped Jobs</a></li>
<li v-for="g in Object.keys(groups)" :class="{'active':g==group}"><a href v-on:click.prevent="group = g">{{g}}</a></li>
</ul>
<table class="table table-striped" id="joblist">
<tr v-for="job in filteredJobs()">
<td><router-link :to="'/jobs/'+job.name">{{job.name}}</router-link></td>
<td class="text-center"><span v-html="runIcon(job.result)"></span> <router-link :to="'/jobs/'+job.name+'/'+job.number">#{{job.number}}</router-link></td>
<td class="text-center">{{formatDate(job.started)}}</td>
<td class="text-center">{{formatDuration(job.started,job.completed)}}</td>
</tr>
</table>
</div> </div>
<div id="page-run-detail"> </div></div>
<dl> </div></template>
<dt>Reason</dt><dd>{{job.reason}}</dd>
<dt v-show="job.upstream.num > 0">Upstream</dt><dd v-show="job.upstream.num > 0"><router-link :to="'/jobs/'+job.upstream.name">{{job.upstream.name}}</router-link> <router-link :to="'/jobs/'+job.upstream.name+'/'+job.upstream.num">#{{job.upstream.num}}</router-link></li></dd> <template id="job"><div>
<dt>Queued for</dt><dd>{{job.queued}}s</dd> <ol class="breadcrumb"><li><router-link to="/">Home</router-link></li><li><router-link to="/jobs">Jobs</router-link></li><li class="active">{{$route.params.name}}</li></ol></ol>
<dt>Started</dt><dd>{{formatDate(job.started)}}</dd> <div class="container-fluid">
<dt v-show="runComplete(job)">Completed</dt><dd v-show="job.completed">{{formatDate(job.completed)}}</dd> <div class="row">
<dt>Duration</dt><dd>{{formatDuration(job.started, job.completed)}}</dd> <div class="col-sm-5 col-md-6 col-lg-7">
</dl> <h3>{{$route.params.name}}</h3>
<dl v-show="job.artifacts.length"> <dl class="dl-horizontal">
<dt>Artifacts</dt> <dt>Last Successful Run</dt>
<dd> <dd><router-link v-if="lastSuccess" :to="'/jobs/'+$route.params.name+'/'+lastSuccess.number">#{{lastSuccess.number}}</router-link> {{lastSuccess?' - at '+formatDate(lastSuccess.started):'never'}}</dd>
<ul style="margin-bottom: 0"> <dt>Last Failed Run</dt>
<li v-for="art in job.artifacts"><a :href="art.url" target="_self">{{art.filename}}</a> [{{ art.size | iecFileSize }}]</li> <dd><router-link v-if="lastFailed" :to="'/jobs/'+$route.params.name+'/'+lastFailed.number">#{{lastFailed.number}}</router-link> {{lastFailed?' - at '+formatDate(lastFailed.started):'never'}}</dd>
</ul>
</dd>
</dl> </dl>
</div>
<div class="col-sm-7 col-md-6 col-lg-5">
<div class="panel panel-default">
<div class="panel-heading">Build time</div>
<div class="panel-body">
<canvas id="chartBt"></canvas>
</div>
</div>
</div>
</div> </div>
<div class="row"><div class="col-xs-12">
<table class="table table-striped">
<thead><tr>
<th><a class="sort" :class="(sort.field=='result'?sort.order:'')" v-on:click="do_sort('result')">&nbsp;</a></th>
<th>Run <a class="sort" :class="(sort.field=='number'?sort.order:'')" v-on:click="do_sort('number')">&nbsp;</a></th>
<th class="text-center">Started <a class="sort" :class="(sort.field=='started'?sort.order:'')" v-on:click="do_sort('started')">&nbsp;</a></th>
<th class="text-center">Duration <a class="sort" :class="(sort.field=='duration'?sort.order:'')" v-on:click="do_sort('duration')">&nbsp;</a></th>
<th class="text-center hidden-xs">Reason <a class="sort" :class="(sort.field=='reason'?sort.order:'')" v-on:click="do_sort('reason')">&nbsp;</a></th>
</tr></thead>
<tr v-show="nQueued">
<td colspan="5"><i>{{nQueued}} run(s) queued</i></td>
</tr>
<tr v-for="job in jobsRunning" track-by="$index">
<td style="width:1px"><span v-html="runIcon(job.result)"></span></td>
<td><router-link :to="'/jobs/'+$route.params.name+'/'+job.number">#{{job.number}}</router-link></td>
<td class="text-center">{{formatDate(job.started)}}</td>
<td class="text-center">{{formatDuration(job.started, job.completed)}}</td>
<td class="text-center hidden-xs">{{job.reason}}</td>
</tr>
<tr v-for="job in jobsRecent" track-by="$index">
<td style="width:1px"><span v-html="runIcon(job.result)"></span></td>
<td><router-link :to="'/jobs/'+$route.params.name+'/'+job.number">#{{job.number}}</router-link></td>
<td class="text-center">{{formatDate(job.started)}}</td>
<td class="text-center">{{formatDuration(job.started, job.completed)}}</td>
<td class="text-center hidden-xs">{{job.reason}}</td>
</tr>
</table>
<ul class="pagination pull-right">
<li><button class="btn btn-default" v-on:click="page_prev" :disabled="sort.page==0">&laquo;</button></li>
<li>Page {{sort.page+1}} of {{pages}}</li>
<li><button class="btn btn-default" v-on:click="page_next" :disabled="sort.page==pages-1">&raquo;</button></li>
</ul>
</div></div>
</div> </div>
<div class="console-log"> </div></template>
<code v-html="log"></code>
<span v-show="job.result == 'running'" v-html="runIcon('running')" style="display: block;"></span> <template id="run"><div>
<ol class="breadcrumb"><li><router-link to="/">Home</router-link></li><li><router-link to="/jobs">Jobs</router-link></li><li><router-link :to="'/jobs/'+$route.params.name">{{$route.params.name}}</router-link></li><li class="active">#{{$route.params.number}}</li></ol></ol>
<div class="container-fluid">
<div class="row">
<div class="col-sm-5 col-md-6 col-lg-7">
<h3 style="float:left"><span v-html="runIcon(job.result)"></span> {{$route.params.name}} #{{$route.params.number}}</h3>
<nav class="pull-left">
<ul class="pagination" style="margin:15px 20px">
<li v-show="$route.params.number > 1"><router-link :to="'/jobs/'+$route.params.name+'/'+($route.params.number-1)">&laquo;</router-link></li>
<li v-show="latestNum > $route.params.number"><router-link :to="'/jobs/'+$route.params.name+'/'+(parseInt($route.params.number)+1)">&raquo;</router-link></li>
</ul>
</nav>
<div style="clear:both;"></div>
<dl class="dl-horizontal">
<dt>Reason</dt><dd>{{job.reason}}</dd>
<dt v-show="job.upstream.num > 0">Upstream</dt><dd v-show="job.upstream.num > 0"><router-link :to="'/jobs/'+job.upstream.name">{{job.upstream.name}}</router-link> <router-link :to="'/jobs/'+job.upstream.name+'/'+job.upstream.num">#{{job.upstream.num}}</router-link></li></dd>
<dt>Queued for</dt><dd>{{job.queued}}s</dd>
<dt>Started</dt><dd>{{formatDate(job.started)}}</dd>
<dt v-show="runComplete(job)">Completed</dt><dd v-show="job.completed">{{formatDate(job.completed)}}</dd>
<dt>Duration</dt><dd>{{formatDuration(job.started, job.completed)}}</dd>
</dl>
</div>
<div class="col-sm-7 col-md-6 col-lg-5">
<div class="progress" v-show="job.result == 'running'">
<div class="progress-bar progress-bar-striped" :class="'progress-bar-'+(job.overtime?'warning':'info')" :class="job.etc?'':'active'" :style="{width:!job.etc?100:job.progress + '%'}"></div>
</div>
<div class="panel panel-default" v-show="job.artifacts.length">
<div class="panel-heading">Artifacts</div>
<div class="panel-body">
<ul class="list-unstyled" style="margin-bottom: 0">
<li v-for="art in job.artifacts"><a :href="art.url" target="_self">{{art.filename}}</a> [{{ art.size | iecFileSize }}]</li>
</ul>
</div>
</div>
</div>
</div>
<div class="row"><div class="col-xs-12">
<button type="button" class="btn btn-default btn-xs pull-right" :class="{'active':autoscroll}" v-on:click="autoscroll = !autoscroll" style="margin-top:10px">Autoscroll</button>
<h4>Console output</h4>
<pre v-html="log"></pre>
</div></div>
</div> </div>
</div></template> </div></template>
<main id="app" style="display: grid; grid-template-rows: auto 1fr auto; height: 100%;"> <div id="app">
<nav id="nav-top" style="display: grid; grid-template-columns: auto auto 1fr auto auto; grid-gap: 15px;"> <nav class="navbar navbar-inverse">
<router-link to="/" style="display: grid; grid-auto-flow: column; align-items: center; margin: 5px; font-size: 20px;"> <div>
<img src="icon.png"> {{title}} <router-link to="/" class="navbar-brand"><img src="icon.png">{{title}}</router-link>
</router-link> <router-link to="/jobs" class="btn navbar-btn pull-right">Jobs</router-link>
<div id="nav-top-links" style="display: grid; grid-auto-flow: column; justify-content: start; gap: 15px; padding: 0 15px; align-items: center; font-size: 16px;">
<router-link to="/jobs">Jobs</router-link>
<router-link v-for="(crumb,i) in _route.path.slice(1).split('/').slice(1,-1)" :to="_route.path.split('/').slice(0,i+3).join('/')">{{crumb}}</router-link>
</div>
<div></div>
<span class="version">{{version}}</span>
<div style="display: grid; align-items: center; padding: 0 15px">
<a v-on:click="toggleNotifications(!notify)" class="nav-icon" :class="{active:notify}" v-show="supportsNotifications" :title="(notify?'Disable':'Enable')+' notifications'">
<svg width="18" viewBox="0 0 12 12">
<g stroke-width="0.5">
<path d="m 6,9 c -1,0 -1,0 -1,1 0,1 2,1 2,0 0,-1 0,-1 -1,-1 z" />
<path d="m 1,10 c 3,-3 1,-9 5,-9 4,0 2,6 5,9 1,1 -3,-1 -5,-1 -2,0 -6,2 -5,1 z" />
</g>
</svg>
</a>
</div> </div>
</nav> </nav>
<a v-on:click="toggleNotifications(!notify)" v-show="supportsNotifications" class="bell pull-right" :class="{'active':notify}" :title="(notify?'Disable':'Enable')+' notifications'">&#128276;</a>
<router-view></router-view> <router-view></router-view>
<div id="connecting-overlay" :class="{shown:!connected}"> <div v-show="!connected" id="popup-connecting"><span class="status spin">⚙&#xfe0e;</span>&nbsp;Connecting...</div>
<div><span v-html="runIcon('running')"></span> Connecting...</div> </div>
</div>
</main>
</body> </body>
</html> </html>

View File

@@ -23,9 +23,8 @@ const timeScale = function(max){
: { scale:function(v){return v;}, label:'Seconds' }; : { scale:function(v){return v;}, label:'Seconds' };
} }
const ServerEventHandler = function() { const ServerEventHandler = function() {
function setupEventSource(path, query, next, comp) { function setupEventSource(path, query, next) {
const es = new EventSource(document.head.baseURI + path.substr(1) + query); const es = new EventSource(document.head.baseURI + path.substr(1) + query);
es.comp = comp;
es.path = path; // save for later in case we need to add query params es.path = path; // save for later in case we need to add query params
es.onmessage = function(msg) { es.onmessage = function(msg) {
msg = JSON.parse(msg.data); msg = JSON.parse(msg.data);
@@ -47,10 +46,8 @@ const ServerEventHandler = function() {
this.comp = comp; this.comp = comp;
// 2. needed to close the ws on navigation away // 2. needed to close the ws on navigation away
comp.es = this; comp.es = this;
comp.esReconnectInterval = 500;
// Update html and nav titles // Update html and nav titles
document.title = comp.$root.title = msg.title; document.title = comp.$root.title = msg.title;
comp.$root.version = msg.version;
// Calculate clock offset (used by ProgressUpdater) // Calculate clock offset (used by ProgressUpdater)
comp.$root.clockSkew = msg.time - Math.floor((new Date()).getTime()/1000); comp.$root.clockSkew = msg.time - Math.floor((new Date()).getTime()/1000);
comp.$root.connected = true; comp.$root.connected = true;
@@ -69,14 +66,8 @@ const ServerEventHandler = function() {
} }
} }
} }
es.onerror = function(e) { es.onerror = function() {
this.comp.$root.connected = false; this.comp.$root.connected = false;
setTimeout(() => {
this.comp.es = setupEventSource(path, query, null, this.comp);
}, this.comp.esReconnectInterval);
if(this.comp.esReconnectInterval < 7500)
this.comp.esReconnectInterval *= 1.5;
this.close();
} }
return es; return es;
} }
@@ -104,24 +95,15 @@ const ServerEventHandler = function() {
const Utils = { const Utils = {
methods: { methods: {
runIcon(result) { runIcon(result) {
return (result == 'success') ? /* checkmark */ var marker = '⚙';
`<svg class="status success" viewBox="0 0 100 100"> var classname = result;
<path d="m 23,46 c -6,0 -17,3 -17,11 0,8 9,30 12,32 3,2 14,5 20,-2 6,-6 24,-36 if (result === 'success')
56,-71 5,-3 -9,-8 -23,-2 -13,6 -33,42 -41,47 -6,-3 -5,-12 -8,-15 z" /> marker = '✔';
</svg>` else if (result === 'failed' || result === 'aborted')
: (result == 'failed' || result == 'aborted') ? /* cross */ marker = '✘';
`<svg class="status failed" viewBox="0 0 100 100"> else
<path d="m 19,20 c 2,8 12,29 15,32 -5,5 -18,21 -21,26 2,3 8,15 11,18 4,-6 17,-21 classname = 'spin';
21,-26 5,5 11,15 15,20 8,-2 15,-9 20,-15 -3,-3 -17,-18 -20,-24 3,-5 23,-26 30,-33 -3,-5 -8,-9 return '<span title="' + result + '" class="status ' + classname + '">' + marker + '&#xfe0e;</span>';
-12,-12 -6,5 -26,26 -29,30 -6,-8 -11,-15 -15,-23 -3,0 -12,5 -15,7 z" />
</svg>`
: /* spinner */
`<svg class="status running" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="40" stroke-width="15" fill="none" stroke-dasharray="175">
<animateTransform attributeName="transform" type="rotate" repeatCount="indefinite" dur="2s" values="0 50 50;360 50 50"></animateTransform>
</circle>
</svg>`
;
}, },
formatDate: function(unix) { formatDate: function(unix) {
// TODO: reimplement when toLocaleDateString() accepts formatting options on most browsers // TODO: reimplement when toLocaleDateString() accepts formatting options on most browsers
@@ -155,8 +137,7 @@ const ProgressUpdater = {
var p = (Math.floor(Date.now()/1000) + this.$root.clockSkew - o.started) / (o.etc - o.started); var p = (Math.floor(Date.now()/1000) + this.$root.clockSkew - o.started) / (o.etc - o.started);
if (p > 1.2) { if (p > 1.2) {
o.overtime = true; o.overtime = true;
} } else if (p >= 1) {
if (p >= 1) {
o.progress = 99; o.progress = 99;
} else { } else {
o.progress = 100 * p; o.progress = 100 * p;
@@ -189,8 +170,7 @@ const Home = function() {
var state = { var state = {
jobsQueued: [], jobsQueued: [],
jobsRecent: [], jobsRecent: [],
resultChanged: [], resultChanged: []
lowPassRates: [],
}; };
var chtUtilization, chtBuildsPerDay, chtBuildsPerJob, chtTimePerJob; var chtUtilization, chtBuildsPerDay, chtBuildsPerJob, chtTimePerJob;
@@ -213,7 +193,6 @@ const Home = function() {
state.jobsRunning = msg.running; state.jobsRunning = msg.running;
state.jobsRecent = msg.recent; state.jobsRecent = msg.recent;
state.resultChanged = msg.resultChanged; state.resultChanged = msg.resultChanged;
state.lowPassRates = msg.lowPassRates;
this.$forceUpdate(); this.$forceUpdate();
// setup charts // setup charts
@@ -256,7 +235,6 @@ const Home = function() {
}] }]
}, },
options:{ options:{
title: { display: true, text: 'Builds per day' },
tooltips:{callbacks:{title: function(tip, data) { tooltips:{callbacks:{title: function(tip, data) {
return buildsPerDayDates[tip[0].index].long; return buildsPerDayDates[tip[0].index].long;
}}}, }}},
@@ -277,7 +255,6 @@ const Home = function() {
}] }]
}, },
options:{ options:{
title: { display: true, text: 'Builds per job' },
scales:{xAxes:[{ticks:{userCallback: (label, index, labels)=>{ scales:{xAxes:[{ticks:{userCallback: (label, index, labels)=>{
if(Number.isInteger(label)) if(Number.isInteger(label))
return label; return label;
@@ -296,7 +273,6 @@ const Home = function() {
}] }]
}, },
options:{ options:{
title: { display: true, text: 'Mean run time this week' },
scales:{xAxes:[{ scales:{xAxes:[{
ticks:{userCallback: tpjScale.scale}, ticks:{userCallback: tpjScale.scale},
scaleLabel: { scaleLabel: {
@@ -309,6 +285,52 @@ const Home = function() {
}}} }}}
} }
}); });
var chtResultChanges = new Chart(document.getElementById("chartResultChanges"), {
type: 'horizontalBar',
data: {
labels: msg.resultChanged.map((e)=>{ return e.name; }),
datasets: [{
//label: '% Passed',
backgroundColor: msg.resultChanged.map((e)=>{return e.lastFailure > e.lastSuccess ? 'firebrick' : 'forestgreen';}),
data: msg.resultChanged.map((e)=>{ return e.lastSuccess - e.lastFailure; }),
itemid: msg.resultChanged.map((e)=> { return 'rcd_' + e.name; })
}]
},
options:{
scales:{
xAxes:[{ticks:{display: false}}],
yAxes:[{ticks:{display: false}}]
},
tooltips:{
enabled:false
}
}
});
var chtPassRates = new Chart(document.getElementById("chartPassRates"), {
type: 'horizontalBar',
data: {
labels: msg.lowPassRates.map((e)=>{ return e.name }),
datasets: [{
stack: 'passrate',
label: '% Passed',
backgroundColor: "forestgreen",
data: msg.lowPassRates.map((e)=>{ return e.passRate*100; })
},{
stack:'passrate',
label: '% Failed',
backgroundColor: "firebrick",
data: msg.lowPassRates.map((e)=>{ return (1-e.passRate)*100; })
}],
},
options:{
scales:{xAxes:[{ticks:{callback:(val,idx,values)=>{
return val + '%';
}}}]},
tooltips:{
enabled:false
}
}
});
var btcScale = timeScale(Math.max(msg.buildTimeChanges.map((e)=>{return Math.max(e.durations)}))); var btcScale = timeScale(Math.max(msg.buildTimeChanges.map((e)=>{return Math.max(e.durations)})));
var chtBuildTimeChanges = new Chart(document.getElementById("chartBuildTimeChanges"), { var chtBuildTimeChanges = new Chart(document.getElementById("chartBuildTimeChanges"), {
type: 'line', type: 'line',
@@ -322,7 +344,6 @@ const Home = function() {
}}) }})
}, },
options:{ options:{
title: { display: true, text: 'Build time changes' },
legend:{display:true}, legend:{display:true},
scales:{ scales:{
xAxes:[{ticks:{display: false}}], xAxes:[{ticks:{display: false}}],
@@ -348,9 +369,6 @@ const Home = function() {
data: msg.buildTimeDist, data: msg.buildTimeDist,
backgroundColor: "steelblue", backgroundColor: "steelblue",
}] }]
},
options: {
title: { display: true, text: 'Build time distribution' }
} }
}); });
}, },
@@ -489,7 +507,6 @@ const Jobs = function() {
var Job = function() { var Job = function() {
var state = { var state = {
description: '',
jobsRunning: [], jobsRunning: [],
jobsRecent: [], jobsRecent: [],
lastSuccess: null, lastSuccess: null,
@@ -507,7 +524,6 @@ var Job = function() {
}, },
methods: { methods: {
status: function(msg) { status: function(msg) {
state.description = msg.description;
state.jobsRunning = msg.running; state.jobsRunning = msg.running;
state.jobsRecent = msg.recent; state.jobsRecent = msg.recent;
state.lastSuccess = msg.lastSuccess; state.lastSuccess = msg.lastSuccess;
@@ -547,7 +563,6 @@ var Job = function() {
}] }]
}, },
options: { options: {
title: { display: true, text: 'Build time' },
scales:{ scales:{
xAxes:[{},{ xAxes:[{},{
id: 'avg', id: 'avg',
@@ -626,7 +641,9 @@ const Run = function() {
job: { artifacts: [], upstream: {} }, job: { artifacts: [], upstream: {} },
latestNum: null, latestNum: null,
log: '', log: '',
autoscroll: false
}; };
var firstLog = false;
const logFetcher = (vm, name, num) => { const logFetcher = (vm, name, num) => {
const abort = new AbortController(); const abort = new AbortController();
fetch('log/'+name+'/'+num, {signal:abort.signal}).then(res => { fetch('log/'+name+'/'+num, {signal:abort.signal}).then(res => {
@@ -641,6 +658,11 @@ const Run = function() {
return; return;
state.log += ansi_up.ansi_to_html(value.replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\033\[\{([^:]+):(\d+)\033\\/g, (m,$1,$2)=>{return '<a href="jobs/'+$1+'" onclick="return vroute(this);">'+$1+'</a>:<a href="jobs/'+$1+'/'+$2+'" onclick="return vroute(this);">#'+$2+'</a>';})); state.log += ansi_up.ansi_to_html(value.replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\033\[\{([^:]+):(\d+)\033\\/g, (m,$1,$2)=>{return '<a href="jobs/'+$1+'" onclick="return vroute(this);">'+$1+'</a>:<a href="jobs/'+$1+'/'+$2+'" onclick="return vroute(this);">#'+$2+'</a>';}));
vm.$forceUpdate(); vm.$forceUpdate();
if (!firstLog) {
firstLog = true;
} else if (state.autoscroll) {
window.scrollTo(0, document.body.scrollHeight);
}
return pump(); return pump();
}); });
}(); }();
@@ -727,7 +749,6 @@ new Vue({
el: '#app', el: '#app',
data: { data: {
title: '', // populated by status ws message title: '', // populated by status ws message
version: '',
clockSkew: 0, clockSkew: 0,
connected: false, connected: false,
notify: 'localStorage' in window && localStorage.getItem('showNotifications') == 1 notify: 'localStorage' in window && localStorage.getItem('showNotifications') == 1
@@ -749,8 +770,7 @@ new Vue({
new Notification('Job ' + data.result, { new Notification('Job ' + data.result, {
body: data.name + ' ' + '#' + data.number + ': ' + data.result body: data.name + ' ' + '#' + data.number + ': ' + data.result
}); });
}, }
runIcon: Utils.methods.runIcon
}, },
watch: { watch: {
notify(e) { localStorage.setItem('showNotifications', e ? 1 : 0); } notify(e) { localStorage.setItem('showNotifications', e ? 1 : 0); }

View File

@@ -1,21 +0,0 @@
{
"short_name": "Laminar",
"name": "Laminar",
"description": "Lightweight Continuous Integration",
"icons": [
{
"src": "/icon.png",
"type": "image/png",
"sizes": "36x36"
},
{
"src": "/favicon-152.png",
"type": "image/png",
"sizes": "152x152"
}
],
"start_url": "/",
"background_color": "#2F3340",
"display": "standalone",
"scope": "/"
}

View File

@@ -1,242 +0,0 @@
/* colour scheme */
:root {
--main-bg: #fff;
--main-fg: #333;
--nav-bg: #2F3340;
--nav-bg-darker: #292b33;
--nav-fg: #d0d0d0;
--nav-fg-light: #fafafa;
--icon-enabled: #d8cb83;
--success: #74af77;
--failure: #883d3d;
--running: #4786ab;
--warning: #de9a34;
--link-fg: #2f4579;
--console-bg: #313235;
--console-fg: #fff;
--alt-row-bg: #fafafa;
--border-grey: #d0d0d0;
}
/* basic resets */
html { box-sizing: border-box; }
*, *:before, *:after { box-sizing: inherit; }
body, h1, h2, h3, h4, h5, h6, p, ol, ul {
margin: 0;
padding: 0;
font-weight: normal;
}
ol, ul { list-style: none; }
body, html { height: 100%; }
body {
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 16px;
color: var(--main-fg);
}
/* main header bar */
#nav-top { background-color: var(--nav-bg); }
#nav-top-links { background-color: var(--nav-bg-darker); }
#nav-top a { color: var(--nav-fg); }
#nav-top a:hover { color: white; text-decoration: none; }
.version {
align-self: center;
font-size: x-small;
color: rgba(255,255,255,0.3);
}
/* navbar svg icons (enable notifications) */
.nav-icon { display: inherit; }
.nav-icon svg { fill: var(--nav-fg); stroke: #000; }
.nav-icon:hover { cursor: pointer; }
.nav-icon:hover svg { fill: var(--nav-fg-light); }
.nav-icon.active svg { fill: var(--icon-enabled); }
/* anchors */
a { color: var(--link-fg); text-decoration: none; }
a:visited { color: var(--link-fg); }
a:active { color: var(--link-fg); }
a:hover { text-decoration: underline; }
/* charts */
canvas {
width: 100% !important;
max-width: 800px;
height: auto !important;
}
#popup-connecting {
position: fixed;
background: white;
border: 1px solid #ddd;
bottom: 10px;
right: 10px;
padding: 20px;
}
/* status icons */
.status {
display: inline-block;
width: 1em;
vertical-align: middle;
margin-top: -2px; /* pixel-pushing */
}
svg.success path { fill: var(--success); }
svg.failed path { fill: var(--failure); }
svg.running circle { stroke: var(--running); }
/* sort indicators */
a.sort {
position: relative;
margin-left: 7px;
}
a.sort:before, a.sort:after {
border: 4px solid transparent;
content: "";
position: absolute;
display: block;
height: 0;
width: 0;
right: 0;
top: 50%;
}
a.sort:before {
border-bottom-color: var(--border-grey);
margin-top: -9px;
}
a.sort:after {
border-top-color: var(--border-grey);
margin-top: 1px;
}
a.sort.dsc:after { border-top-color: var(--main-fg); }
a.sort.asc:before { border-bottom-color: var(--main-fg); }
a.sort:hover { text-decoration: none; cursor:pointer; }
a.sort:not(.asc):hover:before { border-bottom-color: var(--main-fg); }
a.sort:not(.dsc):hover:after { border-top-color: var(--main-fg); }
/* job group tabs */
a.active { color: var(--main-fg); }
a.active:hover { text-decoration: none; }
/* run console output */
.console-log { padding: 15px; background-color: var(--console-bg); }
.console-log code { white-space: pre-wrap; color: var(--console-fg); }
.console-log a { color: var(--console-fg); }
/* text input (job filtering) */
input { padding: 5px 8px; }
/* description list (run detail) */
dl { display: grid; grid-template-columns: auto 1fr; }
dt { text-align: right; font-weight: bold; min-width: 85px; }
dt,dd { line-height: 2; }
/* tables */
table { border-spacing: 0; width: 100%; }
th { text-align: left; border-bottom: 1px solid var(--border-grey); }
td, th { padding: 8px; }
table.striped td { border-top: 1px solid var(--border-grey); }
table.striped tr:nth-child(even) { background-color: var(--alt-row-bg); }
td:first-child, th:first-child { padding-left: 15px; }
td:last-child, th:last-child { padding-right: 15px; }
/* next/prev navigation buttons */
button {
border: 1px solid var(--border-grey);
background-color: var(--alt-row-bg);
padding: 6px;
min-width: 29px;
}
button[disabled] { cursor: not-allowed; color: var(--border-grey); }
button:not([disabled]) { cursor: pointer; color: var(--main-fg); }
/* progress bar */
.progress {
width: 100%;
height: 8px;
border: 1px solid;
border-radius: 4px;
overflow: hidden;
border-color: var(--border-grey);
background-color: var(--alt-row-bg);
}
.progress-bar {
height: 100%;
width: 100%;
background-color: var(--running);
background-image: linear-gradient(45deg, transparent 35%, rgba(255,255,255,0.18) 35% 65%, transparent 65%);
background-size: 1rem;
transition: width .6s linear;
}
.progress-bar.overtime { background-color: var(--warning); }
.progress-bar.indeterminate {
animation: animate-stripes 1s linear infinite;
}
@keyframes animate-stripes {
from { background-position: 1rem 0; } to { background-position: 0 0; }
}
/* connecting overlay */
#connecting-overlay {
position: fixed;
top: 0; right: 0; bottom: 0; left: 0;
display: grid;
align-content: end; justify-content: end;
color: var(--nav-fg-light);
font-size: 18px;
padding: 30px;
visibility: hidden;
background-color: rgba(0,0,0,0.75);
opacity: 0;
transition: opacity 0.5s ease, visibility 0s 0.5s;
}
#connecting-overlay.shown {
visibility: visible;
opacity: 1;
transition: opacity 0.5s ease 2s;
}
#connecting-overlay > div { opacity: 1; }
/* responsive layout */
#page-home-main {
display: grid;
grid-template-columns: auto 1fr;
}
@media (max-width: 865px) {
#page-home-main {
grid-template-columns: 1fr;
}
.vp-sm-hide { display: none; }
}
#page-home-stats {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
padding: 15px;
gap: 15px;
text-align: center;
}
@media (max-width: 650px) {
#page-home-stats {
grid-template-columns: 1fr;
}
}
#page-job-main {
display: grid;
grid-template: auto 1fr / minmax(550px, 1fr) 1fr;
}
@media (max-width: 965px) {
#page-job-main {
grid-template: auto auto 1fr / 1fr;
}
}
#page-run-detail {
display: grid;
grid-template-columns: minmax(400px, auto) 1fr;
gap: 5px;
}
@media (max-width: 780px) {
#page-run-detail {
grid-template-columns: 1fr;
}
}

View File

@@ -81,10 +81,10 @@ public:
std::string jobName = context.getParams().getJobName(); std::string jobName = context.getParams().getJobName();
LLOG(INFO, "RPC run", jobName); LLOG(INFO, "RPC run", jobName);
std::shared_ptr<Run> run = laminar.queueJob(jobName, params(context.getParams().getParams())); std::shared_ptr<Run> run = laminar.queueJob(jobName, params(context.getParams().getParams()));
if(run) { if(Run* r = run.get()) {
return run->whenFinished().then([context,run](RunState state) mutable { return r->whenFinished().then([context,r](RunState state) mutable {
context.getResults().setResult(fromRunState(state)); context.getResults().setResult(fromRunState(state));
context.getResults().setBuildNum(run->build); context.getResults().setBuildNum(r->build);
}); });
} else { } else {
context.getResults().setResult(LaminarCi::JobResult::UNKNOWN); context.getResults().setResult(LaminarCi::JobResult::UNKNOWN);
@@ -92,6 +92,20 @@ public:
} }
} }
// Set a parameter on a running build
kj::Promise<void> set(SetContext context) override {
std::string jobName = context.getParams().getRun().getJob();
uint buildNum = context.getParams().getRun().getBuildNum();
LLOG(INFO, "RPC set", jobName, buildNum);
LaminarCi::MethodResult result = laminar.setParam(jobName, buildNum,
context.getParams().getParam().getName(), context.getParams().getParam().getValue())
? LaminarCi::MethodResult::SUCCESS
: LaminarCi::MethodResult::FAILED;
context.getResults().setResult(result);
return kj::READY_NOW;
}
// List jobs in queue // List jobs in queue
kj::Promise<void> listQueued(ListQueuedContext context) override { kj::Promise<void> listQueued(ListQueuedContext context) override {
const std::list<std::shared_ptr<Run>>& queue = laminar.listQueuedJobs(); const std::list<std::shared_ptr<Run>>& queue = laminar.listQueuedJobs();

View File

@@ -1,5 +1,5 @@
/// ///
/// Copyright 2015-2020 Oliver Giles /// Copyright 2015-2018 Oliver Giles
/// ///
/// This file is part of Laminar /// This file is part of Laminar
/// ///
@@ -52,9 +52,7 @@ Run::Run(std::string name, ParamMap pm, kj::Path&& rootPath) :
queuedAt(time(nullptr)), queuedAt(time(nullptr)),
rootPath(kj::mv(rootPath)), rootPath(kj::mv(rootPath)),
started(kj::newPromiseAndFulfiller<void>()), started(kj::newPromiseAndFulfiller<void>()),
startedFork(started.promise.fork()), finished(kj::newPromiseAndFulfiller<RunState>())
finished(kj::newPromiseAndFulfiller<RunState>()),
finishedFork(finished.promise.fork())
{ {
for(auto it = params.begin(); it != params.end();) { for(auto it = params.begin(); it != params.end();) {
if(it->first[0] == '=') { if(it->first[0] == '=') {
@@ -77,53 +75,113 @@ Run::~Run() {
LLOG(INFO, "Run destroyed"); LLOG(INFO, "Run destroyed");
} }
static void setEnvFromFile(const kj::Path& rootPath, kj::Path file) { bool Run::configure(uint buildNum, std::shared_ptr<Context> nd, const kj::Directory& fsHome)
StringMap vars = parseConfFile((rootPath/file).toString(true).cStr());
for(auto& it : vars) {
setenv(it.first.c_str(), it.second.c_str(), true);
}
}
kj::Promise<RunState> Run::start(uint buildNum, std::shared_ptr<Context> ctx, const kj::Directory &fsHome, std::function<kj::Promise<int>(kj::Maybe<pid_t>&)> getPromise)
{ {
kj::Path cfgDir{"cfg"}; kj::Path cfgDir{"cfg"};
// create the run directory
kj::Path rd{"run",name,std::to_string(buildNum)};
bool createWorkdir = true;
KJ_IF_MAYBE(ls, fsHome.tryLstat(rd)) {
LASSERT(ls->type == kj::FsNode::Type::DIRECTORY);
LLOG(WARNING, "Working directory already exists, removing", rd.toString());
if(fsHome.tryRemove(rd) == false) {
LLOG(WARNING, "Failed to remove working directory");
createWorkdir = false;
}
}
if(createWorkdir && fsHome.tryOpenSubdir(rd, kj::WriteMode::CREATE|kj::WriteMode::CREATE_PARENT) == nullptr) {
LLOG(ERROR, "Could not create working directory", rd.toString());
return false;
}
// create an archive directory
kj::Path archive = kj::Path{"archive",name,std::to_string(buildNum)};
if(fsHome.exists(archive)) {
LLOG(WARNING, "Archive directory already exists", archive.toString());
} else if(fsHome.tryOpenSubdir(archive, kj::WriteMode::CREATE|kj::WriteMode::CREATE_PARENT) == nullptr) {
LLOG(ERROR, "Could not create archive directory", archive.toString());
return false;
}
// create a workspace for this job if it doesn't exist
kj::Path ws{"run",name,"workspace"};
if(!fsHome.exists(ws)) {
fsHome.openSubdir(ws, kj::WriteMode::CREATE|kj::WriteMode::CREATE_PARENT);
// prepend the workspace init script
if(fsHome.exists(cfgDir/"jobs"/(name+".init")))
addScript(cfgDir/"jobs"/(name+".init"), kj::mv(ws));
}
// add scripts
// global before-run script
if(fsHome.exists(cfgDir/"before"))
addScript(cfgDir/"before", rd.clone());
// job before-run script
if(fsHome.exists(cfgDir/"jobs"/(name+".before")))
addScript(cfgDir/"jobs"/(name+".before"), rd.clone());
// main run script. must exist.
addScript(cfgDir/"jobs"/(name+".run"), rd.clone());
// job after-run script
if(fsHome.exists(cfgDir/"jobs"/(name+".after")))
addScript(cfgDir/"jobs"/(name+".after"), rd.clone(), true);
// global after-run script
if(fsHome.exists(cfgDir/"after"))
addScript(cfgDir/"after", rd.clone(), true);
// add environment files
if(fsHome.exists(cfgDir/"env"))
addEnv(cfgDir/"env");
if(fsHome.exists(cfgDir/"contexts"/(nd->name+".env")))
addEnv(cfgDir/"contexts"/(nd->name+".env"));
if(fsHome.exists(cfgDir/"jobs"/(name+".env")))
addEnv(cfgDir/"jobs"/(name+".env"));
// add job timeout if specified // add job timeout if specified
if(fsHome.exists(cfgDir/"jobs"/(name+".conf"))) { if(fsHome.exists(cfgDir/"jobs"/(name+".conf"))) {
timeout = parseConfFile((rootPath/cfgDir/"jobs"/(name+".conf")).toString(true).cStr()).get<int>("TIMEOUT", 0); timeout = parseConfFile((rootPath/cfgDir/"jobs"/(name+".conf")).toString(true).cStr()).get<int>("TIMEOUT", 0);
} }
int plog[2]; // All good, we've "started"
LSYSCALL(pipe(plog)); startedAt = time(nullptr);
build = buildNum;
context = nd;
// Fork a process leader to run all the steps of the job. This gives us a nice // notifies the rpc client if the start command was used
// process tree output (job name and number as the process name) and helps started.fulfiller->fulfill();
// contain any wayward descendent processes.
pid_t leader;
LSYSCALL(leader = fork());
if(leader == 0) { return true;
// All output from this process will be captured in the plog pipe }
close(plog[0]);
dup2(plog[1], STDOUT_FILENO);
dup2(plog[1], STDERR_FILENO);
close(plog[1]);
// All initial/fixed env vars can be set here. Dynamic ones, including std::string Run::reason() const {
// "RESULT" and any set by `laminarc set` have to be handled in the subprocess. return reasonMsg;
}
// add environment files bool Run::step() {
if(fsHome.exists(cfgDir/"env")) if(!scripts.size())
setEnvFromFile(rootPath, cfgDir/"env"); return true;
if(fsHome.exists(cfgDir/"contexts"/(ctx->name+".env")))
setEnvFromFile(rootPath, cfgDir/"contexts"/(ctx->name+".env"));
if(fsHome.exists(cfgDir/"jobs"/(name+".env")))
setEnvFromFile(rootPath, cfgDir/"jobs"/(name+".env"));
// parameterized vars Script currentScript = kj::mv(scripts.front());
for(auto& pair : params) { scripts.pop();
setenv(pair.first.c_str(), pair.second.c_str(), false);
} int pfd[2];
pipe(pfd);
pid_t pid = fork();
if(pid == 0) { // child
// reset signal mask (SIGCHLD blocked in Laminar::start)
sigset_t mask;
sigemptyset(&mask);
sigaddset(&mask, SIGCHLD);
sigprocmask(SIG_UNBLOCK, &mask, nullptr);
// set pgid == pid for easy killing on abort
setpgid(0, 0);
close(pfd[0]);
dup2(pfd[1], 1);
dup2(pfd[1], 2);
close(pfd[1]);
std::string buildNum = std::to_string(build);
std::string PATH = (rootPath/"cfg"/"scripts").toString(true).cStr(); std::string PATH = (rootPath/"cfg"/"scripts").toString(true).cStr();
if(const char* p = getenv("PATH")) { if(const char* p = getenv("PATH")) {
@@ -131,62 +189,72 @@ kj::Promise<RunState> Run::start(uint buildNum, std::shared_ptr<Context> ctx, co
PATH.append(p); PATH.append(p);
} }
std::string runNumStr = std::to_string(buildNum); LSYSCALL(chdir((rootPath/currentScript.cwd).toString(true).cStr()));
// conf file env vars
for(kj::Path& file : env) {
StringMap vars = parseConfFile((rootPath/file).toString(true).cStr());
for(auto& it : vars) {
setenv(it.first.c_str(), it.second.c_str(), true);
}
}
// parameterized vars
for(auto& pair : params) {
setenv(pair.first.c_str(), pair.second.c_str(), false);
}
setenv("PATH", PATH.c_str(), true); setenv("PATH", PATH.c_str(), true);
setenv("RUN", runNumStr.c_str(), true); setenv("RUN", buildNum.c_str(), true);
setenv("JOB", name.c_str(), true); setenv("JOB", name.c_str(), true);
setenv("CONTEXT", ctx->name.c_str(), true); setenv("CONTEXT", context->name.c_str(), true);
setenv("RESULT", to_string(result).c_str(), true);
setenv("LAST_RESULT", to_string(lastResult).c_str(), true); setenv("LAST_RESULT", to_string(lastResult).c_str(), true);
setenv("WORKSPACE", (rootPath/"run"/name/"workspace").toString(true).cStr(), true); setenv("WORKSPACE", (rootPath/"run"/name/"workspace").toString(true).cStr(), true);
setenv("ARCHIVE", (rootPath/"archive"/name/runNumStr).toString(true).cStr(), true); setenv("ARCHIVE", (rootPath/"archive"/name/buildNum).toString(true).cStr(), true);
// RESULT set in leader process
// leader process assumes $LAMINAR_HOME as CWD fprintf(stderr, "[laminar] Executing %s\n", currentScript.path.toString().cStr());
LSYSCALL(chdir(rootPath.toString(true).cStr())); kj::String execPath = (rootPath/currentScript.path).toString(true);
setenv("PWD", rootPath.toString(true).cStr(), 1); execl(execPath.cStr(), execPath.cStr(), NULL);
// cannot use LLOG because stdout/stderr are captured
// We could just fork/wait over all the steps here directly, but then we fprintf(stderr, "[laminar] Failed to execute %s\n", currentScript.path.toString().cStr());
// can't set a nice name for the process tree. There is pthread_setname_np, _exit(1);
// but it's limited to 16 characters, which most of the time probably isn't
// enough. Instead, we'll just exec ourselves and handle that in laminard's
// main() by calling leader_main()
char* procName;
if(asprintf(&procName, "{laminar} %s:%d", name.data(), buildNum) > 0)
execl("/proc/self/exe", procName, NULL); // does not return
_exit(EXIT_FAILURE);
} }
// All good, we've "started" LLOG(INFO, "Forked", currentScript.path, currentScript.cwd, pid);
startedAt = time(nullptr); close(pfd[1]);
build = buildNum;
context = ctx;
output_fd = plog[0]; current_pid = pid;
close(plog[1]); output_fd = pfd[0];
pid = leader;
// notifies the rpc client if the start command was used
started.fulfiller->fulfill();
return getPromise(pid).then([this](int status){
// The leader process passes a RunState through the return value.
// Check it didn't die abnormally, then cast to get it back.
result = WIFEXITED(status) ? RunState(WEXITSTATUS(status)) : RunState::ABORTED;
finished.fulfiller->fulfill(RunState(result));
return result;
});
}
std::string Run::reason() const {
return reasonMsg;
}
bool Run::abort() {
// if the Maybe is empty, wait() was already called on this process
KJ_IF_MAYBE(p, pid) {
kill(-*p, SIGTERM);
return true;
}
return false; return false;
} }
void Run::addScript(kj::Path scriptPath, kj::Path scriptWorkingDir, bool runOnAbort) {
scripts.push({kj::mv(scriptPath), kj::mv(scriptWorkingDir), runOnAbort});
}
void Run::addEnv(kj::Path path) {
env.push_back(kj::mv(path));
}
void Run::abort(bool respectRunOnAbort) {
while(scripts.size() && (!respectRunOnAbort || !scripts.front().runOnAbort))
scripts.pop();
// if the Maybe is empty, wait() was already called on this process
KJ_IF_MAYBE(p, current_pid) {
kill(-*p, SIGTERM);
}
}
void Run::reaped(int status) {
// once state is non-success it cannot change again
if(result != RunState::SUCCESS)
return;
if(WIFSIGNALED(status) && (WTERMSIG(status) == SIGTERM || WTERMSIG(status) == SIGKILL))
result = RunState::ABORTED;
else if(status != 0)
result = RunState::FAILED;
// otherwise preserve earlier status
finished.fulfiller->fulfill(RunState(result));
}

View File

@@ -57,15 +57,24 @@ public:
Run(const Run&) = delete; Run(const Run&) = delete;
Run& operator=(const Run&) = delete; Run& operator=(const Run&) = delete;
kj::Promise<RunState> start(uint buildNum, std::shared_ptr<Context> ctx, const kj::Directory &fsHome, std::function<kj::Promise<int>(kj::Maybe<pid_t>&)> getPromise); // Call this to "start" the run with a specific number and context
bool configure(uint buildNum, std::shared_ptr<Context> context, const kj::Directory &fsHome);
// executes the next script (if any), returning true if there is nothing
// more to be done.
bool step();
// aborts this run // aborts this run
bool abort(); void abort(bool respectRunOnAbort);
// called when a process owned by this run has been reaped. The status
// may be used to set the run's job status
void reaped(int status);
std::string reason() const; std::string reason() const;
kj::Promise<void> whenStarted() { return startedFork.addBranch(); } kj::Promise<void>&& whenStarted() { return kj::mv(started.promise); }
kj::Promise<RunState> whenFinished() { return finishedFork.addBranch(); } kj::Promise<RunState>&& whenFinished() { return kj::mv(finished.promise); }
std::shared_ptr<Context> context; std::shared_ptr<Context> context;
RunState result; RunState result;
@@ -75,7 +84,7 @@ public:
int parentBuild = 0; int parentBuild = 0;
uint build = 0; uint build = 0;
std::string log; std::string log;
kj::Maybe<pid_t> pid; kj::Maybe<pid_t> current_pid;
int output_fd; int output_fd;
std::unordered_map<std::string, std::string> params; std::unordered_map<std::string, std::string> params;
int timeout = 0; int timeout = 0;
@@ -96,14 +105,14 @@ private:
}; };
kj::Path rootPath; kj::Path rootPath;
std::queue<Script> scripts;
std::list<kj::Path> env;
std::string reasonMsg; std::string reasonMsg;
kj::PromiseFulfillerPair<void> started; kj::PromiseFulfillerPair<void> started;
kj::ForkedPromise<void> startedFork;
kj::PromiseFulfillerPair<RunState> finished; kj::PromiseFulfillerPair<RunState> finished;
kj::ForkedPromise<RunState> finishedFork;
}; };
// All this below is a somewhat overengineered method of keeping track of // All this below is a somewhat overengineered method of keeping track of
// currently executing builds (Run objects). This would probably scale // currently executing builds (Run objects). This would probably scale
// very well, but it's completely gratuitous since we are not likely to // very well, but it's completely gratuitous since we are not likely to

View File

@@ -24,7 +24,6 @@
#include <capnp/message.h> #include <capnp/message.h>
#include <capnp/capability.h> #include <capnp/capability.h>
#include <functional> #include <functional>
#include <sys/types.h>
struct Laminar; struct Laminar;
struct Http; struct Http;

View File

@@ -1,25 +0,0 @@
///
/// Copyright 2020 Oliver Giles
///
/// This file is part of Laminar
///
/// Laminar is free software: you can redistribute it and/or modify
/// it under the terms of the GNU General Public License as published by
/// the Free Software Foundation, either version 3 of the License, or
/// (at your option) any later version.
///
/// Laminar is distributed in the hope that it will be useful,
/// but WITHOUT ANY WARRANTY; without even the implied warranty of
/// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
/// GNU General Public License for more details.
///
/// You should have received a copy of the GNU General Public License
/// along with Laminar. If not, see <http://www.gnu.org/licenses/>
///
#define str(x) #x
#define xstr(x) str(x)
const char* laminar_version() {
return xstr(LAMINAR_VERSION);
}

View File

@@ -1,5 +1,5 @@
/// ///
/// Copyright 2019-2020 Oliver Giles /// Copyright 2019 Oliver Giles
/// ///
/// This file is part of Laminar /// This file is part of Laminar
/// ///
@@ -19,16 +19,13 @@
#ifndef LAMINAR_FIXTURE_H_ #ifndef LAMINAR_FIXTURE_H_
#define LAMINAR_FIXTURE_H_ #define LAMINAR_FIXTURE_H_
#include <capnp/rpc-twoparty.h>
#include <gtest/gtest.h>
#include "laminar.capnp.h" #include "laminar.capnp.h"
#include "eventsource.h" #include "eventsource.h"
#include "tempdir.h" #include "tempdir.h"
#include "laminar.h" #include "laminar.h"
#include "log.h"
#include "server.h" #include "server.h"
#include "conf.h"
#include <capnp/rpc-twoparty.h>
#include <gtest/gtest.h>
class LaminarFixture : public ::testing::Test { class LaminarFixture : public ::testing::Test {
public: public:
@@ -41,7 +38,7 @@ public:
settings.bind_rpc = bind_rpc.c_str(); settings.bind_rpc = bind_rpc.c_str();
settings.bind_http = bind_http.c_str(); settings.bind_http = bind_http.c_str();
settings.archive_url = "/test-archive/"; settings.archive_url = "/test-archive/";
server = new Server(*ioContext); server = new Server(ioContext);
laminar = new Laminar(*server, settings); laminar = new Laminar(*server, settings);
} }
~LaminarFixture() noexcept(true) { ~LaminarFixture() noexcept(true) {
@@ -50,76 +47,19 @@ public:
} }
kj::Own<EventSource> eventSource(const char* path) { kj::Own<EventSource> eventSource(const char* path) {
return kj::heap<EventSource>(*ioContext, bind_http.c_str(), path); return kj::heap<EventSource>(ioContext, bind_http.c_str(), path);
} }
void defineJob(const char* name, const char* scriptContent, const char* configContent = nullptr) { void defineJob(const char* name, const char* scriptContent) {
KJ_IF_MAYBE(f, tmp.fs->tryOpenFile(kj::Path{"cfg", "jobs", std::string(name) + ".run"}, KJ_IF_MAYBE(f, tmp.fs->tryOpenFile(kj::Path{"cfg", "jobs", std::string(name) + ".run"},
kj::WriteMode::CREATE | kj::WriteMode::CREATE_PARENT | kj::WriteMode::EXECUTABLE)) { kj::WriteMode::CREATE | kj::WriteMode::CREATE_PARENT | kj::WriteMode::EXECUTABLE)) {
(*f)->writeAll(std::string("#!/bin/sh\n") + scriptContent + "\n"); (*f)->writeAll(std::string("#!/bin/sh\n") + scriptContent + "\n");
} }
if(configContent) {
KJ_IF_MAYBE(f, tmp.fs->tryOpenFile(kj::Path{"cfg", "jobs", std::string(name) + ".conf"}, kj::WriteMode::CREATE)) {
(*f)->writeAll(configContent);
}
}
}
struct RunExec {
LaminarCi::JobResult result;
kj::String log;
};
RunExec runJob(const char* name, kj::Maybe<StringMap> params = nullptr) {
auto req = client().runRequest();
req.setJobName(name);
KJ_IF_MAYBE(p, params) {
auto params = req.initParams(p->size());
int i = 0;
for(auto kv : *p) {
params[i].setName(kv.first);
params[i].setValue(kv.second);
i++;
}
}
auto res = req.send().wait(ioContext->waitScope);
std::string path = std::string{"/log/"} + name + "/" + std::to_string(res.getBuildNum());
kj::HttpHeaderTable headerTable;
kj::String log = kj::newHttpClient(ioContext->lowLevelProvider->getTimer(), headerTable,
*ioContext->provider->getNetwork().parseAddress(bind_http.c_str()).wait(ioContext->waitScope))
->request(kj::HttpMethod::GET, path, kj::HttpHeaders(headerTable)).response.wait(ioContext->waitScope).body
->readAllText().wait(ioContext->waitScope);
return { res.getResult(), kj::mv(log) };
}
kj::String stripLaminarLogLines(const kj::String& str) {
auto out = kj::heapString(str.size());
char *o = out.begin();
for(const char *p = str.cStr(), *e = p + str.size(); p < e;) {
const char *nl = strchrnul(p, '\n');
if(!kj::StringPtr{p}.startsWith("[laminar]")) {
memcpy(o, p, nl - p + 1);
o += nl - p + 1;
}
p = nl + 1;
}
*o = '\0';
return out;
}
StringMap parseFromString(kj::StringPtr content) {
char tmp[16] = "/tmp/lt.XXXXXX";
int fd = mkstemp(tmp);
LSYSCALL(write(fd, content.begin(), content.size()));
close(fd);
StringMap map = parseConfFile(tmp);
unlink(tmp);
return map;
} }
LaminarCi::Client client() { LaminarCi::Client client() {
if(!rpc) { if(!rpc) {
auto stream = ioContext->provider->getNetwork().parseAddress(bind_rpc).wait(ioContext->waitScope)->connect().wait(ioContext->waitScope); auto stream = ioContext.provider->getNetwork().parseAddress(bind_rpc).wait(ioContext.waitScope)->connect().wait(ioContext.waitScope);
auto net = kj::heap<capnp::TwoPartyVatNetwork>(*stream, capnp::rpc::twoparty::Side::CLIENT); auto net = kj::heap<capnp::TwoPartyVatNetwork>(*stream, capnp::rpc::twoparty::Side::CLIENT);
rpc = kj::heap<capnp::RpcSystem<capnp::rpc::twoparty::VatId>>(*net, nullptr).attach(kj::mv(net), kj::mv(stream)); rpc = kj::heap<capnp::RpcSystem<capnp::rpc::twoparty::VatId>>(*net, nullptr).attach(kj::mv(net), kj::mv(stream));
} }
@@ -136,7 +76,7 @@ public:
Settings settings; Settings settings;
Server* server; Server* server;
Laminar* laminar; Laminar* laminar;
static kj::AsyncIoContext* ioContext; static kj::AsyncIoContext ioContext;
}; };
#endif // LAMINAR_FIXTURE_H_ #endif // LAMINAR_FIXTURE_H_

View File

@@ -18,14 +18,13 @@
/// ///
#include <kj/async-unix.h> #include <kj/async-unix.h>
#include "laminar-fixture.h" #include "laminar-fixture.h"
#include "conf.h"
// TODO: consider handling this differently // TODO: consider handling this differently
kj::AsyncIoContext* LaminarFixture::ioContext; kj::AsyncIoContext LaminarFixture::ioContext = kj::setupAsyncIo();
TEST_F(LaminarFixture, EmptyStatusMessageStructure) { TEST_F(LaminarFixture, EmptyStatusMessageStructure) {
auto es = eventSource("/"); auto es = eventSource("/");
ioContext->waitScope.poll(); ioContext.waitScope.poll();
ASSERT_EQ(1, es->messages().size()); ASSERT_EQ(1, es->messages().size());
auto json = es->messages().front().GetObject(); auto json = es->messages().front().GetObject();
@@ -52,7 +51,12 @@ TEST_F(LaminarFixture, JobNotifyHomePage) {
defineJob("foo", "true"); defineJob("foo", "true");
auto es = eventSource("/"); auto es = eventSource("/");
runJob("foo"); auto req = client().runRequest();
req.setJobName("foo");
ASSERT_EQ(LaminarCi::JobResult::SUCCESS, req.send().wait(ioContext.waitScope).getResult());
// wait for job completed
ioContext.waitScope.poll();
ASSERT_EQ(4, es->messages().size()); ASSERT_EQ(4, es->messages().size());
@@ -80,8 +84,13 @@ TEST_F(LaminarFixture, OnlyRelevantNotifications) {
auto es1Run = eventSource("/jobs/job1/1"); auto es1Run = eventSource("/jobs/job1/1");
auto es2Run = eventSource("/jobs/job2/1"); auto es2Run = eventSource("/jobs/job2/1");
runJob("job1"); auto req1 = client().runRequest();
runJob("job2"); req1.setJobName("job1");
ASSERT_EQ(LaminarCi::JobResult::SUCCESS, req1.send().wait(ioContext.waitScope).getResult());
auto req2 = client().runRequest();
req2.setJobName("job2");
ASSERT_EQ(LaminarCi::JobResult::SUCCESS, req2.send().wait(ioContext.waitScope).getResult());
ioContext.waitScope.poll();
EXPECT_EQ(7, esHome->messages().size()); EXPECT_EQ(7, esHome->messages().size());
EXPECT_EQ(7, esJobs->messages().size()); EXPECT_EQ(7, esJobs->messages().size());
@@ -92,74 +101,3 @@ TEST_F(LaminarFixture, OnlyRelevantNotifications) {
EXPECT_EQ(4, es1Run->messages().size()); EXPECT_EQ(4, es1Run->messages().size());
EXPECT_EQ(4, es2Run->messages().size()); EXPECT_EQ(4, es2Run->messages().size());
} }
TEST_F(LaminarFixture, FailedStatus) {
defineJob("job1", "false");
auto run = runJob("job1");
ASSERT_EQ(LaminarCi::JobResult::FAILED, run.result);
}
TEST_F(LaminarFixture, WorkingDirectory) {
defineJob("job1", "pwd");
auto run = runJob("job1");
ASSERT_EQ(LaminarCi::JobResult::SUCCESS, run.result);
std::string cwd{tmp.path.append(kj::Path{"run","job1","1"}).toString(true).cStr()};
EXPECT_EQ(cwd + "\n", stripLaminarLogLines(run.log).cStr());
}
TEST_F(LaminarFixture, Environment) {
defineJob("foo", "env");
auto run = runJob("foo");
std::string ws{tmp.path.append(kj::Path{"run","foo","workspace"}).toString(true).cStr()};
std::string archive{tmp.path.append(kj::Path{"archive","foo","1"}).toString(true).cStr()};
StringMap map = parseFromString(run.log);
EXPECT_EQ("1", map["RUN"]);
EXPECT_EQ("foo", map["JOB"]);
EXPECT_EQ("success", map["RESULT"]);
EXPECT_EQ("unknown", map["LAST_RESULT"]);
EXPECT_EQ(ws, map["WORKSPACE"]);
EXPECT_EQ(archive, map["ARCHIVE"]);
}
TEST_F(LaminarFixture, ParamsToEnv) {
defineJob("foo", "env");
StringMap params;
params["foo"] = "bar";
auto run = runJob("foo", params);
StringMap map = parseFromString(run.log);
EXPECT_EQ("bar", map["foo"]);
}
TEST_F(LaminarFixture, Abort) {
defineJob("job1", "yes");
auto req = client().runRequest();
req.setJobName("job1");
auto res = req.send();
// There isn't a nice way of knowing when the leader process is ready to
// handle SIGTERM. Just wait until it prints something to the log
ioContext->waitScope.poll();
kj::HttpHeaderTable headerTable;
char _;
kj::newHttpClient(ioContext->lowLevelProvider->getTimer(), headerTable,
*ioContext->provider->getNetwork().parseAddress(bind_http.c_str()).wait(ioContext->waitScope))
->request(kj::HttpMethod::GET, "/log/job1/1", kj::HttpHeaders(headerTable)).response.wait(ioContext->waitScope).body
->tryRead(&_, 1, 1).wait(ioContext->waitScope);
// now it should be ready to abort
ASSERT_TRUE(laminar->abort("job1", 1));
EXPECT_EQ(LaminarCi::JobResult::ABORTED, res.wait(ioContext->waitScope).getResult());
}
TEST_F(LaminarFixture, JobDescription) {
defineJob("foo", "true", "DESCRIPTION=bar");
auto es = eventSource("/jobs/foo");
ioContext->waitScope.poll();
ASSERT_EQ(1, es->messages().size());
auto json = es->messages().front().GetObject();
ASSERT_TRUE(json.HasMember("data"));
auto data = json["data"].GetObject();
ASSERT_TRUE(data.HasMember("description"));
EXPECT_STREQ("bar", data["description"].GetString());
}

View File

@@ -18,22 +18,10 @@
/// ///
#include <kj/async-unix.h> #include <kj/async-unix.h>
#include <gtest/gtest.h> #include <gtest/gtest.h>
#include <kj/debug.h>
#include "laminar-fixture.h" // gtest main supplied in order to call captureChildExit
#include "leader.h"
// gtest main supplied in order to call captureChildExit and handle process leader
int main(int argc, char **argv) { int main(int argc, char **argv) {
if(argv[0][0] == '{')
return leader_main();
// TODO: consider handling this differently
auto ioContext = kj::setupAsyncIo();
LaminarFixture::ioContext = &ioContext;
kj::UnixEventPort::captureChildExit(); kj::UnixEventPort::captureChildExit();
//kj::_::Debug::setLogLevel(kj::_::Debug::Severity::INFO);
::testing::InitGoogleTest(&argc, argv); ::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS(); return RUN_ALL_TESTS();

View File

@@ -1,5 +1,5 @@
/// ///
/// Copyright 2018-2020 Oliver Giles /// Copyright 2018 Oliver Giles
/// ///
/// This file is part of Laminar /// This file is part of Laminar
/// ///
@@ -19,8 +19,6 @@
#ifndef LAMINAR_TEMPDIR_H_ #ifndef LAMINAR_TEMPDIR_H_
#define LAMINAR_TEMPDIR_H_ #define LAMINAR_TEMPDIR_H_
#include "log.h"
#include <kj/filesystem.h> #include <kj/filesystem.h>
#include <stdlib.h> #include <stdlib.h>
@@ -39,7 +37,7 @@ public:
private: private:
static kj::Path mkdtemp() { static kj::Path mkdtemp() {
char dir[] = "/tmp/laminar-test-XXXXXX"; char dir[] = "/tmp/laminar-test-XXXXXX";
LASSERT(::mkdtemp(dir) != nullptr, "mkdtemp failed"); ::mkdtemp(dir);
return kj::Path::parse(&dir[1]); return kj::Path::parse(&dir[1]);
} }
}; };

View File

@@ -1,5 +1,5 @@
/// ///
/// Copyright 2018-2020 Oliver Giles /// Copyright 2018 Oliver Giles
/// ///
/// This file is part of Laminar /// This file is part of Laminar
/// ///
@@ -16,9 +16,8 @@
/// You should have received a copy of the GNU General Public License /// You should have received a copy of the GNU General Public License
/// along with Laminar. If not, see <http://www.gnu.org/licenses/> /// along with Laminar. If not, see <http://www.gnu.org/licenses/>
/// ///
#include "conf.h"
#include "log.h"
#include <gtest/gtest.h> #include <gtest/gtest.h>
#include "conf.h"
class ConfTest : public ::testing::Test { class ConfTest : public ::testing::Test {
protected: protected:
@@ -31,7 +30,7 @@ protected:
} }
void parseConf(std::string conf) { void parseConf(std::string conf) {
lseek(fd, SEEK_SET, 0); lseek(fd, SEEK_SET, 0);
LSYSCALL(write(fd, conf.data(), conf.size())); write(fd, conf.data(), conf.size());
cfg = parseConfFile(tmpFile); cfg = parseConfFile(tmpFile);
} }
StringMap cfg; StringMap cfg;

136
test/unit-run.cpp Normal file
View File

@@ -0,0 +1,136 @@
///
/// Copyright 2018 Oliver Giles
///
/// This file is part of Laminar
///
/// Laminar is free software: you can redistribute it and/or modify
/// it under the terms of the GNU General Public License as published by
/// the Free Software Foundation, either version 3 of the License, or
/// (at your option) any later version.
///
/// Laminar is distributed in the hope that it will be useful,
/// but WITHOUT ANY WARRANTY; without even the implied warranty of
/// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
/// GNU General Public License for more details.
///
/// You should have received a copy of the GNU General Public License
/// along with Laminar. If not, see <http://www.gnu.org/licenses/>
///
#include <gtest/gtest.h>
#include "run.h"
#include "log.h"
#include "context.h"
#include "conf.h"
#include "tempdir.h"
class RunTest : public testing::Test {
protected:
RunTest() :
testing::Test(),
context(std::make_shared<Context>()),
tmp(),
run("foo", ParamMap{}, tmp.path.clone())
{
}
~RunTest() noexcept {}
void wait() {
int state = -1;
waitpid(run.current_pid.orDefault(0), &state, 0);
run.reaped(state);
}
void runAll() {
while(!run.step())
wait();
}
std::string readAllOutput() {
std::string res;
char tmp[64];
for(ssize_t n = read(run.output_fd, tmp, 64); n > 0; n = read(run.output_fd, tmp, 64))
res += std::string(tmp, n);
// strip the first "[laminar] executing.. line
return strchr(res.c_str(), '\n') + 1;
}
StringMap parseFromString(std::string content) {
char tmp[16] = "/tmp/lt.XXXXXX";
int fd = mkstemp(tmp);
write(fd, content.data(), content.size());
close(fd);
StringMap map = parseConfFile(tmp);
unlink(tmp);
return map;
}
std::shared_ptr<Context> context;
TempDir tmp;
class Run run;
void setRunLink(const char * path) {
KJ_IF_MAYBE(f, tmp.fs->tryOpenFile(kj::Path{"cfg", "jobs", run.name + ".run"},
kj::WriteMode::CREATE | kj::WriteMode::CREATE_PARENT | kj::WriteMode::EXECUTABLE)) {
(f->get())->writeAll(std::string("#!/bin/sh\nexec ") + path + "\n");
}
}
};
TEST_F(RunTest, WorkingDirectory) {
setRunLink("pwd");
run.configure(1, context, *tmp.fs);
runAll();
std::string cwd{tmp.path.append(kj::Path{"run","foo","1"}).toString(true).cStr()};
EXPECT_EQ(cwd + "\n", readAllOutput());
}
TEST_F(RunTest, SuccessStatus) {
setRunLink("true");
run.configure(1, context, *tmp.fs);
runAll();
EXPECT_EQ(RunState::SUCCESS, run.result);
}
TEST_F(RunTest, FailedStatus) {
setRunLink("false");
run.configure(1, context, *tmp.fs);
runAll();
EXPECT_EQ(RunState::FAILED, run.result);
}
TEST_F(RunTest, Environment) {
setRunLink("env");
run.configure(1234, context, *tmp.fs);
runAll();
std::string ws{tmp.path.append(kj::Path{"run","foo","workspace"}).toString(true).cStr()};
std::string archive{tmp.path.append(kj::Path{"archive","foo","1234"}).toString(true).cStr()};
StringMap map = parseFromString(readAllOutput());
EXPECT_EQ("1234", map["RUN"]);
EXPECT_EQ("foo", map["JOB"]);
EXPECT_EQ("success", map["RESULT"]);
EXPECT_EQ("unknown", map["LAST_RESULT"]);
EXPECT_EQ(ws, map["WORKSPACE"]);
EXPECT_EQ(archive, map["ARCHIVE"]);
}
TEST_F(RunTest, ParamsToEnv) {
setRunLink("env");
run.params["foo"] = "bar";
run.configure(1, context, *tmp.fs);
runAll();
StringMap map = parseFromString(readAllOutput());
EXPECT_EQ("bar", map["foo"]);
}
TEST_F(RunTest, Abort) {
setRunLink("yes");
run.configure(1, context, *tmp.fs);
run.step();
usleep(200); // TODO fix
run.abort(false);
wait();
EXPECT_EQ(RunState::ABORTED, run.result);
}