├── www ├── layouts │ ├── partials │ │ ├── _force_tailwind.html │ │ ├── meta.html │ │ ├── css.html │ │ └── icons │ │ │ ├── book.html │ │ │ ├── code.html │ │ │ ├── home.html │ │ │ ├── github.html │ │ │ └── wip.html │ ├── home.html │ ├── _default │ │ ├── section.html │ │ ├── single.html │ │ └── baseof.html │ └── shortcodes │ │ ├── button-group.html │ │ ├── button.html │ │ ├── wip.html │ │ ├── section-index.html │ │ └── automation-kiss.html ├── .gitignore ├── static │ ├── img │ │ ├── scanlines.png │ │ ├── desktop.svg │ │ ├── server.svg │ │ ├── server-success.svg │ │ ├── server-error.svg │ │ └── server-pending.svg │ ├── fonts │ │ └── star-trek-lcars.ttf │ └── css │ │ └── style.css ├── postcss.config.js ├── content │ ├── tutorials │ │ ├── cicd.md │ │ ├── _index.md │ │ ├── inventory.md │ │ ├── getting-started.md │ │ ├── setup-auth.md │ │ └── cli.md │ └── _index.md ├── tailwind.config.js ├── package.json ├── config.toml ├── styles │ └── main.css └── LICENSE.txt ├── tests ├── all │ ├── prelude │ │ ├── mod.rs │ │ └── inventory_test.rs │ ├── tasks │ │ ├── mod.rs │ │ ├── common.rs │ │ └── exec_test.rs │ └── main.rs ├── run_integration_tests.sh ├── setup_tricorder.sh └── setup_ssh.sh ├── src ├── prelude │ ├── result.rs │ ├── tasks │ │ ├── mod.rs │ │ ├── task.rs │ │ └── task_runner.rs │ ├── inventory │ │ ├── mod.rs │ │ ├── host_tag.rs │ │ ├── host_id.rs │ │ ├── tag_expr.rs │ │ ├── host_entry.rs │ │ └── host_registry.rs │ ├── error.rs │ └── mod.rs ├── tasks │ ├── mod.rs │ ├── info.rs │ ├── exec.rs │ ├── download.rs │ ├── upload.rs │ └── module.rs ├── cli │ ├── info.rs │ ├── download.rs │ ├── exec.rs │ ├── module.rs │ ├── external.rs │ ├── upload.rs │ └── mod.rs ├── lib.rs └── main.rs ├── examples └── inventory.toml ├── .gitignore ├── Cargo.toml ├── LICENSE.txt ├── .github └── workflows │ └── deploy-site.yml ├── README.md └── Cargo.lock /www/layouts/partials/_force_tailwind.html: -------------------------------------------------------------------------------- 1 |
-------------------------------------------------------------------------------- /www/.gitignore: -------------------------------------------------------------------------------- 1 | resources/ 2 | .hugo_build.lock 3 | node_modules/ 4 | public/ -------------------------------------------------------------------------------- /tests/all/prelude/mod.rs: -------------------------------------------------------------------------------- 1 | #[path = "inventory_test.rs"] 2 | mod inventory_test; 3 | -------------------------------------------------------------------------------- /www/layouts/home.html: -------------------------------------------------------------------------------- 1 | {{ define "content" }} 2 | {{ .Content }} 3 | {{ end }} 4 | -------------------------------------------------------------------------------- /tests/all/tasks/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod common; 2 | 3 | #[path = "exec_test.rs"] 4 | mod exec_test; 5 | -------------------------------------------------------------------------------- /www/layouts/_default/section.html: -------------------------------------------------------------------------------- 1 | {{ define "content" }} 2 | {{ .Content }} 3 | {{ end }} 4 | -------------------------------------------------------------------------------- /www/layouts/_default/single.html: -------------------------------------------------------------------------------- 1 | {{ define "content" }} 2 | {{ .Content }} 3 | {{ end }} 4 | -------------------------------------------------------------------------------- /www/layouts/shortcodes/button-group.html: -------------------------------------------------------------------------------- 1 |
2 | {{ .Inner }} 3 |
-------------------------------------------------------------------------------- /www/static/img/scanlines.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/linkdd/tricorder/HEAD/www/static/img/scanlines.png -------------------------------------------------------------------------------- /src/prelude/result.rs: -------------------------------------------------------------------------------- 1 | pub type Result = std::result::Result>; 2 | -------------------------------------------------------------------------------- /www/layouts/shortcodes/button.html: -------------------------------------------------------------------------------- 1 | 2 | {{ .Params.label }} 3 | 4 | -------------------------------------------------------------------------------- /tests/all/main.rs: -------------------------------------------------------------------------------- 1 | #[path = "prelude/mod.rs"] 2 | mod prelude; 3 | 4 | #[path = "tasks/mod.rs"] 5 | mod tasks; 6 | -------------------------------------------------------------------------------- /www/static/fonts/star-trek-lcars.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/linkdd/tricorder/HEAD/www/static/fonts/star-trek-lcars.ttf -------------------------------------------------------------------------------- /examples/inventory.toml: -------------------------------------------------------------------------------- 1 | [[hosts]] 2 | 3 | id = "localhost" 4 | address = "localhost:22" 5 | user = "root" 6 | tags = ["local"] 7 | vars = { msg = "hi" } 8 | -------------------------------------------------------------------------------- /src/tasks/mod.rs: -------------------------------------------------------------------------------- 1 | //! Available **tricorder** tasks. 2 | 3 | pub mod download; 4 | pub mod exec; 5 | pub mod info; 6 | pub mod module; 7 | pub mod upload; 8 | -------------------------------------------------------------------------------- /src/prelude/tasks/mod.rs: -------------------------------------------------------------------------------- 1 | mod task; 2 | mod task_runner; 3 | 4 | pub use self::{ 5 | task::{GenericTask, TaskResult}, 6 | task_runner::TaskRunner, 7 | }; 8 | -------------------------------------------------------------------------------- /tests/run_integration_tests.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env 2 | 3 | set -ex 4 | 5 | source ./tests/setup_ssh.sh 6 | source ./tests/setup_tricorder.sh 7 | 8 | cargo test -- --test-threads=1 9 | -------------------------------------------------------------------------------- /www/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | ...(process.env.NODE_ENV === 'production' ? { cssnano: {} } : {}), 6 | }, 7 | } 8 | -------------------------------------------------------------------------------- /www/layouts/shortcodes/wip.html: -------------------------------------------------------------------------------- 1 |
2 |
{{ partial "icons/wip.html" .Page }}
3 |
Work In Progress
4 |
5 | -------------------------------------------------------------------------------- /src/prelude/inventory/mod.rs: -------------------------------------------------------------------------------- 1 | mod host_entry; 2 | mod host_id; 3 | mod host_registry; 4 | mod host_tag; 5 | mod tag_expr; 6 | 7 | pub use self::{host_entry::Host, host_id::HostId, host_registry::Inventory, host_tag::HostTag}; 8 | -------------------------------------------------------------------------------- /www/content/tutorials/cicd.md: -------------------------------------------------------------------------------- 1 | +++ 2 | title = "howto cicd" 3 | documentName = "CI/CD integration" 4 | description = "Setup tricorder with common CI/CD pipelines (Github, Gitlab, Jenkins)" 5 | menuHref = "/tutorials/" 6 | weight = 5 7 | +++ 8 | 9 | {{< wip >}} 10 | -------------------------------------------------------------------------------- /www/tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: [ 3 | './layouts/**/*.html', 4 | ], 5 | theme: { 6 | extend: { 7 | fontFamily: { 8 | startrek: ['Star Trek LCARS', 'sans-serif'], 9 | }, 10 | }, 11 | }, 12 | plugins: [], 13 | } 14 | -------------------------------------------------------------------------------- /www/layouts/partials/meta.html: -------------------------------------------------------------------------------- 1 | {{ if eq "/" .RelPermalink }} 2 | {{ .Site.Title }} 3 | {{ else }} 4 | {{ .Site.Title }} - {{ .Page.Title }} 5 | {{ end }} 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /www/layouts/partials/css.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /www/content/tutorials/_index.md: -------------------------------------------------------------------------------- 1 | +++ 2 | title = "howto" 3 | +++ 4 | 5 | # Tutorials list 6 | 7 | {{< section-index >}} 8 | 9 | # Other resources 10 | 11 | {{< button-group >}} 12 | {{< button label="Source Code" href="https://github.com/linkdd/tricorder" >}} 13 | {{< button label="API reference" href="https://docs.rs/tricorder/latest/tricorder/" >}} 14 | {{< /button-group >}} 15 | -------------------------------------------------------------------------------- /src/prelude/tasks/task.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::{Host, Result}; 2 | use serde_json::Value; 3 | 4 | /// Describe the result of a `Task` execution 5 | pub type TaskResult = Result; 6 | 7 | /// Generic Task trait 8 | pub trait GenericTask: Send + Sync { 9 | /// Called to prepare contextual data for the task execution 10 | fn prepare(&self, host: Host) -> Result; 11 | 12 | /// Called to execute the task 13 | fn apply(&self, host: Host, data: Data) -> TaskResult; 14 | } 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | debug/ 4 | target/ 5 | 6 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 7 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 8 | #Cargo.lock 9 | 10 | # These are backup files generated by rustfmt 11 | **/*.rs.bk 12 | 13 | # MSVC Windows builds of rustc generate these, which store debugging information 14 | *.pdb 15 | 16 | # Test suite files 17 | tests/sshd 18 | tests/tricorder 19 | -------------------------------------------------------------------------------- /src/cli/info.rs: -------------------------------------------------------------------------------- 1 | //! Command Line Interface to the `tricorder::tasks::info` task 2 | //! 3 | //! Example: 4 | //! 5 | //! ```shell 6 | //! $ tricorder -i inventory info 7 | //! ``` 8 | 9 | use crate::prelude::*; 10 | use crate::tasks::info; 11 | 12 | use clap::ArgMatches; 13 | 14 | pub fn run(hosts: Vec, matches: &ArgMatches) -> Result<()> { 15 | let parallel = matches.is_present("parallel"); 16 | 17 | let task = info::Task::new(); 18 | let res = hosts.run_task(&task, parallel)?; 19 | println!("{}", res); 20 | 21 | Ok(()) 22 | } 23 | -------------------------------------------------------------------------------- /tests/all/tasks/common.rs: -------------------------------------------------------------------------------- 1 | use std::{env, panic}; 2 | use tricorder::prelude::*; 3 | 4 | pub fn within_context(test_fn: T) -> () 5 | where 6 | T: FnOnce(Inventory) -> () + panic::UnwindSafe, 7 | { 8 | let cwd = env::current_dir().unwrap(); 9 | let test_dir = cwd.join("tests").join("tricorder"); 10 | env::set_current_dir(test_dir).unwrap(); 11 | 12 | let inventory = Inventory::from_file("./inventory.toml").unwrap(); 13 | 14 | let result = panic::catch_unwind(|| test_fn(inventory)); 15 | 16 | env::set_current_dir(cwd).unwrap(); 17 | 18 | assert!(result.is_ok()); 19 | } 20 | -------------------------------------------------------------------------------- /www/layouts/shortcodes/section-index.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | {{ range .Page.Pages }} 12 | 13 | 16 | 17 | 18 | 19 | {{ end }} 20 | 21 |
DocumentDescriptionReading time
14 | {{ .Params.DocumentName }} 15 | {{ .Params.description }}N/A
22 |
-------------------------------------------------------------------------------- /src/prelude/error.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | error::Error as BaseError, 3 | fmt::{Display, Formatter, Result}, 4 | }; 5 | 6 | #[derive(Debug, Clone)] 7 | pub enum Error { 8 | MissingInput(String), 9 | CommandExecutionFailed(String), 10 | UploadFailed(String), 11 | FileNotFound(String), 12 | IsADirectory(String), 13 | IsAbsolute(String), 14 | InvalidHostId(String), 15 | InvalidHostTag(String), 16 | InvalidToken(String), 17 | Other(String), 18 | } 19 | 20 | impl Display for Error { 21 | fn fmt(&self, f: &mut Formatter) -> Result { 22 | write!(f, "{:?}", self) 23 | } 24 | } 25 | 26 | impl BaseError for Error {} 27 | -------------------------------------------------------------------------------- /www/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tricorder-www", 3 | "version": "1.0.0", 4 | "scripts": { 5 | "build": "npm run build:css && npm run build:html", 6 | "build:css": "tailwindcss -i styles/main.css -o static/css/style.css --minify", 7 | "build:html": "hugo --minify", 8 | "watch": "concurrently \"npm:watch:css\" \"npm:watch:html\"", 9 | "watch:css": "tailwindcss -i styles/main.css -o static/css/style.css --watch", 10 | "watch:html": "hugo serve -w" 11 | }, 12 | "dependencies": { 13 | "autoprefixer": "^10.4.4", 14 | "postcss": "^8.4.31", 15 | "tailwindcss": "^3.0.23" 16 | }, 17 | "devDependencies": { 18 | "concurrently": "^7.1.0" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /www/content/tutorials/inventory.md: -------------------------------------------------------------------------------- 1 | +++ 2 | title = "howto inventory" 3 | documentName = "Inventory file" 4 | description = "Syntax of the inventory file and dynamic inventories" 5 | menuHref = "/tutorials/" 6 | weight = 2 7 | +++ 8 | 9 | {{< wip >}} 10 | # Example of static inventory file: 11 | ```toml 12 | [[hosts]] 13 | id = "testserver" 14 | tags = ["server", "test"] 15 | address = "192.168.178.6:22" 16 | user = "testuser" 17 | vars = { 18 | module_ping = { 19 | gateway = "192.168.178.1", internet = "8.8.8.8" 20 | } 21 | } 22 | 23 | [[hosts]] 24 | id = "production" 25 | tags = ["server", "database", "webserver"] 26 | address = "192.168.178.7:22" 27 | user = "produsesr" 28 | ``` -------------------------------------------------------------------------------- /www/content/tutorials/getting-started.md: -------------------------------------------------------------------------------- 1 | +++ 2 | title = "howto start" 3 | documentName = "Getting Started" 4 | description = "Build, install and run tricorder" 5 | menuHref = "/tutorials/" 6 | weight = 1 7 | +++ 8 | 9 | {{< wip >}} 10 | # Build from Source 11 | 12 | **Requirements:** 13 | 14 | | package | minimal Version | 15 | | - | - | 16 | | Rust | tested with version 1.69 | 17 | | cargo | tested with version 1.67.0 | 18 | | git | tested with version 2.40.0 | 19 | 20 | Clone from repository: 21 | ```shell 22 | git clone git@github.com:linkdd/tricorder.git 23 | cd tricorder 24 | ``` 25 | Compile from source (requirement rust installed via rustup): 26 | ```shell 27 | cargo install --locked --path . 28 | ``` 29 | Make sure `~/.cargo/bin` is on the $PATH -------------------------------------------------------------------------------- /tests/setup_tricorder.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -ex 3 | 4 | # This script prepare the fixtures for tricorder's test-suite 5 | 6 | TRICORDER_DIR=$(pwd)/tests/tricorder 7 | TRICORDER_USER=$(whoami) 8 | 9 | # Blow away any prior state and re-configure our inventory 10 | rm -rf $TRICORDER_DIR 11 | mkdir -p $TRICORDER_DIR 12 | 13 | cat > $TRICORDER_DIR/inventory.toml <<-EOT 14 | [[hosts]] 15 | 16 | id = "localhost" 17 | address = "localhost:${SSH_FIXTURE_PORT}" 18 | user = "${TRICORDER_USER}" 19 | tags = ["local", "test-success"] 20 | vars = { msg = "hi" } 21 | 22 | [[hosts]] 23 | 24 | id = "localhost-fail" 25 | address = "non-existant-domain:22" 26 | user = "${TRICORDER_USER}" 27 | tags = ["local", "test-failure"] 28 | vars = { msg = "hi" } 29 | EOT 30 | -------------------------------------------------------------------------------- /www/layouts/partials/icons/book.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tricorder" 3 | version = "0.10.0" 4 | 5 | description = "Automation, the KISS way, no YAML involved." 6 | keywords = ["automation", "ssh"] 7 | categories = ["command-line-utilities"] 8 | 9 | homepage = "https://linkdd.github.io/tricorder/" 10 | repository = "https://github.com/linkdd/tricorder" 11 | 12 | license = "MIT" 13 | readme = "README.md" 14 | 15 | exclude = ["examples/", "www/", ".github/"] 16 | 17 | edition = "2021" 18 | 19 | [dependencies] 20 | 21 | toml = "0.5" 22 | serde = "1.0" 23 | serde_json = "1.0" 24 | serde_derive = "1.0" 25 | regex = "1.5" 26 | 27 | rayon = "1.5" 28 | 29 | clap = { version = "3.1", features = ["cargo"] } 30 | ssh2 = "0.9" 31 | is_executable = "1.0" 32 | shell-words = "1.1" 33 | file-mode = "0.1" 34 | tinytemplate = "1.2" 35 | bet = "1.0" 36 | logos = "0.12" 37 | -------------------------------------------------------------------------------- /www/layouts/partials/icons/code.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /www/layouts/partials/icons/home.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /www/config.toml: -------------------------------------------------------------------------------- 1 | baseURL = "https://linkdd.github.io/tricorder/" 2 | title = "tricorder" 3 | 4 | [params] 5 | 6 | license = """ 7 | Website content is distributed under the terms of the 8 | [CC-BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/) license. 9 | """ 10 | 11 | [menu] 12 | [[menu.main]] 13 | 14 | weight = 10 15 | identifier = "home" 16 | name = "Home" 17 | url = "/" 18 | params = { icon = "home" } 19 | 20 | [[menu.main]] 21 | 22 | weight = 20 23 | identifier = "repo" 24 | name = "Repository" 25 | url = "https://github.com/linkdd/tricorder" 26 | params = { icon = "github" } 27 | 28 | [[menu.main]] 29 | 30 | weight = 30 31 | identifier = "tutorials" 32 | name = "Tutorials" 33 | url = "/tutorials/" 34 | params = { icon = "book" } 35 | 36 | [[menu.main]] 37 | 38 | weight = 40 39 | identifier = "docs" 40 | name = "Documentation" 41 | url = "https://docs.rs/tricorder/latest/tricorder/" 42 | params = { icon = "code" } 43 | -------------------------------------------------------------------------------- /www/layouts/_default/baseof.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ partial "meta.html" . }} 5 | {{ partial "css.html" . }} 6 | 7 | 8 | 9 |
10 | 24 | 25 |
26 |
{{ .Page.Title }}
27 | 28 | {{ block "content" . }} 29 | {{ end }} 30 | 31 |
{{ .Site.Params.License | markdownify }}
32 |
33 |
34 | 35 | 36 | -------------------------------------------------------------------------------- /www/layouts/partials/icons/github.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /www/content/tutorials/setup-auth.md: -------------------------------------------------------------------------------- 1 | +++ 2 | title = "howto auth" 3 | documentName = "SSH authentication" 4 | description = "Setup SSH server and ssh-agent configuration" 5 | menuHref = "/tutorials/" 6 | weight = 3 7 | +++ 8 | 9 | {{< wip >}} 10 | # Client 11 | ## Install using apt 12 | ```shell 13 | $ sudo apt update 14 | $ sudo apt install openssh-client 15 | ``` 16 | ## Install using pacman 17 | ```shell 18 | $ sudo pacman -Sy openssh 19 | ``` 20 | 21 | 22 | ## Generate and use ssh-keypair 23 | Generate keypair using following command. 24 | note: you can rename the key with the `-f` flag 25 | ```shell 26 | $ ssh-keygen -t ed25519 27 | ``` 28 | 29 | This generates a keypair (public and private key) under `~/.ssh`. 30 | 31 | You can upload the key with following command: 32 | ```shell 33 | $ ssh-copy-id -i ~/.ssh/id_ed25519 @ 34 | ``` 35 | or copy the contents of the public key (.pub) to the hosts authorized_keys file (~/.ssh/authorized_keys). 36 | 37 | ## Add key to ssh-agent 38 | Following command adds your key to your agent: 39 | ```shell 40 | ssh-add ~/.ssh/id_ed25519 41 | ``` -------------------------------------------------------------------------------- /src/cli/download.rs: -------------------------------------------------------------------------------- 1 | //! Download a file from multiple remote hosts. 2 | //! 3 | //! Usage: 4 | //! 5 | //! ```shell 6 | //! $ tricorder -i inventory download REMOTE_PATH LOCAL_PATH 7 | //! ``` 8 | //! 9 | //! The files will be downloaded to: `{pwd}/{host.id}/{local_path}` 10 | 11 | use crate::prelude::*; 12 | use crate::tasks::download; 13 | 14 | use clap::ArgMatches; 15 | 16 | pub fn run(hosts: Vec, matches: &ArgMatches) -> Result<()> { 17 | let remote_path = get_path(matches.value_of("remote_path"))?; 18 | let local_path = get_path(matches.value_of("local_path"))?; 19 | let parallel = matches.is_present("parallel"); 20 | 21 | let task = download::Task::new(remote_path, local_path); 22 | let res = hosts.run_task(&task, parallel)?; 23 | println!("{}", res); 24 | 25 | Ok(()) 26 | } 27 | 28 | fn get_path(arg: Option<&str>) -> Result { 29 | if let Some(path) = arg { 30 | Ok(String::from(path)) 31 | } else { 32 | Err(Box::new(Error::MissingInput( 33 | "No input file provided".to_string(), 34 | ))) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /www/layouts/partials/icons/wip.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2022, David Delassus 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /src/cli/exec.rs: -------------------------------------------------------------------------------- 1 | //! Command Line Interface to the `tricorder::tasks::exec` task. 2 | //! 3 | //! Usage: 4 | //! 5 | //! ```shell 6 | //! $ tricorder -i inventory do -- echo "run on all hosts" 7 | //! $ tricorder -i inventory -H foo do -- echo "run only on host 'foo'" 8 | //! $ tricorder -i inventory -t myapp do -- echo "run only on hosts tagged with 'myapp'" 9 | //! ``` 10 | //! 11 | //! Commands can be templated with data from the host as defined in the 12 | //! inventory: 13 | //! 14 | //! ```shell 15 | //! $ tricorder -i inventory do -- echo "{host.id} says {host.vars.msg}" 16 | //! ``` 17 | 18 | use crate::prelude::*; 19 | use crate::tasks::exec; 20 | 21 | use clap::ArgMatches; 22 | 23 | pub fn run(hosts: Vec, matches: &ArgMatches) -> Result<()> { 24 | let cmd_tmpl = get_command(matches.values_of("cmd")); 25 | let parallel = matches.is_present("parallel"); 26 | 27 | let task = exec::Task::new(cmd_tmpl); 28 | let res = hosts.run_task(&task, parallel)?; 29 | println!("{}", res); 30 | 31 | Ok(()) 32 | } 33 | 34 | fn get_command(arg: Option>) -> String { 35 | arg.map(|vals| vals.collect::>()) 36 | .map(|argv| shell_words::join(argv)) 37 | .unwrap() 38 | } 39 | -------------------------------------------------------------------------------- /src/prelude/mod.rs: -------------------------------------------------------------------------------- 1 | //! Core features of **tricorder**. 2 | //! 3 | //! **tricorder** uses an inventory to configure which hosts it needs to connect 4 | //! to and which data are associated to those specific hosts. 5 | //! 6 | //! This inventory can be built from a TOML document: 7 | //! 8 | //! ```toml 9 | //! [[hosts]] 10 | //! 11 | //! id = "localhost" 12 | //! address = "localhost:22" 13 | //! user = "root" 14 | //! tags = ["local"] 15 | //! vars = { foo = "bar" } 16 | //! ``` 17 | //! 18 | //! A JSON document: 19 | //! 20 | //! ```json 21 | //! {"hosts": [ 22 | //! { 23 | //! "id": "localhost", 24 | //! "address": "localhost:22", 25 | //! "user": "root", 26 | //! "tags": ["local"], 27 | //! "vars": {"foo": "bar"} 28 | //! } 29 | //! ]} 30 | //! ``` 31 | //! 32 | //! Or directly via the Rust API: 33 | //! 34 | //! ```rust 35 | //! use tricorder::prelude::{Inventory, Host}; 36 | //! use serde_json::json; 37 | //! 38 | //! let inventory = Inventory::new() 39 | //! .add_host( 40 | //! Host::new(Host::id("localhost").unwrap(), "localhost:22".to_string()) 41 | //! .set_user("root".to_string()) 42 | //! .add_tag(Host::tag("local").unwrap()) 43 | //! .set_var("foo".to_string(), json!("bar")) 44 | //! .to_owned() 45 | //! ) 46 | //! .to_owned(); 47 | //! ``` 48 | 49 | mod error; 50 | mod inventory; 51 | mod result; 52 | mod tasks; 53 | 54 | pub use self::{error::Error, inventory::*, result::Result, tasks::*}; 55 | -------------------------------------------------------------------------------- /.github/workflows/deploy-site.yml: -------------------------------------------------------------------------------- 1 | name: deploy-site 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - 'www/**' 9 | - '.github/workflows/deploy-site.yml' 10 | 11 | jobs: 12 | www: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: checkout-code@scm 16 | uses: actions/checkout@main 17 | - name: checkout-ghpages@scm 18 | uses: actions/checkout@main 19 | with: 20 | ref: gh-pages 21 | path: www/public/ 22 | 23 | - name: setup@hugo 24 | uses: peaceiris/actions-hugo@v2 25 | with: 26 | hugo-version: '0.96.0' 27 | 28 | - name: setup@node 29 | uses: actions/setup-node@master 30 | with: 31 | node-version: '16.x' 32 | 33 | - name: setup@yarn 34 | run: npm install -g yarn 35 | 36 | - name: install@yarn 37 | run: | 38 | cd www 39 | yarn install 40 | 41 | - name: docs@yarn 42 | run: | 43 | cd www 44 | yarn run build 45 | 46 | - name: publish@scm 47 | env: 48 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 49 | run: | 50 | cd www/public/ 51 | touch .nojekyll 52 | git config --local user.email "action@github.com" 53 | git config --local user.name "GitHub Action" 54 | git add . 55 | git commit -m ":construction_worker: publish website" --allow-empty 56 | git push origin gh-pages 57 | -------------------------------------------------------------------------------- /src/prelude/inventory/host_tag.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::{Error, Result}; 2 | 3 | use regex::Regex; 4 | use serde::de::{Deserialize, Deserializer}; 5 | use serde_derive::Serialize; 6 | 7 | const HOST_TAG_REGEX: &str = r"^[^!\&\|\t\n\r\f\(\) ]+$"; 8 | 9 | #[derive(Debug, Clone, Serialize, PartialEq)] 10 | pub struct HostTag(String); 11 | 12 | impl HostTag { 13 | /// Create a new host identifier from a string. 14 | /// 15 | /// Example: 16 | /// 17 | /// ```rust 18 | /// use tricorder::prelude::HostTag; 19 | /// 20 | /// let tag = HostTag::new("example").unwrap(); 21 | /// # assert_eq!(tag.to_string(), String::from("example")); 22 | /// ``` 23 | pub fn new(src: &str) -> Result { 24 | let re = Regex::new(HOST_TAG_REGEX)?; 25 | if !re.is_match(src) { 26 | Err(Box::new(Error::InvalidHostTag(format!( 27 | "Tag {} does not match regex {}", 28 | src, HOST_TAG_REGEX 29 | )))) 30 | } else { 31 | Ok(Self(src.to_string())) 32 | } 33 | } 34 | 35 | pub fn to_string(self) -> String { 36 | let Self(s) = self; 37 | s 38 | } 39 | } 40 | 41 | impl<'de> Deserialize<'de> for HostTag { 42 | fn deserialize(deserializer: D) -> std::result::Result 43 | where 44 | D: Deserializer<'de>, 45 | { 46 | let src = String::deserialize(deserializer)?; 47 | HostTag::new(src.as_str()).map_err(serde::de::Error::custom) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/prelude/inventory/host_id.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::{Error, Result}; 2 | 3 | use regex::Regex; 4 | use serde::de::{Deserialize, Deserializer}; 5 | use serde_derive::Serialize; 6 | 7 | const HOST_ID_REGEX: &str = r"^[a-zA-Z0-9_][a-zA-Z0-9_\-]*$"; 8 | 9 | #[derive(Debug, Clone, Serialize, PartialEq)] 10 | pub struct HostId(String); 11 | 12 | impl HostId { 13 | /// Create a new host identifier from a string. 14 | /// 15 | /// Example: 16 | /// 17 | /// ```rust 18 | /// use tricorder::prelude::HostId; 19 | /// 20 | /// let id = HostId::new("example").unwrap(); 21 | /// # assert_eq!(id.to_string(), String::from("example")); 22 | /// ``` 23 | pub fn new(src: &str) -> Result { 24 | let re = Regex::new(HOST_ID_REGEX)?; 25 | if !re.is_match(src) { 26 | Err(Box::new(Error::InvalidHostId(format!( 27 | "ID {} does not match regex {}", 28 | src, HOST_ID_REGEX 29 | )))) 30 | } else { 31 | Ok(Self(src.to_string())) 32 | } 33 | } 34 | 35 | /// Return the underlying string 36 | pub fn to_string(self) -> String { 37 | let Self(s) = self; 38 | s.clone() 39 | } 40 | } 41 | 42 | impl<'de> Deserialize<'de> for HostId { 43 | fn deserialize(deserializer: D) -> std::result::Result 44 | where 45 | D: Deserializer<'de>, 46 | { 47 | let src = String::deserialize(deserializer)?; 48 | HostId::new(src.as_str()).map_err(serde::de::Error::custom) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /www/static/img/desktop.svg: -------------------------------------------------------------------------------- 1 | Laptop -------------------------------------------------------------------------------- /src/cli/module.rs: -------------------------------------------------------------------------------- 1 | //! Upload a Modoule to remote host and call it with data. 2 | //! 3 | //! A module is an executable, that accepts structured 4 | //! data as Input (json) via stdin and performs 5 | //! actions based on the provided data. 6 | //! 7 | //! The Module optionally takes default data from 8 | //! these get overwritten with custom values in 9 | //! host var host.vars.module_ 10 | //! should contain all information of the 11 | //! expected data structure 12 | //! 13 | //! Usage: 14 | //! 15 | //! ```shell 16 | //! $ tricorder -i inventory module --data --module 17 | //! $ tricorder -i inventory module --module 18 | //! ``` 19 | //! 20 | 21 | use crate::prelude::*; 22 | use crate::tasks::module; 23 | 24 | use clap::ArgMatches; 25 | 26 | pub fn run(hosts: Vec, matches: &ArgMatches) -> Result<()> { 27 | let data_path = get_data_path(matches.value_of("data_file_path")); 28 | let module_path = get_path(matches.value_of("module"))?; 29 | let parallel = matches.is_present("parallel"); 30 | 31 | let task = module::Task::new(data_path, module_path); 32 | 33 | let res = hosts.run_task(&task, parallel)?; 34 | println!("{}", res); 35 | 36 | Ok(()) 37 | } 38 | 39 | fn get_path(arg: Option<&str>) -> Result { 40 | if let Some(path) = arg { 41 | Ok(String::from(path)) 42 | } else { 43 | Err(Box::new(Error::MissingInput( 44 | "No input file provided".to_string(), 45 | ))) 46 | } 47 | } 48 | 49 | fn get_data_path(arg: Option<&str>) -> Option { 50 | arg.map(String::from) 51 | } 52 | -------------------------------------------------------------------------------- /tests/setup_ssh.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -ex 3 | 4 | # This script spawns an SSH daemon with a known configuration so that we can 5 | # test various functionality against it. 6 | 7 | SSH_FIXTURE_PORT=8022 8 | SSH_DIR=$(pwd)/tests/sshd 9 | 10 | cleanup_ssh() { 11 | # Stop the SSH server and local SSH agent 12 | kill $(< $SSH_DIR/sshd.pid) $SSH_AGENT_PID || true 13 | 14 | test -f $SSH_DIR/sshd.log && cat $SSH_DIR/sshd.log 15 | } 16 | trap cleanup_ssh EXIT 17 | 18 | # Blow away any prior state and re-configure our test server 19 | rm -rf $SSH_DIR 20 | mkdir -p $SSH_DIR 21 | 22 | eval $(ssh-agent -s) 23 | 24 | ssh-keygen -t rsa -f $SSH_DIR/id_rsa -N "" -q 25 | chmod 0600 $SSH_DIR/id_rsa* 26 | ssh-add $SSH_DIR/id_rsa 27 | cp $SSH_DIR/id_rsa.pub $SSH_DIR/authorized_keys 28 | 29 | ssh-keygen -f $SSH_DIR/ssh_host_rsa_key -N "" -t rsa 30 | 31 | cat > $SSH_DIR/sshd_config <<-EOT 32 | AuthorizedKeysFile=$SSH_DIR/authorized_keys 33 | HostKey=$SSH_DIR/ssh_host_rsa_key 34 | HostKeyAlgorithms ssh-rsa 35 | PidFile=$SSH_DIR/sshd.pid 36 | Subsystem sftp internal-sftp 37 | PrintMotd yes 38 | PermitTunnel yes 39 | KbdInteractiveAuthentication yes 40 | AllowTcpForwarding yes 41 | MaxStartups 500 42 | # Relax modes when the repo is under eg: /var/tmp 43 | StrictModes no 44 | EOT 45 | 46 | cat $SSH_DIR/sshd_config 47 | 48 | # Detect path to sshd binary 49 | SSHD=/usr/sbin/sshd 50 | 51 | if [ ! -f $SSHD ] 52 | then 53 | SSHD=/usr/bin/sshd 54 | fi 55 | 56 | if [ ! -f $SSHD ] 57 | then 58 | SSHD=$(which sshd) 59 | fi 60 | 61 | # Start an SSH server 62 | $SSHD -p $SSH_FIXTURE_PORT -f $SSH_DIR/sshd_config -E $SSH_DIR/sshd.log 63 | # Give it a moment to start up 64 | sleep 2 65 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! Automation the [KISS](https://en.wikipedia.org/wiki/KISS_principle) way. 2 | //! 3 | //! # Introduction 4 | //! 5 | //! [Ansible](https://ansible.com) is a great tool for automation. But it 6 | //! suffers from the same problem of many such tools: a big pile of custom YAML 7 | //! DSL. 8 | //! 9 | //! YAML is used to provide a declarative syntax of your automated workflow. 10 | //! This is nice for simple use cases, but automation can become rather complex 11 | //! very quickly. 12 | //! 13 | //! Then those tools implement control flow structures (conditional execution, 14 | //! loops, parallelization, ...), then the ability to save values into 15 | //! variables. 16 | //! 17 | //! Before you know it, you're programming in YAML. And the developer experience 18 | //! of such a language is terrible. 19 | //! 20 | //! **tricorder** aims to fix this. It gives you a single tool to perform tasks 21 | //! on multiple remotes. You then use your common UNIX tools like `bash`, `jq`, 22 | //! `curl`, etc... to compose those tasks together. 23 | //! 24 | //! # About tricorder 25 | //! 26 | //! The name comes from 27 | //! [Star Trek's Tricorder](https://en.wikipedia.org/wiki/Tricorder), a 28 | //! multifunction hand-held device to perform sensor environment scans, data 29 | //! recording, and data analysis. Pretty much anything required by the plot. 30 | //! 31 | //! The main goal of **tricorder** is to provide the basic tools to perform 32 | //! tasks on remote hosts and get out of your way. Allowing you to integrate it 33 | //! with any scripting language or programming language of your choice, instead 34 | //! of forcing you to develop in a sub-par custom YAML DSL. 35 | 36 | pub mod cli; 37 | pub mod prelude; 38 | pub mod tasks; 39 | -------------------------------------------------------------------------------- /src/cli/external.rs: -------------------------------------------------------------------------------- 1 | //! Run an external command found in `$PATH`. 2 | //! 3 | //! External subcommands are called with the following environment variables: 4 | //! 5 | //! | Variable | Description | 6 | //! | --- | --- | 7 | //! | `TRICORDER_INVENTORY` | Value of the `-i, --inventory` flag | 8 | //! | `TRICORDER_HOST_ID` | Value of the `-H, --host_id` flag | 9 | //! | `TRICORDER_HOST_TAGS` | Value of the `-t, --host_tags` flag | 10 | //! 11 | //! Internally, calling `tricorder [global-options...] SUBCOMMAND [options...]` 12 | //! would be similar to: 13 | //! 14 | //! ```shell 15 | //! $ export TRICORDER_INVENTORY="..." 16 | //! $ export TRICORDER_HOST_ID="..." 17 | //! $ export TRICORDER_HOST_TAGS="..." 18 | //! $ tricorder-SUBCOMMAND [options...] 19 | //! ``` 20 | 21 | use crate::prelude::Result; 22 | 23 | use clap::ArgMatches; 24 | use std::process::{exit, Command}; 25 | 26 | pub fn run( 27 | command: &str, 28 | inventory_arg: Option<&str>, 29 | host_id_arg: Option<&str>, 30 | host_tags_arg: Option<&str>, 31 | matches: &ArgMatches, 32 | ) -> Result<()> { 33 | let bin = format!("tricorder-{}", command); 34 | let args = matches 35 | .values_of_os("") 36 | .unwrap_or_default() 37 | .collect::>(); 38 | 39 | let status = Command::new(bin) 40 | .args(args) 41 | .env("TRICORDER_INVENTORY", inventory_arg.unwrap_or("")) 42 | .env("TRICORDER_HOST_ID", host_id_arg.unwrap_or("")) 43 | .env("TRICORDER_HOST_TAGS", host_tags_arg.unwrap_or("")) 44 | .status()?; 45 | 46 | match status.code() { 47 | Some(code) => { 48 | exit(code); 49 | } 50 | None => { 51 | eprintln!("Subcommand was terminated by a signal."); 52 | exit(127); 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/tasks/info.rs: -------------------------------------------------------------------------------- 1 | //! Gather information on hosts 2 | //! 3 | //! Example usage: 4 | //! 5 | //! ```no_run 6 | //! use tricorder::prelude::*; 7 | //! use tricorder::tasks::info; 8 | //! use serde_json::json; 9 | //! 10 | //! let inventory = Inventory::new() 11 | //! .add_host( 12 | //! Host::new(Host::id("localhost").unwrap(), "localhost:22".to_string()) 13 | //! .set_user("root".to_string()) 14 | //! .add_tag(Host::tag("local").unwrap()) 15 | //! .set_var("msg".to_string(), json!("hello")) 16 | //! .to_owned() 17 | //! ) 18 | //! .to_owned(); 19 | //! 20 | //! let task = info::Task::new(); 21 | //! let result = inventory.hosts.run_task_seq(&task).unwrap(); 22 | //! ``` 23 | //! 24 | //! The result is a JSON document with the following structure: 25 | //! 26 | //! ```json 27 | //! [ 28 | //! { 29 | //! "host": "localhost", 30 | //! "success": true, 31 | //! "info": { 32 | //! "id": "localhost", 33 | //! "address": "localhost:22", 34 | //! "user": "root", 35 | //! "tags": ["local"], 36 | //! "vars": {"msg": "hello"} 37 | //! } 38 | //! } 39 | //! ] 40 | //! ``` 41 | //! 42 | //! > **NB:** In the future, will be gathered facts like: 43 | //! > - network interfaces 44 | //! > - hostname and FQDN 45 | //! > - OS / Kernel version 46 | //! > - partitions / mount points 47 | //! > - ... 48 | 49 | use crate::prelude::*; 50 | 51 | use serde_json::json; 52 | 53 | /// Describe an `info` task 54 | pub struct Task; 55 | 56 | impl Task { 57 | /// Create a new `info` task 58 | pub fn new() -> Self { 59 | Self {} 60 | } 61 | } 62 | 63 | impl GenericTask<()> for Task { 64 | fn prepare(&self, _host: Host) -> Result<()> { 65 | Ok(()) 66 | } 67 | 68 | fn apply(&self, host: Host, _data: ()) -> TaskResult { 69 | Ok(json!(host)) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/cli/upload.rs: -------------------------------------------------------------------------------- 1 | //! Upload a file to multiple remote hosts. 2 | //! 3 | //! Usage: 4 | //! 5 | //! ```shell 6 | //! $ tricorder -i inventory upload LOCAL_PATH REMOTE_PATH [FILE_MODE] 7 | //! $ tricorder -i inventory upload -T LOCAL_PATH REMOTE_PATH [FILE_MODE] 8 | //! ``` 9 | //! 10 | //! If not provided, `FILE_MODE` defaults to `0644`. 11 | //! 12 | //! The following options are available: 13 | //! 14 | //! | Flag | Description | 15 | //! | --- | --- | 16 | //! | `-T, --template` | If set, treats `LOCAL_PATH` as a template with the current host as input data. | 17 | 18 | use crate::prelude::*; 19 | use crate::tasks::upload; 20 | 21 | use clap::ArgMatches; 22 | use file_mode::Mode; 23 | use std::convert::TryFrom; 24 | 25 | pub fn run(hosts: Vec, matches: &ArgMatches) -> Result<()> { 26 | let local_path = get_path(matches.value_of("local_path"))?; 27 | let remote_path = get_path(matches.value_of("remote_path"))?; 28 | let file_mode = get_file_mode(matches.value_of("file_mode"))?; 29 | let parallel = matches.is_present("parallel"); 30 | 31 | let task = if matches.is_present("template") { 32 | upload::Task::new_template(local_path, remote_path, file_mode) 33 | } else { 34 | upload::Task::new_file(local_path, remote_path, file_mode) 35 | }; 36 | 37 | let res = hosts.run_task(&task, parallel)?; 38 | println!("{}", res); 39 | 40 | Ok(()) 41 | } 42 | 43 | fn get_path(arg: Option<&str>) -> Result { 44 | if let Some(path) = arg { 45 | Ok(String::from(path)) 46 | } else { 47 | Err(Box::new(Error::MissingInput( 48 | "No input file provided".to_string(), 49 | ))) 50 | } 51 | } 52 | 53 | fn get_file_mode(arg: Option<&str>) -> Result { 54 | let mut mode = Mode::from(0o644); 55 | 56 | if let Some(mode_str) = arg { 57 | mode.set_str(mode_str)?; 58 | } 59 | 60 | let file_mode = i32::try_from(mode.mode())?; 61 | Ok(file_mode) 62 | } 63 | -------------------------------------------------------------------------------- /src/tasks/exec.rs: -------------------------------------------------------------------------------- 1 | //! Execute a command on a remote host 2 | //! 3 | //! Example usage: 4 | //! 5 | //! ```no_run 6 | //! use tricorder::prelude::*; 7 | //! use tricorder::tasks::exec; 8 | //! use serde_json::json; 9 | //! 10 | //! let inventory = Inventory::new() 11 | //! .add_host( 12 | //! Host::new(Host::id("localhost").unwrap(), "localhost:22".to_string()) 13 | //! .set_user("root".to_string()) 14 | //! .add_tag(Host::tag("local").unwrap()) 15 | //! .set_var("msg".to_string(), json!("hello")) 16 | //! .to_owned() 17 | //! ) 18 | //! .to_owned(); 19 | //! 20 | //! let task = exec::Task::new("echo \"{host.id} says {host.vars.msg}\"".to_string()); 21 | //! let result = inventory.hosts.run_task_seq(&task).unwrap(); 22 | //! ``` 23 | //! 24 | //! The result is a JSON document with the following structure: 25 | //! 26 | //! ```json 27 | //! [ 28 | //! { 29 | //! "host": "example-0", 30 | //! "success": true, 31 | //! "info": { 32 | //! "exit_code": 0, 33 | //! "stdout": "...", 34 | //! "stderr": "..." 35 | //! } 36 | //! }, 37 | //! { 38 | //! "host": "example-1", 39 | //! "success": false, 40 | //! "error": "..." 41 | //! } 42 | //! ] 43 | //! ``` 44 | 45 | use crate::prelude::*; 46 | 47 | use serde_json::json; 48 | use tinytemplate::{format_unescaped, TinyTemplate}; 49 | 50 | use std::io::prelude::*; 51 | 52 | /// Describe an `exec` task 53 | pub struct Task { 54 | /// Command template to execute on the remote host. 55 | /// 56 | /// Example: `"echo \"{host.id} says {host.vars.msg}\""` 57 | command_template: String, 58 | } 59 | 60 | impl Task { 61 | /// Create a new `exec` task 62 | pub fn new(command_template: String) -> Self { 63 | Self { command_template } 64 | } 65 | } 66 | 67 | impl GenericTask for Task { 68 | fn prepare(&self, host: Host) -> Result { 69 | let mut tt = TinyTemplate::new(); 70 | tt.set_default_formatter(&format_unescaped); 71 | tt.add_template("cmd", self.command_template.as_str())?; 72 | 73 | let ctx = json!({ "host": host }); 74 | let cmd = tt.render("cmd", &ctx)?; 75 | Ok(cmd) 76 | } 77 | 78 | fn apply(&self, host: Host, command: String) -> TaskResult { 79 | let sess = host.get_session()?; 80 | let mut channel = sess.channel_session()?; 81 | channel.exec(&command)?; 82 | 83 | let mut stdout = String::new(); 84 | channel.read_to_string(&mut stdout)?; 85 | let mut stderr = String::new(); 86 | channel.stderr().read_to_string(&mut stderr)?; 87 | 88 | channel.wait_close()?; 89 | 90 | let exit_code = channel.exit_status()?; 91 | 92 | Ok(json!({ 93 | "exit_code": exit_code, 94 | "stdout": stdout, 95 | "stderr": stderr, 96 | })) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/tasks/download.rs: -------------------------------------------------------------------------------- 1 | //! Download a file from a remote host 2 | //! 3 | //! Example usage: 4 | //! 5 | //! ```no_run 6 | //! use tricorder::prelude::*; 7 | //! use tricorder::tasks::download; 8 | //! use serde_json::json; 9 | //! 10 | //! let inventory = Inventory::new() 11 | //! .add_host( 12 | //! Host::new(Host::id("localhost").unwrap(), "localhost:22".to_string()) 13 | //! .set_user("root".to_string()) 14 | //! .add_tag(Host::tag("local").unwrap()) 15 | //! .set_var("msg".to_string(), json!("hello")) 16 | //! .to_owned() 17 | //! ) 18 | //! .to_owned(); 19 | //! 20 | //! let task = download::Task::new( 21 | //! "/path/to/remote/file.ext".to_string(), 22 | //! "file.ext".to_string(), 23 | //! ); 24 | //! let result = inventory.hosts.run_task_seq(&task).unwrap(); 25 | //! ``` 26 | //! 27 | //! The result is a JSON document with the following structure: 28 | //! 29 | //! ```json 30 | //! [ 31 | //! { 32 | //! "host": "example-0", 33 | //! "success": true, 34 | //! "info": { 35 | //! "file_path": "/example-0/file.ext", 36 | //! "file_size": 12345 37 | //! } 38 | //! }, 39 | //! { 40 | //! "host": "example-1", 41 | //! "success": false, 42 | //! "error": "..." 43 | //! } 44 | //! ] 45 | //! ``` 46 | 47 | use crate::prelude::*; 48 | 49 | use serde_json::json; 50 | 51 | use std::{env, fs, io, path::Path}; 52 | 53 | /// Describe a `download` task 54 | pub struct Task { 55 | /// Path to file on remote 56 | remote_path: String, 57 | /// Relative path on local machine to download the file to. 58 | /// 59 | /// The full path will be `{pwd}/{host.id}/{local_path}`. 60 | local_path: String, 61 | } 62 | 63 | impl Task { 64 | pub fn new(remote_path: String, local_path: String) -> Self { 65 | Self { 66 | remote_path, 67 | local_path, 68 | } 69 | } 70 | } 71 | 72 | impl GenericTask for Task { 73 | fn prepare(&self, host: Host) -> Result { 74 | let local_path = Path::new(&self.local_path); 75 | 76 | if local_path.is_absolute() { 77 | return Err(Box::new(Error::IsAbsolute( 78 | "Local path should be a relative path, not absolute".to_string(), 79 | ))); 80 | } 81 | 82 | let cwd = env::current_dir()?; 83 | let fullpath = cwd.join(host.id.to_string()).join(local_path); 84 | let fulldir = fullpath.parent().unwrap(); 85 | fs::create_dir_all(fulldir)?; 86 | 87 | Ok(String::from(fullpath.to_string_lossy())) 88 | } 89 | 90 | fn apply(&self, host: Host, local_path: String) -> TaskResult { 91 | let sess = host.get_session()?; 92 | let sftp = sess.sftp()?; 93 | 94 | let mut remote_file = sftp.open(Path::new(&self.remote_path))?; 95 | let mut local_file = fs::File::create(&local_path)?; 96 | let size = io::copy(&mut remote_file, &mut local_file)?; 97 | 98 | Ok(json!({ 99 | "file_path": local_path, 100 | "file_size": size, 101 | })) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/prelude/inventory/tag_expr.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::{Error, Result}; 2 | use bet::BeTree; 3 | use logos::Logos; 4 | 5 | #[derive(Logos, Debug, PartialEq)] 6 | enum Token { 7 | #[token("(")] 8 | OpenParen, 9 | 10 | #[token(")")] 11 | CloseParen, 12 | 13 | #[token("&")] 14 | AndOp, 15 | 16 | #[token("|")] 17 | OrOp, 18 | 19 | #[token("!")] 20 | NotOp, 21 | 22 | #[regex(r"[^!\&\|\t\n\r\f\(\) ]+", |lex| lex.slice().parse())] 23 | Tag(String), 24 | 25 | #[error] 26 | #[regex(r"[ \t\n\f]+", logos::skip)] 27 | Error, 28 | } 29 | 30 | #[derive(Debug, Clone, Copy, PartialEq)] 31 | enum BoolOp { 32 | And, 33 | Or, 34 | Not, 35 | } 36 | 37 | fn parse(input: &str) -> Result> { 38 | let mut expr = BeTree::new(); 39 | let lex = Token::lexer(input); 40 | 41 | for tok in lex { 42 | match tok { 43 | Token::OpenParen => expr.open_par(), 44 | Token::CloseParen => expr.close_par(), 45 | Token::AndOp => expr.push_operator(BoolOp::And), 46 | Token::OrOp => expr.push_operator(BoolOp::Or), 47 | Token::NotOp => expr.push_operator(BoolOp::Not), 48 | Token::Tag(tag) => expr.push_atom(tag), 49 | _ => { 50 | return Err(Box::new(Error::InvalidToken(format!( 51 | "Invalid token in tag expression: {:?}", 52 | tok 53 | )))); 54 | } 55 | } 56 | } 57 | 58 | Ok(expr) 59 | } 60 | 61 | /// Evaluate a boolean tag expression against a list of tags. 62 | pub fn eval_tag_expr(expr: &str, tags: Vec) -> Result { 63 | let expr = parse(expr)?; 64 | let res = expr.eval_faillible( 65 | // evaluate leafs 66 | |tag| Ok(tags.contains(tag)), 67 | // evaluate operators 68 | |op, a, b| match (op, b) { 69 | (BoolOp::And, Some(b)) => Ok(a & b), 70 | (BoolOp::Or, Some(b)) => Ok(a | b), 71 | (BoolOp::Not, None) => Ok(!a), 72 | _ => Err("unexpected operation"), 73 | }, 74 | // short-circuit 75 | |op, a| match (op, a) { 76 | (BoolOp::And, false) => true, 77 | (BoolOp::Or, true) => true, 78 | _ => false, 79 | }, 80 | )?; 81 | 82 | match res { 83 | Some(val) => Ok(val), 84 | None => unreachable!("No boolean were returned"), 85 | } 86 | } 87 | 88 | #[cfg(test)] 89 | mod tests { 90 | use super::*; 91 | 92 | #[test] 93 | fn eval_tag_expr_should_return_appropriate_values() { 94 | let tags = vec!["foo".to_string(), "bar".to_string(), "baz".to_string()]; 95 | 96 | assert!(eval_tag_expr("foo", tags.clone()).unwrap()); 97 | assert!(eval_tag_expr("foo | biz", tags.clone()).unwrap()); 98 | assert!(!eval_tag_expr("foo & biz", tags.clone()).unwrap()); 99 | assert!(eval_tag_expr("foo & (bar | biz)", tags.clone()).unwrap()); 100 | assert!(!eval_tag_expr("foo & !(bar | biz)", tags.clone()).unwrap()); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/prelude/inventory/host_entry.rs: -------------------------------------------------------------------------------- 1 | use super::{host_id::HostId, host_tag::HostTag}; 2 | use crate::prelude::Result; 3 | 4 | use serde_derive::{Deserialize, Serialize}; 5 | use serde_json::Value; 6 | 7 | use ssh2::Session; 8 | use std::{collections::HashMap, net::TcpStream}; 9 | 10 | /// Abstraction of a host found in the inventory 11 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] 12 | pub struct Host { 13 | /// Host identifier 14 | pub id: HostId, 15 | /// SSH host address in the form of `hostname:port` 16 | pub address: String, 17 | /// SSH user to authenticate with (defaults to `root`) 18 | #[serde(default = "default_user")] 19 | pub user: String, 20 | /// Tags used to apply commands on a subset of hosts from the inventory (defaults to `[]`) 21 | #[serde(default = "default_tags")] 22 | pub tags: Vec, 23 | /// Variables specific to this host, used by templates (defaults to `{}`) 24 | #[serde(default = "default_vars")] 25 | pub vars: HashMap, 26 | } 27 | 28 | impl Host { 29 | /// Shortcut to `HostId::new()` 30 | pub fn id(src: &str) -> Result { 31 | HostId::new(src) 32 | } 33 | 34 | /// Shortcut to `HostTag::new()` 35 | pub fn tag(src: &str) -> Result { 36 | HostTag::new(src) 37 | } 38 | 39 | /// Create a new host 40 | pub fn new(id: HostId, address: String) -> Self { 41 | Self { 42 | id, 43 | address, 44 | user: default_user(), 45 | tags: default_tags(), 46 | vars: default_vars(), 47 | } 48 | } 49 | 50 | /// Override this host's user 51 | pub fn set_user(&mut self, user: String) -> &mut Self { 52 | self.user = user; 53 | self 54 | } 55 | 56 | /// Add tag to this host 57 | pub fn add_tag(&mut self, tag: HostTag) -> &mut Self { 58 | self.tags.push(tag); 59 | self 60 | } 61 | 62 | /// Remove tag from this host 63 | pub fn remove_tag(&mut self, tag: HostTag) -> &mut Self { 64 | self.tags.retain(|current_tag| *current_tag != tag); 65 | self 66 | } 67 | 68 | /// Set host variable 69 | pub fn set_var(&mut self, key: String, val: Value) -> &mut Self { 70 | self.vars.insert(key, val); 71 | self 72 | } 73 | 74 | /// Remove host variable 75 | pub fn remove_var(&mut self, key: String) -> &mut Self { 76 | self.vars.remove(&key); 77 | self 78 | } 79 | 80 | /// Open SSH session to host and authenticate using `ssh-agent` 81 | pub fn get_session(&self) -> Result { 82 | let sock = TcpStream::connect(self.address.clone())?; 83 | let mut sess = Session::new()?; 84 | 85 | sess.set_tcp_stream(sock); 86 | sess.handshake()?; 87 | sess.userauth_agent(&self.user)?; 88 | 89 | Ok(sess) 90 | } 91 | } 92 | 93 | fn default_user() -> String { 94 | String::from("root") 95 | } 96 | 97 | fn default_tags() -> Vec { 98 | vec![] 99 | } 100 | 101 | fn default_vars() -> HashMap { 102 | HashMap::new() 103 | } 104 | -------------------------------------------------------------------------------- /www/static/img/server.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 7 | 9 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use clap::{arg, command, Command}; 2 | use tricorder::{cli, prelude::Result}; 3 | 4 | fn main() -> Result<()> { 5 | let matches = command!() 6 | .propagate_version(true) 7 | .subcommand_required(true) 8 | .allow_external_subcommands(true) 9 | .allow_invalid_utf8_for_external_subcommands(true) 10 | .arg( 11 | arg!(inventory: -i --inventory "Path to TOML inventory file or program producing JSON inventory") 12 | .required(false) 13 | ) 14 | .arg( 15 | arg!(host_id: -H --host_id "Identifier of the host to connect to") 16 | .required(false) 17 | ) 18 | .arg( 19 | arg!(host_tags: -t --host_tags "Comma-separated list of tags identifying the hosts to connect to") 20 | .required(false) 21 | ) 22 | .subcommand( 23 | Command::new("info") 24 | .about("Gather information about hosts in the inventory") 25 | .arg( 26 | arg!(parallel: -p --parallel "If set, the task will be executed concurrently") 27 | ) 28 | ) 29 | .subcommand( 30 | Command::new("do") 31 | .about("Execute a command on multiple hosts") 32 | .arg( 33 | arg!(parallel: -p --parallel "If set, the task will be executed concurrently") 34 | ) 35 | .arg( 36 | arg!(cmd: [COMMAND] "Command to run on each host") 37 | .last(true) 38 | .required(true) 39 | ) 40 | ) 41 | .subcommand( 42 | Command::new("upload") 43 | .about("Upload a file to multiple hosts") 44 | .arg( 45 | arg!(parallel: -p --parallel "If set, the task will be executed concurrently") 46 | ) 47 | .arg( 48 | arg!(template: -T --template "If set, the file is a template with the current host as context data") 49 | ) 50 | .arg( 51 | arg!(local_path: [LOCAL_PATH] "Path on local host to the file to be uploaded") 52 | .required(true) 53 | ) 54 | .arg( 55 | arg!(remote_path: [REMOTE_PATH] "Path on remote host to upload the file") 56 | .required(true) 57 | ) 58 | .arg( 59 | arg!(file_mode: [MODE] "UNIX file mode to set on the uploaded file (default: 0644)") 60 | ) 61 | ) 62 | .subcommand( 63 | Command::new("download") 64 | .about("Download a file from multiple hosts") 65 | .arg( 66 | arg!(parallel: -p --parallel "If set, the task will be executed concurrently") 67 | ) 68 | .arg( 69 | arg!(remote_path: [REMOTE_PATH] "Path to the file on the remote host") 70 | .required(true) 71 | ) 72 | .arg( 73 | arg!(local_path: [LOCAL_PATH] "Path to the destination on local machine") 74 | .required(true) 75 | ) 76 | ) 77 | .subcommand( 78 | Command::new("module") 79 | .about("upload and execute Module with data") 80 | .arg( 81 | arg!(parallel: -p --parallel "If set, the task will be executed concurrently") 82 | ) 83 | .arg( 84 | arg!(data_file_path: -d --data [DATA_PATH] "sets the Data-path") 85 | .required(false) 86 | ) 87 | .arg( 88 | arg!(module: -m --module [MODULE_PATH] "Module that should run") 89 | .required(true) 90 | ) 91 | ) 92 | .get_matches(); 93 | 94 | cli::run(matches) 95 | } 96 | -------------------------------------------------------------------------------- /www/static/img/server-success.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 10 | 12 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | -------------------------------------------------------------------------------- /www/content/tutorials/cli.md: -------------------------------------------------------------------------------- 1 | +++ 2 | title = "howto cli" 3 | documentName = "Command Line Interface" 4 | description = "Flags and commands descriptions" 5 | menuHref = "/tutorials/" 6 | weight = 4 7 | +++ 8 | 9 | {{< wip >}} 10 | 11 | # tricorder 12 | This is the executable. Here, you can control which hosts are affected by the subcommand. 13 | You can select host from an inventory file by using the flag `--inventory`. 14 | 15 | ## Flags: 16 | 17 | | Flag | Description | 18 | | - | - | 19 | | -i --inventory \ | Path to TOML inventory file or program producing JSON inventory| 20 | | -H --host_id \| Identifier of the host to connect to | 21 | | -t --host_tags \ | Comma-separated list of tags identifying the hosts to connect to | 22 | 23 | 24 | # info (Subcommand) 25 | Gather information on hosts 26 | 27 | ## Examples: 28 | ```shell 29 | $ tricorder -i inventory info 30 | ``` 31 | 32 | ## Flags: 33 | 34 | | Flag | Description | 35 | | - | - | 36 | | -p --parallel | If set, the task will be executed concurrently | 37 | 38 | # do (Subcommand) 39 | Execute a command on multiple hosts. 40 | 41 | ## Examples: 42 | ```shell 43 | $ tricorder -i inventory do -- echo "run on all hosts" 44 | $ tricorder -i inventory -H foo do -- echo "run only on host 'foo'" 45 | $ tricorder -i inventory -t myapp do -- echo "run only on hosts tagged with 'myapp'" 46 | ``` 47 | Commands can be templated with data from the host as defined in the 48 | inventory: 49 | ```shell 50 | $ tricorder -i inventory do -- echo "{host.id} says {host.vars.msg}" 51 | ``` 52 | 53 | ## Flags 54 | 55 | | Flags | Description | 56 | | - | - | 57 | | -p --parallel | If set, the task will be executed concurrently | 58 | 59 | # upload (Subcommand) 60 | Upload a file to multiple remote hosts. 61 | 62 | ## Examples: 63 | ```shell 64 | $ tricorder -i inventory upload LOCAL_PATH REMOTE_PATH [FILE_MODE] 65 | $ tricorder -i inventory upload -T LOCAL_PATH REMOTE_PATH [FILE_MODE] 66 | ``` 67 | 68 | ## Flags: 69 | 70 | | Flags | Description | 71 | | - | - | 72 | | -p --parallel | If set, the task will be executed concurrently | 73 | | -T --template | If set, the task will be executed concurrently | 74 | | [LOCAL_PATH] | Path on local host to the file to be uploaded | 75 | | [REMOTE_PATH] | Path on remote host to upload the file | 76 | | [MODE] (default: 0644) | UNIX file mode to set on the uploaded file | 77 | 78 | # download (Subcommand) 79 | Download a file from multiple remote hosts. 80 | The files will be downloaded to: `{pwd}/{host.id}/{local_path}` 81 | 82 | ## Examples: 83 | ```shell 84 | $ tricorder -i inventory download REMOTE_PATH LOCAL_PATH 85 | ``` 86 | 87 | ## Flags: 88 | 89 | | Flags | Description | 90 | | - | - | 91 | | -p --parallel | If set, the task will be executed concurrently | 92 | | [LOCAL_PATH] | Path on local host to the file to be uploaded | 93 | | [REMOTE_PATH] | Path on remote host to upload the file | 94 | 95 | # module (Subcommand) 96 | Upload a module to the remote host and call it with data. 97 | Data can be a specified JSON data file. 98 | The data in the file will be overwritten by variables in `host.vars.module_.` 99 | 100 | A module is an executable, that gets uploaded to `~/.local/tricorder/`. The Module reads the supplied data from stdin. 101 | 102 | You could also create a Module, that calls external sources like APIs or a database to get its data. 103 | 104 | ## Examples: 105 | ```shell 106 | $ tricorder -i inventory module --data --module 107 | $ tricorder -i inventory module --module 108 | ``` 109 | 110 | ## Flags: 111 | | Flags | Description | 112 | | - | - | 113 | | -p --parallel | If set, the task will be executed concurrently | 114 | | -d --data [DATA_PATH] | Path to the file containing the data in JSON-format | 115 | | -m --module [MODULE_PATH] | Path to the executable that should be run | 116 | 117 | -------------------------------------------------------------------------------- /www/static/img/server-error.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 10 | 12 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | -------------------------------------------------------------------------------- /www/static/img/server-pending.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 10 | 12 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 54 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | -------------------------------------------------------------------------------- /www/content/_index.md: -------------------------------------------------------------------------------- 1 | +++ 2 | title = "tricorder" 3 | +++ 4 | 5 | # Automation the KISS way 6 | 7 | {{< automation-kiss >}} 8 | 9 | # No YAML involved 10 | 11 | ## First an inventory 12 | 13 | ```toml 14 | [[hosts]] 15 | id = "backend" 16 | address = "10.0.1.10:22" 17 | user = "root" 18 | tags = ["server", "backend", "myapp"] 19 | vars = { msg = "hi" } 20 | ``` 21 | 22 | ## Then a command 23 | 24 | ```console 25 | $ tricorder -i /path/to/inventory do -- echo "{host.id} says {host.vars.msg}" 26 | ``` 27 | 28 | ## Finally, a JSON output 29 | 30 | ```json 31 | [ 32 | { 33 | "host": "backend", 34 | "success": true, 35 | "info": { 36 | "exit_code": 0, 37 | "stdout": "backend says hi\n", 38 | "stderr": "" 39 | } 40 | } 41 | ] 42 | ``` 43 | 44 | # Rust API 45 | 46 | ## Add dependency 47 | 48 | ```toml 49 | tricorder = "0.9" 50 | ``` 51 | 52 | ## Write your recipe 53 | 54 | ### First, import symbols 55 | 56 | ```rust 57 | use tricorder::prelude::*; 58 | use tricorder::tasks::exec; 59 | use serde_json::json; 60 | ``` 61 | 62 | ### Then, build your inventory 63 | 64 | ```rust 65 | let inventory = Inventory::new() 66 | .add_host( 67 | Host::new(Host::id("localhost").unwrap(), "localhost:22".to_string()) 68 | .set_user("root".to_string()) 69 | .add_tag(Host::tag("local").unwrap()) 70 | .set_var("msg".to_string(), json!("hello")) 71 | .to_owned() 72 | ) 73 | .to_owned(); 74 | ``` 75 | 76 | ### Finally, run your tasks 77 | 78 | ```rust 79 | let task = exec::Task::new("echo \"{host.id} says {host.vars.msg}\"".to_string()); 80 | ``` 81 | 82 | **Sequentially:** 83 | 84 | ```rust 85 | let result = inventory.hosts.run_task_seq(&task).unwrap(); 86 | ``` 87 | 88 | **Or concurrently:** 89 | 90 | ```rust 91 | let result = inventory.hosts.run_task_parallel(&task).unwrap(); 92 | ``` 93 | 94 | The result is a `serde_json::Value`: 95 | 96 | ```rust 97 | println!("{}", result); 98 | ``` 99 | 100 | ## Build and run 101 | 102 | ```console 103 | $ cargo run 104 | ``` 105 | 106 | # Backstory 107 | 108 | {{< figure 109 | src="https://upload.wikimedia.org/wikipedia/commons/thumb/c/c5/12.5.12GeorgeTakeiByLuigiNovi10.jpg/440px-12.5.12GeorgeTakeiByLuigiNovi10.jpg" 110 | title="Actor George Takei autographs a tricorder" 111 | >}} 112 | 113 | [Ansible](https://ansible.com) is a great tool for automation. But it suffers 114 | from the same problem of many such tools: a big pile of custom YAML DSL. 115 | 116 | YAML is used to provide a declarative syntax of your automated workflow. This is 117 | nice for simple use cases, but automation can become rather complex very 118 | quickly. 119 | 120 | Once those tools start implementing: 121 | 122 | - control flow structures (conditions, loops) 123 | - variable assignations 124 | - modules 125 | - package management 126 | - ... 127 | 128 | Your YAML files become a programming language with terrible developer 129 | experience. 130 | 131 | **tricorder** aims to fix this. It gives you a single tool to perform tasks on 132 | multiple remotes. You then use your common UNIX tools like `bash`, `jq`, `curl`, 133 | etc... to compose those tasks together. 134 | 135 | The name comes from [Star Trek's Tricorder](https://en.wikipedia.org/wiki/Tricorder), 136 | a multifunction hand-held device to perform sensor environment scans, data 137 | recording, and data analysis. Pretty much anything required by the plot. 138 | 139 | The main goal of **tricorder** is to provide the basic tools to perform tasks on 140 | remote hosts and get out of your way. Allowing you to integrate it with any 141 | scripting language or programming language of your choice, instead of forcing 142 | you to develop in a sub-par custom YAML DSL. 143 | 144 | > Spock stared hard at his tricorder, as if by sheer will he might force it to 145 | > tell him the answer to his questions. 146 | 147 | # Reading resources 148 | 149 | {{< button-group >}} 150 | {{< button label="Source Code" href="https://github.com/linkdd/tricorder" >}} 151 | {{< button label="API reference" href="https://docs.rs/tricorder/latest/tricorder/" >}} 152 | {{< button label="Tutorials" href="/tutorials/" >}} 153 | {{< /button-group >}} 154 | -------------------------------------------------------------------------------- /src/prelude/inventory/host_registry.rs: -------------------------------------------------------------------------------- 1 | use super::{host_entry::Host, host_id::HostId, tag_expr::eval_tag_expr}; 2 | use crate::prelude::{Error, Result}; 3 | 4 | use serde_derive::{Deserialize, Serialize}; 5 | 6 | use is_executable::IsExecutable; 7 | use std::{fs, path::Path, process::Command}; 8 | 9 | /// Abstraction of inventory file 10 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] 11 | pub struct Inventory { 12 | /// List of host provided by inventory (defaults to `[]`) 13 | #[serde(default = "default_hostlist")] 14 | pub hosts: Vec, 15 | } 16 | 17 | impl Inventory { 18 | /// Create a new empty inventory 19 | pub fn new() -> Self { 20 | Inventory { hosts: vec![] } 21 | } 22 | 23 | /// Deserialize a TOML document into an inventory. 24 | /// 25 | /// Example: 26 | /// 27 | /// ```toml 28 | /// [[hosts]] 29 | /// 30 | /// id = "localhost" 31 | /// address = "localhost:22" 32 | /// user = "root" 33 | /// tags = ["local"] 34 | /// vars = { foo = "bar" } 35 | /// ``` 36 | pub fn from_toml(content: &str) -> Result { 37 | let inventory: Self = toml::from_str(content)?; 38 | Ok(inventory) 39 | } 40 | 41 | /// Deserialize a JSON document into an inventory. 42 | /// 43 | /// Example: 44 | /// 45 | /// ```json 46 | /// {"hosts": [ 47 | /// { 48 | /// "id": "localhost", 49 | /// "address": "localhost:22", 50 | /// "user": "root", 51 | /// "tags": ["local"], 52 | /// "vars": {"foo": "bar"} 53 | /// } 54 | /// ]} 55 | /// ``` 56 | pub fn from_json(content: &str) -> Result { 57 | let inventory: Self = serde_json::from_str(content)?; 58 | Ok(inventory) 59 | } 60 | 61 | /// Parse inventory from a file or executable 62 | pub fn from_file(path: &str) -> Result { 63 | let inventory_path = Path::new(path); 64 | 65 | if inventory_path.exists() { 66 | if inventory_path.is_executable() { 67 | let result = Command::new(path).output()?; 68 | 69 | if !result.status.success() { 70 | Err(Box::new(Error::CommandExecutionFailed(format!( 71 | "Failed to execute inventory {}: {}", 72 | path, result.status 73 | )))) 74 | } else { 75 | let content = String::from_utf8(result.stdout)?; 76 | Ok(Inventory::from_json(&content)?) 77 | } 78 | } else { 79 | let content = fs::read_to_string(path)?; 80 | Ok(Inventory::from_toml(&content)?) 81 | } 82 | } else { 83 | Err(Box::new(Error::FileNotFound(format!( 84 | "Inventory '{}' does not exist", 85 | path 86 | )))) 87 | } 88 | } 89 | 90 | /// Add host to the inventory. 91 | pub fn add_host(&mut self, host: Host) -> &mut Self { 92 | self.hosts.push(host); 93 | self 94 | } 95 | 96 | /// Remove host from the inventory or do nothing if the host's ID was not 97 | /// found. 98 | pub fn remove_host(&mut self, host_id: HostId) -> &mut Self { 99 | self.hosts.retain(|host| host.id != host_id); 100 | self 101 | } 102 | 103 | /// Get `Some(host)` by its ID, or `None` if it does not exist. 104 | pub fn get_host_by_id(&self, id: HostId) -> Option { 105 | self.hosts 106 | .iter() 107 | .find(|host| host.id == id) 108 | .map(|host| host.clone()) 109 | } 110 | 111 | /// Get a list of host matching the tag expression. 112 | pub fn get_hosts_by_tags(&self, tag_expr: String) -> Result> { 113 | let mut hosts = vec![]; 114 | 115 | for host in self.hosts.iter() { 116 | let tags = host 117 | .tags 118 | .iter() 119 | .map(|tag| tag.clone().to_string()) 120 | .collect(); 121 | if eval_tag_expr(&tag_expr, tags)? { 122 | hosts.push(host.clone()); 123 | } 124 | } 125 | 126 | Ok(hosts) 127 | } 128 | } 129 | 130 | fn default_hostlist() -> Vec { 131 | vec![] 132 | } 133 | -------------------------------------------------------------------------------- /src/cli/mod.rs: -------------------------------------------------------------------------------- 1 | //! Command Line Interface to **tricorder** capabilities. 2 | //! 3 | //! **tricorder** is distributed as a static command line tool. It is agent-less 4 | //! and connects to remote hosts via SSH (authentication is done via `ssh-agent` 5 | //! on the local host). 6 | //! 7 | //! It requires an [[Inventory]] and a selection of hosts to perform a task: 8 | //! 9 | //! | Global flag | Description | 10 | //! | --- | --- | 11 | //! | `-i, --inventory ` | Path to a TOML inventory file or an executable producing a JSON inventory | 12 | //! | `-H, --host_id ` | Specific host on which to perform the task | 13 | //! | `-t, --host_tags ` | Boolean tag expression to select the hosts (example: `foo & !(bar | baz)`) | 14 | //! 15 | //! > **NB:** 16 | //! > - If `-H` is provided, `-t` will be ignored. 17 | //! > - If `-i` is omitted, we assume an inventory with only `root@localhost:22` 18 | //! > - The host needs only one tag from the list to match in order to be selected (boolean OR) 19 | 20 | pub mod download; 21 | pub mod exec; 22 | pub mod external; 23 | pub mod info; 24 | pub mod module; 25 | pub mod upload; 26 | 27 | use crate::prelude::{Host, HostId, Inventory, Result}; 28 | 29 | use clap::ArgMatches; 30 | 31 | pub fn run(matches: ArgMatches) -> Result<()> { 32 | let inventory_arg = matches.value_of("inventory"); 33 | let host_id_arg = matches.value_of("host_id"); 34 | let host_tags_arg = matches.value_of("host_tags"); 35 | 36 | match matches.subcommand() { 37 | Some(("info", sub_matches)) => { 38 | let inventory = get_inventory(inventory_arg); 39 | let hosts = get_host_list(inventory, host_id_arg, host_tags_arg)?; 40 | info::run(hosts, sub_matches) 41 | } 42 | Some(("do", sub_matches)) => { 43 | let inventory = get_inventory(inventory_arg); 44 | let hosts = get_host_list(inventory, host_id_arg, host_tags_arg)?; 45 | exec::run(hosts, sub_matches) 46 | } 47 | Some(("upload", sub_matches)) => { 48 | let inventory = get_inventory(inventory_arg); 49 | let hosts = get_host_list(inventory, host_id_arg, host_tags_arg)?; 50 | upload::run(hosts, sub_matches) 51 | } 52 | Some(("download", sub_matches)) => { 53 | let inventory = get_inventory(inventory_arg); 54 | let hosts = get_host_list(inventory, host_id_arg, host_tags_arg)?; 55 | download::run(hosts, sub_matches) 56 | } 57 | Some(("module", sub_matches)) => { 58 | let inventory = get_inventory(inventory_arg); 59 | let hosts = get_host_list(inventory, host_id_arg, host_tags_arg)?; 60 | module::run(hosts, sub_matches) 61 | } 62 | Some((cmd, sub_matches)) => { 63 | external::run(cmd, inventory_arg, host_id_arg, host_tags_arg, sub_matches) 64 | } 65 | _ => { 66 | unreachable!("Exhausted list of subcommands and subcommand_required prevents `None`") 67 | } 68 | } 69 | } 70 | 71 | fn get_inventory(arg: Option<&str>) -> Inventory { 72 | match arg { 73 | Some(path) => match Inventory::from_file(path) { 74 | Ok(inventory) => inventory, 75 | Err(err) => { 76 | eprintln!("{}, ignoring...", err); 77 | Inventory::new() 78 | } 79 | }, 80 | None => { 81 | eprintln!("No inventory provided, using empty inventory..."); 82 | Inventory::new() 83 | } 84 | } 85 | } 86 | 87 | fn get_host_list( 88 | inventory: Inventory, 89 | host_id_arg: Option<&str>, 90 | host_tags_arg: Option<&str>, 91 | ) -> Result> { 92 | if let Some(host_id) = host_id_arg { 93 | let hostlist = match inventory.get_host_by_id(HostId::new(host_id)?) { 94 | Some(host) => { 95 | vec![host] 96 | } 97 | None => { 98 | eprintln!("Host '{}' not found in inventory, ignoring...", host_id); 99 | vec![] 100 | } 101 | }; 102 | return Ok(hostlist); 103 | } 104 | 105 | if let Some(host_tags) = host_tags_arg { 106 | return Ok(inventory.get_hosts_by_tags(host_tags.to_string())?); 107 | } 108 | 109 | return Ok(inventory.hosts.clone()); 110 | } 111 | -------------------------------------------------------------------------------- /www/layouts/shortcodes/automation-kiss.html: -------------------------------------------------------------------------------- 1 | 13 | 14 | 22 | 23 | 123 | -------------------------------------------------------------------------------- /tests/all/tasks/exec_test.rs: -------------------------------------------------------------------------------- 1 | use serde_json::json; 2 | use tricorder::prelude::*; 3 | use tricorder::tasks::exec; 4 | 5 | use super::common::within_context; 6 | 7 | #[test] 8 | fn it_should_return_output() { 9 | within_context(|inventory| { 10 | let hosts = inventory 11 | .get_hosts_by_tags("test-success".to_string()) 12 | .unwrap(); 13 | let echo_task = exec::Task::new("echo '{host.id} says {host.vars.msg}'".to_string()); 14 | let result = hosts.run_task_seq(&echo_task).unwrap(); 15 | 16 | assert_eq!( 17 | result, 18 | json!([ 19 | { 20 | "host": "localhost", 21 | "success": true, 22 | "info": { 23 | "exit_code": 0 as i32, 24 | "stdout": "localhost says hi\n", 25 | "stderr": "" 26 | } 27 | } 28 | ]) 29 | ); 30 | }); 31 | } 32 | 33 | #[test] 34 | fn it_should_return_exit_code() { 35 | within_context(|inventory| { 36 | let hosts = inventory 37 | .get_hosts_by_tags("test-success".to_string()) 38 | .unwrap(); 39 | let fail_task = 40 | exec::Task::new("echo '{host.id} says {host.vars.msg}' >&2; exit 42".to_string()); 41 | let result = hosts.run_task_seq(&fail_task).unwrap(); 42 | 43 | assert_eq!( 44 | result, 45 | json!([ 46 | { 47 | "host": "localhost", 48 | "success": true, 49 | "info": { 50 | "exit_code": 42 as i32, 51 | "stdout": "", 52 | "stderr": "localhost says hi\n" 53 | } 54 | } 55 | ]) 56 | ); 57 | }); 58 | } 59 | 60 | #[test] 61 | fn it_should_return_an_error() { 62 | within_context(|inventory| { 63 | let hosts = inventory 64 | .get_hosts_by_tags("test-failure".to_string()) 65 | .unwrap(); 66 | let echo_task = exec::Task::new("echo '{host.id}' says {host.vars.msg}".to_string()); 67 | let result = hosts.run_task_seq(&echo_task).unwrap(); 68 | 69 | assert_eq!( 70 | result, 71 | json!([ 72 | { 73 | "host": "localhost-fail", 74 | "success": false, 75 | "error": "failed to lookup address information: Name or service not known", 76 | } 77 | ]) 78 | ); 79 | }); 80 | } 81 | 82 | #[test] 83 | fn it_should_run_on_all_hosts_sequentially() { 84 | within_context(|inventory| { 85 | let echo_task = exec::Task::new("echo '{host.id}' says {host.vars.msg}".to_string()); 86 | let result = inventory.hosts.run_task_seq(&echo_task).unwrap(); 87 | 88 | assert_eq!( 89 | result, 90 | json!([ 91 | { 92 | "host": "localhost", 93 | "success": true, 94 | "info": { 95 | "exit_code": 0 as i32, 96 | "stdout": "localhost says hi\n", 97 | "stderr": "" 98 | } 99 | }, 100 | { 101 | "host": "localhost-fail", 102 | "success": false, 103 | "error": "failed to lookup address information: Name or service not known", 104 | } 105 | ]) 106 | ); 107 | }); 108 | } 109 | 110 | #[test] 111 | fn it_should_run_on_all_hosts_concurrently() { 112 | within_context(|inventory| { 113 | let echo_task = exec::Task::new("echo '{host.id}' says {host.vars.msg}".to_string()); 114 | let result = inventory.hosts.run_task_parallel(&echo_task).unwrap(); 115 | 116 | assert_eq!( 117 | result, 118 | json!([ 119 | { 120 | "host": "localhost", 121 | "success": true, 122 | "info": { 123 | "exit_code": 0 as i32, 124 | "stdout": "localhost says hi\n", 125 | "stderr": "" 126 | } 127 | }, 128 | { 129 | "host": "localhost-fail", 130 | "success": false, 131 | "error": "failed to lookup address information: Name or service not known", 132 | } 133 | ]) 134 | ); 135 | }); 136 | } 137 | 138 | #[test] 139 | fn it_should_fail_for_invalid_command_templates() { 140 | within_context(|inventory| { 141 | let echo_task = exec::Task::new("echo '{host.id' says {host.vars.msg}".to_string()); 142 | let result = inventory.hosts.run_task_parallel(&echo_task); 143 | 144 | assert!(result.is_err()); 145 | }); 146 | } 147 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tricorder 2 | 3 | Automation the [KISS](https://en.wikipedia.org/wiki/KISS_principle) way. 4 | 5 | [![Crates.io](https://img.shields.io/crates/v/tricorder?style=flat-square)](https://crates.io/crates/tricorder) 6 | [![Crates.io](https://img.shields.io/crates/l/tricorder?style=flat-square)](https://crates.io/crates/tricorder) 7 | [![Crates.io](https://img.shields.io/crates/d/tricorder?style=flat-square)](https://crates.io/crates/tricorder) 8 | [![docs.rs](https://img.shields.io/docsrs/tricorder?style=flat-square)](https://docs.rs/tricorder) 9 | [![website](https://img.shields.io/github/actions/workflow/status/linkdd/tricorder/deploy-site.yml?label=website&style=flat-square)](https://linkdd.github.io/tricorder/) 10 | [![Gitter](https://img.shields.io/gitter/room/linkdd/tricorder?style=flat-square)](https://matrix.to/#/#tricorder:gitter.im) 11 | 12 | ## Introduction 13 | 14 | [Ansible](https://ansible.com) is a great tool for automation. But it suffers 15 | from the same problem of many such tools: a big pile of custom YAML DSL. 16 | 17 | YAML is used to provide a declarative syntax of your automated workflow. This is 18 | nice for simple use cases, but automation can become rather complex very 19 | quickly. 20 | 21 | But once those tools start implementing: 22 | 23 | - control flow structures (conditions, loops) 24 | - variable assignations 25 | - modules 26 | - package management 27 | - ... 28 | 29 | Your YAML files become a programming language with terrible developer 30 | experience. 31 | 32 | **tricorder** aims to fix this. It gives you a single tool to perform tasks on 33 | multiple remotes. You then use your common UNIX tools like `bash`, `jq`, `curl`, 34 | etc... to compose those tasks together. 35 | 36 | ## Usage 37 | 38 | Just like *Ansible*, **tricorder** uses an inventory file, listing the hosts 39 | to connect to: 40 | 41 | ```toml 42 | [[hosts]] 43 | 44 | id = "backend" 45 | tags = ["server", "backend", "myapp"] 46 | address = "10.0.1.10:22" 47 | user = "admin" 48 | 49 | [[hosts]] 50 | 51 | id = "frontend" 52 | tags = ["server", "frontend", "myapp"] 53 | address = "10.0.1.20:22" 54 | user = "admin" 55 | ``` 56 | 57 | > **NB:** The inventory is either a TOML file or an executable producing a JSON 58 | > output. This way you can create dynamic inventories by querying a remote 59 | > service or database. 60 | 61 | Then, run one of the following commands: 62 | 63 | ``` 64 | $ tricorder -i /path/to/inventory do -- echo "run on all hosts" 65 | $ tricorder -i /path/to/inventory -H backend do -- echo "run on specific host" 66 | $ tricorder -i /path/to/inventory -t "server & myapp" do -- echo "run on all hosts matching tags" 67 | ``` 68 | 69 | Or to run concurrently instead of sequencially: 70 | 71 | ``` 72 | $ tricorder -i /path/to/inventory do -p -- echo "run on all hosts" 73 | $ tricorder -i /path/to/inventory -H backend do -p -- echo "run on specific host" 74 | $ tricorder -i /path/to/inventory -t "server & myapp" do -p -- echo "run on all hosts matching tags" 75 | ``` 76 | 77 | > **NB:** Authentication is done via `ssh-agent` only. 78 | 79 | Every logging messages is written on `stderr`, the command result for each host 80 | is written as a JSON document on `stdout`: 81 | 82 | ```json 83 | [ 84 | { 85 | "host": "backend", 86 | "success": false, 87 | "error": "..." 88 | }, 89 | { 90 | "host": "frontend", 91 | "success": true, 92 | "info": { 93 | "exit_code": 0, 94 | "stdout": "...", 95 | "stderr": "...", 96 | } 97 | } 98 | ] 99 | ``` 100 | 101 | This way, you can compose this tool with `jq` to extract the relevant informations 102 | in your scripts. 103 | 104 | ## Usage with the Rust API 105 | 106 | **tricorder** is also available as a Rust crate to include it directly in your 107 | software: 108 | 109 | ```rust 110 | use tricorder::prelude::*; 111 | use tricorder::tasks::exec; 112 | use serde_json::json; 113 | 114 | let inventory = Inventory::new() 115 | .add_host( 116 | Host::new(Host::id("localhost").unwrap(), "localhost:22".to_string()) 117 | .set_user("root".to_string()) 118 | .add_tag(Host::tag("local").unwrap()) 119 | .set_var("msg".to_string(), json!("hello")) 120 | .to_owned() 121 | ) 122 | .to_owned(); 123 | 124 | let task = exec::Task::new("echo \"{host.id} says {host.vars.msg}\"".to_string()); 125 | 126 | // Run the task sequentially: 127 | let result = inventory.hosts.run_task_seq(&task).unwrap(); 128 | // Run the task concurrently: 129 | let result = inventory.hosts.run_task_parallel(&task).unwrap(); 130 | 131 | println!("{}", result); 132 | ``` 133 | 134 | ## Documentation 135 | 136 | For more informations, consult the [documentation](https://docs.rs/tricorder). 137 | 138 | ## Roadmap 139 | 140 | Checkout the [Bug Tracker](https://github.com/linkdd/tricorder/milestones). 141 | 142 | ## License 143 | 144 | This software is released under the terms of the [MIT License](./LICENSE.txt). 145 | -------------------------------------------------------------------------------- /src/tasks/upload.rs: -------------------------------------------------------------------------------- 1 | //! Upload a file on a remote host 2 | //! 3 | //! Example usage: 4 | //! 5 | //! ```no_run 6 | //! use tricorder::prelude::*; 7 | //! use tricorder::tasks::upload; 8 | //! use serde_json::json; 9 | //! 10 | //! let inventory = Inventory::new() 11 | //! .add_host( 12 | //! Host::new(Host::id("localhost").unwrap(), "localhost:22".to_string()) 13 | //! .set_user("root".to_string()) 14 | //! .add_tag(Host::tag("local").unwrap()) 15 | //! .set_var("msg".to_string(), json!("hello")) 16 | //! .to_owned() 17 | //! ) 18 | //! .to_owned(); 19 | //! 20 | //! let task = upload::Task::new_template( 21 | //! "/path/to/local/file.ext".to_string(), 22 | //! "/path/to/remote/file.ext".to_string(), 23 | //! 0o644 24 | //! ); 25 | //! let result = inventory.hosts.run_task_seq(&task).unwrap(); 26 | //! ``` 27 | //! 28 | //! The result is a JSON document with the following structure: 29 | //! 30 | //! ```json 31 | //! [ 32 | //! { 33 | //! "host": "example-0", 34 | //! "success": true, 35 | //! "info": { 36 | //! "file_size": 12345 37 | //! } 38 | //! }, 39 | //! { 40 | //! "host": "example-1", 41 | //! "success": false, 42 | //! "error": "..." 43 | //! } 44 | //! ] 45 | //! ``` 46 | 47 | use crate::prelude::*; 48 | 49 | use serde_json::json; 50 | use std::{ 51 | fs, 52 | io::{prelude::*, BufRead, BufReader}, 53 | path::Path, 54 | }; 55 | use tinytemplate::{format_unescaped, TinyTemplate}; 56 | 57 | /// Describe an `upload` task 58 | pub struct Task { 59 | /// If true, `local_path` is treated as a template 60 | is_template: bool, 61 | /// Path to local file to upload 62 | local_path: String, 63 | /// Path to target file on remote host 64 | remote_path: String, 65 | /// UNIX file mode to set on the uploaded file 66 | file_mode: i32, 67 | } 68 | 69 | impl Task { 70 | /// Create a new `upload` task where `local_path` is a template 71 | pub fn new_template(local_path: String, remote_path: String, file_mode: i32) -> Self { 72 | Self { 73 | is_template: true, 74 | local_path, 75 | remote_path, 76 | file_mode, 77 | } 78 | } 79 | 80 | /// Create a new `upload` task where `local_path` is a static file 81 | pub fn new_file(local_path: String, remote_path: String, file_mode: i32) -> Self { 82 | Self { 83 | is_template: false, 84 | local_path, 85 | remote_path, 86 | file_mode, 87 | } 88 | } 89 | } 90 | 91 | pub enum TaskContext { 92 | Template { content: String, file_size: u64 }, 93 | File { file_size: u64 }, 94 | } 95 | 96 | impl GenericTask for Task { 97 | fn prepare(&self, host: Host) -> Result { 98 | let local_path = Path::new(self.local_path.as_str()); 99 | 100 | if !local_path.exists() { 101 | return Err(Box::new(Error::FileNotFound(format!( 102 | "No such file: {}", 103 | self.local_path 104 | )))); 105 | } else if local_path.is_dir() { 106 | return Err(Box::new(Error::IsADirectory(format!( 107 | "Path is a directory, not a file: {}", 108 | self.local_path 109 | )))); 110 | } 111 | 112 | if self.is_template { 113 | let template = fs::read_to_string(self.local_path.clone())?; 114 | 115 | let mut tt = TinyTemplate::new(); 116 | tt.set_default_formatter(&format_unescaped); 117 | tt.add_template("file", template.as_str())?; 118 | 119 | let ctx = json!({ "host": host }); 120 | let content = tt.render("file", &ctx)?; 121 | let file_size = u64::try_from(content.len())?; 122 | 123 | Ok(TaskContext::Template { content, file_size }) 124 | } else { 125 | let file_size = local_path.metadata()?.len(); 126 | 127 | Ok(TaskContext::File { file_size }) 128 | } 129 | } 130 | 131 | fn apply(&self, host: Host, context: TaskContext) -> TaskResult { 132 | let file_size = match context { 133 | TaskContext::Template { 134 | file_size: size, .. 135 | } => size, 136 | TaskContext::File { file_size: size } => size, 137 | }; 138 | 139 | let sess = host.get_session()?; 140 | let mut channel = sess.scp_send( 141 | Path::new(&self.remote_path), 142 | self.file_mode, 143 | file_size, 144 | None, 145 | )?; 146 | 147 | match context { 148 | TaskContext::Template { content, .. } => { 149 | channel.write(content.as_bytes())?; 150 | } 151 | TaskContext::File { .. } => { 152 | let file = fs::File::open(&self.local_path)?; 153 | let block_size = 4 * 1024 * 1024; // 4 megabytes 154 | let mut reader = BufReader::with_capacity(block_size, file); 155 | 156 | loop { 157 | let buffer = reader.fill_buf()?; 158 | let length = buffer.len(); 159 | 160 | if length > 0 { 161 | channel.write(buffer)?; 162 | } else { 163 | break; 164 | } 165 | 166 | reader.consume(length); 167 | } 168 | } 169 | } 170 | 171 | channel.send_eof()?; 172 | channel.wait_eof()?; 173 | channel.close()?; 174 | channel.wait_close()?; 175 | 176 | Ok(json!({ "file_size": file_size })) 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /src/prelude/tasks/task_runner.rs: -------------------------------------------------------------------------------- 1 | use super::task::{GenericTask, TaskResult}; 2 | use crate::prelude::{Host, Result}; 3 | 4 | use rayon::prelude::*; 5 | use serde_json::{json, Value}; 6 | 7 | /// TaskRunner trait to extend the `Vec` type. 8 | pub trait TaskRunner { 9 | /// Helper function to run a task on multiple hosts either sequentially or 10 | /// concurrently. 11 | fn run_task(&self, task: &dyn GenericTask, parallel: bool) -> TaskResult; 12 | 13 | /// Run a task sequentially on multiple hosts. 14 | /// 15 | /// This function first calls the `prepare()` method for all hosts. All should 16 | /// succeed, or else the error is returned. 17 | /// 18 | /// Once the task is prepared for all hosts, this function calls the `apply()` 19 | /// method with the contextual data produce at the previous step. 20 | fn run_task_seq(&self, task: &dyn GenericTask) -> TaskResult; 21 | 22 | /// Run a task concurrently on multiple hosts. 23 | /// 24 | /// This function first calls the `prepare()` method for all hosts. All should 25 | /// succeed, or else the error is returned. 26 | /// 27 | /// Once the task is prepared for all hosts, this function calls the `apply()` 28 | /// method with the contextual data produce at the previous step. 29 | fn run_task_parallel(&self, task: &dyn GenericTask) -> TaskResult; 30 | } 31 | 32 | impl TaskRunner for Vec { 33 | fn run_task(&self, task: &dyn GenericTask, parallel: bool) -> TaskResult { 34 | if parallel { 35 | self.run_task_parallel(task) 36 | } else { 37 | self.run_task_seq(task) 38 | } 39 | } 40 | 41 | fn run_task_seq(&self, task: &dyn GenericTask) -> TaskResult { 42 | let results: Vec = self 43 | .into_iter() 44 | .map(|host| prepare_host(task, host)) 45 | .collect::>>()? 46 | .into_iter() 47 | .map(|(host, data)| apply_to_host(task, host, data)) 48 | .collect(); 49 | 50 | Ok(json!(results)) 51 | } 52 | 53 | fn run_task_parallel(&self, task: &dyn GenericTask) -> TaskResult { 54 | let results: Vec = self 55 | .into_par_iter() 56 | .map(|host| prepare_host(task, host)) 57 | .collect::>>()? 58 | .into_par_iter() 59 | .map(|(host, data)| apply_to_host(task, host, data)) 60 | .collect(); 61 | 62 | Ok(json!(results)) 63 | } 64 | } 65 | 66 | fn prepare_host<'host, Data: Send>( 67 | task: &dyn GenericTask, 68 | host: &'host Host, 69 | ) -> Result<(&'host Host, Data)> { 70 | let data = task.prepare(host.clone())?; 71 | Ok((host, data)) 72 | } 73 | 74 | fn apply_to_host<'host, Data: Send>( 75 | task: &dyn GenericTask, 76 | host: &'host Host, 77 | data: Data, 78 | ) -> Value { 79 | task.apply(host.clone(), data).map_or_else( 80 | |err| { 81 | json!({ 82 | "host": host.id, 83 | "success": false, 84 | "error": format!("{}", err), 85 | }) 86 | }, 87 | |info| { 88 | json!({ 89 | "host": host.id, 90 | "success": true, 91 | "info": info, 92 | }) 93 | }, 94 | ) 95 | } 96 | 97 | #[cfg(test)] 98 | mod tests { 99 | use super::*; 100 | use crate::prelude::Error; 101 | 102 | pub struct DummyTask; 103 | 104 | impl DummyTask { 105 | pub fn new() -> Self { 106 | Self {} 107 | } 108 | } 109 | 110 | impl GenericTask for DummyTask { 111 | fn prepare(&self, host: Host) -> Result { 112 | if host.id.to_string() == String::from("success") { 113 | Ok(1) 114 | } else { 115 | Err(Box::new(Error::Other(String::from("failure")))) 116 | } 117 | } 118 | 119 | fn apply(&self, host: Host, data: i32) -> TaskResult { 120 | if host.id.to_string() == String::from("success") { 121 | Ok(json!(data + 1)) 122 | } else { 123 | Err(Box::new(Error::Other(String::from("failure")))) 124 | } 125 | } 126 | } 127 | 128 | fn setup_success_host() -> Host { 129 | Host::new(Host::id("success").unwrap(), "success:22".to_string()) 130 | } 131 | 132 | fn setup_failure_host() -> Host { 133 | Host::new(Host::id("failure").unwrap(), "failure:22".to_string()) 134 | } 135 | 136 | #[test] 137 | fn prepare_host_should_work() { 138 | let success_host = setup_success_host(); 139 | let failure_host = setup_failure_host(); 140 | let task = DummyTask::new(); 141 | 142 | match prepare_host(&task, &success_host) { 143 | Ok((host, 1)) => { 144 | assert_eq!(host, &success_host); 145 | assert!(true); 146 | } 147 | Err(_) => { 148 | assert!(false, "Unexpected error for successful host"); 149 | } 150 | _ => { 151 | assert!(false, "Unexpected result for successful host"); 152 | } 153 | } 154 | 155 | match prepare_host(&task, &failure_host) { 156 | Err(_) => { 157 | assert!(true); 158 | } 159 | _ => { 160 | assert!(false, "Unexpected result for failed host"); 161 | } 162 | } 163 | } 164 | 165 | #[test] 166 | fn apply_to_host_should_work() { 167 | let success_host = setup_success_host(); 168 | let failure_host = setup_failure_host(); 169 | let task = DummyTask::new(); 170 | 171 | assert_eq!( 172 | apply_to_host(&task, &success_host, 1), 173 | json!({ 174 | "host": "success", 175 | "success": true, 176 | "info": 2 as i32 177 | }) 178 | ); 179 | 180 | assert_eq!( 181 | apply_to_host(&task, &failure_host, 1), 182 | json!({ 183 | "host": "failure", 184 | "success": false, 185 | "error": "Other(\"failure\")" 186 | }) 187 | ); 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /tests/all/prelude/inventory_test.rs: -------------------------------------------------------------------------------- 1 | use tricorder::prelude::{Host, Inventory}; 2 | 3 | #[test] 4 | fn new_should_create_an_empty_inventory() { 5 | let inventory = Inventory::new(); 6 | 7 | assert_eq!(inventory.hosts.len(), 0); 8 | } 9 | 10 | #[test] 11 | fn add_host_should_work() { 12 | let inventory = Inventory::new() 13 | .add_host(Host::new( 14 | Host::id("example-0").unwrap(), 15 | "127.0.1.1:22".to_string(), 16 | )) 17 | .to_owned(); 18 | 19 | assert_eq!(inventory.hosts.len(), 1); 20 | 21 | let host = inventory 22 | .get_host_by_id(Host::id("example-0").unwrap()) 23 | .expect("host example-0 should exist"); 24 | 25 | assert_eq!(host.address, String::from("127.0.1.1:22")); 26 | } 27 | 28 | #[test] 29 | fn remove_host_should_work() { 30 | let inventory = Inventory::new() 31 | .add_host(Host::new( 32 | Host::id("example-0").unwrap(), 33 | "127.0.1.1:22".to_string(), 34 | )) 35 | .add_host(Host::new( 36 | Host::id("example-1").unwrap(), 37 | "127.0.1.2:22".to_string(), 38 | )) 39 | .remove_host(Host::id("example-1").unwrap()) 40 | .to_owned(); 41 | 42 | assert_eq!(inventory.hosts.len(), 1); 43 | 44 | let host = inventory 45 | .get_host_by_id(Host::id("example-0").unwrap()) 46 | .expect("host example-0 should exist"); 47 | 48 | assert_eq!(host.address, String::from("127.0.1.1:22")); 49 | } 50 | 51 | #[test] 52 | fn from_toml_should_return_an_inventory() { 53 | let content = r#" 54 | [[hosts]] 55 | 56 | id = "example-0" 57 | address = "127.0.1.1:22" 58 | "#; 59 | 60 | match Inventory::from_toml(content) { 61 | Ok(inventory) => { 62 | assert_eq!(inventory.hosts.len(), 1); 63 | 64 | let host = inventory 65 | .get_host_by_id(Host::id("example-0").unwrap()) 66 | .expect("host example-0 should exist"); 67 | 68 | assert_eq!(host.address, String::from("127.0.1.1:22")); 69 | } 70 | Err(err) => { 71 | assert!(false, "error while parsing TOML: {}", err); 72 | } 73 | } 74 | } 75 | 76 | #[test] 77 | fn from_toml_should_fail_on_invalid_content() { 78 | let content = "{this is not valid toml}"; 79 | 80 | match Inventory::from_toml(content) { 81 | Ok(_) => assert!(false, "invalid TOML should not be parsed"), 82 | Err(_) => assert!(true), 83 | }; 84 | } 85 | 86 | #[test] 87 | fn from_toml_should_fail_on_invalid_hostid() { 88 | let content = r#" 89 | [[hosts]] 90 | 91 | id = "example-0$" 92 | address = "127.0.1.1:22" 93 | "#; 94 | 95 | match Inventory::from_toml(content) { 96 | Ok(_) => assert!(false, "invalid id should not be parsed"), 97 | Err(_) => assert!(true), 98 | }; 99 | } 100 | 101 | #[test] 102 | fn from_toml_should_fail_on_invalid_hosttag() { 103 | let content = r#" 104 | [[hosts]] 105 | 106 | id = "example-0" 107 | address = "127.0.1.1:22" 108 | tags = ["&foo"] 109 | "#; 110 | 111 | match Inventory::from_toml(content) { 112 | Ok(_) => assert!(false, "invalid tag should not be parsed"), 113 | Err(_) => assert!(true), 114 | }; 115 | } 116 | 117 | #[test] 118 | fn from_json_should_return_an_inventory() { 119 | let content = r#" 120 | {"hosts": [ 121 | { 122 | "id": "example-0", 123 | "address": "127.0.1.1:22" 124 | } 125 | ]} 126 | "#; 127 | 128 | match Inventory::from_json(content) { 129 | Ok(inventory) => { 130 | assert_eq!(inventory.hosts.len(), 1); 131 | 132 | let host = inventory 133 | .get_host_by_id(Host::id("example-0").unwrap()) 134 | .expect("host example-0 should exist"); 135 | 136 | assert_eq!(host.address, String::from("127.0.1.1:22")); 137 | } 138 | Err(err) => { 139 | assert!(false, "error while parsing JSON: {}", err); 140 | } 141 | } 142 | } 143 | 144 | #[test] 145 | fn from_json_should_fail_on_invalid_content() { 146 | let content = "{this is not valid toml}"; 147 | 148 | match Inventory::from_json(content) { 149 | Ok(_) => assert!(false, "invalid JSON should not be parsed"), 150 | Err(_) => assert!(true), 151 | }; 152 | } 153 | 154 | #[test] 155 | fn from_json_should_fail_on_invalid_hostid() { 156 | let content = r#" 157 | {"hosts": [ 158 | { 159 | "id": "example-0$", 160 | "address": "127.0.1.1:22" 161 | } 162 | ]} 163 | "#; 164 | 165 | match Inventory::from_json(content) { 166 | Ok(_) => assert!(false, "invalid id should not be parsed"), 167 | Err(_) => assert!(true), 168 | }; 169 | } 170 | 171 | #[test] 172 | fn from_json_should_fail_on_invalid_hosttag() { 173 | let content = r#" 174 | {"hosts": [ 175 | { 176 | "id": "example-0", 177 | "address": "127.0.1.1:22", 178 | "tags": ["&foo"] 179 | } 180 | ]} 181 | "#; 182 | 183 | match Inventory::from_json(content) { 184 | Ok(_) => assert!(false, "invalid tag should not be parsed"), 185 | Err(_) => assert!(true), 186 | }; 187 | } 188 | 189 | #[test] 190 | fn get_host_by_tags_should_work() { 191 | let inventory = Inventory::new() 192 | .add_host( 193 | Host::new(Host::id("example-0").unwrap(), "127.0.1.1:22".to_string()) 194 | .add_tag(Host::tag("foo").unwrap()) 195 | .to_owned(), 196 | ) 197 | .add_host( 198 | Host::new(Host::id("example-1").unwrap(), "127.0.1.2:22".to_string()) 199 | .add_tag(Host::tag("bar").unwrap()) 200 | .to_owned(), 201 | ) 202 | .to_owned(); 203 | 204 | assert_eq!(inventory.hosts.len(), 2); 205 | 206 | let foo_hosts = inventory.get_hosts_by_tags("foo".to_string()).unwrap(); 207 | let bar_hosts = inventory.get_hosts_by_tags("bar".to_string()).unwrap(); 208 | 209 | assert_eq!(foo_hosts.len(), 1); 210 | match foo_hosts.get(0) { 211 | Some(host) => { 212 | assert_eq!(host.id, Host::id("example-0").unwrap()); 213 | } 214 | None => { 215 | assert!(false, "Expected host example-0 not found"); 216 | } 217 | } 218 | 219 | assert_eq!(bar_hosts.len(), 1); 220 | match bar_hosts.get(0) { 221 | Some(host) => { 222 | assert_eq!(host.id, Host::id("example-1").unwrap()); 223 | } 224 | None => { 225 | assert!(false, "Expected host example-1 not found"); 226 | } 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /src/tasks/module.rs: -------------------------------------------------------------------------------- 1 | //! Upload a Module to the remote host and call it with data 2 | //! 3 | //! Example usage: 4 | //! 5 | //! an example for a Moule could be a simple shell-script 6 | //! which reads data from stdin and just echos it 7 | //! the shell script is named /tmp/mod.sh in this example 8 | //! ```shell 9 | //! #!/bin/bash 10 | //! read -r data 11 | //! echo "$data" 12 | //! ``` 13 | //! 14 | //! ```no_run 15 | //! use serde_json::json; 16 | //! use tricorder::prelude::*; 17 | //! use tricorder::tasks::module; 18 | //! 19 | //! const MODULE_FILE: &str = r#" 20 | //! #!/bin/bash 21 | //! read -r data 22 | //! echo echo output: "$data" 23 | //! "#; 24 | //! 25 | //! const DATA_FILE: &str = r#" 26 | //! { 27 | //! "data": "data_from_file", 28 | //! "overwrittendata":"data from file shoul be overwritten by var modue_mod.sh" 29 | //! } 30 | //! "#; 31 | //! 32 | //! const BINARY_PATH: &str = "/tmp/mod.sh"; 33 | //! const DATA_PATH: &str = "/tmp/data_file.json"; 34 | //! 35 | //! fn main() { 36 | //! //write the module file 37 | //! 38 | //! std::fs::write(BINARY_PATH, MODULE_FILE).unwrap(); 39 | //! std::fs::write(DATA_PATH, DATA_FILE).unwrap(); 40 | //! 41 | //! let inventory = Inventory::new() 42 | //! .add_host( 43 | //! Host::new(Host::id("localhost").unwrap(), "localhost:22".to_string()) 44 | //! .set_user("root".to_string()) 45 | //! .add_tag(Host::tag("local").unwrap()) 46 | //! .set_var("msg".to_string(), json!("hello")) 47 | //! .set_var( 48 | //! // you can define host variables overwriteing the values of the data file 49 | //! // these variables sould be named "module_" in This case mod.sh (see BINARY_PATH) 50 | //! "module_mod.sh".to_string(), 51 | //! json!({"overwrittendata":"data_from_var1", "vardata":"data_from_var2"}), 52 | //! ) 53 | //! .to_owned(), 54 | //! ) 55 | //! .to_owned(); 56 | //! 57 | //! let task = module::Task::new(Some(DATA_PATH.to_string()), BINARY_PATH.to_string()); 58 | //! 59 | //! let result = inventory.hosts.run_task_seq(&task).unwrap(); 60 | //! 61 | //! println!("{:#?}", result); 62 | //! } 63 | //! ``` 64 | //! 65 | //! The result looks like this: 66 | //! ```json 67 | //! Array [ 68 | //! Object { 69 | //! "host": String("localhost"), 70 | //! "info": Object { 71 | //! "exit_code": Number(0), 72 | //! "stderr": String(""), 73 | //! "stdout": String("echo output: {\"data\":\"data_from_file\",\"overwrittendata\":\"data_from_var1\",\"vardata\":\"data_from_var2\"}\n"), 74 | //! }, 75 | //! "success": Bool(true), 76 | //! }, 77 | //! ] 78 | //! ``` 79 | //! 80 | //! you can see, that the variable "overwrittendata" gets 81 | //! overwritten by the host-variable module_mod.sh 82 | 83 | use crate::prelude::*; 84 | 85 | use serde_json::{json, Value}; 86 | use ssh2::Channel; 87 | 88 | use std::fs::{self, File}; 89 | use std::io::prelude::*; 90 | use std::path::Path; 91 | 92 | /// Describe an `module` task 93 | pub struct Task { 94 | data_path: Option, 95 | module_path: String, 96 | module_name: String, 97 | } 98 | 99 | impl Task { 100 | /// Create a new `module` task 101 | 102 | pub fn new(data_path: Option, module_path: String) -> Self { 103 | let module_name = module_path.split("/").last().unwrap().to_owned(); 104 | Self { 105 | data_path, 106 | module_path, 107 | module_name, 108 | } 109 | } 110 | } 111 | 112 | impl GenericTask for Task { 113 | fn prepare(&self, host: Host) -> Result { 114 | let hostvars = host.vars.clone(); 115 | 116 | let default = json!({}); 117 | 118 | let host_var_data: &Value = hostvars 119 | .get(&format!("module_{}", self.module_name)) 120 | .unwrap_or(&default); 121 | 122 | if let Some(datapath) = self.data_path.clone() { 123 | let mut data: Value = fs::read_to_string(datapath)?.parse()?; 124 | 125 | merge(&mut data, host_var_data); 126 | Ok(data) 127 | } else { 128 | Ok(host_var_data.clone()) 129 | } 130 | } 131 | 132 | fn apply(&self, host: Host, data: Value) -> TaskResult { 133 | let sess = host.get_session()?; 134 | 135 | let mut channel = sess.channel_session()?; 136 | channel.exec("echo $HOME")?; 137 | 138 | let mut home_path = String::new(); 139 | channel.read_to_string(&mut home_path)?; 140 | channel.wait_close()?; 141 | 142 | self.upload_module(&host, home_path.trim())?; 143 | 144 | let mut channel = self.execute_module(&host, data)?; 145 | 146 | let mut stdout = String::new(); 147 | channel.read_to_string(&mut stdout)?; 148 | let mut stderr = String::new(); 149 | channel.stderr().read_to_string(&mut stderr)?; 150 | 151 | channel.wait_close()?; 152 | 153 | let exit_code = channel.exit_status()?; 154 | 155 | Ok(json!({ 156 | "exit_code": exit_code, 157 | "stdout": stdout, 158 | "stderr": stderr, 159 | })) 160 | } 161 | } 162 | impl Task { 163 | fn execute_module(&self, host: &Host, data: Value) -> Result { 164 | let sess = host.get_session()?; 165 | let mut channel = sess.channel_session()?; 166 | channel.exec(&format!("~/.local/tricorder/modules/{}", self.module_name))?; 167 | 168 | channel.write_all(serde_json::to_string(&data)?.as_bytes())?; 169 | channel.send_eof()?; 170 | Ok(channel) 171 | } 172 | 173 | fn upload_module(&self, host: &Host, home_path: &str) -> Result<()> { 174 | let sess = host.get_session()?; 175 | 176 | // create folder 177 | let mut channel = sess.channel_session()?; 178 | channel.exec("mkdir -p ~/.local/tricorder/modules")?; 179 | 180 | let mut module_binary_file = File::open(format!("{}", self.module_path))?; 181 | 182 | let mut module_binary: Vec = vec![]; 183 | module_binary_file.read_to_end(&mut module_binary)?; 184 | 185 | let mut remote_file = sess.scp_send( 186 | Path::new(&format!( 187 | "{}/.local/tricorder/modules/{}", 188 | home_path, self.module_name 189 | )), 190 | 0o700, 191 | module_binary.len() as u64, 192 | None, 193 | )?; 194 | 195 | remote_file.write(&module_binary)?; 196 | // Close the channel and wait for the whole content to be transferred 197 | remote_file.send_eof()?; 198 | remote_file.wait_eof()?; 199 | remote_file.close()?; 200 | remote_file.wait_close()?; 201 | 202 | Ok(()) 203 | } 204 | } 205 | 206 | fn merge(a: &mut Value, b: &Value) { 207 | match (a, b) { 208 | (&mut Value::Object(ref mut a), &Value::Object(ref b)) => { 209 | for (k, v) in b { 210 | merge(a.entry(k.clone()).or_insert(Value::Null), v); 211 | } 212 | } 213 | (a, b) => { 214 | *a = b.clone(); 215 | } 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /www/styles/main.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | @font-face { 7 | font-family: 'Star Trek LCARS'; 8 | src: url('../fonts/star-trek-lcars.ttf'); 9 | } 10 | 11 | @keyframes blinker { 12 | from { opacity: 1.0; } 13 | 50% { opacity: 0; } 14 | to { opacity: 1.0; } 15 | } 16 | } 17 | 18 | @layer components { 19 | main { 20 | @apply min-h-screen w-full; 21 | @apply flex flex-row; 22 | @apply bg-slate-800; 23 | } 24 | 25 | main nav { 26 | @apply w-16 min-h-full; 27 | @apply flex flex-col; 28 | @apply bg-slate-700 shadow-2xl; 29 | } 30 | 31 | main nav .menu { 32 | @apply sticky top-0; 33 | } 34 | 35 | main nav .menu a { 36 | @apply w-16 h-16; 37 | @apply flex align-middle; 38 | @apply border-l-4 border-l-transparent hover:border-l-blue-500; 39 | @apply transition-all ease-in-out duration-150; 40 | } 41 | 42 | main nav .menu a.active { 43 | @apply border-l-blue-300 hover:border-l-blue-500; 44 | } 45 | 46 | main nav .menu a svg { 47 | @apply w-1/2 h-auto m-auto; 48 | } 49 | 50 | main nav .menu a svg path, 51 | main nav .menu a svg polygon, 52 | main nav .menu a svg rect { 53 | @apply transition-all ease-in-out duration-150; 54 | @apply fill-white; 55 | } 56 | 57 | main nav .menu a.active svg path, 58 | main nav .menu a.active svg polygon, 59 | main nav .menu a.active svg rect { 60 | @apply fill-blue-300; 61 | } 62 | 63 | main nav .menu a:hover svg path, 64 | main nav .menu a:hover svg polygon, 65 | main nav .menu a:hover svg rect { 66 | @apply fill-blue-500; 67 | } 68 | 69 | main nav .menu a svg circle { 70 | @apply transition-all ease-in-out duration-150; 71 | @apply stroke-white; 72 | } 73 | 74 | main nav .menu a:hover svg circle { 75 | @apply stroke-blue-500; 76 | } 77 | 78 | main article { 79 | @apply border-r-4 border-r-orange-300; 80 | @apply text-white; 81 | width: calc(100% - 4rem); 82 | } 83 | 84 | main article header { 85 | @apply lg:w-4/5; 86 | @apply p-3 lg:mx-auto mb-6; 87 | @apply text-black text-center text-4xl font-bold font-mono; 88 | @apply bg-yellow-500 shadow-2xl lg:rounded-b-full; 89 | } 90 | 91 | main article header::before { 92 | @apply font-startrek; 93 | content: '$> '; 94 | } 95 | 96 | main article header::after { 97 | @apply font-startrek; 98 | content: '_'; 99 | animation: blinker 1s infinite; 100 | } 101 | 102 | main article footer { 103 | @apply p-12 mt-12 clear-both; 104 | @apply text-center; 105 | } 106 | 107 | main article h1 { 108 | @apply w-11/12 rounded-l-full; 109 | @apply my-6 p-3 pl-6 ml-auto clear-both; 110 | @apply bg-orange-300 shadow-2xl; 111 | @apply text-black text-2xl font-bold font-startrek; 112 | } 113 | 114 | main article h2 { 115 | @apply w-10/12 rounded-l-full; 116 | @apply my-6 p-3 pl-6 ml-auto clear-both; 117 | background: linear-gradient(135deg, rgb(3 105 161) 85%, transparent 85%, transparent 86%, rgb(253 186 116) 86%); 118 | @apply shadow-2xl; 119 | @apply text-gray-300 text-2xl font-bold font-startrek; 120 | } 121 | 122 | main article h3 { 123 | @apply w-9/12 rounded-l-full; 124 | @apply my-6 p-3 pl-6 ml-auto clear-both; 125 | background: linear-gradient(135deg, rgb(153 27 27) 70%, transparent 70%, transparent 71%, rgb(3 105 161) 71%, rgb(3 105 161) 85%, transparent 85%, transparent 86%, rgb(253 186 116) 86%); 126 | @apply shadow-2xl; 127 | @apply text-gray-300 text-2xl font-bold font-startrek; 128 | } 129 | 130 | main article h4, 131 | main article h5, 132 | main article h6 { 133 | @apply my-6 p-3 pl-6; 134 | @apply font-bold text-xl; 135 | } 136 | 137 | main article p { 138 | @apply mx-6 px-6 py-3; 139 | @apply font-sans text-lg text-justify;; 140 | } 141 | 142 | main article p code { 143 | @apply p-1; 144 | @apply bg-neutral-800 text-red-300 145 | } 146 | 147 | main article a { 148 | @apply transition-all ease-in-out duration-150; 149 | @apply text-blue-400 hover:text-blue-600 underline; 150 | } 151 | 152 | main article figure { 153 | @apply m-6 p-0 float-right; 154 | @apply bg-neutral-800 border-4 border-orange-300 shadow-2xl; 155 | } 156 | 157 | main article figure figcaption { 158 | @apply text-center; 159 | } 160 | 161 | main article ul { 162 | @apply px-6 ml-12; 163 | @apply list-disc; 164 | } 165 | 166 | main article blockquote { 167 | @apply mx-12; 168 | @apply border-l-4 border-l-blue-300; 169 | @apply italic; 170 | } 171 | 172 | main article blockquote p { 173 | @apply px-1; 174 | } 175 | 176 | main article .highlight { 177 | @apply p-6 mx-6; 178 | @apply overflow-auto; 179 | } 180 | 181 | main article .highlight pre { 182 | @apply p-6; 183 | @apply shadow-2xl; 184 | @apply overflow-auto; 185 | } 186 | 187 | .lcars-screen { 188 | @apply my-6 m-auto; 189 | @apply w-3/4; 190 | @apply flex flex-row items-stretch; 191 | @apply shadow-2xl; 192 | } 193 | 194 | .lcars-screen .buttons { 195 | @apply flex flex-col items-stretch justify-between; 196 | @apply bg-neutral-800 pr-1; 197 | @apply border-l-4 border-orange-300 rounded-l-2xl; 198 | } 199 | 200 | .lcars-screen .buttons a { 201 | @apply p-6; 202 | @apply transition-all ease-in-out duration-150; 203 | @apply bg-orange-300 hover:bg-yellow-500; 204 | @apply text-black font-startrek font-bold text-center no-underline; 205 | } 206 | 207 | .lcars-screen .buttons a:first-child { 208 | @apply rounded-tl-xl; 209 | } 210 | 211 | .lars-screen .buttons a:not(:first-child):not(:last-child) { 212 | @apply my-1; 213 | } 214 | 215 | .lcars-screen .buttons a:last-child { 216 | @apply rounded-bl-xl; 217 | } 218 | 219 | .lcars-screen .viewport { 220 | @apply p-6 relative flex-1; 221 | @apply border-4 border-orange-300; 222 | @apply bg-neutral-800; 223 | } 224 | 225 | .lcars-screen .viewport::before { 226 | content: ''; 227 | @apply left-0 right-0; 228 | @apply bg-neutral-800; 229 | @apply m-auto absolute block z-10; 230 | width: 95%; 231 | top: -4px; 232 | height: calc(100% + 8px); 233 | } 234 | 235 | .lcars-screen .viewport canvas { 236 | @apply relative block z-20 w-full h-full; 237 | } 238 | 239 | .lcars-actions { 240 | @apply my-6 py-6 sm:mx-6 md:w-3/4 md:mx-auto; 241 | @apply text-center; 242 | } 243 | 244 | .lcars-actions a { 245 | @apply p-6; 246 | @apply transition-all ease-in-out duration-150; 247 | @apply bg-orange-300 text-black font-bold font-startrek no-underline; 248 | @apply hover:bg-yellow-500 hover:text-black; 249 | } 250 | 251 | .lcars-actions a:first-child { 252 | @apply rounded-l-full; 253 | @apply pl-9 mr-1; 254 | } 255 | 256 | .lcars-actions a:not(:first-child):not(:last-child) { 257 | @apply mx-1; 258 | } 259 | 260 | .lcars-actions a:last-child { 261 | @apply rounded-r-full; 262 | @apply pr-9 ml-1; 263 | } 264 | 265 | .lcars-index { 266 | @apply p-6 w-full; 267 | } 268 | 269 | .lcars-index table { 270 | @apply w-full; 271 | } 272 | 273 | .lcars-index table td, 274 | .lcars-index table th { 275 | @apply p-3; 276 | @apply text-left whitespace-nowrap font-startrek text-2xl; 277 | } 278 | 279 | .lcars-index table th { 280 | @apply text-red-600 uppercase; 281 | } 282 | 283 | .lcars-index table tr td { 284 | @apply transition-all ease-in-out duration-150; 285 | @apply text-blue-400; 286 | } 287 | 288 | .lcars-index table td a { 289 | @apply no-underline uppercase; 290 | } 291 | 292 | .lcars-index table tr:hover td, 293 | .lcars-index table tr:hover td a { 294 | @apply text-blue-600; 295 | @apply cursor-pointer; 296 | } 297 | 298 | .lcars-notice { 299 | @apply sm:w-full md:w-1/4; 300 | @apply sm:m-6 md:my-6 md:mx-auto p-6; 301 | @apply flex flex-row items-center; 302 | @apply border-4 border-orange-600 bg-neutral-800; 303 | @apply shadow-2xl; 304 | } 305 | 306 | .lcars-notice-icon svg { 307 | @apply w-32 h-auto; 308 | } 309 | 310 | .lcars-notice-icon svg path, 311 | .lcars-notice-icon svg polygon, 312 | .lcars-notice-icon svg rect { 313 | @apply fill-yellow-500; 314 | } 315 | 316 | .lcars-notice-icon svg circle { 317 | @apply stroke-yellow-500; 318 | } 319 | 320 | .lcars-notice-text { 321 | @apply text-yellow-500 text-4xl text-center font-bold font-startrek; 322 | } 323 | } 324 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "aho-corasick" 7 | version = "0.7.18" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "1e37cfd5e7657ada45f742d6e99ca5788580b5c529dc78faf11ece6dc702656f" 10 | dependencies = [ 11 | "memchr", 12 | ] 13 | 14 | [[package]] 15 | name = "atty" 16 | version = "0.2.14" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" 19 | dependencies = [ 20 | "hermit-abi", 21 | "libc", 22 | "winapi", 23 | ] 24 | 25 | [[package]] 26 | name = "autocfg" 27 | version = "1.1.0" 28 | source = "registry+https://github.com/rust-lang/crates.io-index" 29 | checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" 30 | 31 | [[package]] 32 | name = "beef" 33 | version = "0.5.1" 34 | source = "registry+https://github.com/rust-lang/crates.io-index" 35 | checksum = "bed554bd50246729a1ec158d08aa3235d1b69d94ad120ebe187e28894787e736" 36 | 37 | [[package]] 38 | name = "bet" 39 | version = "1.0.1" 40 | source = "registry+https://github.com/rust-lang/crates.io-index" 41 | checksum = "05187b4047565a2bb9aeab0c3e8740175871fd616984d816b0c8f1f6cb71125e" 42 | 43 | [[package]] 44 | name = "bitflags" 45 | version = "1.3.2" 46 | source = "registry+https://github.com/rust-lang/crates.io-index" 47 | checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" 48 | 49 | [[package]] 50 | name = "cc" 51 | version = "1.0.73" 52 | source = "registry+https://github.com/rust-lang/crates.io-index" 53 | checksum = "2fff2a6927b3bb87f9595d67196a70493f627687a71d87a0d692242c33f58c11" 54 | 55 | [[package]] 56 | name = "cfg-if" 57 | version = "1.0.0" 58 | source = "registry+https://github.com/rust-lang/crates.io-index" 59 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 60 | 61 | [[package]] 62 | name = "clap" 63 | version = "3.1.6" 64 | source = "registry+https://github.com/rust-lang/crates.io-index" 65 | checksum = "d8c93436c21e4698bacadf42917db28b23017027a4deccb35dbe47a7e7840123" 66 | dependencies = [ 67 | "atty", 68 | "bitflags", 69 | "indexmap", 70 | "lazy_static", 71 | "os_str_bytes", 72 | "strsim", 73 | "termcolor", 74 | "textwrap", 75 | ] 76 | 77 | [[package]] 78 | name = "crossbeam-channel" 79 | version = "0.5.4" 80 | source = "registry+https://github.com/rust-lang/crates.io-index" 81 | checksum = "5aaa7bd5fb665c6864b5f963dd9097905c54125909c7aa94c9e18507cdbe6c53" 82 | dependencies = [ 83 | "cfg-if", 84 | "crossbeam-utils", 85 | ] 86 | 87 | [[package]] 88 | name = "crossbeam-deque" 89 | version = "0.8.1" 90 | source = "registry+https://github.com/rust-lang/crates.io-index" 91 | checksum = "6455c0ca19f0d2fbf751b908d5c55c1f5cbc65e03c4225427254b46890bdde1e" 92 | dependencies = [ 93 | "cfg-if", 94 | "crossbeam-epoch", 95 | "crossbeam-utils", 96 | ] 97 | 98 | [[package]] 99 | name = "crossbeam-epoch" 100 | version = "0.9.8" 101 | source = "registry+https://github.com/rust-lang/crates.io-index" 102 | checksum = "1145cf131a2c6ba0615079ab6a638f7e1973ac9c2634fcbeaaad6114246efe8c" 103 | dependencies = [ 104 | "autocfg", 105 | "cfg-if", 106 | "crossbeam-utils", 107 | "lazy_static", 108 | "memoffset", 109 | "scopeguard", 110 | ] 111 | 112 | [[package]] 113 | name = "crossbeam-utils" 114 | version = "0.8.8" 115 | source = "registry+https://github.com/rust-lang/crates.io-index" 116 | checksum = "0bf124c720b7686e3c2663cf54062ab0f68a88af2fb6a030e87e30bf721fcb38" 117 | dependencies = [ 118 | "cfg-if", 119 | "lazy_static", 120 | ] 121 | 122 | [[package]] 123 | name = "either" 124 | version = "1.6.1" 125 | source = "registry+https://github.com/rust-lang/crates.io-index" 126 | checksum = "e78d4f1cc4ae33bbfc157ed5d5a5ef3bc29227303d595861deb238fcec4e9457" 127 | 128 | [[package]] 129 | name = "file-mode" 130 | version = "0.1.2" 131 | source = "registry+https://github.com/rust-lang/crates.io-index" 132 | checksum = "773ea145485772b8d354624b32adbe20e776353d3e48c7b03ef44e3455e9815c" 133 | dependencies = [ 134 | "libc", 135 | ] 136 | 137 | [[package]] 138 | name = "fnv" 139 | version = "1.0.7" 140 | source = "registry+https://github.com/rust-lang/crates.io-index" 141 | checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" 142 | 143 | [[package]] 144 | name = "hashbrown" 145 | version = "0.11.2" 146 | source = "registry+https://github.com/rust-lang/crates.io-index" 147 | checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e" 148 | 149 | [[package]] 150 | name = "hermit-abi" 151 | version = "0.1.19" 152 | source = "registry+https://github.com/rust-lang/crates.io-index" 153 | checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" 154 | dependencies = [ 155 | "libc", 156 | ] 157 | 158 | [[package]] 159 | name = "indexmap" 160 | version = "1.8.0" 161 | source = "registry+https://github.com/rust-lang/crates.io-index" 162 | checksum = "282a6247722caba404c065016bbfa522806e51714c34f5dfc3e4a3a46fcb4223" 163 | dependencies = [ 164 | "autocfg", 165 | "hashbrown", 166 | ] 167 | 168 | [[package]] 169 | name = "instant" 170 | version = "0.1.12" 171 | source = "registry+https://github.com/rust-lang/crates.io-index" 172 | checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c" 173 | dependencies = [ 174 | "cfg-if", 175 | ] 176 | 177 | [[package]] 178 | name = "is_executable" 179 | version = "1.0.1" 180 | source = "registry+https://github.com/rust-lang/crates.io-index" 181 | checksum = "fa9acdc6d67b75e626ad644734e8bc6df893d9cd2a834129065d3dd6158ea9c8" 182 | dependencies = [ 183 | "winapi", 184 | ] 185 | 186 | [[package]] 187 | name = "itoa" 188 | version = "1.0.1" 189 | source = "registry+https://github.com/rust-lang/crates.io-index" 190 | checksum = "1aab8fc367588b89dcee83ab0fd66b72b50b72fa1904d7095045ace2b0c81c35" 191 | 192 | [[package]] 193 | name = "lazy_static" 194 | version = "1.4.0" 195 | source = "registry+https://github.com/rust-lang/crates.io-index" 196 | checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" 197 | 198 | [[package]] 199 | name = "libc" 200 | version = "0.2.121" 201 | source = "registry+https://github.com/rust-lang/crates.io-index" 202 | checksum = "efaa7b300f3b5fe8eb6bf21ce3895e1751d9665086af2d64b42f19701015ff4f" 203 | 204 | [[package]] 205 | name = "libssh2-sys" 206 | version = "0.2.23" 207 | source = "registry+https://github.com/rust-lang/crates.io-index" 208 | checksum = "b094a36eb4b8b8c8a7b4b8ae43b2944502be3e59cd87687595cf6b0a71b3f4ca" 209 | dependencies = [ 210 | "cc", 211 | "libc", 212 | "libz-sys", 213 | "openssl-sys", 214 | "pkg-config", 215 | "vcpkg", 216 | ] 217 | 218 | [[package]] 219 | name = "libz-sys" 220 | version = "1.1.5" 221 | source = "registry+https://github.com/rust-lang/crates.io-index" 222 | checksum = "6f35facd4a5673cb5a48822be2be1d4236c1c99cb4113cab7061ac720d5bf859" 223 | dependencies = [ 224 | "cc", 225 | "libc", 226 | "pkg-config", 227 | "vcpkg", 228 | ] 229 | 230 | [[package]] 231 | name = "lock_api" 232 | version = "0.4.6" 233 | source = "registry+https://github.com/rust-lang/crates.io-index" 234 | checksum = "88943dd7ef4a2e5a4bfa2753aaab3013e34ce2533d1996fb18ef591e315e2b3b" 235 | dependencies = [ 236 | "scopeguard", 237 | ] 238 | 239 | [[package]] 240 | name = "logos" 241 | version = "0.12.0" 242 | source = "registry+https://github.com/rust-lang/crates.io-index" 243 | checksum = "427e2abca5be13136da9afdbf874e6b34ad9001dd70f2b103b083a85daa7b345" 244 | dependencies = [ 245 | "logos-derive", 246 | ] 247 | 248 | [[package]] 249 | name = "logos-derive" 250 | version = "0.12.0" 251 | source = "registry+https://github.com/rust-lang/crates.io-index" 252 | checksum = "56a7d287fd2ac3f75b11f19a1c8a874a7d55744bd91f7a1b3e7cf87d4343c36d" 253 | dependencies = [ 254 | "beef", 255 | "fnv", 256 | "proc-macro2", 257 | "quote", 258 | "regex-syntax", 259 | "syn", 260 | "utf8-ranges", 261 | ] 262 | 263 | [[package]] 264 | name = "memchr" 265 | version = "2.4.1" 266 | source = "registry+https://github.com/rust-lang/crates.io-index" 267 | checksum = "308cc39be01b73d0d18f82a0e7b2a3df85245f84af96fdddc5d202d27e47b86a" 268 | 269 | [[package]] 270 | name = "memoffset" 271 | version = "0.6.5" 272 | source = "registry+https://github.com/rust-lang/crates.io-index" 273 | checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" 274 | dependencies = [ 275 | "autocfg", 276 | ] 277 | 278 | [[package]] 279 | name = "num_cpus" 280 | version = "1.13.1" 281 | source = "registry+https://github.com/rust-lang/crates.io-index" 282 | checksum = "19e64526ebdee182341572e50e9ad03965aa510cd94427a4549448f285e957a1" 283 | dependencies = [ 284 | "hermit-abi", 285 | "libc", 286 | ] 287 | 288 | [[package]] 289 | name = "openssl-sys" 290 | version = "0.9.72" 291 | source = "registry+https://github.com/rust-lang/crates.io-index" 292 | checksum = "7e46109c383602735fa0a2e48dd2b7c892b048e1bf69e5c3b1d804b7d9c203cb" 293 | dependencies = [ 294 | "autocfg", 295 | "cc", 296 | "libc", 297 | "pkg-config", 298 | "vcpkg", 299 | ] 300 | 301 | [[package]] 302 | name = "os_str_bytes" 303 | version = "6.0.0" 304 | source = "registry+https://github.com/rust-lang/crates.io-index" 305 | checksum = "8e22443d1643a904602595ba1cd8f7d896afe56d26712531c5ff73a15b2fbf64" 306 | dependencies = [ 307 | "memchr", 308 | ] 309 | 310 | [[package]] 311 | name = "parking_lot" 312 | version = "0.11.2" 313 | source = "registry+https://github.com/rust-lang/crates.io-index" 314 | checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" 315 | dependencies = [ 316 | "instant", 317 | "lock_api", 318 | "parking_lot_core", 319 | ] 320 | 321 | [[package]] 322 | name = "parking_lot_core" 323 | version = "0.8.5" 324 | source = "registry+https://github.com/rust-lang/crates.io-index" 325 | checksum = "d76e8e1493bcac0d2766c42737f34458f1c8c50c0d23bcb24ea953affb273216" 326 | dependencies = [ 327 | "cfg-if", 328 | "instant", 329 | "libc", 330 | "redox_syscall", 331 | "smallvec", 332 | "winapi", 333 | ] 334 | 335 | [[package]] 336 | name = "pkg-config" 337 | version = "0.3.24" 338 | source = "registry+https://github.com/rust-lang/crates.io-index" 339 | checksum = "58893f751c9b0412871a09abd62ecd2a00298c6c83befa223ef98c52aef40cbe" 340 | 341 | [[package]] 342 | name = "proc-macro2" 343 | version = "1.0.36" 344 | source = "registry+https://github.com/rust-lang/crates.io-index" 345 | checksum = "c7342d5883fbccae1cc37a2353b09c87c9b0f3afd73f5fb9bba687a1f733b029" 346 | dependencies = [ 347 | "unicode-xid", 348 | ] 349 | 350 | [[package]] 351 | name = "quote" 352 | version = "1.0.16" 353 | source = "registry+https://github.com/rust-lang/crates.io-index" 354 | checksum = "b4af2ec4714533fcdf07e886f17025ace8b997b9ce51204ee69b6da831c3da57" 355 | dependencies = [ 356 | "proc-macro2", 357 | ] 358 | 359 | [[package]] 360 | name = "rayon" 361 | version = "1.5.1" 362 | source = "registry+https://github.com/rust-lang/crates.io-index" 363 | checksum = "c06aca804d41dbc8ba42dfd964f0d01334eceb64314b9ecf7c5fad5188a06d90" 364 | dependencies = [ 365 | "autocfg", 366 | "crossbeam-deque", 367 | "either", 368 | "rayon-core", 369 | ] 370 | 371 | [[package]] 372 | name = "rayon-core" 373 | version = "1.9.1" 374 | source = "registry+https://github.com/rust-lang/crates.io-index" 375 | checksum = "d78120e2c850279833f1dd3582f730c4ab53ed95aeaaaa862a2a5c71b1656d8e" 376 | dependencies = [ 377 | "crossbeam-channel", 378 | "crossbeam-deque", 379 | "crossbeam-utils", 380 | "lazy_static", 381 | "num_cpus", 382 | ] 383 | 384 | [[package]] 385 | name = "redox_syscall" 386 | version = "0.2.12" 387 | source = "registry+https://github.com/rust-lang/crates.io-index" 388 | checksum = "8ae183fc1b06c149f0c1793e1eb447c8b04bfe46d48e9e48bfb8d2d7ed64ecf0" 389 | dependencies = [ 390 | "bitflags", 391 | ] 392 | 393 | [[package]] 394 | name = "regex" 395 | version = "1.5.5" 396 | source = "registry+https://github.com/rust-lang/crates.io-index" 397 | checksum = "1a11647b6b25ff05a515cb92c365cec08801e83423a235b51e231e1808747286" 398 | dependencies = [ 399 | "aho-corasick", 400 | "memchr", 401 | "regex-syntax", 402 | ] 403 | 404 | [[package]] 405 | name = "regex-syntax" 406 | version = "0.6.25" 407 | source = "registry+https://github.com/rust-lang/crates.io-index" 408 | checksum = "f497285884f3fcff424ffc933e56d7cbca511def0c9831a7f9b5f6153e3cc89b" 409 | 410 | [[package]] 411 | name = "ryu" 412 | version = "1.0.9" 413 | source = "registry+https://github.com/rust-lang/crates.io-index" 414 | checksum = "73b4b750c782965c211b42f022f59af1fbceabdd026623714f104152f1ec149f" 415 | 416 | [[package]] 417 | name = "scopeguard" 418 | version = "1.1.0" 419 | source = "registry+https://github.com/rust-lang/crates.io-index" 420 | checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" 421 | 422 | [[package]] 423 | name = "serde" 424 | version = "1.0.136" 425 | source = "registry+https://github.com/rust-lang/crates.io-index" 426 | checksum = "ce31e24b01e1e524df96f1c2fdd054405f8d7376249a5110886fb4b658484789" 427 | 428 | [[package]] 429 | name = "serde_derive" 430 | version = "1.0.136" 431 | source = "registry+https://github.com/rust-lang/crates.io-index" 432 | checksum = "08597e7152fcd306f41838ed3e37be9eaeed2b61c42e2117266a554fab4662f9" 433 | dependencies = [ 434 | "proc-macro2", 435 | "quote", 436 | "syn", 437 | ] 438 | 439 | [[package]] 440 | name = "serde_json" 441 | version = "1.0.79" 442 | source = "registry+https://github.com/rust-lang/crates.io-index" 443 | checksum = "8e8d9fa5c3b304765ce1fd9c4c8a3de2c8db365a5b91be52f186efc675681d95" 444 | dependencies = [ 445 | "itoa", 446 | "ryu", 447 | "serde", 448 | ] 449 | 450 | [[package]] 451 | name = "shell-words" 452 | version = "1.1.0" 453 | source = "registry+https://github.com/rust-lang/crates.io-index" 454 | checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" 455 | 456 | [[package]] 457 | name = "smallvec" 458 | version = "1.8.0" 459 | source = "registry+https://github.com/rust-lang/crates.io-index" 460 | checksum = "f2dd574626839106c320a323308629dcb1acfc96e32a8cba364ddc61ac23ee83" 461 | 462 | [[package]] 463 | name = "ssh2" 464 | version = "0.9.3" 465 | source = "registry+https://github.com/rust-lang/crates.io-index" 466 | checksum = "269343e64430067a14937ae0e3c4ec604c178fb896dde0964b1acd22b3e2eeb1" 467 | dependencies = [ 468 | "bitflags", 469 | "libc", 470 | "libssh2-sys", 471 | "parking_lot", 472 | ] 473 | 474 | [[package]] 475 | name = "strsim" 476 | version = "0.10.0" 477 | source = "registry+https://github.com/rust-lang/crates.io-index" 478 | checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" 479 | 480 | [[package]] 481 | name = "syn" 482 | version = "1.0.89" 483 | source = "registry+https://github.com/rust-lang/crates.io-index" 484 | checksum = "ea297be220d52398dcc07ce15a209fce436d361735ac1db700cab3b6cdfb9f54" 485 | dependencies = [ 486 | "proc-macro2", 487 | "quote", 488 | "unicode-xid", 489 | ] 490 | 491 | [[package]] 492 | name = "termcolor" 493 | version = "1.1.3" 494 | source = "registry+https://github.com/rust-lang/crates.io-index" 495 | checksum = "bab24d30b911b2376f3a13cc2cd443142f0c81dda04c118693e35b3835757755" 496 | dependencies = [ 497 | "winapi-util", 498 | ] 499 | 500 | [[package]] 501 | name = "textwrap" 502 | version = "0.15.0" 503 | source = "registry+https://github.com/rust-lang/crates.io-index" 504 | checksum = "b1141d4d61095b28419e22cb0bbf02755f5e54e0526f97f1e3d1d160e60885fb" 505 | 506 | [[package]] 507 | name = "tinytemplate" 508 | version = "1.2.1" 509 | source = "registry+https://github.com/rust-lang/crates.io-index" 510 | checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" 511 | dependencies = [ 512 | "serde", 513 | "serde_json", 514 | ] 515 | 516 | [[package]] 517 | name = "toml" 518 | version = "0.5.8" 519 | source = "registry+https://github.com/rust-lang/crates.io-index" 520 | checksum = "a31142970826733df8241ef35dc040ef98c679ab14d7c3e54d827099b3acecaa" 521 | dependencies = [ 522 | "serde", 523 | ] 524 | 525 | [[package]] 526 | name = "tricorder" 527 | version = "0.10.0" 528 | dependencies = [ 529 | "bet", 530 | "clap", 531 | "file-mode", 532 | "is_executable", 533 | "logos", 534 | "rayon", 535 | "regex", 536 | "serde", 537 | "serde_derive", 538 | "serde_json", 539 | "shell-words", 540 | "ssh2", 541 | "tinytemplate", 542 | "toml", 543 | ] 544 | 545 | [[package]] 546 | name = "unicode-xid" 547 | version = "0.2.2" 548 | source = "registry+https://github.com/rust-lang/crates.io-index" 549 | checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3" 550 | 551 | [[package]] 552 | name = "utf8-ranges" 553 | version = "1.0.4" 554 | source = "registry+https://github.com/rust-lang/crates.io-index" 555 | checksum = "b4ae116fef2b7fea257ed6440d3cfcff7f190865f170cdad00bb6465bf18ecba" 556 | 557 | [[package]] 558 | name = "vcpkg" 559 | version = "0.2.15" 560 | source = "registry+https://github.com/rust-lang/crates.io-index" 561 | checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" 562 | 563 | [[package]] 564 | name = "winapi" 565 | version = "0.3.9" 566 | source = "registry+https://github.com/rust-lang/crates.io-index" 567 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 568 | dependencies = [ 569 | "winapi-i686-pc-windows-gnu", 570 | "winapi-x86_64-pc-windows-gnu", 571 | ] 572 | 573 | [[package]] 574 | name = "winapi-i686-pc-windows-gnu" 575 | version = "0.4.0" 576 | source = "registry+https://github.com/rust-lang/crates.io-index" 577 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 578 | 579 | [[package]] 580 | name = "winapi-util" 581 | version = "0.1.5" 582 | source = "registry+https://github.com/rust-lang/crates.io-index" 583 | checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" 584 | dependencies = [ 585 | "winapi", 586 | ] 587 | 588 | [[package]] 589 | name = "winapi-x86_64-pc-windows-gnu" 590 | version = "0.4.0" 591 | source = "registry+https://github.com/rust-lang/crates.io-index" 592 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 593 | -------------------------------------------------------------------------------- /www/LICENSE.txt: -------------------------------------------------------------------------------- 1 | Attribution-NonCommercial-ShareAlike 4.0 International 2 | 3 | ======================================================================= 4 | 5 | Creative Commons Corporation ("Creative Commons") is not a law firm and 6 | does not provide legal services or legal advice. Distribution of 7 | Creative Commons public licenses does not create a lawyer-client or 8 | other relationship. Creative Commons makes its licenses and related 9 | information available on an "as-is" basis. Creative Commons gives no 10 | warranties regarding its licenses, any material licensed under their 11 | terms and conditions, or any related information. Creative Commons 12 | disclaims all liability for damages resulting from their use to the 13 | fullest extent possible. 14 | 15 | Using Creative Commons Public Licenses 16 | 17 | Creative Commons public licenses provide a standard set of terms and 18 | conditions that creators and other rights holders may use to share 19 | original works of authorship and other material subject to copyright 20 | and certain other rights specified in the public license below. The 21 | following considerations are for informational purposes only, are not 22 | exhaustive, and do not form part of our licenses. 23 | 24 | Considerations for licensors: Our public licenses are 25 | intended for use by those authorized to give the public 26 | permission to use material in ways otherwise restricted by 27 | copyright and certain other rights. Our licenses are 28 | irrevocable. Licensors should read and understand the terms 29 | and conditions of the license they choose before applying it. 30 | Licensors should also secure all rights necessary before 31 | applying our licenses so that the public can reuse the 32 | material as expected. Licensors should clearly mark any 33 | material not subject to the license. This includes other CC- 34 | licensed material, or material used under an exception or 35 | limitation to copyright. More considerations for licensors: 36 | wiki.creativecommons.org/Considerations_for_licensors 37 | 38 | Considerations for the public: By using one of our public 39 | licenses, a licensor grants the public permission to use the 40 | licensed material under specified terms and conditions. If 41 | the licensor's permission is not necessary for any reason--for 42 | example, because of any applicable exception or limitation to 43 | copyright--then that use is not regulated by the license. Our 44 | licenses grant only permissions under copyright and certain 45 | other rights that a licensor has authority to grant. Use of 46 | the licensed material may still be restricted for other 47 | reasons, including because others have copyright or other 48 | rights in the material. A licensor may make special requests, 49 | such as asking that all changes be marked or described. 50 | Although not required by our licenses, you are encouraged to 51 | respect those requests where reasonable. More considerations 52 | for the public: 53 | wiki.creativecommons.org/Considerations_for_licensees 54 | 55 | ======================================================================= 56 | 57 | Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International 58 | Public License 59 | 60 | By exercising the Licensed Rights (defined below), You accept and agree 61 | to be bound by the terms and conditions of this Creative Commons 62 | Attribution-NonCommercial-ShareAlike 4.0 International Public License 63 | ("Public License"). To the extent this Public License may be 64 | interpreted as a contract, You are granted the Licensed Rights in 65 | consideration of Your acceptance of these terms and conditions, and the 66 | Licensor grants You such rights in consideration of benefits the 67 | Licensor receives from making the Licensed Material available under 68 | these terms and conditions. 69 | 70 | 71 | Section 1 -- Definitions. 72 | 73 | a. Adapted Material means material subject to Copyright and Similar 74 | Rights that is derived from or based upon the Licensed Material 75 | and in which the Licensed Material is translated, altered, 76 | arranged, transformed, or otherwise modified in a manner requiring 77 | permission under the Copyright and Similar Rights held by the 78 | Licensor. For purposes of this Public License, where the Licensed 79 | Material is a musical work, performance, or sound recording, 80 | Adapted Material is always produced where the Licensed Material is 81 | synched in timed relation with a moving image. 82 | 83 | b. Adapter's License means the license You apply to Your Copyright 84 | and Similar Rights in Your contributions to Adapted Material in 85 | accordance with the terms and conditions of this Public License. 86 | 87 | c. BY-NC-SA Compatible License means a license listed at 88 | creativecommons.org/compatiblelicenses, approved by Creative 89 | Commons as essentially the equivalent of this Public License. 90 | 91 | d. Copyright and Similar Rights means copyright and/or similar rights 92 | closely related to copyright including, without limitation, 93 | performance, broadcast, sound recording, and Sui Generis Database 94 | Rights, without regard to how the rights are labeled or 95 | categorized. For purposes of this Public License, the rights 96 | specified in Section 2(b)(1)-(2) are not Copyright and Similar 97 | Rights. 98 | 99 | e. Effective Technological Measures means those measures that, in the 100 | absence of proper authority, may not be circumvented under laws 101 | fulfilling obligations under Article 11 of the WIPO Copyright 102 | Treaty adopted on December 20, 1996, and/or similar international 103 | agreements. 104 | 105 | f. Exceptions and Limitations means fair use, fair dealing, and/or 106 | any other exception or limitation to Copyright and Similar Rights 107 | that applies to Your use of the Licensed Material. 108 | 109 | g. License Elements means the license attributes listed in the name 110 | of a Creative Commons Public License. The License Elements of this 111 | Public License are Attribution, NonCommercial, and ShareAlike. 112 | 113 | h. Licensed Material means the artistic or literary work, database, 114 | or other material to which the Licensor applied this Public 115 | License. 116 | 117 | i. Licensed Rights means the rights granted to You subject to the 118 | terms and conditions of this Public License, which are limited to 119 | all Copyright and Similar Rights that apply to Your use of the 120 | Licensed Material and that the Licensor has authority to license. 121 | 122 | j. Licensor means the individual(s) or entity(ies) granting rights 123 | under this Public License. 124 | 125 | k. NonCommercial means not primarily intended for or directed towards 126 | commercial advantage or monetary compensation. For purposes of 127 | this Public License, the exchange of the Licensed Material for 128 | other material subject to Copyright and Similar Rights by digital 129 | file-sharing or similar means is NonCommercial provided there is 130 | no payment of monetary compensation in connection with the 131 | exchange. 132 | 133 | l. Share means to provide material to the public by any means or 134 | process that requires permission under the Licensed Rights, such 135 | as reproduction, public display, public performance, distribution, 136 | dissemination, communication, or importation, and to make material 137 | available to the public including in ways that members of the 138 | public may access the material from a place and at a time 139 | individually chosen by them. 140 | 141 | m. Sui Generis Database Rights means rights other than copyright 142 | resulting from Directive 96/9/EC of the European Parliament and of 143 | the Council of 11 March 1996 on the legal protection of databases, 144 | as amended and/or succeeded, as well as other essentially 145 | equivalent rights anywhere in the world. 146 | 147 | n. You means the individual or entity exercising the Licensed Rights 148 | under this Public License. Your has a corresponding meaning. 149 | 150 | 151 | Section 2 -- Scope. 152 | 153 | a. License grant. 154 | 155 | 1. Subject to the terms and conditions of this Public License, 156 | the Licensor hereby grants You a worldwide, royalty-free, 157 | non-sublicensable, non-exclusive, irrevocable license to 158 | exercise the Licensed Rights in the Licensed Material to: 159 | 160 | a. reproduce and Share the Licensed Material, in whole or 161 | in part, for NonCommercial purposes only; and 162 | 163 | b. produce, reproduce, and Share Adapted Material for 164 | NonCommercial purposes only. 165 | 166 | 2. Exceptions and Limitations. For the avoidance of doubt, where 167 | Exceptions and Limitations apply to Your use, this Public 168 | License does not apply, and You do not need to comply with 169 | its terms and conditions. 170 | 171 | 3. Term. The term of this Public License is specified in Section 172 | 6(a). 173 | 174 | 4. Media and formats; technical modifications allowed. The 175 | Licensor authorizes You to exercise the Licensed Rights in 176 | all media and formats whether now known or hereafter created, 177 | and to make technical modifications necessary to do so. The 178 | Licensor waives and/or agrees not to assert any right or 179 | authority to forbid You from making technical modifications 180 | necessary to exercise the Licensed Rights, including 181 | technical modifications necessary to circumvent Effective 182 | Technological Measures. For purposes of this Public License, 183 | simply making modifications authorized by this Section 2(a) 184 | (4) never produces Adapted Material. 185 | 186 | 5. Downstream recipients. 187 | 188 | a. Offer from the Licensor -- Licensed Material. Every 189 | recipient of the Licensed Material automatically 190 | receives an offer from the Licensor to exercise the 191 | Licensed Rights under the terms and conditions of this 192 | Public License. 193 | 194 | b. Additional offer from the Licensor -- Adapted Material. 195 | Every recipient of Adapted Material from You 196 | automatically receives an offer from the Licensor to 197 | exercise the Licensed Rights in the Adapted Material 198 | under the conditions of the Adapter's License You apply. 199 | 200 | c. No downstream restrictions. You may not offer or impose 201 | any additional or different terms or conditions on, or 202 | apply any Effective Technological Measures to, the 203 | Licensed Material if doing so restricts exercise of the 204 | Licensed Rights by any recipient of the Licensed 205 | Material. 206 | 207 | 6. No endorsement. Nothing in this Public License constitutes or 208 | may be construed as permission to assert or imply that You 209 | are, or that Your use of the Licensed Material is, connected 210 | with, or sponsored, endorsed, or granted official status by, 211 | the Licensor or others designated to receive attribution as 212 | provided in Section 3(a)(1)(A)(i). 213 | 214 | b. Other rights. 215 | 216 | 1. Moral rights, such as the right of integrity, are not 217 | licensed under this Public License, nor are publicity, 218 | privacy, and/or other similar personality rights; however, to 219 | the extent possible, the Licensor waives and/or agrees not to 220 | assert any such rights held by the Licensor to the limited 221 | extent necessary to allow You to exercise the Licensed 222 | Rights, but not otherwise. 223 | 224 | 2. Patent and trademark rights are not licensed under this 225 | Public License. 226 | 227 | 3. To the extent possible, the Licensor waives any right to 228 | collect royalties from You for the exercise of the Licensed 229 | Rights, whether directly or through a collecting society 230 | under any voluntary or waivable statutory or compulsory 231 | licensing scheme. In all other cases the Licensor expressly 232 | reserves any right to collect such royalties, including when 233 | the Licensed Material is used other than for NonCommercial 234 | purposes. 235 | 236 | 237 | Section 3 -- License Conditions. 238 | 239 | Your exercise of the Licensed Rights is expressly made subject to the 240 | following conditions. 241 | 242 | a. Attribution. 243 | 244 | 1. If You Share the Licensed Material (including in modified 245 | form), You must: 246 | 247 | a. retain the following if it is supplied by the Licensor 248 | with the Licensed Material: 249 | 250 | i. identification of the creator(s) of the Licensed 251 | Material and any others designated to receive 252 | attribution, in any reasonable manner requested by 253 | the Licensor (including by pseudonym if 254 | designated); 255 | 256 | ii. a copyright notice; 257 | 258 | iii. a notice that refers to this Public License; 259 | 260 | iv. a notice that refers to the disclaimer of 261 | warranties; 262 | 263 | v. a URI or hyperlink to the Licensed Material to the 264 | extent reasonably practicable; 265 | 266 | b. indicate if You modified the Licensed Material and 267 | retain an indication of any previous modifications; and 268 | 269 | c. indicate the Licensed Material is licensed under this 270 | Public License, and include the text of, or the URI or 271 | hyperlink to, this Public License. 272 | 273 | 2. You may satisfy the conditions in Section 3(a)(1) in any 274 | reasonable manner based on the medium, means, and context in 275 | which You Share the Licensed Material. For example, it may be 276 | reasonable to satisfy the conditions by providing a URI or 277 | hyperlink to a resource that includes the required 278 | information. 279 | 3. If requested by the Licensor, You must remove any of the 280 | information required by Section 3(a)(1)(A) to the extent 281 | reasonably practicable. 282 | 283 | b. ShareAlike. 284 | 285 | In addition to the conditions in Section 3(a), if You Share 286 | Adapted Material You produce, the following conditions also apply. 287 | 288 | 1. The Adapter's License You apply must be a Creative Commons 289 | license with the same License Elements, this version or 290 | later, or a BY-NC-SA Compatible License. 291 | 292 | 2. You must include the text of, or the URI or hyperlink to, the 293 | Adapter's License You apply. You may satisfy this condition 294 | in any reasonable manner based on the medium, means, and 295 | context in which You Share Adapted Material. 296 | 297 | 3. You may not offer or impose any additional or different terms 298 | or conditions on, or apply any Effective Technological 299 | Measures to, Adapted Material that restrict exercise of the 300 | rights granted under the Adapter's License You apply. 301 | 302 | 303 | Section 4 -- Sui Generis Database Rights. 304 | 305 | Where the Licensed Rights include Sui Generis Database Rights that 306 | apply to Your use of the Licensed Material: 307 | 308 | a. for the avoidance of doubt, Section 2(a)(1) grants You the right 309 | to extract, reuse, reproduce, and Share all or a substantial 310 | portion of the contents of the database for NonCommercial purposes 311 | only; 312 | 313 | b. if You include all or a substantial portion of the database 314 | contents in a database in which You have Sui Generis Database 315 | Rights, then the database in which You have Sui Generis Database 316 | Rights (but not its individual contents) is Adapted Material, 317 | including for purposes of Section 3(b); and 318 | 319 | c. You must comply with the conditions in Section 3(a) if You Share 320 | all or a substantial portion of the contents of the database. 321 | 322 | For the avoidance of doubt, this Section 4 supplements and does not 323 | replace Your obligations under this Public License where the Licensed 324 | Rights include other Copyright and Similar Rights. 325 | 326 | 327 | Section 5 -- Disclaimer of Warranties and Limitation of Liability. 328 | 329 | a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE 330 | EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS 331 | AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF 332 | ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, 333 | IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, 334 | WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR 335 | PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, 336 | ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT 337 | KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT 338 | ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU. 339 | 340 | b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE 341 | TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, 342 | NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT, 343 | INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES, 344 | COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR 345 | USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN 346 | ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR 347 | DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR 348 | IN PART, THIS LIMITATION MAY NOT APPLY TO YOU. 349 | 350 | c. The disclaimer of warranties and limitation of liability provided 351 | above shall be interpreted in a manner that, to the extent 352 | possible, most closely approximates an absolute disclaimer and 353 | waiver of all liability. 354 | 355 | 356 | Section 6 -- Term and Termination. 357 | 358 | a. This Public License applies for the term of the Copyright and 359 | Similar Rights licensed here. However, if You fail to comply with 360 | this Public License, then Your rights under this Public License 361 | terminate automatically. 362 | 363 | b. Where Your right to use the Licensed Material has terminated under 364 | Section 6(a), it reinstates: 365 | 366 | 1. automatically as of the date the violation is cured, provided 367 | it is cured within 30 days of Your discovery of the 368 | violation; or 369 | 370 | 2. upon express reinstatement by the Licensor. 371 | 372 | For the avoidance of doubt, this Section 6(b) does not affect any 373 | right the Licensor may have to seek remedies for Your violations 374 | of this Public License. 375 | 376 | c. For the avoidance of doubt, the Licensor may also offer the 377 | Licensed Material under separate terms or conditions or stop 378 | distributing the Licensed Material at any time; however, doing so 379 | will not terminate this Public License. 380 | 381 | d. Sections 1, 5, 6, 7, and 8 survive termination of this Public 382 | License. 383 | 384 | 385 | Section 7 -- Other Terms and Conditions. 386 | 387 | a. The Licensor shall not be bound by any additional or different 388 | terms or conditions communicated by You unless expressly agreed. 389 | 390 | b. Any arrangements, understandings, or agreements regarding the 391 | Licensed Material not stated herein are separate from and 392 | independent of the terms and conditions of this Public License. 393 | 394 | 395 | Section 8 -- Interpretation. 396 | 397 | a. For the avoidance of doubt, this Public License does not, and 398 | shall not be interpreted to, reduce, limit, restrict, or impose 399 | conditions on any use of the Licensed Material that could lawfully 400 | be made without permission under this Public License. 401 | 402 | b. To the extent possible, if any provision of this Public License is 403 | deemed unenforceable, it shall be automatically reformed to the 404 | minimum extent necessary to make it enforceable. If the provision 405 | cannot be reformed, it shall be severed from this Public License 406 | without affecting the enforceability of the remaining terms and 407 | conditions. 408 | 409 | c. No term or condition of this Public License will be waived and no 410 | failure to comply consented to unless expressly agreed to by the 411 | Licensor. 412 | 413 | d. Nothing in this Public License constitutes or may be interpreted 414 | as a limitation upon, or waiver of, any privileges and immunities 415 | that apply to the Licensor or You, including from the legal 416 | processes of any jurisdiction or authority. 417 | 418 | ======================================================================= 419 | 420 | Creative Commons is not a party to its public 421 | licenses. Notwithstanding, Creative Commons may elect to apply one of 422 | its public licenses to material it publishes and in those instances 423 | will be considered the “Licensor.” The text of the Creative Commons 424 | public licenses is dedicated to the public domain under the CC0 Public 425 | Domain Dedication. Except for the limited purpose of indicating that 426 | material is shared under a Creative Commons public license or as 427 | otherwise permitted by the Creative Commons policies published at 428 | creativecommons.org/policies, Creative Commons does not authorize the 429 | use of the trademark "Creative Commons" or any other trademark or logo 430 | of Creative Commons without its prior written consent including, 431 | without limitation, in connection with any unauthorized modifications 432 | to any of its public licenses or any other arrangements, 433 | understandings, or agreements concerning use of licensed material. For 434 | the avoidance of doubt, this paragraph does not form part of the 435 | public licenses. 436 | 437 | Creative Commons may be contacted at creativecommons.org. 438 | -------------------------------------------------------------------------------- /www/static/css/style.css: -------------------------------------------------------------------------------- 1 | /* 2 | ! tailwindcss v3.0.23 | MIT License | https://tailwindcss.com 3 | */ 4 | 5 | /* 6 | 1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) 7 | 2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116) 8 | */ 9 | 10 | *, 11 | ::before, 12 | ::after { 13 | box-sizing: border-box; 14 | /* 1 */ 15 | border-width: 0; 16 | /* 2 */ 17 | border-style: solid; 18 | /* 2 */ 19 | border-color: #e5e7eb; 20 | /* 2 */ 21 | } 22 | 23 | ::before, 24 | ::after { 25 | --tw-content: ''; 26 | } 27 | 28 | /* 29 | 1. Use a consistent sensible line-height in all browsers. 30 | 2. Prevent adjustments of font size after orientation changes in iOS. 31 | 3. Use a more readable tab size. 32 | 4. Use the user's configured `sans` font-family by default. 33 | */ 34 | 35 | html { 36 | line-height: 1.5; 37 | /* 1 */ 38 | -webkit-text-size-adjust: 100%; 39 | /* 2 */ 40 | -moz-tab-size: 4; 41 | /* 3 */ 42 | -o-tab-size: 4; 43 | tab-size: 4; 44 | /* 3 */ 45 | font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 46 | /* 4 */ 47 | } 48 | 49 | /* 50 | 1. Remove the margin in all browsers. 51 | 2. Inherit line-height from `html` so users can set them as a class directly on the `html` element. 52 | */ 53 | 54 | body { 55 | margin: 0; 56 | /* 1 */ 57 | line-height: inherit; 58 | /* 2 */ 59 | } 60 | 61 | /* 62 | 1. Add the correct height in Firefox. 63 | 2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) 64 | 3. Ensure horizontal rules are visible by default. 65 | */ 66 | 67 | hr { 68 | height: 0; 69 | /* 1 */ 70 | color: inherit; 71 | /* 2 */ 72 | border-top-width: 1px; 73 | /* 3 */ 74 | } 75 | 76 | /* 77 | Add the correct text decoration in Chrome, Edge, and Safari. 78 | */ 79 | 80 | abbr:where([title]) { 81 | -webkit-text-decoration: underline dotted; 82 | text-decoration: underline dotted; 83 | } 84 | 85 | /* 86 | Remove the default font size and weight for headings. 87 | */ 88 | 89 | h1, 90 | h2, 91 | h3, 92 | h4, 93 | h5, 94 | h6 { 95 | font-size: inherit; 96 | font-weight: inherit; 97 | } 98 | 99 | /* 100 | Reset links to optimize for opt-in styling instead of opt-out. 101 | */ 102 | 103 | a { 104 | color: inherit; 105 | text-decoration: inherit; 106 | } 107 | 108 | /* 109 | Add the correct font weight in Edge and Safari. 110 | */ 111 | 112 | b, 113 | strong { 114 | font-weight: bolder; 115 | } 116 | 117 | /* 118 | 1. Use the user's configured `mono` font family by default. 119 | 2. Correct the odd `em` font sizing in all browsers. 120 | */ 121 | 122 | code, 123 | kbd, 124 | samp, 125 | pre { 126 | font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; 127 | /* 1 */ 128 | font-size: 1em; 129 | /* 2 */ 130 | } 131 | 132 | /* 133 | Add the correct font size in all browsers. 134 | */ 135 | 136 | small { 137 | font-size: 80%; 138 | } 139 | 140 | /* 141 | Prevent `sub` and `sup` elements from affecting the line height in all browsers. 142 | */ 143 | 144 | sub, 145 | sup { 146 | font-size: 75%; 147 | line-height: 0; 148 | position: relative; 149 | vertical-align: baseline; 150 | } 151 | 152 | sub { 153 | bottom: -0.25em; 154 | } 155 | 156 | sup { 157 | top: -0.5em; 158 | } 159 | 160 | /* 161 | 1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) 162 | 2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) 163 | 3. Remove gaps between table borders by default. 164 | */ 165 | 166 | table { 167 | text-indent: 0; 168 | /* 1 */ 169 | border-color: inherit; 170 | /* 2 */ 171 | border-collapse: collapse; 172 | /* 3 */ 173 | } 174 | 175 | /* 176 | 1. Change the font styles in all browsers. 177 | 2. Remove the margin in Firefox and Safari. 178 | 3. Remove default padding in all browsers. 179 | */ 180 | 181 | button, 182 | input, 183 | optgroup, 184 | select, 185 | textarea { 186 | font-family: inherit; 187 | /* 1 */ 188 | font-size: 100%; 189 | /* 1 */ 190 | line-height: inherit; 191 | /* 1 */ 192 | color: inherit; 193 | /* 1 */ 194 | margin: 0; 195 | /* 2 */ 196 | padding: 0; 197 | /* 3 */ 198 | } 199 | 200 | /* 201 | Remove the inheritance of text transform in Edge and Firefox. 202 | */ 203 | 204 | button, 205 | select { 206 | text-transform: none; 207 | } 208 | 209 | /* 210 | 1. Correct the inability to style clickable types in iOS and Safari. 211 | 2. Remove default button styles. 212 | */ 213 | 214 | button, 215 | [type='button'], 216 | [type='reset'], 217 | [type='submit'] { 218 | -webkit-appearance: button; 219 | /* 1 */ 220 | background-color: transparent; 221 | /* 2 */ 222 | background-image: none; 223 | /* 2 */ 224 | } 225 | 226 | /* 227 | Use the modern Firefox focus style for all focusable elements. 228 | */ 229 | 230 | :-moz-focusring { 231 | outline: auto; 232 | } 233 | 234 | /* 235 | Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737) 236 | */ 237 | 238 | :-moz-ui-invalid { 239 | box-shadow: none; 240 | } 241 | 242 | /* 243 | Add the correct vertical alignment in Chrome and Firefox. 244 | */ 245 | 246 | progress { 247 | vertical-align: baseline; 248 | } 249 | 250 | /* 251 | Correct the cursor style of increment and decrement buttons in Safari. 252 | */ 253 | 254 | ::-webkit-inner-spin-button, 255 | ::-webkit-outer-spin-button { 256 | height: auto; 257 | } 258 | 259 | /* 260 | 1. Correct the odd appearance in Chrome and Safari. 261 | 2. Correct the outline style in Safari. 262 | */ 263 | 264 | [type='search'] { 265 | -webkit-appearance: textfield; 266 | /* 1 */ 267 | outline-offset: -2px; 268 | /* 2 */ 269 | } 270 | 271 | /* 272 | Remove the inner padding in Chrome and Safari on macOS. 273 | */ 274 | 275 | ::-webkit-search-decoration { 276 | -webkit-appearance: none; 277 | } 278 | 279 | /* 280 | 1. Correct the inability to style clickable types in iOS and Safari. 281 | 2. Change font properties to `inherit` in Safari. 282 | */ 283 | 284 | ::-webkit-file-upload-button { 285 | -webkit-appearance: button; 286 | /* 1 */ 287 | font: inherit; 288 | /* 2 */ 289 | } 290 | 291 | /* 292 | Add the correct display in Chrome and Safari. 293 | */ 294 | 295 | summary { 296 | display: list-item; 297 | } 298 | 299 | /* 300 | Removes the default spacing and border for appropriate elements. 301 | */ 302 | 303 | blockquote, 304 | dl, 305 | dd, 306 | h1, 307 | h2, 308 | h3, 309 | h4, 310 | h5, 311 | h6, 312 | hr, 313 | figure, 314 | p, 315 | pre { 316 | margin: 0; 317 | } 318 | 319 | fieldset { 320 | margin: 0; 321 | padding: 0; 322 | } 323 | 324 | legend { 325 | padding: 0; 326 | } 327 | 328 | ol, 329 | ul, 330 | menu { 331 | list-style: none; 332 | margin: 0; 333 | padding: 0; 334 | } 335 | 336 | /* 337 | Prevent resizing textareas horizontally by default. 338 | */ 339 | 340 | textarea { 341 | resize: vertical; 342 | } 343 | 344 | /* 345 | 1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300) 346 | 2. Set the default placeholder color to the user's configured gray 400 color. 347 | */ 348 | 349 | input::-moz-placeholder, textarea::-moz-placeholder { 350 | opacity: 1; 351 | /* 1 */ 352 | color: #9ca3af; 353 | /* 2 */ 354 | } 355 | 356 | input:-ms-input-placeholder, textarea:-ms-input-placeholder { 357 | opacity: 1; 358 | /* 1 */ 359 | color: #9ca3af; 360 | /* 2 */ 361 | } 362 | 363 | input::placeholder, 364 | textarea::placeholder { 365 | opacity: 1; 366 | /* 1 */ 367 | color: #9ca3af; 368 | /* 2 */ 369 | } 370 | 371 | /* 372 | Set the default cursor for buttons. 373 | */ 374 | 375 | button, 376 | [role="button"] { 377 | cursor: pointer; 378 | } 379 | 380 | /* 381 | Make sure disabled buttons don't get the pointer cursor. 382 | */ 383 | 384 | :disabled { 385 | cursor: default; 386 | } 387 | 388 | /* 389 | 1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14) 390 | 2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210) 391 | This can trigger a poorly considered lint error in some tools but is included by design. 392 | */ 393 | 394 | img, 395 | svg, 396 | video, 397 | canvas, 398 | audio, 399 | iframe, 400 | embed, 401 | object { 402 | display: block; 403 | /* 1 */ 404 | vertical-align: middle; 405 | /* 2 */ 406 | } 407 | 408 | /* 409 | Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14) 410 | */ 411 | 412 | img, 413 | video { 414 | max-width: 100%; 415 | height: auto; 416 | } 417 | 418 | /* 419 | Ensure the default browser behavior of the `hidden` attribute. 420 | */ 421 | 422 | [hidden] { 423 | display: none; 424 | } 425 | 426 | @font-face { 427 | font-family: 'Star Trek LCARS'; 428 | 429 | src: url('../fonts/star-trek-lcars.ttf'); 430 | } 431 | 432 | @-webkit-keyframes blinker { 433 | from { 434 | opacity: 1.0; 435 | } 436 | 437 | 50% { 438 | opacity: 0; 439 | } 440 | 441 | to { 442 | opacity: 1.0; 443 | } 444 | } 445 | 446 | @keyframes blinker { 447 | from { 448 | opacity: 1.0; 449 | } 450 | 451 | 50% { 452 | opacity: 0; 453 | } 454 | 455 | to { 456 | opacity: 1.0; 457 | } 458 | } 459 | 460 | *, ::before, ::after { 461 | --tw-translate-x: 0; 462 | --tw-translate-y: 0; 463 | --tw-rotate: 0; 464 | --tw-skew-x: 0; 465 | --tw-skew-y: 0; 466 | --tw-scale-x: 1; 467 | --tw-scale-y: 1; 468 | --tw-pan-x: ; 469 | --tw-pan-y: ; 470 | --tw-pinch-zoom: ; 471 | --tw-scroll-snap-strictness: proximity; 472 | --tw-ordinal: ; 473 | --tw-slashed-zero: ; 474 | --tw-numeric-figure: ; 475 | --tw-numeric-spacing: ; 476 | --tw-numeric-fraction: ; 477 | --tw-ring-inset: ; 478 | --tw-ring-offset-width: 0px; 479 | --tw-ring-offset-color: #fff; 480 | --tw-ring-color: rgb(59 130 246 / 0.5); 481 | --tw-ring-offset-shadow: 0 0 #0000; 482 | --tw-ring-shadow: 0 0 #0000; 483 | --tw-shadow: 0 0 #0000; 484 | --tw-shadow-colored: 0 0 #0000; 485 | --tw-blur: ; 486 | --tw-brightness: ; 487 | --tw-contrast: ; 488 | --tw-grayscale: ; 489 | --tw-hue-rotate: ; 490 | --tw-invert: ; 491 | --tw-saturate: ; 492 | --tw-sepia: ; 493 | --tw-drop-shadow: ; 494 | --tw-backdrop-blur: ; 495 | --tw-backdrop-brightness: ; 496 | --tw-backdrop-contrast: ; 497 | --tw-backdrop-grayscale: ; 498 | --tw-backdrop-hue-rotate: ; 499 | --tw-backdrop-invert: ; 500 | --tw-backdrop-opacity: ; 501 | --tw-backdrop-saturate: ; 502 | --tw-backdrop-sepia: ; 503 | } 504 | 505 | main { 506 | min-height: 100vh; 507 | width: 100%; 508 | display: flex; 509 | flex-direction: row; 510 | --tw-bg-opacity: 1; 511 | background-color: rgb(30 41 59 / var(--tw-bg-opacity)); 512 | } 513 | 514 | main nav { 515 | min-height: 100%; 516 | width: 4rem; 517 | display: flex; 518 | flex-direction: column; 519 | --tw-bg-opacity: 1; 520 | background-color: rgb(51 65 85 / var(--tw-bg-opacity)); 521 | --tw-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25); 522 | --tw-shadow-colored: 0 25px 50px -12px var(--tw-shadow-color); 523 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 524 | } 525 | 526 | main nav .menu { 527 | position: -webkit-sticky; 528 | position: sticky; 529 | top: 0px; 530 | } 531 | 532 | main nav .menu a { 533 | height: 4rem; 534 | width: 4rem; 535 | display: flex; 536 | vertical-align: middle; 537 | border-left-width: 4px; 538 | border-left-color: transparent; 539 | } 540 | 541 | main nav .menu a:hover { 542 | --tw-border-opacity: 1; 543 | border-left-color: rgb(59 130 246 / var(--tw-border-opacity)); 544 | } 545 | 546 | main nav .menu a { 547 | transition-property: all; 548 | transition-duration: 150ms; 549 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 550 | } 551 | 552 | main nav .menu a.active { 553 | --tw-border-opacity: 1; 554 | border-left-color: rgb(147 197 253 / var(--tw-border-opacity)); 555 | } 556 | 557 | main nav .menu a.active:hover { 558 | --tw-border-opacity: 1; 559 | border-left-color: rgb(59 130 246 / var(--tw-border-opacity)); 560 | } 561 | 562 | main nav .menu a svg { 563 | margin: auto; 564 | height: auto; 565 | width: 50%; 566 | } 567 | 568 | main nav .menu a svg path, main nav .menu a svg polygon, main nav .menu a svg rect { 569 | transition-property: all; 570 | transition-duration: 150ms; 571 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 572 | fill: #fff; 573 | } 574 | 575 | main nav .menu a.active svg path, main nav .menu a.active svg polygon, main nav .menu a.active svg rect { 576 | fill: #93c5fd; 577 | } 578 | 579 | main nav .menu a:hover svg path, main nav .menu a:hover svg polygon, main nav .menu a:hover svg rect { 580 | fill: #3b82f6; 581 | } 582 | 583 | main nav .menu a svg circle { 584 | transition-property: all; 585 | transition-duration: 150ms; 586 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 587 | stroke: #fff; 588 | } 589 | 590 | main nav .menu a:hover svg circle { 591 | stroke: #3b82f6; 592 | } 593 | 594 | main article { 595 | border-right-width: 4px; 596 | --tw-border-opacity: 1; 597 | border-right-color: rgb(253 186 116 / var(--tw-border-opacity)); 598 | --tw-text-opacity: 1; 599 | color: rgb(255 255 255 / var(--tw-text-opacity)); 600 | width: calc(100% - 4rem); 601 | } 602 | 603 | @media (min-width: 1024px) { 604 | main article header { 605 | width: 80%; 606 | } 607 | } 608 | 609 | main article header { 610 | margin-bottom: 1.5rem; 611 | padding: 0.75rem; 612 | } 613 | 614 | @media (min-width: 1024px) { 615 | main article header { 616 | margin-left: auto; 617 | margin-right: auto; 618 | } 619 | } 620 | 621 | main article header { 622 | text-align: center; 623 | font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; 624 | font-size: 2.25rem; 625 | line-height: 2.5rem; 626 | font-weight: 700; 627 | --tw-text-opacity: 1; 628 | color: rgb(0 0 0 / var(--tw-text-opacity)); 629 | --tw-bg-opacity: 1; 630 | background-color: rgb(234 179 8 / var(--tw-bg-opacity)); 631 | --tw-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25); 632 | --tw-shadow-colored: 0 25px 50px -12px var(--tw-shadow-color); 633 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 634 | } 635 | 636 | @media (min-width: 1024px) { 637 | main article header { 638 | border-bottom-right-radius: 9999px; 639 | border-bottom-left-radius: 9999px; 640 | } 641 | } 642 | 643 | main article header::before { 644 | font-family: Star Trek LCARS, sans-serif; 645 | content: '$> '; 646 | } 647 | 648 | main article header::after { 649 | font-family: Star Trek LCARS, sans-serif; 650 | content: '_'; 651 | -webkit-animation: blinker 1s infinite; 652 | animation: blinker 1s infinite; 653 | } 654 | 655 | main article footer { 656 | clear: both; 657 | margin-top: 3rem; 658 | padding: 3rem; 659 | text-align: center; 660 | } 661 | 662 | main article h1 { 663 | width: 91.666667%; 664 | border-top-left-radius: 9999px; 665 | border-bottom-left-radius: 9999px; 666 | clear: both; 667 | margin-top: 1.5rem; 668 | margin-bottom: 1.5rem; 669 | margin-left: auto; 670 | padding: 0.75rem; 671 | padding-left: 1.5rem; 672 | --tw-bg-opacity: 1; 673 | background-color: rgb(253 186 116 / var(--tw-bg-opacity)); 674 | --tw-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25); 675 | --tw-shadow-colored: 0 25px 50px -12px var(--tw-shadow-color); 676 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 677 | font-family: Star Trek LCARS, sans-serif; 678 | font-size: 1.5rem; 679 | line-height: 2rem; 680 | font-weight: 700; 681 | --tw-text-opacity: 1; 682 | color: rgb(0 0 0 / var(--tw-text-opacity)); 683 | } 684 | 685 | main article h2 { 686 | width: 83.333333%; 687 | border-top-left-radius: 9999px; 688 | border-bottom-left-radius: 9999px; 689 | clear: both; 690 | margin-top: 1.5rem; 691 | margin-bottom: 1.5rem; 692 | margin-left: auto; 693 | padding: 0.75rem; 694 | padding-left: 1.5rem; 695 | background: linear-gradient(135deg, rgb(3 105 161) 85%, transparent 85%, transparent 86%, rgb(253 186 116) 86%); 696 | --tw-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25); 697 | --tw-shadow-colored: 0 25px 50px -12px var(--tw-shadow-color); 698 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 699 | font-family: Star Trek LCARS, sans-serif; 700 | font-size: 1.5rem; 701 | line-height: 2rem; 702 | font-weight: 700; 703 | --tw-text-opacity: 1; 704 | color: rgb(209 213 219 / var(--tw-text-opacity)); 705 | } 706 | 707 | main article h3 { 708 | width: 75%; 709 | border-top-left-radius: 9999px; 710 | border-bottom-left-radius: 9999px; 711 | clear: both; 712 | margin-top: 1.5rem; 713 | margin-bottom: 1.5rem; 714 | margin-left: auto; 715 | padding: 0.75rem; 716 | padding-left: 1.5rem; 717 | background: linear-gradient(135deg, rgb(153 27 27) 70%, transparent 70%, transparent 71%, rgb(3 105 161) 71%, rgb(3 105 161) 85%, transparent 85%, transparent 86%, rgb(253 186 116) 86%); 718 | --tw-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25); 719 | --tw-shadow-colored: 0 25px 50px -12px var(--tw-shadow-color); 720 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 721 | font-family: Star Trek LCARS, sans-serif; 722 | font-size: 1.5rem; 723 | line-height: 2rem; 724 | font-weight: 700; 725 | --tw-text-opacity: 1; 726 | color: rgb(209 213 219 / var(--tw-text-opacity)); 727 | } 728 | 729 | main article h4, main article h5, main article h6 { 730 | margin-top: 1.5rem; 731 | margin-bottom: 1.5rem; 732 | padding: 0.75rem; 733 | padding-left: 1.5rem; 734 | font-size: 1.25rem; 735 | line-height: 1.75rem; 736 | font-weight: 700; 737 | } 738 | 739 | main article p { 740 | margin-left: 1.5rem; 741 | margin-right: 1.5rem; 742 | padding-left: 1.5rem; 743 | padding-right: 1.5rem; 744 | padding-top: 0.75rem; 745 | padding-bottom: 0.75rem; 746 | text-align: justify; 747 | font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 748 | font-size: 1.125rem; 749 | line-height: 1.75rem; 750 | } 751 | 752 | main article p code { 753 | padding: 0.25rem; 754 | --tw-bg-opacity: 1; 755 | background-color: rgb(38 38 38 / var(--tw-bg-opacity)); 756 | --tw-text-opacity: 1; 757 | color: rgb(252 165 165 / var(--tw-text-opacity)); 758 | } 759 | 760 | main article a { 761 | transition-property: all; 762 | transition-duration: 150ms; 763 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 764 | --tw-text-opacity: 1; 765 | color: rgb(96 165 250 / var(--tw-text-opacity)); 766 | -webkit-text-decoration-line: underline; 767 | text-decoration-line: underline; 768 | } 769 | 770 | main article a:hover { 771 | --tw-text-opacity: 1; 772 | color: rgb(37 99 235 / var(--tw-text-opacity)); 773 | } 774 | 775 | main article figure { 776 | float: right; 777 | margin: 1.5rem; 778 | padding: 0px; 779 | border-width: 4px; 780 | --tw-border-opacity: 1; 781 | border-color: rgb(253 186 116 / var(--tw-border-opacity)); 782 | --tw-bg-opacity: 1; 783 | background-color: rgb(38 38 38 / var(--tw-bg-opacity)); 784 | --tw-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25); 785 | --tw-shadow-colored: 0 25px 50px -12px var(--tw-shadow-color); 786 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 787 | } 788 | 789 | main article figure figcaption { 790 | text-align: center; 791 | } 792 | 793 | main article ul { 794 | margin-left: 3rem; 795 | padding-left: 1.5rem; 796 | padding-right: 1.5rem; 797 | list-style-type: disc; 798 | } 799 | 800 | main article blockquote { 801 | margin-left: 3rem; 802 | margin-right: 3rem; 803 | border-left-width: 4px; 804 | --tw-border-opacity: 1; 805 | border-left-color: rgb(147 197 253 / var(--tw-border-opacity)); 806 | font-style: italic; 807 | } 808 | 809 | main article blockquote p { 810 | padding-left: 0.25rem; 811 | padding-right: 0.25rem; 812 | } 813 | 814 | main article .highlight { 815 | margin-left: 1.5rem; 816 | margin-right: 1.5rem; 817 | padding: 1.5rem; 818 | overflow: auto; 819 | } 820 | 821 | main article .highlight pre { 822 | padding: 1.5rem; 823 | --tw-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25); 824 | --tw-shadow-colored: 0 25px 50px -12px var(--tw-shadow-color); 825 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 826 | overflow: auto; 827 | } 828 | 829 | .lcars-screen { 830 | margin: auto; 831 | margin-top: 1.5rem; 832 | margin-bottom: 1.5rem; 833 | width: 75%; 834 | display: flex; 835 | flex-direction: row; 836 | align-items: stretch; 837 | --tw-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25); 838 | --tw-shadow-colored: 0 25px 50px -12px var(--tw-shadow-color); 839 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 840 | } 841 | 842 | .lcars-screen .buttons { 843 | display: flex; 844 | flex-direction: column; 845 | align-items: stretch; 846 | justify-content: space-between; 847 | --tw-bg-opacity: 1; 848 | background-color: rgb(38 38 38 / var(--tw-bg-opacity)); 849 | padding-right: 0.25rem; 850 | border-top-left-radius: 1rem; 851 | border-bottom-left-radius: 1rem; 852 | border-left-width: 4px; 853 | --tw-border-opacity: 1; 854 | border-color: rgb(253 186 116 / var(--tw-border-opacity)); 855 | } 856 | 857 | .lcars-screen .buttons a { 858 | padding: 1.5rem; 859 | transition-property: all; 860 | transition-duration: 150ms; 861 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 862 | --tw-bg-opacity: 1; 863 | background-color: rgb(253 186 116 / var(--tw-bg-opacity)); 864 | } 865 | 866 | .lcars-screen .buttons a:hover { 867 | --tw-bg-opacity: 1; 868 | background-color: rgb(234 179 8 / var(--tw-bg-opacity)); 869 | } 870 | 871 | .lcars-screen .buttons a { 872 | text-align: center; 873 | font-family: Star Trek LCARS, sans-serif; 874 | font-weight: 700; 875 | --tw-text-opacity: 1; 876 | color: rgb(0 0 0 / var(--tw-text-opacity)); 877 | -webkit-text-decoration-line: none; 878 | text-decoration-line: none; 879 | } 880 | 881 | .lcars-screen .buttons a:first-child { 882 | border-top-left-radius: 0.75rem; 883 | } 884 | 885 | .lars-screen .buttons a:not(:first-child):not(:last-child) { 886 | margin-top: 0.25rem; 887 | margin-bottom: 0.25rem; 888 | } 889 | 890 | .lcars-screen .buttons a:last-child { 891 | border-bottom-left-radius: 0.75rem; 892 | } 893 | 894 | .lcars-screen .viewport { 895 | position: relative; 896 | flex: 1 1 0%; 897 | padding: 1.5rem; 898 | border-width: 4px; 899 | --tw-border-opacity: 1; 900 | border-color: rgb(253 186 116 / var(--tw-border-opacity)); 901 | --tw-bg-opacity: 1; 902 | background-color: rgb(38 38 38 / var(--tw-bg-opacity)); 903 | } 904 | 905 | .lcars-screen .viewport::before { 906 | content: ''; 907 | left: 0px; 908 | right: 0px; 909 | --tw-bg-opacity: 1; 910 | background-color: rgb(38 38 38 / var(--tw-bg-opacity)); 911 | position: absolute; 912 | z-index: 10; 913 | margin: auto; 914 | display: block; 915 | width: 95%; 916 | top: -4px; 917 | height: calc(100% + 8px); 918 | } 919 | 920 | .lcars-screen .viewport canvas { 921 | position: relative; 922 | z-index: 20; 923 | display: block; 924 | height: 100%; 925 | width: 100%; 926 | } 927 | 928 | .lcars-actions { 929 | margin-top: 1.5rem; 930 | margin-bottom: 1.5rem; 931 | padding-top: 1.5rem; 932 | padding-bottom: 1.5rem; 933 | } 934 | 935 | @media (min-width: 640px) { 936 | .lcars-actions { 937 | margin-left: 1.5rem; 938 | margin-right: 1.5rem; 939 | } 940 | } 941 | 942 | @media (min-width: 768px) { 943 | .lcars-actions { 944 | margin-left: auto; 945 | margin-right: auto; 946 | } 947 | 948 | .lcars-actions { 949 | width: 75%; 950 | } 951 | } 952 | 953 | .lcars-actions { 954 | text-align: center; 955 | } 956 | 957 | .lcars-actions a { 958 | padding: 1.5rem; 959 | transition-property: all; 960 | transition-duration: 150ms; 961 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 962 | --tw-bg-opacity: 1; 963 | background-color: rgb(253 186 116 / var(--tw-bg-opacity)); 964 | font-family: Star Trek LCARS, sans-serif; 965 | font-weight: 700; 966 | --tw-text-opacity: 1; 967 | color: rgb(0 0 0 / var(--tw-text-opacity)); 968 | -webkit-text-decoration-line: none; 969 | text-decoration-line: none; 970 | } 971 | 972 | .lcars-actions a:hover { 973 | --tw-bg-opacity: 1; 974 | background-color: rgb(234 179 8 / var(--tw-bg-opacity)); 975 | --tw-text-opacity: 1; 976 | color: rgb(0 0 0 / var(--tw-text-opacity)); 977 | } 978 | 979 | .lcars-actions a:first-child { 980 | border-top-left-radius: 9999px; 981 | border-bottom-left-radius: 9999px; 982 | margin-right: 0.25rem; 983 | padding-left: 2.25rem; 984 | } 985 | 986 | .lcars-actions a:not(:first-child):not(:last-child) { 987 | margin-left: 0.25rem; 988 | margin-right: 0.25rem; 989 | } 990 | 991 | .lcars-actions a:last-child { 992 | border-top-right-radius: 9999px; 993 | border-bottom-right-radius: 9999px; 994 | margin-left: 0.25rem; 995 | padding-right: 2.25rem; 996 | } 997 | 998 | .lcars-index { 999 | width: 100%; 1000 | padding: 1.5rem; 1001 | } 1002 | 1003 | .lcars-index table { 1004 | width: 100%; 1005 | } 1006 | 1007 | .lcars-index table td, .lcars-index table th { 1008 | padding: 0.75rem; 1009 | white-space: nowrap; 1010 | text-align: left; 1011 | font-family: Star Trek LCARS, sans-serif; 1012 | font-size: 1.5rem; 1013 | line-height: 2rem; 1014 | } 1015 | 1016 | .lcars-index table th { 1017 | text-transform: uppercase; 1018 | --tw-text-opacity: 1; 1019 | color: rgb(220 38 38 / var(--tw-text-opacity)); 1020 | } 1021 | 1022 | .lcars-index table tr td { 1023 | transition-property: all; 1024 | transition-duration: 150ms; 1025 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 1026 | --tw-text-opacity: 1; 1027 | color: rgb(96 165 250 / var(--tw-text-opacity)); 1028 | } 1029 | 1030 | .lcars-index table td a { 1031 | text-transform: uppercase; 1032 | -webkit-text-decoration-line: none; 1033 | text-decoration-line: none; 1034 | } 1035 | 1036 | .lcars-index table tr:hover td, .lcars-index table tr:hover td a { 1037 | --tw-text-opacity: 1; 1038 | color: rgb(37 99 235 / var(--tw-text-opacity)); 1039 | cursor: pointer; 1040 | } 1041 | 1042 | @media (min-width: 640px) { 1043 | .lcars-notice { 1044 | width: 100%; 1045 | } 1046 | } 1047 | 1048 | @media (min-width: 768px) { 1049 | .lcars-notice { 1050 | width: 25%; 1051 | } 1052 | } 1053 | 1054 | .lcars-notice { 1055 | padding: 1.5rem; 1056 | } 1057 | 1058 | @media (min-width: 640px) { 1059 | .lcars-notice { 1060 | margin: 1.5rem; 1061 | } 1062 | } 1063 | 1064 | @media (min-width: 768px) { 1065 | .lcars-notice { 1066 | margin-top: 1.5rem; 1067 | margin-bottom: 1.5rem; 1068 | } 1069 | 1070 | .lcars-notice { 1071 | margin-left: auto; 1072 | margin-right: auto; 1073 | } 1074 | } 1075 | 1076 | .lcars-notice { 1077 | display: flex; 1078 | flex-direction: row; 1079 | align-items: center; 1080 | border-width: 4px; 1081 | --tw-border-opacity: 1; 1082 | border-color: rgb(234 88 12 / var(--tw-border-opacity)); 1083 | --tw-bg-opacity: 1; 1084 | background-color: rgb(38 38 38 / var(--tw-bg-opacity)); 1085 | --tw-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25); 1086 | --tw-shadow-colored: 0 25px 50px -12px var(--tw-shadow-color); 1087 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 1088 | } 1089 | 1090 | .lcars-notice-icon svg { 1091 | height: auto; 1092 | width: 8rem; 1093 | } 1094 | 1095 | .lcars-notice-icon svg path, .lcars-notice-icon svg polygon, .lcars-notice-icon svg rect { 1096 | fill: #eab308; 1097 | } 1098 | 1099 | .lcars-notice-icon svg circle { 1100 | stroke: #eab308; 1101 | } 1102 | 1103 | .lcars-notice-text { 1104 | text-align: center; 1105 | font-family: Star Trek LCARS, sans-serif; 1106 | font-size: 2.25rem; 1107 | line-height: 2.5rem; 1108 | font-weight: 700; 1109 | --tw-text-opacity: 1; 1110 | color: rgb(234 179 8 / var(--tw-text-opacity)); 1111 | } 1112 | 1113 | .block { 1114 | display: block; 1115 | } 1116 | 1117 | .table { 1118 | display: table; 1119 | } 1120 | 1121 | .hidden { 1122 | display: none; 1123 | } 1124 | 1125 | .w-full { 1126 | width: 100%; 1127 | } 1128 | --------------------------------------------------------------------------------