├── tiron-common ├── src │ ├── run.rs │ ├── event.rs │ ├── lib.rs │ ├── node.rs │ ├── action.rs │ ├── value.rs │ └── error.rs └── Cargo.toml ├── tiron-lsp ├── src │ └── main.rs └── Cargo.toml ├── .gitignore ├── docs ├── templates │ ├── page.html │ ├── docs │ │ ├── page.html │ │ └── section.html │ ├── actions.html │ ├── actions-item.html │ ├── blog-page.html │ ├── blog.html │ ├── base.html │ └── index.html ├── content │ └── docs │ │ ├── actions │ │ ├── _index.md │ │ ├── command.md │ │ ├── git.md │ │ ├── copy.md │ │ ├── package.md │ │ └── file.md │ │ ├── _index.md │ │ └── getting-started │ │ ├── _index.md │ │ └── overview.md ├── config.toml ├── tailwind.config.js └── styles │ └── styles.css ├── examples └── example_tiron_project │ ├── jobs │ └── job1 │ │ ├── files │ │ └── test.rcl │ │ ├── test.tr │ │ └── main.tr │ ├── tiron.tr │ └── main.tr ├── tiron-node ├── src │ ├── lib.rs │ ├── main.rs │ ├── action │ │ ├── data.rs │ │ ├── git.rs │ │ ├── package │ │ │ ├── provider.rs │ │ │ └── mod.rs │ │ ├── copy.rs │ │ ├── file.rs │ │ ├── command.rs │ │ └── mod.rs │ ├── stdio.rs │ └── node.rs └── Cargo.toml ├── tiron-tui ├── src │ ├── lib.rs │ ├── event.rs │ ├── tui.rs │ ├── app.rs │ └── run.rs └── Cargo.toml ├── tiron ├── src │ ├── main.rs │ ├── lib.rs │ ├── job.rs │ ├── group.rs │ ├── local.rs │ ├── cli.rs │ ├── doc.rs │ ├── fmt.rs │ ├── node.rs │ ├── core.rs │ ├── run.rs │ ├── remote.rs │ └── runbook.rs └── Cargo.toml ├── .gitattributes ├── .github ├── scripts │ └── install.sh └── workflows │ ├── ci.yml │ └── release.yml ├── Cargo.toml ├── README.md └── LICENSE /tiron-common/src/run.rs: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /tiron-common/src/event.rs: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /tiron-lsp/src/main.rs: -------------------------------------------------------------------------------- 1 | fn main() {} 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /docs/public 3 | -------------------------------------------------------------------------------- /docs/templates/page.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} -------------------------------------------------------------------------------- /docs/templates/docs/page.html: -------------------------------------------------------------------------------- 1 | {% extends "page.html" %} -------------------------------------------------------------------------------- /examples/example_tiron_project/jobs/job1/files/test.rcl: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tiron-node/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod action; 2 | pub mod node; 3 | pub mod stdio; 4 | -------------------------------------------------------------------------------- /tiron-tui/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod app; 2 | pub mod event; 3 | mod reflow; 4 | pub mod run; 5 | mod tui; 6 | -------------------------------------------------------------------------------- /docs/content/docs/actions/_index.md: -------------------------------------------------------------------------------- 1 | +++ 2 | title = "Actions" 3 | sort_by = "title" 4 | weight = 2 5 | +++ 6 | -------------------------------------------------------------------------------- /tiron-node/src/main.rs: -------------------------------------------------------------------------------- 1 | fn main() -> anyhow::Result<()> { 2 | tiron_node::node::start()?; 3 | Ok(()) 4 | } 5 | -------------------------------------------------------------------------------- /tiron/src/main.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | if let Err(e) = tiron::core::cmd() { 3 | let _ = e.report_stderr(); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /tiron-common/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod action; 2 | pub mod error; 3 | pub mod event; 4 | pub mod node; 5 | pub mod run; 6 | pub mod value; 7 | -------------------------------------------------------------------------------- /tiron-lsp/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tiron-lsp" 3 | version.workspace = true 4 | edition.workspace = true 5 | 6 | [dependencies] 7 | -------------------------------------------------------------------------------- /docs/content/docs/_index.md: -------------------------------------------------------------------------------- 1 | +++ 2 | title = "docs" 3 | template = "docs/page.html" 4 | sort_by = "weight" 5 | redirect_to = "docs/getting-started/" 6 | +++ 7 | -------------------------------------------------------------------------------- /tiron/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod cli; 2 | pub mod core; 3 | mod doc; 4 | mod fmt; 5 | mod group; 6 | mod job; 7 | mod local; 8 | mod node; 9 | mod remote; 10 | mod run; 11 | mod runbook; 12 | -------------------------------------------------------------------------------- /docs/content/docs/getting-started/_index.md: -------------------------------------------------------------------------------- 1 | +++ 2 | title = "Getting Started" 3 | sort_by = "weight" 4 | weight = 1 5 | template = "docs/page.html" 6 | redirect_to = "docs/getting-started/overview/" 7 | +++ 8 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform normalization 2 | * text=auto 3 | 4 | *.rs text diff=rust 5 | *.toml text diff=toml 6 | Cargo.lock text 7 | 8 | *.tr text linguist-language=HCL 9 | -------------------------------------------------------------------------------- /tiron-common/src/node.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use crate::action::ActionData; 4 | 5 | #[derive(Deserialize, Serialize)] 6 | pub enum NodeMessage { 7 | Action(ActionData), 8 | Shutdown, 9 | } 10 | -------------------------------------------------------------------------------- /tiron/src/job.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use hcl_edit::structure::Block; 4 | 5 | #[derive(Clone)] 6 | pub struct Job { 7 | pub block: Block, 8 | pub imported: Option, 9 | } 10 | 11 | impl Job {} 12 | -------------------------------------------------------------------------------- /examples/example_tiron_project/jobs/job1/test.tr: -------------------------------------------------------------------------------- 1 | job "job2" { 2 | action "copy" { 3 | name = "the first action in job2" 4 | 5 | params { 6 | src = "/tmp/test.tr" 7 | dest = "/tmp/test.conf" 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /docs/templates/actions.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 | 9 | {% endblock content %} -------------------------------------------------------------------------------- /docs/templates/actions-item.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |

5 | {{ page.title }} 6 |

7 |
8 | {{ page.content | safe }} 9 |
10 | {% endblock content %} -------------------------------------------------------------------------------- /docs/templates/blog-page.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |

5 | {{ page.title }} 6 |

7 |

{{ page.date }}

8 |
9 | {{ page.content | safe }} 10 |
11 | {% endblock content %} -------------------------------------------------------------------------------- /examples/example_tiron_project/tiron.tr: -------------------------------------------------------------------------------- 1 | group "group1" { 2 | host "machine1" { } 3 | } 4 | 5 | group "group2" { 6 | host "machine1" { 7 | var1 = "machine1_var1" 8 | } 9 | 10 | group "group1" { 11 | var3 = "var3" 12 | } 13 | 14 | apache = "apache2" 15 | var1 = "var1" 16 | var2 = "var2" 17 | } 18 | -------------------------------------------------------------------------------- /examples/example_tiron_project/jobs/job1/main.tr: -------------------------------------------------------------------------------- 1 | use "test.tr" { 2 | job "job2" { } 3 | } 4 | 5 | job "job1" { 6 | action "copy" { 7 | name = "the first action" 8 | 9 | params { 10 | src = "/tmp/test.tr" 11 | dest = "/tmp/test.conf" 12 | } 13 | } 14 | 15 | action "job" { 16 | params { 17 | name = "job2" 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tiron-common/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tiron-common" 3 | version.workspace = true 4 | edition.workspace = true 5 | 6 | [dependencies] 7 | hcl-rs = { workspace = true } 8 | hcl-edit = { workspace = true } 9 | anyhow = { workspace = true } 10 | serde = { workspace = true } 11 | uuid = { workspace = true } 12 | -------------------------------------------------------------------------------- /docs/content/docs/actions/command.md: -------------------------------------------------------------------------------- 1 | +++ 2 | title = "command" 3 | template = "docs/section.html" 4 | +++ 5 | 6 | # command 7 | 8 | Run the command on the remote machine 9 | 10 | ### Parameters 11 | 12 | | Parameter | Description | 13 | | -------------- | ----------- | 14 | | **cmd**
String
Required: true | The command to run | 15 | | **args**
List of String
Required: false | The command arguments | 16 | -------------------------------------------------------------------------------- /docs/templates/blog.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |

5 | {{ section.title }} 6 |

7 | 13 | {% endblock content %} -------------------------------------------------------------------------------- /docs/content/docs/actions/git.md: -------------------------------------------------------------------------------- 1 | +++ 2 | title = "git" 3 | template = "docs/section.html" 4 | +++ 5 | 6 | # git 7 | 8 | Manage Git repositories 9 | 10 | ### Parameters 11 | 12 | | Parameter | Description | 13 | | -------------- | ----------- | 14 | | **repo**
String
Required: true | address of the git repository | 15 | | **dest**
String
Required: true | The path of where the repository should be checked out. | 16 | -------------------------------------------------------------------------------- /docs/content/docs/actions/copy.md: -------------------------------------------------------------------------------- 1 | +++ 2 | title = "copy" 3 | template = "docs/section.html" 4 | +++ 5 | 6 | # copy 7 | 8 | Copy the file to the remote machine 9 | 10 | ### Parameters 11 | 12 | | Parameter | Description | 13 | | -------------- | ----------- | 14 | | **src**
String
Required: true | Local path of a file to be copied | 15 | | **dest**
String
Required: true | The path where file should be copied to on remote server | 16 | -------------------------------------------------------------------------------- /tiron/src/group.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, path::PathBuf}; 2 | 3 | #[derive(Clone)] 4 | pub enum HostOrGroup { 5 | Host(String), 6 | Group(String), 7 | } 8 | 9 | #[derive(Clone)] 10 | pub struct HostOrGroupConfig { 11 | pub host: HostOrGroup, 12 | pub vars: HashMap, 13 | } 14 | 15 | #[derive(Clone)] 16 | pub struct GroupConfig { 17 | pub hosts: Vec, 18 | pub vars: HashMap, 19 | pub imported: Option, 20 | } 21 | -------------------------------------------------------------------------------- /tiron-tui/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tiron-tui" 3 | version.workspace = true 4 | edition.workspace = true 5 | 6 | [dependencies] 7 | unicode-segmentation = "1.10" 8 | unicode-width = "0.1" 9 | uuid = { workspace = true } 10 | ratatui = { workspace = true } 11 | crossterm = { workspace = true } 12 | anyhow = { workspace = true } 13 | crossbeam-channel = { workspace = true } 14 | tiron-node = { workspace = true } 15 | tiron-common = { workspace = true } 16 | -------------------------------------------------------------------------------- /docs/content/docs/actions/package.md: -------------------------------------------------------------------------------- 1 | +++ 2 | title = "package" 3 | template = "docs/section.html" 4 | +++ 5 | 6 | # package 7 | 8 | Install packages 9 | 10 | ### Parameters 11 | 12 | | Parameter | Description | 13 | | -------------- | ----------- | 14 | | **name**
String or List of String
Required: true | the name of the packages to be installed | 15 | | **state**
Enum of "present", "absent", "latest"
Required: true | Whether to install or remove or update packages
`present` to install
`absent` to remove
`latest` to update | 16 | -------------------------------------------------------------------------------- /docs/config.toml: -------------------------------------------------------------------------------- 1 | # The URL the site will be built for 2 | base_url = "https://tiron.run" 3 | 4 | # Whether to automatically compile all Sass files in the sass directory 5 | compile_sass = false 6 | 7 | # Whether to build a search index to be used later on by a JavaScript library 8 | build_search_index = false 9 | 10 | [markdown] 11 | # Whether to do syntax highlighting 12 | # Theme can be customised by setting the `highlight_theme` variable to a theme supported by Zola 13 | highlight_code = true 14 | highlight_theme = "OneHalfLight" 15 | 16 | [extra] 17 | # Put all your custom variables here 18 | -------------------------------------------------------------------------------- /tiron/src/local.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use crossbeam_channel::{Receiver, Sender}; 3 | use tiron_common::{action::ActionMessage, node::NodeMessage}; 4 | use tiron_node::node; 5 | 6 | pub fn start_local() -> (Sender, Receiver) { 7 | let (writer_tx, writer_rx) = crossbeam_channel::unbounded::(); 8 | let (reader_tx, reader_rx) = crossbeam_channel::unbounded::(); 9 | 10 | std::thread::spawn(move || -> Result<()> { 11 | node::mainloop(writer_rx, reader_tx)?; 12 | Ok(()) 13 | }); 14 | 15 | (writer_tx, reader_rx) 16 | } 17 | -------------------------------------------------------------------------------- /docs/tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | purge: ["./templates/**/*.html", "./theme/**/*.html"], 3 | theme: { 4 | screens: { 5 | sm: '640px', 6 | md: '768px', 7 | lg: '1024px', 8 | xl: '1280px', 9 | }, 10 | fontFamily: { 11 | body: '"Inter", sans-serif', 12 | heading: '"Inter", sans-serif', 13 | sans: '"Inter", sans-serif', 14 | serif: '"Inter", sans-serif', 15 | mono: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', 16 | }, 17 | }, 18 | variants: {}, 19 | plugins: [ 20 | require('@tailwindcss/typography'), 21 | ], 22 | }; -------------------------------------------------------------------------------- /tiron-tui/src/event.rs: -------------------------------------------------------------------------------- 1 | use tiron_common::action::ActionMessage; 2 | use uuid::Uuid; 3 | 4 | pub enum AppEvent { 5 | UserInput(UserInputEvent), 6 | Run(RunEvent), 7 | Action { 8 | run: Uuid, 9 | host: Uuid, 10 | msg: ActionMessage, 11 | }, 12 | } 13 | 14 | pub enum UserInputEvent { 15 | ScrollUp, 16 | ScrollDown, 17 | ScrollToTop, 18 | ScrollToBottom, 19 | PageUp, 20 | PageDown, 21 | PrevRun, 22 | NextRun, 23 | PrevHost, 24 | NextHost, 25 | Resize, 26 | Quit, 27 | } 28 | 29 | pub enum RunEvent { 30 | RunStarted { id: Uuid }, 31 | RunCompleted { id: Uuid, success: bool }, 32 | } 33 | -------------------------------------------------------------------------------- /tiron-node/src/action/data.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use super::{ 4 | command::CommandAction, copy::CopyAction, file::FileAction, git::GitAction, 5 | package::PackageAction, Action, 6 | }; 7 | 8 | pub fn all_actions() -> HashMap> { 9 | [ 10 | Box::::default() as Box, 11 | Box::::default() as Box, 12 | Box::::default() as Box, 13 | Box::::default() as Box, 14 | Box::::default() as Box, 15 | ] 16 | .into_iter() 17 | .map(|a| (a.name(), a)) 18 | .collect() 19 | } 20 | -------------------------------------------------------------------------------- /docs/content/docs/actions/file.md: -------------------------------------------------------------------------------- 1 | +++ 2 | title = "file" 3 | template = "docs/section.html" 4 | +++ 5 | 6 | # file 7 | 8 | Manage files/folders and their properties 9 | 10 | ### Parameters 11 | 12 | | Parameter | Description | 13 | | -------------- | ----------- | 14 | | **path**
String
Required: true | Path of the file or folder that's managed | 15 | | **state**
Enum of "file", "absent", "directory"
Required: false | Default to `file`

If `file`, a file will be managed.
If `directory`, a directory will be recursively created and all of its parent components if they are missing.
If `absent`, directories will be recursively deleted and all its contents, and files or symlinks will be unlinked. | 16 | -------------------------------------------------------------------------------- /tiron/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tiron" 3 | version.workspace = true 4 | edition.workspace = true 5 | 6 | [dependencies] 7 | hcl-rs = { workspace = true } 8 | hcl-edit = { workspace = true } 9 | itertools = { workspace = true } 10 | clap = { workspace = true } 11 | crossbeam-channel = { workspace = true } 12 | strum = { workspace = true } 13 | strum_macros = { workspace = true } 14 | serde = { workspace = true } 15 | bincode = { workspace = true } 16 | anyhow = { workspace = true } 17 | uuid = { workspace = true } 18 | tiron-tui = { workspace = true } 19 | tiron-node = { workspace = true } 20 | tiron-common = { workspace = true } 21 | -------------------------------------------------------------------------------- /tiron-node/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tiron-node" 3 | version.workspace = true 4 | edition.workspace = true 5 | 6 | [dependencies] 7 | hcl-rs = { workspace = true } 8 | hcl-edit = { workspace = true } 9 | itertools = { workspace = true } 10 | tempfile = { workspace = true } 11 | serde_json = { workspace = true } 12 | os_info = { workspace = true } 13 | documented = { workspace = true } 14 | uuid = { workspace = true } 15 | clap = { workspace = true } 16 | serde = { workspace = true } 17 | anyhow = { workspace = true } 18 | bincode = { workspace = true } 19 | crossbeam-channel = { workspace = true } 20 | tiron-common = { workspace = true } 21 | -------------------------------------------------------------------------------- /.github/scripts/install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -eu 3 | 4 | echo_latest_stable_version() { 5 | # https://gist.github.com/lukechilds/a83e1d7127b78fef38c2914c4ececc3c#gistcomment-2758860 6 | version="$(curl -fsSLI -o /dev/null -w "%{url_effective}" https://github.com/lapce/tiron/releases/latest)" 7 | version="${version#https://github.com/lapce/tiron/releases/tag/v}" 8 | echo "${version}" 9 | } 10 | 11 | os() { 12 | uname="$(uname)" 13 | case $uname in 14 | Linux) echo linux ;; 15 | Darwin) echo darwin ;; 16 | FreeBSD) echo freebsd ;; 17 | *) echo "$uname" ;; 18 | esac 19 | } 20 | 21 | arch() { 22 | uname_m=$(uname -m) 23 | case $uname_m in 24 | aarch64) echo arm64 ;; 25 | x86_64) echo amd64 ;; 26 | armv7l) echo armv7 ;; 27 | *) echo "$uname_m" ;; 28 | esac 29 | } 30 | 31 | main() { 32 | OS=${OS:-$(os)} 33 | ARCH=${ARCH:-$(arch)} 34 | VERSION=$(echo_latest_stable_version) 35 | echo "Now install Tiron ${VERSION} to /usr/local/bin/" 36 | curl -sL "https://github.com/lapce/tiron/releases/download/v$VERSION/tiron-${VERSION}-${OS}-${ARCH}.gz" | sudo sh -c 'gzip -d > /usr/local/bin/tiron' && sudo chmod +x /usr/local/bin/tiron 37 | } 38 | 39 | main "$@" -------------------------------------------------------------------------------- /tiron/src/cli.rs: -------------------------------------------------------------------------------- 1 | use clap::{Parser, Subcommand}; 2 | 3 | #[derive(Parser)] 4 | #[clap(name = "tiron")] 5 | #[clap(about = "A reasonable automation engine")] 6 | #[clap(version = env!("CARGO_PKG_VERSION"))] 7 | pub struct Cli { 8 | #[command(subcommand)] 9 | pub cmd: CliCmd, 10 | } 11 | 12 | #[derive(Debug, Subcommand)] 13 | pub enum CliCmd { 14 | /// Run Tiron runbooks 15 | Run { 16 | /// The runbooks for Tiron to run. 17 | /// 18 | /// Default to main.tr if unspecified 19 | runbooks: Vec, 20 | }, 21 | /// Check Tiron runbooks 22 | Check { 23 | /// The runbooks for Tiron to check. 24 | /// 25 | /// Default to main.tr if unspecified 26 | runbooks: Vec, 27 | }, 28 | /// Format Tiron runbooks 29 | Fmt { 30 | /// If unspecified, Tiron will scan the current directory for *.tr files. 31 | /// 32 | /// If you provide a directory, it will scan that directory. 33 | /// 34 | /// If you provide a file, it will only format that file. 35 | targets: Vec, 36 | }, 37 | /// Show Tiron action docs 38 | Action { 39 | /// name of the action 40 | name: Option, 41 | }, 42 | #[clap(hide = true)] 43 | GenerateDoc, 44 | } 45 | -------------------------------------------------------------------------------- /examples/example_tiron_project/main.tr: -------------------------------------------------------------------------------- 1 | use "jobs/job1/main.tr" { 2 | job "job1" { } 3 | } 4 | 5 | use "tiron.tr" { 6 | group "group2" { } 7 | } 8 | 9 | group "production" { 10 | host "localhost" { 11 | apache = "apache2" 12 | } 13 | } 14 | 15 | group "gropu3" { 16 | group "group2" { } 17 | } 18 | 19 | run "production" { 20 | name = "initial run" 21 | remote_user = "dz" 22 | become = true 23 | 24 | action "package" { 25 | params { 26 | name = [ 27 | apache, 28 | "mariadb-connector-c", 29 | "${apache}" 30 | ] 31 | state = "present" 32 | } 33 | } 34 | 35 | action "copy" { 36 | params { 37 | src = "/tmp/test.tr" 38 | dest = "/tmp/test.conf" 39 | } 40 | } 41 | 42 | action "job" { 43 | name = "run job1" 44 | 45 | params { 46 | name = "job1" 47 | } 48 | } 49 | } 50 | 51 | run "group2" { 52 | remote_user = "dz" 53 | 54 | action "job" { 55 | params { 56 | name = "job1" 57 | } 58 | } 59 | 60 | action "copy" { 61 | params { 62 | src = "/tmp/test.tr" 63 | dest = "/tmp/test.conf" 64 | } 65 | } 66 | 67 | action "job" { 68 | params { 69 | name = "job1" 70 | } 71 | } 72 | 73 | action "job" { 74 | params { 75 | name = "job1" 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /docs/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Tiron - Reasonable Automation Engine 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | {% block header %} 15 |
16 |
17 | 24 |
25 | 37 |
38 |
39 |
40 | {% endblock %} 41 | {% block content %} {% endblock %} 42 | 43 | 44 | -------------------------------------------------------------------------------- /docs/styles/styles.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer components { 6 | .markdown-content { 7 | @apply text-gray-800 8 | } 9 | 10 | .markdown-content h1 { 11 | @apply text-4xl font-bold w-full mx-auto mb-6; 12 | } 13 | 14 | .markdown-content h3 { 15 | @apply text-2xl font-semibold w-full mx-auto mt-5; 16 | } 17 | 18 | .markdown-content h4 { 19 | @apply text-lg font-semibold w-full mx-auto mt-5; 20 | } 21 | 22 | .markdown-content p { 23 | @apply w-full mx-auto mt-5; 24 | } 25 | 26 | .markdown-content code { 27 | @apply bg-gray-100 px-1.5 rounded; 28 | } 29 | 30 | .markdown-content pre { 31 | @apply relative overflow-auto rounded-md w-full mt-5 32 | } 33 | 34 | .markdown-content pre code { 35 | @apply bg-gray-100 block py-2 px-2 w-full 36 | } 37 | 38 | .markdown-content a { 39 | @apply text-blue-500 underline; 40 | } 41 | 42 | .markdown-content a:hover { 43 | @apply text-pink-400 underline; 44 | } 45 | 46 | .markdown-content table { 47 | @apply w-full rounded-xl border mt-5 48 | } 49 | 50 | .markdown-content table thead { 51 | @apply bg-gray-100 52 | } 53 | 54 | .markdown-content table th { 55 | @apply py-4 px-4 text-left 56 | } 57 | 58 | .markdown-content table th:not(:first-child) { 59 | @apply border-l 60 | } 61 | 62 | .markdown-content table td { 63 | @apply py-4 px-4 border-t leading-relaxed 64 | } 65 | 66 | .markdown-content table td:not(:first-child) { 67 | @apply border-l 68 | } 69 | } -------------------------------------------------------------------------------- /tiron-node/src/stdio.rs: -------------------------------------------------------------------------------- 1 | use std::io::{BufRead, Write}; 2 | 3 | use anyhow::Result; 4 | use crossbeam_channel::Receiver; 5 | use serde::{de::DeserializeOwned, Serialize}; 6 | use serde_json::Value; 7 | 8 | pub fn stdio_transport( 9 | mut writer: W, 10 | writer_receiver: Receiver, 11 | mut reader: R, 12 | reader_sender: crossbeam_channel::Sender, 13 | ) where 14 | W: 'static + Write + Send, 15 | R: 'static + BufRead + Send, 16 | RpcMessage1: 'static + Serialize + DeserializeOwned + Send + Sync, 17 | RpcMessage2: 'static + Serialize + DeserializeOwned + Send + Sync, 18 | { 19 | std::thread::spawn(move || { 20 | for value in writer_receiver { 21 | if write_msg(&mut writer, value).is_err() { 22 | return; 23 | }; 24 | } 25 | }); 26 | std::thread::spawn(move || -> Result<()> { 27 | loop { 28 | if let Some(msg) = read_msg(&mut reader)? { 29 | reader_sender.send(msg)?; 30 | } 31 | } 32 | }); 33 | } 34 | 35 | pub fn write_msg(out: &mut W, msg: RpcMessage) -> Result<()> 36 | where 37 | W: Write, 38 | RpcMessage: Serialize, 39 | { 40 | let msg = format!("{}\n", serde_json::to_string(&msg)?); 41 | out.write_all(msg.as_bytes())?; 42 | out.flush()?; 43 | Ok(()) 44 | } 45 | 46 | pub fn read_msg(inp: &mut R) -> Result> 47 | where 48 | R: BufRead, 49 | RpcMessage: DeserializeOwned, 50 | { 51 | let mut buf = String::new(); 52 | let _ = inp.read_line(&mut buf)?; 53 | let value: Value = serde_json::from_str(&buf)?; 54 | 55 | let msg = match serde_json::from_value::(value) { 56 | Ok(msg) => Some(msg), 57 | Err(_) => None, 58 | }; 59 | Ok(msg) 60 | } 61 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace.package] 2 | version = "0.1.7" 3 | edition = "2021" 4 | 5 | [package] 6 | name = "tiron-workspace" 7 | version.workspace = true 8 | edition.workspace = true 9 | default-run = "tiron" 10 | 11 | [[bin]] 12 | name = "tiron" 13 | path = "tiron/src/main.rs" 14 | 15 | [[bin]] 16 | name = "tiron-node" 17 | path = "tiron-node/src/main.rs" 18 | 19 | [[bin]] 20 | name = "tiron-lsp" 21 | path = "tiron-lsp/src/main.rs" 22 | 23 | [dependencies] 24 | clap = { workspace = true } 25 | anyhow = { workspace = true } 26 | tiron = { workspace = true } 27 | tiron-node = { workspace = true } 28 | 29 | [workspace] 30 | members = [ 31 | "tiron", 32 | "tiron-tui", 33 | "tiron-lsp", 34 | "tiron-node", 35 | "tiron-common", 36 | ] 37 | 38 | [workspace.dependencies] 39 | hcl-rs = { git = "https://github.com/lapce/hcl-rs", rev = "fb0ac2875760a8219899f5a4d774d0996a5b06dd" } 40 | hcl-edit = { git = "https://github.com/lapce/hcl-rs", rev = "fb0ac2875760a8219899f5a4d774d0996a5b06dd" } 41 | tempfile = "3.10.1" 42 | os_info = "3.7" 43 | itertools = "0.12.1" 44 | documented = "0.4.1" 45 | ratatui = "0.26.1" 46 | crossterm = "0.27.0" 47 | serde_json = "1.0.115" 48 | bincode = "1.3.3" 49 | anyhow = "1.0.81" 50 | uuid = { version = "1.8.0", features = ["serde", "v4"] } 51 | clap = { version = "4.5.0", default-features = false, features = ["std", "help", "usage", "derive"] } 52 | crossbeam-channel = { version = "0.5.11" } 53 | strum = { version = "0.26.2" } 54 | strum_macros = { version = "0.26.2" } 55 | serde = { version = "1.0.197", features = ["derive"] } 56 | tiron = { path = "./tiron" } 57 | tiron-tui = { path = "./tiron-tui" } 58 | tiron-node = { path = "./tiron-node" } 59 | tiron-common = { path = "./tiron-common" } 60 | -------------------------------------------------------------------------------- /tiron/src/doc.rs: -------------------------------------------------------------------------------- 1 | use std::{io::Write, path::PathBuf}; 2 | 3 | use anyhow::{anyhow, Result}; 4 | use itertools::Itertools; 5 | use tiron_node::action::data::all_actions; 6 | 7 | pub fn generate_doc() -> Result<()> { 8 | let path = PathBuf::from("docs/content/docs/actions/"); 9 | if !path.exists() { 10 | return Err(anyhow!("can't find actions folder")); 11 | } 12 | let actions = all_actions(); 13 | for action in actions.values() { 14 | let doc = action.doc(); 15 | let mut file = std::fs::OpenOptions::new() 16 | .create(true) 17 | .write(true) 18 | .truncate(true) 19 | .open(path.join(format!("{}.md", action.name())))?; 20 | file.write_all(b"+++\n")?; 21 | file.write_all(format!("title = \"{}\"\n", action.name()).as_bytes())?; 22 | file.write_all(b"template = \"docs/section.html\"\n")?; 23 | file.write_all(b"+++\n\n")?; 24 | file.write_all(format!("# {}\n\n", action.name()).as_bytes())?; 25 | file.write_all(format!("{}\n\n", doc.description).as_bytes())?; 26 | file.write_all(b"### Parameters\n\n")?; 27 | file.write_all(b"| Parameter | Description |\n")?; 28 | file.write_all(b"| -------------- | ----------- |\n")?; 29 | for param in &doc.params { 30 | file.write_all(format!("| **{}**
", param.name).as_bytes())?; 31 | file.write_all( 32 | format!( 33 | " {}
", 34 | param.type_.iter().map(|t| t.to_string()).join(" or ") 35 | ) 36 | .as_bytes(), 37 | )?; 38 | file.write_all(format!("Required: {} |", param.required).as_bytes())?; 39 | file.write_all( 40 | format!( 41 | " {} |\n", 42 | param.description.replace("\n\n", "
").replace('\n', " ") 43 | ) 44 | .as_bytes(), 45 | )?; 46 | } 47 | } 48 | Ok(()) 49 | } 50 | -------------------------------------------------------------------------------- /tiron-common/src/action.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use uuid::Uuid; 3 | 4 | #[derive(Copy, Clone, Hash, PartialEq, Eq, Deserialize, Serialize)] 5 | pub struct ActionId(Uuid); 6 | 7 | impl Default for ActionId { 8 | fn default() -> Self { 9 | Self::new() 10 | } 11 | } 12 | 13 | impl ActionId { 14 | pub fn new() -> Self { 15 | Self(Uuid::new_v4()) 16 | } 17 | } 18 | 19 | #[derive(Deserialize, Serialize)] 20 | pub enum ActionMessage { 21 | NodeStartFailed { 22 | reason: String, 23 | }, 24 | ActionStarted { 25 | id: ActionId, 26 | }, 27 | ActionOutputLine { 28 | id: ActionId, 29 | content: String, 30 | level: ActionOutputLevel, 31 | }, 32 | ActionResult { 33 | id: ActionId, 34 | success: bool, 35 | }, 36 | NodeShutdown { 37 | success: bool, 38 | }, 39 | } 40 | 41 | /// ActionData is the data that's being sent from core to node 42 | /// with the input serialized 43 | #[derive(Clone, Deserialize, Serialize)] 44 | pub struct ActionData { 45 | pub id: ActionId, 46 | pub name: String, 47 | pub action: String, 48 | pub input: Vec, 49 | } 50 | 51 | /// ActionOutput is the output that's returned from the node 52 | /// from executing the action 53 | #[derive(Clone, Deserialize, Serialize, Default)] 54 | pub struct ActionOutput { 55 | pub started: bool, 56 | pub lines: Vec, 57 | // whether this action was succesfully or not 58 | // the action isn't completed if this is None 59 | pub success: Option, 60 | } 61 | 62 | /// ActionOutputLine is one line for the ActionOutput 63 | #[derive(Clone, Deserialize, Serialize)] 64 | pub struct ActionOutputLine { 65 | pub content: String, 66 | pub level: ActionOutputLevel, 67 | } 68 | 69 | /// ActionOutputLevel indicates the severity of line in the output 70 | #[derive(Clone, Deserialize, Serialize)] 71 | pub enum ActionOutputLevel { 72 | Success, 73 | Info, 74 | Warn, 75 | Error, 76 | } 77 | -------------------------------------------------------------------------------- /tiron/src/fmt.rs: -------------------------------------------------------------------------------- 1 | use std::{fs, path::PathBuf}; 2 | 3 | use hcl::format::{Format, Formatter}; 4 | use tiron_common::error::Error; 5 | 6 | pub fn fmt(targets: Vec) -> Result<(), Error> { 7 | let targets = if targets.is_empty() { 8 | vec![std::env::current_dir().map_err(|e| Error::new(e.to_string()))?] 9 | } else { 10 | targets.iter().map(PathBuf::from).collect() 11 | }; 12 | 13 | for target in targets { 14 | fmt_target(target)?; 15 | } 16 | 17 | Ok(()) 18 | } 19 | 20 | fn fmt_target(path: PathBuf) -> Result<(), Error> { 21 | if !path.exists() { 22 | return Error::new(format!("path {} doesn't exist", path.to_string_lossy())).err(); 23 | } 24 | 25 | if path.is_dir() { 26 | let mut runbooks = Vec::new(); 27 | for path in fs::read_dir(path).map_err(|e| Error::new(e.to_string()))? { 28 | let path = path.map_err(|e| Error::new(e.to_string()))?; 29 | if path.file_name().to_string_lossy().ends_with(".tr") { 30 | runbooks.push(path.path()); 31 | } 32 | } 33 | for path in runbooks { 34 | fmt_runbook(path)?; 35 | } 36 | } else { 37 | fmt_runbook(path)?; 38 | } 39 | 40 | Ok(()) 41 | } 42 | 43 | fn fmt_runbook(path: PathBuf) -> Result<(), Error> { 44 | let data = std::fs::read_to_string(&path).map_err(|e| { 45 | Error::new(format!( 46 | "can't read runbook {} error: {e}", 47 | path.to_string_lossy() 48 | )) 49 | })?; 50 | let body = hcl::parse(&data).map_err(|e| { 51 | if let hcl::Error::Parse(e) = e { 52 | Error::from_hcl(e, path.clone()) 53 | } else { 54 | Error::new(e.to_string()) 55 | } 56 | })?; 57 | let mut file = std::fs::File::options() 58 | .truncate(true) 59 | .write(true) 60 | .open(&path) 61 | .map_err(|e| Error::new(e.to_string()))?; 62 | let mut formatter = Formatter::new(&mut file); 63 | body.format(&mut formatter).map_err(|e| { 64 | if let hcl::Error::Parse(e) = e { 65 | Error::from_hcl(e, path.clone()) 66 | } else { 67 | Error::new(e.to_string()) 68 | } 69 | })?; 70 | 71 | Ok(()) 72 | } 73 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | pull_request: 6 | types: [opened, synchronize, reopened, ready_for_review] 7 | 8 | name: CI 9 | 10 | concurrency: 11 | group: ${{ github.ref }}-${{ github.workflow }} 12 | cancel-in-progress: true 13 | 14 | env: 15 | CARGO_TERM_COLOR: always 16 | CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse 17 | 18 | jobs: 19 | build: 20 | name: Rust on ${{ matrix.os }} (${{ join(matrix.features, ',') }}) 21 | if: github.event.pull_request.draft == false 22 | needs: [fmt, clippy] 23 | strategy: 24 | fail-fast: false 25 | matrix: 26 | os: [ ubuntu-latest ] 27 | runs-on: ${{ matrix.os }} 28 | steps: 29 | - name: Checkout repo 30 | uses: actions/checkout@v4 31 | 32 | - name: Update toolchain & add llvm-tools 33 | run: | 34 | rustup update 35 | rustup component add llvm-tools-preview 36 | 37 | - name: Cache Rust dependencies 38 | uses: Swatinem/rust-cache@v2 39 | 40 | - name: Fetch dependencies 41 | run: cargo fetch --locked 42 | 43 | - name: Build 44 | run: cargo build -p tiron --frozen 45 | 46 | - name: Build node 47 | run: cargo build -p tiron-node --frozen 48 | 49 | - name: Build lsp 50 | run: cargo build -p tiron-lsp --frozen 51 | 52 | - name: Run tests 53 | run: cargo test --workspace 54 | 55 | fmt: 56 | name: Rustfmt 57 | runs-on: ubuntu-latest 58 | steps: 59 | - name: Checkout repo 60 | uses: actions/checkout@v4 61 | 62 | - name: Update toolchain & add rustfmt 63 | run: | 64 | rustup update 65 | rustup component add rustfmt 66 | 67 | - name: Run rustfmt 68 | run: cargo fmt --all --check 69 | 70 | clippy: 71 | name: Clippy on ${{ matrix.os }} 72 | strategy: 73 | fail-fast: false 74 | matrix: 75 | os: [ ubuntu-latest ] 76 | runs-on: ${{ matrix.os }} 77 | steps: 78 | - name: Checkout repo 79 | uses: actions/checkout@v4 80 | 81 | - name: Update toolchain & add clippy 82 | run: | 83 | rustup update 84 | rustup component add clippy 85 | 86 | - name: Cache Rust dependencies 87 | uses: Swatinem/rust-cache@v2 88 | 89 | - name: Fetch dependencies 90 | run: cargo fetch --locked 91 | 92 | - name: Run clippy 93 | run: cargo clippy -- -D warnings -------------------------------------------------------------------------------- /tiron-tui/src/tui.rs: -------------------------------------------------------------------------------- 1 | use std::io::{stdout, Stdout}; 2 | 3 | use anyhow::Result; 4 | use crossbeam_channel::Sender; 5 | use crossterm::{ 6 | event::{Event, KeyCode, KeyEventKind, KeyModifiers}, 7 | execute, 8 | terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, 9 | }; 10 | use ratatui::{backend::CrosstermBackend, Terminal}; 11 | 12 | use crate::event::{AppEvent, UserInputEvent}; 13 | 14 | /// A type alias for the terminal type used in this application 15 | pub type Tui = Terminal>; 16 | 17 | /// Initialize the terminal 18 | pub fn init() -> Result { 19 | execute!(stdout(), EnterAlternateScreen)?; 20 | enable_raw_mode()?; 21 | let tui = Terminal::new(CrosstermBackend::new(stdout()))?; 22 | Ok(tui) 23 | } 24 | 25 | /// Restore the terminal to its original state 26 | pub fn restore() -> Result<()> { 27 | execute!(stdout(), LeaveAlternateScreen)?; 28 | disable_raw_mode()?; 29 | Ok(()) 30 | } 31 | 32 | pub fn handle_events(tx: Sender) -> Result<()> { 33 | while let Ok(event) = crossterm::event::read() { 34 | let event = match event { 35 | Event::Key(key_event) if key_event.kind == KeyEventKind::Press => { 36 | match key_event.code { 37 | KeyCode::Char('q') => UserInputEvent::Quit, 38 | KeyCode::Char('j') => UserInputEvent::ScrollDown, 39 | KeyCode::Char('k') => UserInputEvent::ScrollUp, 40 | KeyCode::Char('g') => UserInputEvent::ScrollToTop, 41 | KeyCode::Char('G') => UserInputEvent::ScrollToBottom, 42 | KeyCode::Char('u') => UserInputEvent::PageUp, 43 | KeyCode::Char('d') => UserInputEvent::PageDown, 44 | KeyCode::Char('p') if key_event.modifiers == KeyModifiers::CONTROL => { 45 | UserInputEvent::PrevRun 46 | } 47 | KeyCode::Char('n') if key_event.modifiers == KeyModifiers::CONTROL => { 48 | UserInputEvent::NextRun 49 | } 50 | KeyCode::Char('p') if key_event.modifiers.is_empty() => { 51 | UserInputEvent::PrevHost 52 | } 53 | KeyCode::Char('n') if key_event.modifiers.is_empty() => { 54 | UserInputEvent::NextHost 55 | } 56 | _ => continue, 57 | } 58 | } 59 | Event::Resize(_, _) => UserInputEvent::Resize, 60 | _ => continue, 61 | }; 62 | tx.send(AppEvent::UserInput(event))?; 63 | } 64 | Ok(()) 65 | } 66 | -------------------------------------------------------------------------------- /tiron-node/src/action/git.rs: -------------------------------------------------------------------------------- 1 | use anyhow::anyhow; 2 | use documented::{Documented, DocumentedFields}; 3 | use serde::{Deserialize, Serialize}; 4 | use tiron_common::error::Error; 5 | 6 | use super::{ 7 | command::run_command, Action, ActionDoc, ActionParamDoc, ActionParamType, ActionParams, 8 | }; 9 | 10 | /// Manage Git repositories 11 | #[derive(Default, Clone, Serialize, Deserialize, Documented, DocumentedFields)] 12 | pub struct GitAction { 13 | /// address of the git repository 14 | repo: String, 15 | /// The path of where the repository should be checked out. 16 | dest: String, 17 | } 18 | 19 | impl Action for GitAction { 20 | fn name(&self) -> String { 21 | "git".to_string() 22 | } 23 | 24 | fn doc(&self) -> ActionDoc { 25 | ActionDoc { 26 | description: Self::DOCS.to_string(), 27 | params: vec![ 28 | ActionParamDoc { 29 | name: "repo".to_string(), 30 | required: true, 31 | description: Self::get_field_docs("repo").unwrap_or_default().to_string(), 32 | type_: vec![ActionParamType::String], 33 | }, 34 | ActionParamDoc { 35 | name: "dest".to_string(), 36 | required: true, 37 | description: Self::get_field_docs("dest").unwrap_or_default().to_string(), 38 | type_: vec![ActionParamType::String], 39 | }, 40 | ], 41 | } 42 | } 43 | 44 | fn input(&self, params: ActionParams) -> Result, Error> { 45 | let repo = params.expect_string(0); 46 | let dest = params.expect_string(1); 47 | 48 | let input = GitAction { 49 | repo: repo.to_string(), 50 | dest: dest.to_string(), 51 | }; 52 | let input = bincode::serialize(&input).map_err(|e| { 53 | Error::new(format!("serialize action input error: {e}")) 54 | .with_origin(params.origin, ¶ms.span) 55 | })?; 56 | Ok(input) 57 | } 58 | 59 | fn execute( 60 | &self, 61 | id: tiron_common::action::ActionId, 62 | input: &[u8], 63 | tx: &crossbeam_channel::Sender, 64 | ) -> anyhow::Result { 65 | let input: GitAction = bincode::deserialize(input)?; 66 | let status = run_command( 67 | id, 68 | tx, 69 | "git", 70 | &["clone".to_string(), input.repo, input.dest], 71 | )?; 72 | if status.success() { 73 | Ok("command".to_string()) 74 | } else { 75 | Err(anyhow!("command failed")) 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /tiron-node/src/node.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::HashMap, 3 | io::{stdin, stdout, BufReader}, 4 | }; 5 | 6 | use anyhow::{anyhow, Result}; 7 | use clap::Parser; 8 | use crossbeam_channel::{Receiver, Sender}; 9 | use tiron_common::{ 10 | action::{ActionData, ActionMessage, ActionOutputLevel}, 11 | node::NodeMessage, 12 | }; 13 | 14 | use crate::{ 15 | action::{data::all_actions, Action}, 16 | stdio::stdio_transport, 17 | }; 18 | 19 | #[derive(Parser)] 20 | #[clap(name = "tiron-node")] 21 | #[clap(version = env!("CARGO_PKG_VERSION"))] 22 | pub struct Cli {} 23 | 24 | pub fn start() -> Result<()> { 25 | let _ = Cli::parse(); 26 | let (writer_tx, writer_rx) = crossbeam_channel::unbounded::(); 27 | let (reader_tx, reader_rx) = crossbeam_channel::unbounded::(); 28 | stdio_transport(stdout(), writer_rx, BufReader::new(stdin()), reader_tx); 29 | mainloop(reader_rx, writer_tx)?; 30 | Ok(()) 31 | } 32 | 33 | pub fn mainloop(rx: Receiver, tx: Sender) -> Result<()> { 34 | let all_actions = all_actions(); 35 | let mut had_error = false; 36 | while let Ok(msg) = rx.recv() { 37 | if had_error { 38 | continue; 39 | } 40 | match msg { 41 | NodeMessage::Action(action) => match node_run_action(&all_actions, &action, &tx) { 42 | Ok(result) => { 43 | tx.send(ActionMessage::ActionOutputLine { 44 | id: action.id, 45 | content: format!("successfully {result}"), 46 | level: ActionOutputLevel::Success, 47 | })?; 48 | tx.send(ActionMessage::ActionResult { 49 | id: action.id, 50 | success: true, 51 | })?; 52 | } 53 | Err(e) => { 54 | tx.send(ActionMessage::ActionOutputLine { 55 | id: action.id, 56 | content: format!("error: {e:#}"), 57 | level: ActionOutputLevel::Error, 58 | })?; 59 | had_error = true; 60 | tx.send(ActionMessage::ActionResult { 61 | id: action.id, 62 | success: false, 63 | })?; 64 | tx.send(ActionMessage::NodeShutdown { success: false })?; 65 | } 66 | }, 67 | NodeMessage::Shutdown => { 68 | tx.send(ActionMessage::NodeShutdown { success: true })?; 69 | } 70 | } 71 | } 72 | Ok(()) 73 | } 74 | 75 | fn node_run_action( 76 | all_actions: &HashMap>, 77 | data: &ActionData, 78 | tx: &Sender, 79 | ) -> Result { 80 | let result = if let Some(action) = all_actions.get(&data.action) { 81 | let _ = tx.send(ActionMessage::ActionStarted { id: data.id }); 82 | action.execute(data.id, &data.input, tx)? 83 | } else { 84 | return Err(anyhow!("can't find action name {}", data.action)); 85 | }; 86 | Ok(result) 87 | } 88 | -------------------------------------------------------------------------------- /tiron-node/src/action/package/provider.rs: -------------------------------------------------------------------------------- 1 | use std::process::ExitStatus; 2 | 3 | use anyhow::{anyhow, Result}; 4 | use crossbeam_channel::Sender; 5 | use tiron_common::action::{ActionId, ActionMessage}; 6 | 7 | use crate::action::command::run_command; 8 | 9 | use super::PackageState; 10 | 11 | pub trait Provider { 12 | fn install(&self); 13 | } 14 | 15 | pub enum PackageProvider { 16 | Apt, 17 | Dnf, 18 | Pacman, 19 | Homebrew, 20 | Winget, 21 | Zypper, 22 | } 23 | 24 | impl PackageProvider { 25 | pub fn detect() -> Result { 26 | use os_info::Type; 27 | 28 | let info = os_info::get(); 29 | let os_type = info.os_type(); 30 | let provider = match info.os_type() { 31 | Type::Arch => Self::Pacman, 32 | Type::Manjaro => Self::Pacman, 33 | 34 | Type::Debian => Self::Apt, 35 | Type::Mint => Self::Apt, 36 | Type::Pop => Self::Apt, 37 | Type::Ubuntu => Self::Apt, 38 | Type::OracleLinux => Self::Apt, 39 | 40 | Type::Fedora => Self::Dnf, 41 | Type::Redhat => Self::Dnf, 42 | Type::RedHatEnterprise => Self::Dnf, 43 | Type::CentOS => Self::Dnf, 44 | 45 | Type::openSUSE => Self::Zypper, 46 | Type::SUSE => Self::Zypper, 47 | 48 | Type::Macos => Self::Homebrew, 49 | 50 | Type::Windows => Self::Winget, 51 | 52 | _ => return Err(anyhow!("Can't find the package manger for OS {os_type}")), 53 | }; 54 | 55 | Ok(provider) 56 | } 57 | 58 | pub fn run( 59 | &self, 60 | id: ActionId, 61 | tx: &Sender, 62 | packages: Vec, 63 | state: PackageState, 64 | ) -> Result { 65 | let cmd = match state { 66 | PackageState::Present => "install", 67 | PackageState::Absent => "remove", 68 | PackageState::Latest => "upgrade", 69 | }; 70 | 71 | let (program, args) = match self { 72 | PackageProvider::Apt => ("apt", vec![cmd, "--yes"]), 73 | PackageProvider::Dnf => ("dnf", vec![cmd, "--assumeyes"]), 74 | PackageProvider::Pacman => ( 75 | "yay", 76 | vec![cmd, "--noconfirm", "--nocleanmenu", "--nodiffmenu"], 77 | ), 78 | PackageProvider::Homebrew => ("brew", vec![cmd]), 79 | PackageProvider::Winget => ( 80 | "winget", 81 | vec![ 82 | cmd, 83 | "--silent", 84 | "--accept-package-agreements", 85 | "--accept-source-agreements", 86 | "--source", 87 | "winget", 88 | ], 89 | ), 90 | PackageProvider::Zypper => ("zypper", vec![cmd, "-y"]), 91 | }; 92 | 93 | let mut args = args.iter().map(|a| a.to_string()).collect::>(); 94 | args.extend_from_slice(&packages); 95 | 96 | let status = run_command(id, tx, program, &args)?; 97 | Ok(status) 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # Tiron 4 | 5 | **Reasonable Automation Engine** 6 |
7 | 8 | 19 | 20 | **Tiron** is an automation tool that's easy to use and aims to be as fast as possible. It’s agentless by using SSH and has a TUI for the outputs of the tasks. There is an example Tiron configuration [here](https://github.com/lapce/tiron/tree/main/examples/example_tiron_project). 21 | 22 |
23 | Screenshot 24 |
25 | 26 | ## Features 27 | * **No YAML:** Tiron uses [HCL](https://github.com/hashicorp/hcl) as the configuration language. 28 | * **Agentless:** By using SSH, Tiron connects to the remote machines without the need to install an agent first. 29 | * **TUI:** Tiron has a built in terminal user interfaces to display the outputs of the running tasks. 30 | * **Correctness:** Tiron pre validates all the runbook files and will throw errors before the task is started to execute. 31 | * **Speed:** On validating all the input, Tiron also pre populates all the data for tasks, and send them to the remote machines in one go to save the roundtrips between the client and remote. 32 | * **LSP:** Tiron provides a LSP server which can provide syntax highlighting, linting, formatting, code jumps, completion etc. 33 | 34 | ## Quickstart 35 | 36 | Run below to install latest Tiron binary to ```/usr/local/bin``` 37 | ```bash 38 | curl -sL https://tiron.run/install.sh | sh 39 | ``` 40 | 41 | More information can be found in the [docs](https://tiron.run/docs/getting-started/overview/). 42 | 43 | ## Usage 44 | 45 | To run a Tiron runbook 46 | 47 | ```console 48 | $ tiron run 49 | ``` 50 | 51 | Full usage: 52 | 53 | ```console 54 | $ tiron -h 55 | 56 | A reasonable automation engine 57 | 58 | Usage: tiron 59 | 60 | Commands: 61 | run Run Tiron runbooks 62 | check Check Tiron runbooks 63 | fmt Format Tiron runbooks 64 | action Show Tiron action docs 65 | help Print this message or the help of the given subcommand(s) 66 | 67 | Options: 68 | -h, --help Print help 69 | -V, --version Print version 70 | ``` 71 | 72 | ## TUI Navigation 73 | 74 | | Key | Action | 75 | | --------------------------------- | ------------ | 76 | | j | Scroll down | 77 | | k | Scroll up | 78 | | d | Page down | 79 | | u | Page up | 80 | | g | Jump to top | 81 | | G | Jump to bottom | 82 | | n | Next Host | 83 | | p | Previous Host | 84 | | Ctrl+n | Next Run | 85 | | Ctrl+p | Previous Run | 86 | 87 | ## License 88 | Tiron is licensed under the Apache 2.0 license. 89 | -------------------------------------------------------------------------------- /tiron-node/src/action/copy.rs: -------------------------------------------------------------------------------- 1 | use std::io::Write; 2 | 3 | use anyhow::{anyhow, Result}; 4 | use crossbeam_channel::Sender; 5 | use documented::{Documented, DocumentedFields}; 6 | use serde::{Deserialize, Serialize}; 7 | use tiron_common::{ 8 | action::{ActionId, ActionMessage}, 9 | error::Error, 10 | }; 11 | 12 | use super::{ 13 | command::run_command, Action, ActionDoc, ActionParamDoc, ActionParamType, ActionParams, 14 | }; 15 | 16 | /// Copy the file to the remote machine 17 | #[derive(Default, Clone, Serialize, Deserialize, Documented, DocumentedFields)] 18 | pub struct CopyAction { 19 | /// Local path of a file to be copied 20 | src: String, 21 | content: Vec, 22 | /// The path where file should be copied to on remote server 23 | dest: String, 24 | } 25 | 26 | impl Action for CopyAction { 27 | fn name(&self) -> String { 28 | "copy".to_string() 29 | } 30 | 31 | fn doc(&self) -> ActionDoc { 32 | ActionDoc { 33 | description: CopyAction::DOCS.to_string(), 34 | params: vec![ 35 | ActionParamDoc { 36 | name: "src".to_string(), 37 | required: true, 38 | description: CopyAction::get_field_docs("src") 39 | .unwrap_or_default() 40 | .to_string(), 41 | type_: vec![ActionParamType::String], 42 | }, 43 | ActionParamDoc { 44 | name: "dest".to_string(), 45 | required: true, 46 | description: CopyAction::get_field_docs("dest") 47 | .unwrap_or_default() 48 | .to_string(), 49 | type_: vec![ActionParamType::String], 50 | }, 51 | ], 52 | } 53 | } 54 | 55 | fn input(&self, params: ActionParams) -> Result, Error> { 56 | let (src, src_span) = params.expect_string_with_span(0); 57 | let src_file = params.origin.cwd.join(src); 58 | let meta = src_file 59 | .metadata() 60 | .map_err(|_| Error::new("can't find src file").with_origin(params.origin, src_span))?; 61 | if !meta.is_file() { 62 | return Error::new("src isn't a file") 63 | .with_origin(params.origin, src_span) 64 | .err(); 65 | } 66 | let content = std::fs::read(&src_file).map_err(|e| { 67 | Error::new(format!("read src file error: {e}")).with_origin(params.origin, src_span) 68 | })?; 69 | 70 | let dest = params.expect_string(1); 71 | 72 | let input = CopyAction { 73 | src: src_file.to_string_lossy().to_string(), 74 | content, 75 | dest: dest.to_string(), 76 | }; 77 | let input = bincode::serialize(&input).map_err(|e| { 78 | Error::new(format!("serialize action input error: {e}")) 79 | .with_origin(params.origin, ¶ms.span) 80 | })?; 81 | 82 | Ok(input) 83 | } 84 | 85 | fn execute(&self, id: ActionId, bytes: &[u8], tx: &Sender) -> Result { 86 | let input: CopyAction = bincode::deserialize(bytes)?; 87 | let mut temp = tempfile::NamedTempFile::new()?; 88 | temp.write_all(&input.content)?; 89 | temp.flush()?; 90 | let status = run_command( 91 | id, 92 | tx, 93 | "cp", 94 | &[ 95 | temp.path().to_string_lossy().to_string(), 96 | input.dest.clone(), 97 | ], 98 | )?; 99 | if status.success() { 100 | Ok(format!("copy to {}", input.dest)) 101 | } else { 102 | Err(anyhow!("can't copy to {}", input.dest)) 103 | } 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /docs/templates/docs/section.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block header %} 4 |
5 |
6 | 13 |
14 | 21 |
22 |
23 |
24 | {% endblock %} 25 | 26 | {% block content %} 27 |
28 | 48 | {% block doc_content %} 49 |
50 |
51 |
52 | {{page.content | safe}} 53 |
54 |
55 |
56 | {% endblock doc_content %} 57 |
58 | {% endblock content %} -------------------------------------------------------------------------------- /tiron/src/node.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use anyhow::Result; 4 | use crossbeam_channel::{Receiver, Sender}; 5 | use tiron_common::{ 6 | action::{ActionData, ActionMessage}, 7 | node::NodeMessage, 8 | }; 9 | use tiron_tui::event::AppEvent; 10 | use uuid::Uuid; 11 | 12 | use crate::{ 13 | local::start_local, 14 | remote::{start_remote, SshHost, SshRemote}, 15 | }; 16 | 17 | #[derive(Clone)] 18 | pub struct Node { 19 | pub id: Uuid, 20 | pub host: String, 21 | pub remote_user: Option, 22 | pub become_: bool, 23 | pub vars: HashMap, 24 | pub actions: Vec, 25 | pub tx: Sender, 26 | } 27 | 28 | impl Node { 29 | pub fn new(host: String, new_vars: HashMap, tx: &Sender) -> Self { 30 | Self { 31 | id: Uuid::new_v4(), 32 | host, 33 | remote_user: new_vars.get("remote_user").and_then(|v| { 34 | if let hcl::Value::String(s) = v { 35 | Some(s.to_string()) 36 | } else { 37 | None 38 | } 39 | }), 40 | become_: new_vars 41 | .get("become") 42 | .map(|v| { 43 | if let hcl::Value::Bool(b) = v { 44 | *b 45 | } else { 46 | false 47 | } 48 | }) 49 | .unwrap_or(false), 50 | vars: new_vars, 51 | actions: Vec::new(), 52 | tx: tx.clone(), 53 | } 54 | } 55 | 56 | pub fn execute(&self, run_id: Uuid, exit_tx: Sender) -> Result<()> { 57 | let (tx, rx) = match self.start() { 58 | Ok((tx, rx)) => (tx, rx), 59 | Err(e) => { 60 | self.tx.send(AppEvent::Action { 61 | run: run_id, 62 | host: self.id, 63 | msg: ActionMessage::NodeStartFailed { 64 | reason: e.to_string(), 65 | }, 66 | })?; 67 | return Err(e); 68 | } 69 | }; 70 | 71 | { 72 | let node_tx = tx.clone(); 73 | let tx = self.tx.clone(); 74 | let host_id = self.id; 75 | std::thread::spawn(move || { 76 | while let Ok(msg) = rx.recv() { 77 | if let ActionMessage::NodeShutdown { success } = &msg { 78 | let success = *success; 79 | let _ = tx.send(AppEvent::Action { 80 | run: run_id, 81 | host: host_id, 82 | msg, 83 | }); 84 | let _ = exit_tx.send(success); 85 | return; 86 | } 87 | let _ = tx.send(AppEvent::Action { 88 | run: run_id, 89 | host: host_id, 90 | msg, 91 | }); 92 | } 93 | let _ = exit_tx.send(false); 94 | // this doens't do anything but to hold the node's tx 95 | // so that it doesn't get dropped 96 | node_tx.is_empty(); 97 | }); 98 | } 99 | 100 | for action_data in &self.actions { 101 | tx.send(NodeMessage::Action(action_data.clone()))?; 102 | } 103 | tx.send(NodeMessage::Shutdown)?; 104 | 105 | Ok(()) 106 | } 107 | 108 | fn start(&self) -> Result<(Sender, Receiver)> { 109 | if self.host == "localhost" || self.host == "127.0.0.1" { 110 | Ok(start_local()) 111 | } else { 112 | start_remote( 113 | SshRemote { 114 | ssh: SshHost { 115 | host: self.host.clone(), 116 | port: None, 117 | user: self.remote_user.clone(), 118 | }, 119 | }, 120 | self.become_, 121 | ) 122 | } 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /tiron-node/src/action/package/mod.rs: -------------------------------------------------------------------------------- 1 | mod provider; 2 | 3 | use anyhow::anyhow; 4 | use crossbeam_channel::Sender; 5 | use documented::{Documented, DocumentedFields}; 6 | use serde::{Deserialize, Serialize}; 7 | use tiron_common::{ 8 | action::{ActionId, ActionMessage}, 9 | error::Error, 10 | }; 11 | 12 | use self::provider::PackageProvider; 13 | 14 | use super::{ 15 | Action, ActionDoc, ActionParamBaseType, ActionParamBaseValue, ActionParamDoc, ActionParamType, 16 | ActionParams, 17 | }; 18 | 19 | #[derive(Default, Clone, Serialize, Deserialize)] 20 | pub enum PackageState { 21 | #[default] 22 | Present, 23 | Absent, 24 | Latest, 25 | } 26 | 27 | /// Install packages 28 | #[derive(Default, Clone, Serialize, Deserialize, Documented, DocumentedFields)] 29 | pub struct PackageAction { 30 | /// the name of the packages to be installed 31 | name: Vec, 32 | /// Whether to install or remove or update packages 33 | /// 34 | /// `present` to install 35 | /// 36 | /// `absent` to remove 37 | /// 38 | /// `latest` to update 39 | state: PackageState, 40 | } 41 | 42 | impl Action for PackageAction { 43 | fn name(&self) -> String { 44 | "package".to_string() 45 | } 46 | 47 | fn doc(&self) -> ActionDoc { 48 | ActionDoc { 49 | description: PackageAction::DOCS.to_string(), 50 | params: vec![ 51 | ActionParamDoc { 52 | name: "name".to_string(), 53 | required: true, 54 | description: PackageAction::get_field_docs("name") 55 | .unwrap_or_default() 56 | .to_string(), 57 | type_: vec![ 58 | ActionParamType::String, 59 | ActionParamType::List(ActionParamBaseType::String), 60 | ], 61 | }, 62 | ActionParamDoc { 63 | name: "state".to_string(), 64 | required: true, 65 | description: PackageAction::get_field_docs("state") 66 | .unwrap_or_default() 67 | .to_string(), 68 | type_: vec![ActionParamType::Enum(vec![ 69 | ActionParamBaseValue::String("present".to_string()), 70 | ActionParamBaseValue::String("absent".to_string()), 71 | ActionParamBaseValue::String("latest".to_string()), 72 | ])], 73 | }, 74 | ], 75 | } 76 | } 77 | 78 | fn input(&self, params: ActionParams) -> Result, Error> { 79 | let name = params.values[0].as_ref().unwrap(); 80 | let names = if let Some(s) = name.string() { 81 | vec![s.to_string()] 82 | } else { 83 | let list = name.expect_list(); 84 | list.iter().map(|v| v.expect_string().to_string()).collect() 85 | }; 86 | 87 | let state = params.expect_base(1); 88 | let state = state.expect_string(); 89 | let state = match state { 90 | "present" => PackageState::Present, 91 | "absent" => PackageState::Absent, 92 | "latest" => PackageState::Latest, 93 | _ => { 94 | unreachable!(); 95 | } 96 | }; 97 | 98 | let input = PackageAction { name: names, state }; 99 | let input = bincode::serialize(&input).map_err(|e| { 100 | Error::new(format!("serialize action input error: {e}")) 101 | .with_origin(params.origin, ¶ms.span) 102 | })?; 103 | Ok(input) 104 | } 105 | 106 | fn execute( 107 | &self, 108 | id: ActionId, 109 | input: &[u8], 110 | tx: &Sender, 111 | ) -> anyhow::Result { 112 | let input: PackageAction = bincode::deserialize(input)?; 113 | let provider = PackageProvider::detect()?; 114 | 115 | let status = provider.run(id, tx, input.name, input.state)?; 116 | if status.success() { 117 | Ok("package".to_string()) 118 | } else { 119 | Err(anyhow!("package failed")) 120 | } 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /tiron-node/src/action/file.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use documented::{Documented, DocumentedFields}; 4 | use serde::{Deserialize, Serialize}; 5 | use tiron_common::error::Error; 6 | 7 | use super::{ 8 | Action, ActionDoc, ActionParamBaseValue, ActionParamDoc, ActionParamType, ActionParams, 9 | }; 10 | 11 | #[derive(Default, Clone, Serialize, Deserialize)] 12 | pub enum FileState { 13 | #[default] 14 | File, 15 | Directory, 16 | Absent, 17 | } 18 | 19 | /// Manage files/folders and their properties 20 | #[derive(Default, Clone, Serialize, Deserialize, Documented, DocumentedFields)] 21 | pub struct FileAction { 22 | /// Path of the file or folder that's managed 23 | path: String, 24 | /// Default to `file`
25 | /// 26 | /// If `file`, a file will be managed. 27 | /// 28 | /// If `directory`, a directory will be recursively created 29 | /// and all of its parent components if they are missing. 30 | /// 31 | /// If `absent`, directories will be recursively deleted 32 | /// and all its contents, and files or symlinks will be unlinked. 33 | state: FileState, 34 | } 35 | 36 | impl Action for FileAction { 37 | fn name(&self) -> String { 38 | "file".to_string() 39 | } 40 | 41 | fn doc(&self) -> ActionDoc { 42 | ActionDoc { 43 | description: Self::DOCS.to_string(), 44 | params: vec![ 45 | ActionParamDoc { 46 | name: "path".to_string(), 47 | required: true, 48 | description: Self::get_field_docs("path").unwrap_or_default().to_string(), 49 | type_: vec![ActionParamType::String], 50 | }, 51 | ActionParamDoc { 52 | name: "state".to_string(), 53 | required: false, 54 | description: Self::get_field_docs("state") 55 | .unwrap_or_default() 56 | .to_string(), 57 | type_: vec![ActionParamType::Enum(vec![ 58 | ActionParamBaseValue::String("file".to_string()), 59 | ActionParamBaseValue::String("absent".to_string()), 60 | ActionParamBaseValue::String("directory".to_string()), 61 | ])], 62 | }, 63 | ], 64 | } 65 | } 66 | 67 | fn input(&self, params: ActionParams) -> Result, Error> { 68 | let path = params.expect_string(0); 69 | let mut input = FileAction { 70 | path: path.to_string(), 71 | ..Default::default() 72 | }; 73 | 74 | if let Some(state) = params.base(1) { 75 | let state = state.expect_string(); 76 | let state = match state { 77 | "file" => FileState::File, 78 | "absent" => FileState::Absent, 79 | "directory" => FileState::Directory, 80 | _ => unreachable!(), 81 | }; 82 | input.state = state; 83 | } 84 | 85 | let input = bincode::serialize(&input).map_err(|e| { 86 | Error::new(format!("serialize action input error: {e}")) 87 | .with_origin(params.origin, ¶ms.span) 88 | })?; 89 | Ok(input) 90 | } 91 | 92 | fn execute( 93 | &self, 94 | _id: tiron_common::action::ActionId, 95 | input: &[u8], 96 | _tx: &crossbeam_channel::Sender, 97 | ) -> anyhow::Result { 98 | let input: FileAction = bincode::deserialize(input)?; 99 | match input.state { 100 | FileState::File => {} 101 | FileState::Directory => { 102 | std::fs::create_dir_all(input.path)?; 103 | } 104 | FileState::Absent => { 105 | let path = PathBuf::from(input.path); 106 | if path.exists() { 107 | if path.is_dir() { 108 | std::fs::remove_dir_all(path)?; 109 | } else { 110 | std::fs::remove_file(path)?; 111 | } 112 | } 113 | } 114 | } 115 | Ok("".to_string()) 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /tiron-common/src/value.rs: -------------------------------------------------------------------------------- 1 | use std::ops::Range; 2 | 3 | use hcl::{ 4 | eval::{Context, Evaluate}, 5 | Map, Number, Value, 6 | }; 7 | use hcl_edit::{expr::Expression, Span}; 8 | 9 | use crate::error::{Error, Origin}; 10 | 11 | /// A wrapper type for attaching span information to a value. 12 | #[derive(Debug, Clone, Eq)] 13 | pub struct Spanned { 14 | value: T, 15 | span: Option>, 16 | } 17 | 18 | impl PartialEq for Spanned 19 | where 20 | T: PartialEq, 21 | { 22 | fn eq(&self, other: &Self) -> bool { 23 | self.value == other.value 24 | } 25 | } 26 | 27 | impl Spanned { 28 | /// Creates a new `Spanned` from a `T`. 29 | pub fn new(value: T) -> Spanned { 30 | Spanned { value, span: None } 31 | } 32 | 33 | fn with_span(mut self, span: Option>) -> Spanned { 34 | self.span = span; 35 | self 36 | } 37 | 38 | /// Returns a reference to the wrapped value. 39 | pub fn value(&self) -> &T { 40 | &self.value 41 | } 42 | 43 | pub fn span(&self) -> &Option> { 44 | &self.span 45 | } 46 | } 47 | 48 | /// Represents a value that is `null`. 49 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 50 | pub struct Null; 51 | 52 | /// Represents any valid decorated HCL value. 53 | #[derive(Debug, PartialEq, Eq, Clone)] 54 | pub enum SpannedValue { 55 | /// Represents a HCL null value. 56 | Null(Spanned), 57 | /// Represents a HCL boolean. 58 | Bool(Spanned), 59 | /// Represents a HCL number, either integer or float. 60 | Number(Spanned), 61 | /// Represents a HCL string. 62 | String(Spanned), 63 | /// Represents a HCL array. 64 | Array(Spanned>), 65 | /// Represents a HCL object. 66 | Object(Spanned>), 67 | } 68 | 69 | impl SpannedValue { 70 | pub fn span(&self) -> &Option> { 71 | match self { 72 | SpannedValue::Null(v) => v.span(), 73 | SpannedValue::Bool(v) => v.span(), 74 | SpannedValue::Number(v) => v.span(), 75 | SpannedValue::String(v) => v.span(), 76 | SpannedValue::Array(v) => v.span(), 77 | SpannedValue::Object(v) => v.span(), 78 | } 79 | } 80 | 81 | pub fn from_value(value: Value, span: Option>) -> SpannedValue { 82 | match value { 83 | Value::Null => SpannedValue::Null(Spanned::new(Null).with_span(span)), 84 | Value::Bool(bool) => SpannedValue::Bool(Spanned::new(bool).with_span(span)), 85 | Value::Number(v) => SpannedValue::Number(Spanned::new(v).with_span(span)), 86 | Value::String(v) => SpannedValue::String(Spanned::new(v).with_span(span)), 87 | Value::Array(array) => SpannedValue::Array( 88 | Spanned::new( 89 | array 90 | .into_iter() 91 | .map(|v| SpannedValue::from_value(v, span.clone())) 92 | .collect(), 93 | ) 94 | .with_span(span), 95 | ), 96 | 97 | Value::Object(map) => SpannedValue::Object( 98 | Spanned::new( 99 | map.into_iter() 100 | .map(|(key, v)| (key, SpannedValue::from_value(v, span.clone()))) 101 | .collect(), 102 | ) 103 | .with_span(span), 104 | ), 105 | } 106 | } 107 | 108 | pub fn from_expression( 109 | origin: &Origin, 110 | ctx: &Context, 111 | expr: hcl_edit::expr::Expression, 112 | ) -> Result { 113 | let span = expr.span(); 114 | match expr { 115 | Expression::Array(exprs) => { 116 | let mut values = Vec::new(); 117 | for expr in exprs.into_iter() { 118 | let value = SpannedValue::from_expression(origin, ctx, expr)?; 119 | values.push(value); 120 | } 121 | Ok(SpannedValue::Array(Spanned::new(values).with_span(span))) 122 | } 123 | _ => { 124 | let expr: hcl::Expression = expr.into(); 125 | let v: hcl::Value = expr 126 | .evaluate(ctx) 127 | .map_err(|e| origin.error(e.to_string(), &span))?; 128 | Ok(SpannedValue::from_value(v, span)) 129 | } 130 | } 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /tiron/src/core.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use anyhow::Result; 4 | use clap::Parser; 5 | use itertools::Itertools; 6 | 7 | use tiron_common::error::Error; 8 | use tiron_node::action::data::all_actions; 9 | use tiron_tui::event::{AppEvent, RunEvent}; 10 | 11 | use crate::{ 12 | cli::{Cli, CliCmd}, 13 | doc::generate_doc, 14 | fmt::fmt, 15 | run::Run, 16 | runbook::Runbook, 17 | }; 18 | 19 | pub fn cmd() -> Result<(), Error> { 20 | let cli = Cli::parse(); 21 | match cli.cmd { 22 | CliCmd::Run { runbooks } => { 23 | let runbooks = if runbooks.is_empty() { 24 | vec!["main".to_string()] 25 | } else { 26 | runbooks 27 | }; 28 | run(runbooks, false)?; 29 | } 30 | CliCmd::Check { runbooks } => { 31 | let runbooks = if runbooks.is_empty() { 32 | vec!["main".to_string()] 33 | } else { 34 | runbooks 35 | }; 36 | let runbooks = run(runbooks, true)?; 37 | println!("successfully checked"); 38 | for runbook in runbooks { 39 | println!("{}", runbook.to_string_lossy()); 40 | } 41 | } 42 | CliCmd::Fmt { targets } => { 43 | fmt(targets)?; 44 | } 45 | CliCmd::Action { name } => action_doc(name), 46 | CliCmd::GenerateDoc => { 47 | generate_doc().map_err(|e| Error::new(e.to_string()))?; 48 | } 49 | } 50 | Ok(()) 51 | } 52 | 53 | pub fn run(runbooks: Vec, check: bool) -> Result, Error> { 54 | let mut app = tiron_tui::app::App::new(); 55 | let runbooks: Vec = runbooks 56 | .iter() 57 | .map(|name| { 58 | let file_name = if !name.ends_with(".tr") { 59 | format!("{name}.tr") 60 | } else { 61 | name.to_string() 62 | }; 63 | 64 | match std::env::current_dir() { 65 | Ok(path) => path.join(file_name), 66 | Err(_) => PathBuf::from(file_name), 67 | } 68 | }) 69 | .collect(); 70 | 71 | let mut runs = Vec::new(); 72 | for path in runbooks.iter() { 73 | let mut runbook = Runbook::new(path.to_path_buf(), app.tx.clone(), 0)?; 74 | runbook.parse(true)?; 75 | runs.push(runbook.runs); 76 | } 77 | let runs: Vec = runs.into_iter().flatten().collect(); 78 | 79 | if !check { 80 | app.runs = runs.iter().map(|run| run.to_panel()).collect(); 81 | 82 | let tx = app.tx.clone(); 83 | std::thread::spawn(move || -> Result<()> { 84 | for run in runs { 85 | let _ = tx.send(AppEvent::Run(RunEvent::RunStarted { id: run.id })); 86 | let success = run.execute()?; 87 | let _ = tx.send(AppEvent::Run(RunEvent::RunCompleted { 88 | id: run.id, 89 | success, 90 | })); 91 | if !success { 92 | break; 93 | } 94 | } 95 | Ok(()) 96 | }); 97 | 98 | app.start().map_err(|e| Error::new(e.to_string()))?; 99 | } 100 | 101 | Ok(runbooks) 102 | } 103 | 104 | fn action_doc(name: Option) { 105 | let actions = all_actions(); 106 | if let Some(name) = name { 107 | if let Some(action) = actions.get(&name) { 108 | println!("{}\n", action.name()); 109 | let doc = action.doc(); 110 | println!("Description:"); 111 | println!(" {}\n", doc.description); 112 | 113 | println!("Params:"); 114 | doc.params.iter().for_each(|p| { 115 | println!(" - {}:", p.name); 116 | println!(" Required: {}", p.required); 117 | println!( 118 | " Type: {}", 119 | p.type_.iter().map(|t| t.to_string()).join(" or ") 120 | ); 121 | println!(" Description:"); 122 | for line in p.description.split('\n') { 123 | println!(" {line}"); 124 | } 125 | }); 126 | } else { 127 | println!("Can't find action {name}"); 128 | } 129 | } else { 130 | println!("All Tiron Actions"); 131 | actions 132 | .iter() 133 | .sorted_by_key(|(k, _)| k.to_string()) 134 | .for_each(|(_, action)| { 135 | println!(" - {}:", action.name()); 136 | println!(" {}", action.doc().description); 137 | }); 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /tiron/src/run.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use hcl::eval::Context; 3 | use hcl_edit::{ 4 | structure::{Block, Structure}, 5 | Span, 6 | }; 7 | use tiron_common::{error::Error, value::SpannedValue}; 8 | use tiron_tui::run::{ActionSection, HostSection, RunPanel}; 9 | use uuid::Uuid; 10 | 11 | use crate::{node::Node, runbook::Runbook}; 12 | 13 | pub struct Run { 14 | pub id: Uuid, 15 | name: Option, 16 | hosts: Vec, 17 | } 18 | 19 | impl Run { 20 | pub fn from_block(runbook: &Runbook, block: &Block, hosts: Vec) -> Result { 21 | let name = block.body.iter().find_map(|s| { 22 | s.as_attribute() 23 | .filter(|a| a.key.as_str() == "name") 24 | .map(|a| &a.value) 25 | }); 26 | let name = if let Some(name) = name { 27 | let hcl_edit::expr::Expression::String(s) = name else { 28 | return runbook 29 | .origin 30 | .error("name should be a string", &name.span()) 31 | .err(); 32 | }; 33 | Some(s.value().to_string()) 34 | } else { 35 | None 36 | }; 37 | 38 | let mut run = Run { 39 | id: Uuid::new_v4(), 40 | name, 41 | hosts, 42 | }; 43 | 44 | for host in run.hosts.iter_mut() { 45 | let mut ctx = Context::new(); 46 | for (name, var) in &host.vars { 47 | ctx.declare_var(name.to_string(), var.to_owned()); 48 | } 49 | 50 | for s in block.body.iter() { 51 | if let Structure::Attribute(a) = s { 52 | let v = 53 | SpannedValue::from_expression(&runbook.origin, &ctx, a.value.to_owned())?; 54 | match a.key.as_str() { 55 | "remote_user" => { 56 | if !host.vars.contains_key("remote_user") { 57 | let SpannedValue::String(s) = v else { 58 | return runbook 59 | .origin 60 | .error("remote_user should be a string", v.span()) 61 | .err(); 62 | }; 63 | host.remote_user = Some(s.value().to_string()); 64 | } 65 | } 66 | "become" => { 67 | if !host.vars.contains_key("become") { 68 | let SpannedValue::Bool(b) = v else { 69 | return runbook 70 | .origin 71 | .error("become should be a bool", v.span()) 72 | .err(); 73 | }; 74 | host.become_ = *b.value(); 75 | } 76 | } 77 | _ => {} 78 | } 79 | } 80 | } 81 | 82 | let actions = runbook.parse_actions(&ctx, block).map_err(|e| { 83 | let mut e = e; 84 | e.message = format!( 85 | "error when parsing actions for host {}: {}", 86 | host.host, e.message 87 | ); 88 | e 89 | })?; 90 | host.actions = actions; 91 | } 92 | 93 | Ok(run) 94 | } 95 | 96 | pub fn execute(&self) -> Result { 97 | let mut receivers = Vec::new(); 98 | 99 | for host in &self.hosts { 100 | let (exit_tx, exit_rx) = crossbeam_channel::bounded::(1); 101 | let host = host.clone(); 102 | let run_id = self.id; 103 | std::thread::spawn(move || { 104 | let _ = host.execute(run_id, exit_tx); 105 | }); 106 | 107 | receivers.push(exit_rx) 108 | } 109 | 110 | let mut errors = 0; 111 | for rx in &receivers { 112 | let result = rx.recv(); 113 | if result != Ok(true) { 114 | errors += 1; 115 | } 116 | } 117 | 118 | Ok(errors == 0) 119 | } 120 | 121 | pub fn to_panel(&self) -> RunPanel { 122 | let hosts = self 123 | .hosts 124 | .iter() 125 | .map(|host| { 126 | HostSection::new( 127 | host.id, 128 | host.host.clone(), 129 | host.actions 130 | .iter() 131 | .map(|action| ActionSection::new(action.id, action.name.clone())) 132 | .collect(), 133 | ) 134 | }) 135 | .collect(); 136 | RunPanel::new(self.id, self.name.clone(), hosts) 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /tiron-node/src/action/command.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | io::{BufRead, BufReader}, 3 | process::{ExitStatus, Stdio}, 4 | }; 5 | 6 | use anyhow::{anyhow, Result}; 7 | use crossbeam_channel::Sender; 8 | use documented::{Documented, DocumentedFields}; 9 | use serde::{Deserialize, Serialize}; 10 | use tiron_common::{ 11 | action::{ActionId, ActionMessage, ActionOutputLevel}, 12 | error::Error, 13 | }; 14 | 15 | use super::{ 16 | Action, ActionDoc, ActionParamBaseType, ActionParamDoc, ActionParamType, ActionParams, 17 | }; 18 | 19 | pub fn run_command( 20 | id: ActionId, 21 | tx: &Sender, 22 | program: &str, 23 | args: &[String], 24 | ) -> Result { 25 | let mut cmd = std::process::Command::new(program); 26 | for arg in args { 27 | cmd.arg(arg); 28 | } 29 | let mut child = cmd 30 | .stdout(Stdio::piped()) 31 | .stderr(Stdio::piped()) 32 | .stdin(Stdio::null()) 33 | .spawn()?; 34 | 35 | let stdout = child.stdout.take(); 36 | let stderr = child.stderr.take(); 37 | 38 | if let Some(stdout) = stdout { 39 | let tx = tx.clone(); 40 | std::thread::spawn(move || { 41 | let mut reader = BufReader::new(stdout); 42 | let mut line = String::new(); 43 | while let Ok(n) = reader.read_line(&mut line) { 44 | if n > 0 { 45 | let line = line.trim_end().to_string(); 46 | let _ = tx.send(ActionMessage::ActionOutputLine { 47 | id, 48 | content: line, 49 | level: ActionOutputLevel::Info, 50 | }); 51 | } else { 52 | break; 53 | } 54 | line.clear(); 55 | } 56 | }); 57 | } 58 | 59 | if let Some(stderr) = stderr { 60 | let tx = tx.clone(); 61 | std::thread::spawn(move || { 62 | let mut reader = BufReader::new(stderr); 63 | let mut line = String::new(); 64 | while let Ok(n) = reader.read_line(&mut line) { 65 | if n > 0 { 66 | let line = line.trim_end().to_string(); 67 | let _ = tx.send(ActionMessage::ActionOutputLine { 68 | id, 69 | content: line, 70 | level: ActionOutputLevel::Info, 71 | }); 72 | } else { 73 | break; 74 | } 75 | line.clear(); 76 | } 77 | }); 78 | } 79 | 80 | let status = child.wait()?; 81 | Ok(status) 82 | } 83 | 84 | /// Run the command on the remote machine 85 | #[derive(Default, Clone, Serialize, Deserialize, Documented, DocumentedFields)] 86 | pub struct CommandAction { 87 | /// The command to run 88 | cmd: String, 89 | /// The command arguments 90 | args: Vec, 91 | } 92 | 93 | impl Action for CommandAction { 94 | fn name(&self) -> String { 95 | "command".to_string() 96 | } 97 | 98 | fn doc(&self) -> ActionDoc { 99 | ActionDoc { 100 | description: Self::DOCS.to_string(), 101 | params: vec![ 102 | ActionParamDoc { 103 | name: "cmd".to_string(), 104 | required: true, 105 | description: Self::get_field_docs("cmd").unwrap_or_default().to_string(), 106 | type_: vec![ActionParamType::String], 107 | }, 108 | ActionParamDoc { 109 | name: "args".to_string(), 110 | required: false, 111 | description: Self::get_field_docs("args").unwrap_or_default().to_string(), 112 | type_: vec![ActionParamType::List(ActionParamBaseType::String)], 113 | }, 114 | ], 115 | } 116 | } 117 | 118 | fn input(&self, params: ActionParams) -> Result, Error> { 119 | let cmd = params.expect_string(0); 120 | 121 | let args = if let Some(list) = params.list(1) { 122 | let args = list 123 | .iter() 124 | .map(|v| v.expect_string().to_string()) 125 | .collect::>(); 126 | Some(args) 127 | } else { 128 | None 129 | }; 130 | 131 | let input = CommandAction { 132 | cmd: cmd.to_string(), 133 | args: args.unwrap_or_default(), 134 | }; 135 | let input = bincode::serialize(&input).map_err(|e| { 136 | Error::new(format!("serialize action input error: {e}")) 137 | .with_origin(params.origin, ¶ms.span) 138 | })?; 139 | Ok(input) 140 | } 141 | 142 | fn execute( 143 | &self, 144 | id: ActionId, 145 | input: &[u8], 146 | tx: &Sender, 147 | ) -> anyhow::Result { 148 | let input: CommandAction = bincode::deserialize(input)?; 149 | let status = run_command(id, tx, &input.cmd, &input.args)?; 150 | if status.success() { 151 | Ok("command".to_string()) 152 | } else { 153 | Err(anyhow!("command failed")) 154 | } 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /docs/content/docs/getting-started/overview.md: -------------------------------------------------------------------------------- 1 | +++ 2 | template = "docs/section.html" 3 | title = "overview" 4 | weight = 2 5 | +++ 6 | 7 | ### Installation 8 | 9 | Run below to install latest Tiron binary to ```/usr/local/bin``` 10 | 11 | ```bash 12 | curl -sL https://tiron.run/install.sh | sh 13 | ``` 14 | 15 | ### Usage 16 | 17 | To run a Tiron runbook 18 | 19 | ```bash 20 | $ tiron run 21 | ``` 22 | 23 | It will run `main.tr` in the current directory. 24 | You can also give a path of the runbook you want to run. 25 | 26 | ```bash 27 | $ tiron run folder/subfolder/production.tr 28 | ``` 29 | 30 | You can also pre validates the runbook without actually running it by using `check` 31 | which takes the same input as `run` 32 | 33 | ```bash 34 | $ tiron check 35 | ``` 36 | 37 | ### Runbook 38 | 39 | The center of Tiron is a runbook. A runbook is a set of settings and actions 40 | for Tiron to know what and how to run things on your remote machines. 41 | 42 | #### HCL 43 | 44 | For Tiron runbook, we use [HCL](https://github.com/hashicorp/hcl) as the configuration 45 | language. 46 | 47 | ### Simple Runbook Example 48 | 49 | We'll start with a very simple runbook for you to get familiar with the concepts 50 | of Tiron runbooks. 51 | 52 | #### group 53 | 54 | Before everything, we need to know what remote machines to run actions on, and that's 55 | the `group` block in Tiron. E.g. you want to have a "webservers" group with remote machines 56 | "web1", "web2", and "web3", you'll define it as follows: 57 | 58 | ```tcl 59 | group "webservers" { 60 | host "web1" {} 61 | host "web2" {} 62 | host "web3" {} 63 | } 64 | ``` 65 | 66 | A group can contain host and other groups at the same time: 67 | 68 | ```tcl 69 | group "production" { 70 | group "webservers" {} 71 | host "db1" {} 72 | } 73 | ``` 74 | 75 | You can define variables in group or host level 76 | 77 | ```tcl 78 | group "production" { 79 | group "webservers" { 80 | group_var = "webservers_group_var" 81 | } 82 | host "db1" { 83 | host_var = "host_var" 84 | } 85 | group_production_var = "group_production_var" 86 | } 87 | ``` 88 | 89 | #### run 90 | 91 | Now we know what remote machines we'll use, 92 | we can start to run things on them. To do that, 93 | you simply have a `run` block on a `group` you defined earlier: 94 | 95 | ```tcl 96 | run "production" { 97 | } 98 | ``` 99 | 100 | For things we want to run the remote machines, we call it `action` in Tiron. 101 | And the following run a "copy" `action` which copies `src_file` from local 102 | to `/tmp/dest_path` on the remote machines. 103 | 104 | ```tcl 105 | run "production" { 106 | action "copy" { 107 | params { 108 | src = "src_file" 109 | dest = "/tmp/dest_path" 110 | } 111 | } 112 | } 113 | ``` 114 | 115 | You can have as many as actions you want in a `run` 116 | 117 | ```tcl 118 | run "production" { 119 | action "action1" {} 120 | action "action2" {} 121 | action "action1" {} 122 | } 123 | ``` 124 | 125 | #### job 126 | 127 | You might have a set of actions you want to reuse in different runs. 128 | `job` would be useful here. A job is defined as a set of actions 129 | that you can use in a `run`. To define a job, you give it a name 130 | and the set of actions it contains. And you can also include another job in a job. 131 | 132 | ```tcl 133 | job "job1" { 134 | action "action1" {} 135 | action "action2" {} 136 | } 137 | 138 | job "job2" { 139 | action "action1" {} 140 | action "action2" {} 141 | action "job" { 142 | params { 143 | name = "job1" 144 | } 145 | } 146 | } 147 | ``` 148 | 149 | Now you can use `job` in your `run` 150 | 151 | ```tcl 152 | run "production" { 153 | action "action1" {} 154 | action "action2" {} 155 | action "action1" {} 156 | action "job" { 157 | params { 158 | name = "job2" 159 | } 160 | } 161 | } 162 | ``` 163 | 164 | #### use 165 | 166 | You might want to use a `group` or `job` from another runbook. And `use` can be used to 167 | import them. 168 | 169 | ```tcl 170 | use "folder/another_runbook.tr" { 171 | job "job1" {} 172 | group "group1" {} 173 | } 174 | ``` 175 | 176 | You can use `as` to bind the imports to a different name. It would be useful if 177 | you have defined a job or group in your runbook with the same name. 178 | 179 | ```tcl 180 | use "folder/another_runbook.tr" { 181 | job "job1" { 182 | as = "another_job_name" 183 | } 184 | group "group1" { 185 | as = "another_group_name" 186 | } 187 | } 188 | 189 | group "group1" { 190 | host "machine1" {} 191 | } 192 | 193 | job "job1" { 194 | action "action1" {} 195 | action "action2" {} 196 | } 197 | 198 | run "group1" { 199 | action "job" { 200 | params { 201 | name = "another_job_name" 202 | } 203 | } 204 | } 205 | 206 | run "another_group_name" { 207 | action "job" { 208 | params { 209 | name = "job1" 210 | } 211 | } 212 | } 213 | ``` 214 | 215 | These are pretty much all the components in Tiron for you to write your runbooks. 216 | The next thing you'll want to check out is the list of `action` we include in Tiron. 217 | You can view the action docs [here](/docs/actions/command/) or via the tiron command in the console 218 | 219 | ```bash 220 | $ tiron action 221 | $ tiron action copy 222 | ``` 223 | 224 | There's also some example runbooks at [https://github.com/lapce/tiron/blob/main/examples/example_tiron_project](https://github.com/lapce/tiron/blob/main/examples/example_tiron_project) 225 | -------------------------------------------------------------------------------- /tiron-common/src/error.rs: -------------------------------------------------------------------------------- 1 | use std::{io::Write, ops::Range, path::PathBuf}; 2 | 3 | use anyhow::Result; 4 | 5 | /// The runbook file path and content 6 | pub struct Origin { 7 | pub cwd: PathBuf, 8 | pub path: PathBuf, 9 | pub data: String, 10 | } 11 | 12 | impl Origin { 13 | pub fn error(&self, message: impl Into, span: &Option>) -> Error { 14 | Error::new(message.into()).with_origin(self, span) 15 | } 16 | } 17 | 18 | pub struct Error { 19 | pub message: String, 20 | pub location: Option, 21 | } 22 | 23 | pub struct ErrorLocation { 24 | pub path: PathBuf, 25 | pub line_content: String, 26 | pub line: usize, 27 | pub start_col: usize, 28 | pub end_col: usize, 29 | } 30 | 31 | impl Error { 32 | pub fn new(message: impl Into) -> Self { 33 | Self { 34 | message: message.into(), 35 | location: None, 36 | } 37 | } 38 | 39 | pub fn with_origin(mut self, origin: &Origin, span: &Option>) -> Self { 40 | if let Some(span) = span { 41 | let line_begin = origin.data[..span.start] 42 | .as_bytes() 43 | .iter() 44 | .rev() 45 | .position(|&b| b == b'\n') 46 | .map_or(0, |pos| span.start - pos); 47 | 48 | let line_content = origin.data[line_begin..] 49 | .as_bytes() 50 | .iter() 51 | .position(|&b| b == b'\n') 52 | .map_or(&origin.data[line_begin..], |pos| { 53 | &origin.data[line_begin..line_begin + pos] 54 | }); 55 | 56 | let line = origin.data[..span.start] 57 | .as_bytes() 58 | .iter() 59 | .filter(|&&b| b == b'\n') 60 | .count() 61 | + 1; 62 | let start_col = span.start - line_begin + 1; 63 | let end_col = span.start - line_begin + span.len(); 64 | self.location = Some(ErrorLocation { 65 | path: origin.path.clone(), 66 | line_content: line_content.to_string(), 67 | line, 68 | start_col, 69 | end_col, 70 | }); 71 | } 72 | self 73 | } 74 | 75 | pub fn from_hcl(err: hcl_edit::parser::Error, path: PathBuf) -> Error { 76 | Error { 77 | message: err.message().to_string(), 78 | location: Some(ErrorLocation { 79 | path, 80 | line_content: err.line().to_string(), 81 | line: err.location().line(), 82 | start_col: err.location().column(), 83 | end_col: err.location().column(), 84 | }), 85 | } 86 | } 87 | 88 | pub fn err(self) -> Result { 89 | Err(self) 90 | } 91 | 92 | pub fn report_stderr(&self) -> Result<()> { 93 | let mut result = Vec::new(); 94 | result.push(Segment::from("Error: ").with_markup(Markup::Error)); 95 | result.push(self.message.clone().into()); 96 | result.push("\n".into()); 97 | if let Some(location) = &self.location { 98 | let line_len = location.line.to_string().len(); 99 | 100 | result.push(" ".repeat(line_len + 1).into()); 101 | result.push(Segment::from("--> ").with_markup(Markup::Error)); 102 | let path = location.path.to_string_lossy(); 103 | result.push(path.as_ref().into()); 104 | let line_col = format!(":{}:{}\n", location.line, location.start_col); 105 | result.push(line_col.as_str().into()); 106 | 107 | result.push(" ".repeat(line_len + 2).into()); 108 | result.push(Segment::from("╷\n").with_markup(Markup::Error)); 109 | result.push(Segment::from(format!(" {} ", location.line)).with_markup(Markup::Error)); 110 | result.push(Segment::from("│ ").with_markup(Markup::Error)); 111 | result.push(location.line_content.clone().into()); 112 | result.push("\n".into()); 113 | result.push(" ".repeat(line_len + 2).into()); 114 | result.push(Segment::from("╵").with_markup(Markup::Error)); 115 | result.push(" ".repeat(location.start_col).into()); 116 | result.push("^".into()); 117 | for _ in location.start_col..location.end_col { 118 | result.push("~".into()); 119 | } 120 | result.push("\n".into()); 121 | } 122 | 123 | let stderr = std::io::stderr(); 124 | let mut out = stderr.lock(); 125 | let mut markup = Markup::None; 126 | for seg in result { 127 | if markup != seg.markup { 128 | markup = seg.markup; 129 | out.write_all(switch_ansi(markup).as_bytes())?; 130 | } 131 | out.write_all(seg.s.as_bytes())?; 132 | } 133 | 134 | std::process::exit(1); 135 | } 136 | } 137 | 138 | struct Segment { 139 | s: String, 140 | markup: Markup, 141 | } 142 | 143 | impl Segment { 144 | pub fn with_markup(mut self, markup: Markup) -> Self { 145 | self.markup = markup; 146 | self 147 | } 148 | } 149 | 150 | impl From<&str> for Segment { 151 | fn from(value: &str) -> Segment { 152 | Segment { 153 | s: value.to_string(), 154 | markup: Markup::None, 155 | } 156 | } 157 | } 158 | 159 | impl From for Segment { 160 | fn from(value: String) -> Segment { 161 | Segment { 162 | s: value, 163 | markup: Markup::None, 164 | } 165 | } 166 | } 167 | 168 | /// A markup hint, used to apply color and other markup to output. 169 | #[derive(Clone, Copy, Debug, Eq, PartialEq)] 170 | pub enum Markup { 171 | /// No special markup applied, default formatting. 172 | None, 173 | 174 | /// Used for error message reporting, styled in bold. 175 | Error, 176 | /// Used for error message reporting, styled in bold. 177 | Warning, 178 | /// Used for trace message reporting, styled in bold. 179 | Trace, 180 | 181 | /// Make something stand out in error messages. 182 | /// 183 | /// We use this to play a similar role as backticks in Markdown, 184 | /// to clarify visually where the boundaries of a quotation are. 185 | Highlight, 186 | 187 | // These are meant for syntax highlighting. 188 | Builtin, 189 | Comment, 190 | Escape, 191 | Field, 192 | Keyword, 193 | Number, 194 | String, 195 | Type, 196 | } 197 | 198 | /// Return the ANSI escape code to switch to style `markup`. 199 | pub fn switch_ansi(markup: Markup) -> &'static str { 200 | let reset = "\x1b[0m"; 201 | let bold_blue = "\x1b[34;1m"; 202 | let bold_green = "\x1b[32;1m"; 203 | let bold_red = "\x1b[31;1m"; 204 | let bold_yellow = "\x1b[33;1m"; 205 | let blue = "\x1b[34m"; 206 | let cyan = "\x1b[36m"; 207 | let magenta = "\x1b[35m"; 208 | let red = "\x1b[31m"; 209 | let white = "\x1b[37m"; 210 | let yellow = "\x1b[33m"; 211 | 212 | match markup { 213 | Markup::None => reset, 214 | Markup::Error => bold_red, 215 | Markup::Warning => bold_yellow, 216 | Markup::Trace => bold_blue, 217 | Markup::Highlight => white, 218 | Markup::Builtin => red, 219 | Markup::Comment => white, 220 | Markup::Field => blue, 221 | Markup::Keyword => bold_green, 222 | Markup::Number => cyan, 223 | Markup::String => red, 224 | Markup::Escape => yellow, 225 | Markup::Type => magenta, 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /docs/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |
5 |
6 |
7 |

Reasonable Automation Engine

8 |

Tiron is an automation tool that's easy to use and aims to be as fast as possible. It’s agentless by using SSH and has a TUI for the outputs of the tasks.

9 |
10 | 11 |
12 | Getting Started 13 | Github 14 |
15 | 16 |
17 |
18 |
19 |
 20 |                         
 21 |                             use 
 22 |                             "jobs/job.tr" 
 23 |                             { 
 24 |                         
 25 |                         
 26 |                               job 
 27 |                             "job1" 
 28 |                             {}
 29 |                         
 30 |                         
 31 |                             } 
 32 |                         
 33 |                         
 34 |                              
 35 |                         
 36 |                         
 37 |                             group 
 38 |                             "production" 
 39 |                             { 
 40 |                         
 41 |                         
 42 |                               host 
 43 |                             "machine1" 
 44 |                             {}
 45 |                         
 46 |                         
 47 |                             } 
 48 |                         
 49 |                         
 50 |                              
 51 |                         
 52 |                         
 53 |                             run 
 54 |                             "production" 
 55 |                             {
 56 |                         
 57 |                         
 58 |                               action 
 59 |                             "copy" 
 60 |                             {
 61 |                         
 62 |                         
 63 |                                 params 
 64 |                             {
 65 |                         
 66 |                         
 67 |                                   src
 68 |                              = 
 69 |                             "src_file"
 70 |                         
 71 |                         
 72 |                                   dest
 73 |                              = 
 74 |                             "/tmp/dest_path"
 75 |                         
 76 |                         
 77 |                                 }
 78 |                         
 79 |                         
 80 |                               }
 81 |                         
 82 |                         
 83 |                             }
 84 |                         
 85 |                     
86 |
87 |
88 |
89 | 90 |
91 |
92 |
93 |
94 | 95 |
96 |
97 |

Features

98 |
    99 |
  • 100 |

    101 | No YAML 102 |

    103 |

    104 | Tiron uses HCL as the configuration language. 105 |

    106 |
  • 107 |
  • 108 |

    109 | Agentless 110 |

    111 |

    112 | By using SSH, Tiron connects to the remote machines without the need to install an agent first. 113 |

    114 |
  • 115 |
  • 116 |

    117 | Builtin TUI 118 |

    119 |

    120 | Tiron has a built in terminal user interfaces to display the outputs of the running tasks. 121 |

    122 |
  • 123 |
  • 124 |

    125 | Correctness 126 |

    127 |

    128 | Tiron pre validates all the runbook files and will throw errors before the task is started to execute. 129 |

    130 |
  • 131 |
  • 132 |

    133 | Speed 134 |

    135 |

    136 | On validating all the input, Tiron also pre populates all the data for tasks, and send them to the remote machines in one go to save the roundtrips between the client and remote. 137 |

    138 |
  • 139 |
  • 140 |

    141 | LSP 142 |

    143 |

    144 | Tiron provides a LSP server which can provide syntax highlighting, linting, formatting, code jumps, completion etc. 145 |

    146 |
  • 147 |
148 |
149 |
150 | 151 |
152 |
153 | 154 | Tiron 155 | 156 |

157 | Copyright © 2024 The Lapce Community 158 |

159 |
160 |
161 | {% endblock %} 162 | -------------------------------------------------------------------------------- /tiron-node/src/action/mod.rs: -------------------------------------------------------------------------------- 1 | mod command; 2 | mod copy; 3 | pub mod data; 4 | mod file; 5 | mod git; 6 | mod package; 7 | 8 | use std::{collections::HashMap, fmt::Display, ops::Range}; 9 | 10 | use crossbeam_channel::Sender; 11 | use itertools::Itertools; 12 | use tiron_common::{ 13 | action::{ActionId, ActionMessage}, 14 | error::{Error, Origin}, 15 | value::SpannedValue, 16 | }; 17 | 18 | pub trait Action { 19 | /// name of the action 20 | fn name(&self) -> String; 21 | 22 | fn doc(&self) -> ActionDoc; 23 | 24 | fn input(&self, params: ActionParams) -> Result, Error>; 25 | 26 | fn execute( 27 | &self, 28 | id: ActionId, 29 | input: &[u8], 30 | tx: &Sender, 31 | ) -> anyhow::Result; 32 | } 33 | 34 | pub enum ActionParamBaseType { 35 | String, 36 | } 37 | 38 | impl ActionParamBaseType { 39 | fn parse_value(&self, value: &SpannedValue) -> Option { 40 | match self { 41 | ActionParamBaseType::String => { 42 | if let SpannedValue::String(s) = value { 43 | return Some(ActionParamBaseValue::String(s.value().to_string())); 44 | } 45 | } 46 | } 47 | None 48 | } 49 | } 50 | 51 | impl Display for ActionParamBaseType { 52 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 53 | match self { 54 | ActionParamBaseType::String => f.write_str("String"), 55 | } 56 | } 57 | } 58 | 59 | pub enum ActionParamType { 60 | String, 61 | Bool, 62 | List(ActionParamBaseType), 63 | Enum(Vec), 64 | } 65 | 66 | impl ActionParamType { 67 | fn parse_attr(&self, value: &SpannedValue) -> Option { 68 | match self { 69 | ActionParamType::String => { 70 | if let SpannedValue::String(s) = value { 71 | return Some(ActionParamValue::String( 72 | s.value().to_string(), 73 | value.span().to_owned(), 74 | )); 75 | } 76 | } 77 | ActionParamType::Bool => { 78 | if let SpannedValue::Bool(v) = value { 79 | return Some(ActionParamValue::Bool(*v.value())); 80 | } 81 | } 82 | ActionParamType::List(base) => { 83 | if let SpannedValue::Array(v) = value { 84 | let mut items = Vec::new(); 85 | for v in v.value().iter() { 86 | let base = base.parse_value(v)?; 87 | items.push(base); 88 | } 89 | return Some(ActionParamValue::List(items)); 90 | } 91 | } 92 | ActionParamType::Enum(options) => { 93 | for option in options { 94 | if option.match_value_new(value) { 95 | return Some(ActionParamValue::Base(option.clone())); 96 | } 97 | } 98 | } 99 | } 100 | 101 | None 102 | } 103 | } 104 | 105 | impl Display for ActionParamType { 106 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 107 | match self { 108 | ActionParamType::String => f.write_str("String"), 109 | ActionParamType::Bool => f.write_str("Boolean"), 110 | ActionParamType::List(t) => f.write_str(&format!("List of {t}")), 111 | ActionParamType::Enum(t) => f.write_str(&format!( 112 | "Enum of {}", 113 | t.iter() 114 | .map(|v| v.to_string()) 115 | .collect::>() 116 | .join(", ") 117 | )), 118 | } 119 | } 120 | } 121 | 122 | pub struct ActionParamDoc { 123 | pub name: String, 124 | pub required: bool, 125 | pub type_: Vec, 126 | pub description: String, 127 | } 128 | 129 | impl ActionParamDoc { 130 | fn parse_attrs( 131 | &self, 132 | origin: &Origin, 133 | attrs: &HashMap, 134 | ) -> Result, Error> { 135 | let param = attrs.get(&self.name); 136 | 137 | if let Some(param) = param { 138 | for type_ in &self.type_ { 139 | if let Some(value) = type_.parse_attr(param) { 140 | return Ok(Some(value)); 141 | } 142 | } 143 | return origin 144 | .error( 145 | format!( 146 | "{} type should be {}", 147 | self.name, 148 | self.type_.iter().map(|t| t.to_string()).join(" or ") 149 | ), 150 | param.span(), 151 | ) 152 | .err(); 153 | } 154 | 155 | if self.required { 156 | return Error::new(format!("can't find {} in params, it's required", self.name)).err(); 157 | } 158 | 159 | Ok(None) 160 | } 161 | } 162 | 163 | pub struct ActionDoc { 164 | pub description: String, 165 | pub params: Vec, 166 | } 167 | 168 | impl ActionDoc { 169 | pub fn parse_attrs<'a>( 170 | &self, 171 | origin: &'a Origin, 172 | attrs: &HashMap, 173 | ) -> Result, Error> { 174 | let mut values = Vec::new(); 175 | for param in &self.params { 176 | let value = param.parse_attrs(origin, attrs)?; 177 | values.push(value); 178 | } 179 | 180 | Ok(ActionParams { 181 | origin, 182 | span: None, 183 | values, 184 | }) 185 | } 186 | } 187 | 188 | pub struct ActionParams<'a> { 189 | pub origin: &'a Origin, 190 | pub span: Option>, 191 | pub values: Vec>, 192 | } 193 | 194 | impl<'a> ActionParams<'a> { 195 | pub fn expect_string(&self, i: usize) -> &str { 196 | self.values[i].as_ref().unwrap().expect_string() 197 | } 198 | 199 | pub fn expect_string_with_span(&self, i: usize) -> (&str, &Option>) { 200 | self.values[i].as_ref().unwrap().expect_string_with_span() 201 | } 202 | 203 | pub fn base(&self, i: usize) -> Option<&ActionParamBaseValue> { 204 | self.values[i].as_ref().map(|v| v.expect_base()) 205 | } 206 | 207 | pub fn expect_base(&self, i: usize) -> &ActionParamBaseValue { 208 | self.values[i].as_ref().unwrap().expect_base() 209 | } 210 | 211 | pub fn list(&self, i: usize) -> Option<&[ActionParamBaseValue]> { 212 | self.values[i].as_ref().map(|v| v.expect_list()) 213 | } 214 | } 215 | 216 | pub enum ActionParamValue { 217 | String(String, Option>), 218 | Bool(bool), 219 | List(Vec), 220 | Base(ActionParamBaseValue), 221 | } 222 | 223 | impl ActionParamValue { 224 | pub fn string(&self) -> Option<&str> { 225 | if let ActionParamValue::String(s, _) = self { 226 | Some(s) 227 | } else { 228 | None 229 | } 230 | } 231 | 232 | pub fn string_with_span(&self) -> Option<(&str, &Option>)> { 233 | if let ActionParamValue::String(s, span) = self { 234 | Some((s, span)) 235 | } else { 236 | None 237 | } 238 | } 239 | 240 | pub fn list(&self) -> Option<&[ActionParamBaseValue]> { 241 | if let ActionParamValue::List(l) = self { 242 | Some(l) 243 | } else { 244 | None 245 | } 246 | } 247 | 248 | pub fn base(&self) -> Option<&ActionParamBaseValue> { 249 | if let ActionParamValue::Base(v) = self { 250 | Some(v) 251 | } else { 252 | None 253 | } 254 | } 255 | 256 | pub fn expect_string(&self) -> &str { 257 | self.string().unwrap() 258 | } 259 | 260 | pub fn expect_string_with_span(&self) -> (&str, &Option>) { 261 | self.string_with_span().unwrap() 262 | } 263 | 264 | pub fn expect_list(&self) -> &[ActionParamBaseValue] { 265 | self.list().unwrap() 266 | } 267 | 268 | pub fn expect_base(&self) -> &ActionParamBaseValue { 269 | self.base().unwrap() 270 | } 271 | } 272 | 273 | #[derive(Clone)] 274 | pub enum ActionParamBaseValue { 275 | String(String), 276 | } 277 | 278 | impl ActionParamBaseValue { 279 | fn match_value_new(&self, value: &SpannedValue) -> bool { 280 | match self { 281 | ActionParamBaseValue::String(base) => { 282 | if let SpannedValue::String(s) = value { 283 | return base == s.value(); 284 | } 285 | } 286 | } 287 | 288 | false 289 | } 290 | 291 | pub fn string(&self) -> Option<&str> { 292 | match self { 293 | ActionParamBaseValue::String(s) => Some(s), 294 | } 295 | } 296 | 297 | pub fn expect_string(&self) -> &str { 298 | self.string().unwrap() 299 | } 300 | } 301 | 302 | impl Display for ActionParamBaseValue { 303 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 304 | match self { 305 | ActionParamBaseValue::String(s) => f.write_str(&format!("\"{s}\"")), 306 | } 307 | } 308 | } 309 | -------------------------------------------------------------------------------- /tiron/src/remote.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | io::BufReader, 3 | process::{Command, Stdio}, 4 | }; 5 | 6 | use anyhow::{anyhow, Result}; 7 | use crossbeam_channel::{Receiver, Sender}; 8 | use serde::{Deserialize, Serialize}; 9 | use tiron_common::{action::ActionMessage, node::NodeMessage}; 10 | use tiron_node::stdio::stdio_transport; 11 | 12 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Hash)] 13 | pub struct SshHost { 14 | pub user: Option, 15 | pub host: String, 16 | pub port: Option, 17 | } 18 | 19 | impl SshHost { 20 | pub fn user_host(&self) -> String { 21 | if let Some(user) = self.user.as_ref() { 22 | format!("{user}@{}", self.host) 23 | } else { 24 | self.host.clone() 25 | } 26 | } 27 | } 28 | 29 | pub struct SshRemote { 30 | pub ssh: SshHost, 31 | } 32 | 33 | impl SshRemote { 34 | #[cfg(windows)] 35 | const SSH_ARGS: &'static [&'static str] = &[]; 36 | 37 | #[cfg(unix)] 38 | const SSH_ARGS: &'static [&'static str] = &[ 39 | "-o", 40 | "ControlMaster=auto", 41 | "-o", 42 | "ControlPath=~/.ssh/cm_%C", 43 | "-o", 44 | "ControlPersist=30m", 45 | "-o", 46 | "ConnectTimeout=15", 47 | ]; 48 | 49 | fn command_builder(&self) -> Command { 50 | let mut cmd = Self::new_command("ssh"); 51 | cmd.args(Self::SSH_ARGS); 52 | 53 | if let Some(port) = self.ssh.port { 54 | cmd.arg("-p").arg(port.to_string()); 55 | } 56 | 57 | cmd.arg(self.ssh.user_host()); 58 | 59 | if !std::env::var("TIRON_DEBUG").unwrap_or_default().is_empty() { 60 | cmd.arg("-v"); 61 | } 62 | 63 | cmd 64 | } 65 | 66 | fn new_command(program: &str) -> Command { 67 | #[allow(unused_mut)] 68 | let mut cmd = Command::new(program); 69 | #[cfg(target_os = "windows")] 70 | use std::os::windows::process::CommandExt; 71 | #[cfg(target_os = "windows")] 72 | cmd.creation_flags(0x08000000); 73 | cmd 74 | } 75 | } 76 | 77 | pub fn start_remote( 78 | remote: SshRemote, 79 | sudo: bool, 80 | ) -> Result<(Sender, Receiver)> { 81 | let (platform, architecture) = host_specification(&remote)?; 82 | 83 | if platform == HostPlatform::UnknownOS { 84 | return Err(anyhow!("Unknown OS")); 85 | } 86 | 87 | if architecture == HostArchitecture::UnknownArch { 88 | return Err(anyhow!("Unknown architecture")); 89 | } 90 | 91 | // ! Below paths have to be synced with what is 92 | // ! returned by Config::proxy_directory() 93 | let tiron_node_path = match platform { 94 | HostPlatform::Windows => "%HOMEDRIVE%%HOMEPATH%\\AppData\\Local\\tiron\\tiron\\data", 95 | HostPlatform::Darwin => "~/Library/Application\\ Support/dev.tiron.tiron", 96 | _ => "~/.local/share/tiron", 97 | }; 98 | 99 | let tiron_node_file = match platform { 100 | HostPlatform::Windows => { 101 | format!( 102 | "{tiron_node_path}\\tiron-node-{}.exe", 103 | env!("CARGO_PKG_VERSION") 104 | ) 105 | } 106 | _ => format!("{tiron_node_path}/tiron-node-{}", env!("CARGO_PKG_VERSION")), 107 | }; 108 | 109 | if !remote 110 | .command_builder() 111 | .args([&tiron_node_file, "--version"]) 112 | .output() 113 | .map(|output| { 114 | String::from_utf8_lossy(&output.stdout).trim() 115 | == format!("tiron-node {}", env!("CARGO_PKG_VERSION")) 116 | }) 117 | .unwrap_or(false) 118 | { 119 | download_remote( 120 | &remote, 121 | &platform, 122 | &architecture, 123 | tiron_node_path, 124 | &tiron_node_file, 125 | )?; 126 | }; 127 | 128 | let mut child = match platform { 129 | // Force cmd.exe usage to resolve %envvar% variables 130 | HostPlatform::Windows => remote 131 | .command_builder() 132 | .args(["cmd", "/c"]) 133 | .arg(&tiron_node_file) 134 | .stdin(Stdio::piped()) 135 | .stdout(Stdio::piped()) 136 | .spawn()?, 137 | _ => { 138 | let mut cmd = remote.command_builder(); 139 | let arg = if sudo { 140 | format!("sudo {tiron_node_file}") 141 | } else { 142 | tiron_node_file 143 | }; 144 | cmd.arg(&arg) 145 | .stdin(Stdio::piped()) 146 | .stdout(Stdio::piped()) 147 | .stderr(Stdio::null()) 148 | .spawn()? 149 | } 150 | }; 151 | let stdin = child 152 | .stdin 153 | .take() 154 | .ok_or_else(|| anyhow!("can't find stdin"))?; 155 | let stdout = BufReader::new( 156 | child 157 | .stdout 158 | .take() 159 | .ok_or_else(|| anyhow!("can't find stdout"))?, 160 | ); 161 | 162 | let (writer_tx, writer_rx) = crossbeam_channel::unbounded::(); 163 | let (reader_tx, reader_rx) = crossbeam_channel::unbounded::(); 164 | stdio_transport(stdin, writer_rx, stdout, reader_tx); 165 | 166 | Ok((writer_tx, reader_rx)) 167 | } 168 | 169 | fn download_remote( 170 | remote: &SshRemote, 171 | platform: &HostPlatform, 172 | architecture: &HostArchitecture, 173 | tiron_node_path: &str, 174 | tiron_node_file: &str, 175 | ) -> Result<()> { 176 | let url = format!( 177 | "https://github.com/lapce/tiron/releases/download/v{}/tiron-node-{}-{platform}-{architecture}.gz", 178 | env!("CARGO_PKG_VERSION"), 179 | env!("CARGO_PKG_VERSION") 180 | ); 181 | remote 182 | .command_builder() 183 | .args([ 184 | "mkdir", 185 | "-p", 186 | tiron_node_path, 187 | "&&", 188 | "curl", 189 | "-L", 190 | &url, 191 | "|", 192 | "gzip", 193 | "-d", 194 | ">", 195 | tiron_node_file, 196 | "&&", 197 | "chmod", 198 | "+x", 199 | tiron_node_file, 200 | ]) 201 | .output()?; 202 | Ok(()) 203 | } 204 | 205 | fn host_specification(remote: &SshRemote) -> Result<(HostPlatform, HostArchitecture)> { 206 | use HostArchitecture::*; 207 | use HostPlatform::*; 208 | 209 | let cmd = remote.command_builder().args(["uname", "-sm"]).output(); 210 | 211 | let spec = match cmd { 212 | Ok(cmd) => { 213 | let stdout = String::from_utf8_lossy(&cmd.stdout).to_lowercase(); 214 | let stdout = stdout.trim(); 215 | match stdout { 216 | // If empty, then we probably deal with Windows and not Unix 217 | // or something went wrong with command output 218 | "" => { 219 | let (os, arch) = host_specification_try_windows(remote)?; 220 | if os != UnknownOS && arch != UnknownArch { 221 | (os, arch) 222 | } else { 223 | return Err(anyhow!(String::from_utf8_lossy(&cmd.stderr).to_string())); 224 | } 225 | } 226 | v => { 227 | if let Some((os, arch)) = v.split_once(' ') { 228 | let os = parse_os(os); 229 | let arch = parse_arch(arch); 230 | if os == UnknownOS || arch == UnknownArch { 231 | return Err(anyhow!(v.to_string())); 232 | } 233 | (os, arch) 234 | } else { 235 | return Err(anyhow!(v.to_string())); 236 | } 237 | } 238 | } 239 | } 240 | Err(e) => return Err(anyhow!(e)), 241 | }; 242 | Ok(spec) 243 | } 244 | 245 | fn host_specification_try_windows(remote: &SshRemote) -> Result<(HostPlatform, HostArchitecture)> { 246 | use HostArchitecture::*; 247 | use HostPlatform::*; 248 | // Try cmd explicitly 249 | let cmd = remote 250 | .command_builder() 251 | .args(["cmd", "/c", "echo %OS% %PROCESSOR_ARCHITECTURE%"]) 252 | .output(); 253 | let spec = match cmd { 254 | Ok(cmd) => { 255 | let stdout = String::from_utf8_lossy(&cmd.stdout).to_lowercase(); 256 | let stdout = stdout.trim(); 257 | match stdout.split_once(' ') { 258 | Some((os, arch)) => (parse_os(os), parse_arch(arch)), 259 | None => { 260 | // PowerShell fallback 261 | let cmd = remote 262 | .command_builder() 263 | .args(["echo", "\"${env:OS} ${env:PROCESSOR_ARCHITECTURE}\""]) 264 | .output(); 265 | match cmd { 266 | Ok(cmd) => { 267 | let stdout = String::from_utf8_lossy(&cmd.stdout).to_lowercase(); 268 | let stdout = stdout.trim(); 269 | match stdout.split_once(' ') { 270 | Some((os, arch)) => (parse_os(os), parse_arch(arch)), 271 | None => (UnknownOS, UnknownArch), 272 | } 273 | } 274 | Err(_) => (UnknownOS, UnknownArch), 275 | } 276 | } 277 | } 278 | } 279 | Err(_) => (UnknownOS, UnknownArch), 280 | }; 281 | Ok(spec) 282 | } 283 | 284 | fn parse_arch(arch: &str) -> HostArchitecture { 285 | use HostArchitecture::*; 286 | // processor architectures be like that 287 | match arch.to_lowercase().as_str() { 288 | "amd64" | "x64" | "x86_64" => AMD64, 289 | "x86" | "i386" | "i586" | "i686" => X86, 290 | "arm" | "armhf" | "armv6" => ARM32v6, 291 | "armv7" | "armv7l" => ARM32v7, 292 | "arm64" | "armv8" | "aarch64" => ARM64, 293 | _ => UnknownArch, 294 | } 295 | } 296 | 297 | fn parse_os(os: &str) -> HostPlatform { 298 | use HostPlatform::*; 299 | match os.to_lowercase().as_str() { 300 | "linux" => Linux, 301 | "darwin" => Darwin, 302 | "windows_nt" => Windows, 303 | v if v.ends_with("bsd") => Bsd, 304 | _ => UnknownOS, 305 | } 306 | } 307 | 308 | #[derive(Clone, Copy, Debug, PartialEq, Eq, strum_macros::Display)] 309 | #[strum(ascii_case_insensitive)] 310 | enum HostPlatform { 311 | UnknownOS, 312 | #[strum(serialize = "windows")] 313 | Windows, 314 | #[strum(serialize = "linux")] 315 | Linux, 316 | #[strum(serialize = "darwin")] 317 | Darwin, 318 | #[strum(serialize = "bsd")] 319 | Bsd, 320 | } 321 | 322 | /// serialise via strum to arch name that is used 323 | /// in CI artefacts 324 | #[derive(Clone, Copy, Debug, PartialEq, Eq, strum_macros::Display)] 325 | #[strum(ascii_case_insensitive)] 326 | enum HostArchitecture { 327 | UnknownArch, 328 | #[strum(serialize = "amd64")] 329 | AMD64, 330 | #[strum(serialize = "x86")] 331 | X86, 332 | #[strum(serialize = "arm64")] 333 | ARM64, 334 | #[strum(serialize = "armv7")] 335 | ARM32v7, 336 | #[strum(serialize = "armhf")] 337 | ARM32v6, 338 | } 339 | -------------------------------------------------------------------------------- /tiron-tui/src/app.rs: -------------------------------------------------------------------------------- 1 | use std::time::SystemTime; 2 | 3 | use anyhow::{anyhow, Result}; 4 | use crossbeam_channel::{Receiver, Sender}; 5 | use ratatui::{ 6 | buffer::Buffer, 7 | layout::{Constraint, Direction, Layout, Rect}, 8 | style::{Color, Stylize}, 9 | widgets::{Block, Borders, List, ListState, Widget}, 10 | Frame, 11 | }; 12 | use tiron_common::action::ActionMessage; 13 | use uuid::Uuid; 14 | 15 | use crate::{ 16 | event::{AppEvent, RunEvent, UserInputEvent}, 17 | run::RunPanel, 18 | tui, 19 | }; 20 | 21 | pub struct App { 22 | exit: bool, 23 | list_state: ListState, 24 | pub runs: Vec, 25 | // the run panel that's currently active 26 | pub active: usize, 27 | pub tx: Sender, 28 | rx: Receiver, 29 | } 30 | 31 | impl Default for App { 32 | fn default() -> Self { 33 | Self::new() 34 | } 35 | } 36 | 37 | impl App { 38 | pub fn new() -> Self { 39 | let (tx, rx) = crossbeam_channel::unbounded(); 40 | Self { 41 | exit: false, 42 | list_state: ListState::default(), 43 | runs: Vec::new(), 44 | active: 0, 45 | tx, 46 | rx, 47 | } 48 | } 49 | 50 | pub fn start(&mut self) -> Result<()> { 51 | let mut terminal = tui::init()?; 52 | self.run(&mut terminal)?; 53 | tui::restore()?; 54 | Ok(()) 55 | } 56 | 57 | fn run(&mut self, terminal: &mut tui::Tui) -> Result<()> { 58 | let tx = self.tx.clone(); 59 | std::thread::spawn(move || { 60 | let _ = tui::handle_events(tx); 61 | }); 62 | while !self.exit { 63 | terminal.draw(|frame| self.render_frame(frame))?; 64 | self.handle_events()?; 65 | } 66 | Ok(()) 67 | } 68 | 69 | fn render_frame(&mut self, frame: &mut Frame) { 70 | frame.render_widget(self, frame.size()); 71 | } 72 | 73 | /// updates the application's state based on user input 74 | fn handle_events(&mut self) -> Result<()> { 75 | match self.rx.recv()? { 76 | AppEvent::UserInput(event) => { 77 | self.handle_user_input(event)?; 78 | } 79 | AppEvent::Action { run, host, msg } => { 80 | self.handle_action_event(run, host, msg)?; 81 | } 82 | AppEvent::Run(event) => { 83 | self.handle_run_event(event)?; 84 | } 85 | }; 86 | Ok(()) 87 | } 88 | 89 | fn handle_user_input(&mut self, event: UserInputEvent) -> Result<()> { 90 | match event { 91 | UserInputEvent::ScrollUp => { 92 | let run = self.get_active_run()?; 93 | let host = run.get_active_host_mut()?; 94 | if host.scroll > 0 { 95 | host.scroll -= 1; 96 | host.scroll_state.prev(); 97 | } 98 | } 99 | UserInputEvent::ScrollDown => { 100 | let run = self.get_active_run()?; 101 | let host = run.get_active_host_mut()?; 102 | if let Some(height) = host.content_height { 103 | if (host.scroll as usize) + host.viewport_height < height { 104 | host.scroll_state.next(); 105 | host.scroll += 1; 106 | } 107 | } 108 | } 109 | UserInputEvent::PageUp => { 110 | let run = self.get_active_run()?; 111 | let host = run.get_active_host_mut()?; 112 | if host.scroll > 0 { 113 | host.scroll = host 114 | .scroll 115 | .saturating_sub((host.viewport_height / 2) as u16); 116 | host.scroll_state = host.scroll_state.position(host.scroll as usize); 117 | } 118 | } 119 | UserInputEvent::PageDown => { 120 | let run = self.get_active_run()?; 121 | let host = run.get_active_host_mut()?; 122 | if let Some(height) = host.content_height { 123 | let max = height.saturating_sub(host.viewport_height) as u16; 124 | host.scroll = (host.scroll + (host.viewport_height / 2) as u16).min(max); 125 | host.scroll_state = host.scroll_state.position(host.scroll as usize); 126 | } 127 | } 128 | UserInputEvent::ScrollToTop => { 129 | let run = self.get_active_run()?; 130 | let host = run.get_active_host_mut()?; 131 | host.scroll = 0; 132 | host.scroll_state = host.scroll_state.position(0); 133 | } 134 | UserInputEvent::ScrollToBottom => { 135 | let run = self.get_active_run()?; 136 | let host = run.get_active_host_mut()?; 137 | if let Some(height) = host.content_height { 138 | host.scroll = height.saturating_sub(host.viewport_height) as u16; 139 | host.scroll_state = host.scroll_state.position(host.scroll as usize); 140 | } 141 | } 142 | UserInputEvent::Resize => { 143 | for run in self.runs.iter_mut() { 144 | for host in run.hosts.iter_mut() { 145 | host.content_height = None; 146 | } 147 | } 148 | } 149 | UserInputEvent::PrevRun => { 150 | if self.active > 0 { 151 | self.active -= 1; 152 | } 153 | } 154 | UserInputEvent::NextRun => { 155 | if self.active < self.runs.len().saturating_sub(1) { 156 | self.active += 1; 157 | } 158 | } 159 | UserInputEvent::PrevHost => { 160 | let run = self.get_active_run()?; 161 | if run.active > 0 { 162 | run.active -= 1; 163 | } 164 | } 165 | UserInputEvent::NextHost => { 166 | let run = self.get_active_run()?; 167 | if run.active < run.hosts.len().saturating_sub(1) { 168 | run.active += 1; 169 | } 170 | } 171 | UserInputEvent::Quit => self.exit(), 172 | } 173 | Ok(()) 174 | } 175 | 176 | fn handle_action_event(&mut self, run: Uuid, host: Uuid, msg: ActionMessage) -> Result<()> { 177 | let run = self 178 | .runs 179 | .iter_mut() 180 | .rev() 181 | .find(|p| p.id == run) 182 | .ok_or_else(|| anyhow!("can't find run"))?; 183 | let host = run 184 | .hosts 185 | .iter_mut() 186 | .rev() 187 | .find(|h| h.id == host) 188 | .ok_or_else(|| anyhow!("can't find host"))?; 189 | match msg { 190 | ActionMessage::ActionStarted { id } => { 191 | let action = host.get_action(id)?; 192 | action.started(); 193 | } 194 | ActionMessage::ActionOutputLine { id, content, level } => { 195 | let action = host.get_action(id)?; 196 | action.output_line(content, level); 197 | host.content_height = None; 198 | } 199 | ActionMessage::ActionResult { id, success } => { 200 | let action = host.get_action(id)?; 201 | action.success(success); 202 | } 203 | ActionMessage::NodeShutdown { success } => { 204 | host.success = Some(( 205 | success, 206 | SystemTime::now() 207 | .duration_since(SystemTime::UNIX_EPOCH) 208 | .map(|d| d.as_secs()) 209 | .unwrap_or(0), 210 | )); 211 | run.sort_hosts(); 212 | } 213 | ActionMessage::NodeStartFailed { reason } => { 214 | host.start_failed = Some(reason); 215 | host.success = Some(( 216 | false, 217 | SystemTime::now() 218 | .duration_since(SystemTime::UNIX_EPOCH) 219 | .map(|d| d.as_secs()) 220 | .unwrap_or(0), 221 | )); 222 | run.sort_hosts(); 223 | } 224 | } 225 | Ok(()) 226 | } 227 | 228 | fn handle_run_event(&mut self, event: RunEvent) -> Result<()> { 229 | match event { 230 | RunEvent::RunStarted { id } => { 231 | let (i, run) = self.get_run(id)?; 232 | run.started = true; 233 | self.active = i; 234 | } 235 | RunEvent::RunCompleted { id, success } => { 236 | let (_, run) = self.get_run(id)?; 237 | run.success = Some(success); 238 | } 239 | } 240 | Ok(()) 241 | } 242 | 243 | fn get_run(&mut self, id: Uuid) -> Result<(usize, &mut RunPanel)> { 244 | let run = self 245 | .runs 246 | .iter_mut() 247 | .enumerate() 248 | .rev() 249 | .find(|(_, p)| p.id == id) 250 | .ok_or_else(|| anyhow!("can't find run"))?; 251 | Ok(run) 252 | } 253 | 254 | fn get_active_run(&mut self) -> Result<&mut RunPanel> { 255 | let focus = self.active.min(self.runs.len().saturating_sub(1)); 256 | let run = self.runs.get_mut(focus).ok_or_else(|| anyhow!("no run"))?; 257 | Ok(run) 258 | } 259 | 260 | fn exit(&mut self) { 261 | self.exit = true; 262 | } 263 | } 264 | 265 | impl Widget for &mut App { 266 | fn render(self, area: Rect, buf: &mut Buffer) { 267 | let layout = Layout::default() 268 | .direction(Direction::Horizontal) 269 | .constraints(vec![ 270 | Constraint::Length(20), 271 | Constraint::Fill(1), 272 | Constraint::Length(20), 273 | ]) 274 | .split(area); 275 | 276 | let focus = self.active.min(self.runs.len().saturating_sub(1)); 277 | if let Some(run) = self.runs.get_mut(focus) { 278 | run.render(layout[1], buf); 279 | run.render_hosts(layout[0], buf) 280 | } 281 | self.list_state.select(Some(focus)); 282 | ratatui::widgets::StatefulWidget::render( 283 | List::new(self.runs.iter().enumerate().map(|(i, run)| { 284 | let name = run.name.clone().unwrap_or_else(|| format!("Run {}", i + 1)); 285 | 286 | let color = if let Some(success) = run.success { 287 | Some(if success { Color::Green } else { Color::Red }) 288 | } else if run.started { 289 | Some(Color::Yellow) 290 | } else { 291 | None 292 | }; 293 | 294 | if let Some(color) = color { 295 | name.fg(color) 296 | } else { 297 | name.into() 298 | } 299 | })) 300 | .highlight_symbol(" > ") 301 | .block(Block::default().borders(Borders::LEFT)), 302 | layout[2], 303 | buf, 304 | &mut self.list_state, 305 | ); 306 | } 307 | } 308 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | tiron_version: 6 | description: "Tiron version for release" 7 | required: true 8 | push: 9 | tags: 10 | - "v*" 11 | 12 | env: 13 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 14 | 15 | jobs: 16 | linux: 17 | runs-on: ubuntu-20.04 18 | steps: 19 | - uses: actions/checkout@v4 20 | 21 | - name: Update toolchain 22 | run: | 23 | rustup update 24 | 25 | - name: Install ARM target 26 | run: rustup target add aarch64-unknown-linux-gnu 27 | 28 | - name: Install ARM gcc 29 | run: sudo apt install -y gcc-aarch64-linux-gnu 30 | 31 | - name: Fetch dependencies 32 | run: cargo fetch --locked 33 | 34 | - name: Build tiron 35 | run: | 36 | RUSTFLAGS="-C linker=aarch64-linux-gnu-gcc" cargo build -p tiron --release --locked --target aarch64-unknown-linux-gnu 37 | cargo build -p tiron --release --locked --target x86_64-unknown-linux-gnu 38 | 39 | - name: Build tiron-node 40 | run: | 41 | RUSTFLAGS="-C linker=aarch64-linux-gnu-gcc" cargo build -p tiron-node --release --locked --target aarch64-unknown-linux-gnu 42 | cargo build -p tiron-node --release --locked --target x86_64-unknown-linux-gnu 43 | 44 | - name: Build tiron-lsp 45 | run: | 46 | RUSTFLAGS="-C linker=aarch64-linux-gnu-gcc" cargo build -p tiron-lsp --release --locked --target aarch64-unknown-linux-gnu 47 | cargo build -p tiron-lsp --release --locked --target x86_64-unknown-linux-gnu 48 | 49 | - name: Gzip 50 | run: | 51 | gzip -c ./target/x86_64-unknown-linux-gnu/release/tiron > ./tiron-${{ github.event.inputs.tiron_version }}-linux-amd64.gz 52 | gzip -c ./target/aarch64-unknown-linux-gnu/release/tiron > ./tiron-${{ github.event.inputs.tiron_version }}-linux-arm64.gz 53 | gzip -c ./target/x86_64-unknown-linux-gnu/release/tiron-node > ./tiron-node-${{ github.event.inputs.tiron_version }}-linux-amd64.gz 54 | gzip -c ./target/aarch64-unknown-linux-gnu/release/tiron-node > ./tiron-node-${{ github.event.inputs.tiron_version }}-linux-arm64.gz 55 | gzip -c ./target/x86_64-unknown-linux-gnu/release/tiron-lsp > ./tiron-lsp-${{ github.event.inputs.tiron_version }}-linux-amd64.gz 56 | gzip -c ./target/aarch64-unknown-linux-gnu/release/tiron-lsp > ./tiron-lsp-${{ github.event.inputs.tiron_version }}-linux-arm64.gz 57 | cp ./.github/scripts/install.sh ./install.sh 58 | 59 | - uses: actions/upload-artifact@v4 60 | with: 61 | name: tiron-linux 62 | path: | 63 | ./tiron-${{ github.event.inputs.tiron_version }}-linux-amd64.gz 64 | ./tiron-${{ github.event.inputs.tiron_version }}-linux-arm64.gz 65 | ./tiron-node-${{ github.event.inputs.tiron_version }}-linux-amd64.gz 66 | ./tiron-node-${{ github.event.inputs.tiron_version }}-linux-arm64.gz 67 | ./tiron-lsp-${{ github.event.inputs.tiron_version }}-linux-amd64.gz 68 | ./tiron-lsp-${{ github.event.inputs.tiron_version }}-linux-arm64.gz 69 | ./install.sh 70 | retention-days: 1 71 | 72 | macos: 73 | runs-on: macos-11 74 | steps: 75 | - uses: actions/checkout@v4 76 | 77 | - name: Install ARM target 78 | run: rustup update && rustup target add aarch64-apple-darwin 79 | 80 | - name: Import Certificate 81 | uses: lapce/import-codesign-certs@72dec84923586f8bef2bed09fdb4f9475c8f623d # use updated action, can be dropped once/if upstream is fixed 82 | with: 83 | p12-file-base64: ${{ secrets.MACOS_CERTIFICATE }} 84 | p12-password: ${{ secrets.MACOS_CERTIFICATE_PWD }} 85 | 86 | - name: Fetch dependencies 87 | run: cargo fetch --locked 88 | 89 | - name: Build tiron 90 | run: | 91 | cargo build -p tiron --release --locked --target aarch64-apple-darwin 92 | cargo build -p tiron --release --locked --target x86_64-apple-darwin 93 | 94 | - name: Build tiron-node 95 | run: | 96 | cargo build -p tiron-node --release --locked --target aarch64-apple-darwin 97 | cargo build -p tiron-node --release --locked --target x86_64-apple-darwin 98 | 99 | - name: Build tiron-lsp 100 | run: | 101 | cargo build -p tiron-lsp --release --locked --target aarch64-apple-darwin 102 | cargo build -p tiron-lsp --release --locked --target x86_64-apple-darwin 103 | 104 | - name: codesign 105 | run: | 106 | /usr/bin/codesign -vvv --deep --strict --options=runtime --force -s ADD049AE64FD743A8E91A47525EFED47153971CB ./target/x86_64-apple-darwin/release/tiron 107 | /usr/bin/codesign -vvv --deep --strict --options=runtime --force -s ADD049AE64FD743A8E91A47525EFED47153971CB ./target/aarch64-apple-darwin/release/tiron 108 | /usr/bin/codesign -vvv --deep --strict --options=runtime --force -s ADD049AE64FD743A8E91A47525EFED47153971CB ./target/x86_64-apple-darwin/release/tiron-node 109 | /usr/bin/codesign -vvv --deep --strict --options=runtime --force -s ADD049AE64FD743A8E91A47525EFED47153971CB ./target/aarch64-apple-darwin/release/tiron-node 110 | /usr/bin/codesign -vvv --deep --strict --options=runtime --force -s ADD049AE64FD743A8E91A47525EFED47153971CB ./target/x86_64-apple-darwin/release/tiron-lsp 111 | /usr/bin/codesign -vvv --deep --strict --options=runtime --force -s ADD049AE64FD743A8E91A47525EFED47153971CB ./target/aarch64-apple-darwin/release/tiron-lsp 112 | 113 | - name: Notarize Release Build 114 | uses: lando/notarize-action@v2 115 | with: 116 | product-path: "./target/x86_64-apple-darwin/release/tiron" 117 | appstore-connect-username: ${{ secrets.NOTARIZE_USERNAME }} 118 | appstore-connect-password: ${{ secrets.NOTARIZE_PASSWORD }} 119 | appstore-connect-team-id: CYSGAZFR8D 120 | primary-bundle-id: "io.tiron" 121 | 122 | - name: Notarize Release Build 123 | uses: lando/notarize-action@v2 124 | with: 125 | product-path: "./target/aarch64-apple-darwin/release/tiron" 126 | appstore-connect-username: ${{ secrets.NOTARIZE_USERNAME }} 127 | appstore-connect-password: ${{ secrets.NOTARIZE_PASSWORD }} 128 | appstore-connect-team-id: CYSGAZFR8D 129 | primary-bundle-id: "io.tiron" 130 | 131 | - name: Gzip 132 | run: | 133 | gzip -c ./target/x86_64-apple-darwin/release/tiron > ./tiron-${{ github.event.inputs.tiron_version }}-darwin-amd64.gz 134 | gzip -c ./target/aarch64-apple-darwin/release/tiron > ./tiron-${{ github.event.inputs.tiron_version }}-darwin-arm64.gz 135 | gzip -c ./target/x86_64-apple-darwin/release/tiron-node > ./tiron-node-${{ github.event.inputs.tiron_version }}-darwin-amd64.gz 136 | gzip -c ./target/aarch64-apple-darwin/release/tiron-node > ./tiron-node-${{ github.event.inputs.tiron_version }}-darwin-arm64.gz 137 | gzip -c ./target/x86_64-apple-darwin/release/tiron-lsp > ./tiron-lsp-${{ github.event.inputs.tiron_version }}-darwin-amd64.gz 138 | gzip -c ./target/aarch64-apple-darwin/release/tiron-lsp > ./tiron-lsp-${{ github.event.inputs.tiron_version }}-darwin-arm64.gz 139 | 140 | - uses: actions/upload-artifact@v4 141 | with: 142 | name: tiron-macos 143 | path: | 144 | ./tiron-${{ github.event.inputs.tiron_version }}-darwin-amd64.gz 145 | ./tiron-${{ github.event.inputs.tiron_version }}-darwin-arm64.gz 146 | ./tiron-node-${{ github.event.inputs.tiron_version }}-darwin-amd64.gz 147 | ./tiron-node-${{ github.event.inputs.tiron_version }}-darwin-arm64.gz 148 | ./tiron-lsp-${{ github.event.inputs.tiron_version }}-darwin-amd64.gz 149 | ./tiron-lsp-${{ github.event.inputs.tiron_version }}-darwin-arm64.gz 150 | retention-days: 1 151 | 152 | windows: 153 | runs-on: windows-latest 154 | defaults: 155 | run: 156 | shell: bash 157 | 158 | steps: 159 | - uses: actions/checkout@v4 160 | 161 | - name: Update rust 162 | run: rustup update && rustup target add aarch64-pc-windows-msvc 163 | 164 | - name: Fetch dependencies 165 | run: cargo fetch --locked 166 | 167 | - name: Build tiron 168 | run: | 169 | cargo build -p tiron --release --locked --target aarch64-pc-windows-msvc 170 | cargo build -p tiron --release --locked --target x86_64-pc-windows-msvc 171 | 172 | - name: Build tiron-node 173 | run: | 174 | cargo build -p tiron-node --release --locked --target aarch64-pc-windows-msvc 175 | cargo build -p tiron-node --release --locked --target x86_64-pc-windows-msvc 176 | 177 | - name: Build tiron-lsp 178 | run: | 179 | cargo build -p tiron-lsp --release --locked --target aarch64-pc-windows-msvc 180 | cargo build -p tiron-lsp --release --locked --target x86_64-pc-windows-msvc 181 | 182 | - name: Gzip 183 | run: | 184 | gzip -c ./target/x86_64-pc-windows-msvc/release/tiron > ./tiron-${{ github.event.inputs.tiron_version }}-windows-amd64.gz 185 | gzip -c ./target/aarch64-pc-windows-msvc/release/tiron > ./tiron-${{ github.event.inputs.tiron_version }}-windows-arm64.gz 186 | gzip -c ./target/x86_64-pc-windows-msvc/release/tiron-node > ./tiron-node-${{ github.event.inputs.tiron_version }}-windows-amd64.gz 187 | gzip -c ./target/aarch64-pc-windows-msvc/release/tiron-node > ./tiron-node-${{ github.event.inputs.tiron_version }}-windows-arm64.gz 188 | gzip -c ./target/x86_64-pc-windows-msvc/release/tiron-lsp > ./tiron-lsp-${{ github.event.inputs.tiron_version }}-windows-amd64.gz 189 | gzip -c ./target/aarch64-pc-windows-msvc/release/tiron-lsp > ./tiron-lsp-${{ github.event.inputs.tiron_version }}-windows-arm64.gz 190 | 191 | - uses: actions/upload-artifact@v4 192 | with: 193 | name: tiron-windows 194 | path: | 195 | ./tiron-${{ github.event.inputs.tiron_version }}-windows-amd64.gz 196 | ./tiron-${{ github.event.inputs.tiron_version }}-windows-arm64.gz 197 | ./tiron-node-${{ github.event.inputs.tiron_version }}-windows-amd64.gz 198 | ./tiron-node-${{ github.event.inputs.tiron_version }}-windows-arm64.gz 199 | ./tiron-lsp-${{ github.event.inputs.tiron_version }}-windows-amd64.gz 200 | ./tiron-lsp-${{ github.event.inputs.tiron_version }}-windows-arm64.gz 201 | retention-days: 1 202 | 203 | 204 | publish: 205 | runs-on: ubuntu-latest 206 | needs: 207 | - linux 208 | - macos 209 | - windows 210 | env: 211 | GH_REPO: ${{ github.repository }} 212 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 213 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 214 | GITHUB_REPO: ${{ github.repository }} 215 | permissions: 216 | contents: write 217 | steps: 218 | - uses: actions/checkout@v4 219 | 220 | - uses: actions/download-artifact@v4 221 | 222 | - if: github.event_name == 'workflow_dispatch' 223 | run: echo "TAG_NAME=v${{ github.event.inputs.tiron_version }}" >> $GITHUB_ENV 224 | 225 | - name: Publish release 226 | if: github.event_name != 'pull_request' 227 | run: | 228 | gh release create $TAG_NAME --title "$TAG_NAME" --target $GITHUB_SHA \ 229 | tiron-linux/* \ 230 | tiron-macos/* \ 231 | tiron-windows/* \ 232 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /tiron-tui/src/run.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{anyhow, Result}; 2 | use ratatui::{ 3 | buffer::Buffer, 4 | layout::{Alignment, Rect}, 5 | style::{Color, Style, Stylize}, 6 | text::StyledGrapheme, 7 | widgets::{ 8 | block::Title, Block, Borders, List, ListState, Paragraph, Scrollbar, ScrollbarOrientation, 9 | ScrollbarState, StatefulWidget, 10 | }, 11 | }; 12 | use tiron_common::action::{ActionId, ActionOutput, ActionOutputLevel, ActionOutputLine}; 13 | use unicode_segmentation::UnicodeSegmentation; 14 | use unicode_width::UnicodeWidthStr; 15 | use uuid::Uuid; 16 | 17 | use crate::reflow::{LineComposer, WordWrapper, WrappedLine}; 18 | 19 | pub struct HostSection { 20 | pub id: Uuid, 21 | pub host: String, 22 | pub actions: Vec, 23 | pub scroll: u16, 24 | pub scroll_state: ScrollbarState, 25 | // cache of the total height of the actions, reset when action gets updated 26 | // or screen size changed 27 | pub content_height: Option, 28 | pub viewport_height: usize, 29 | pub success: Option<(bool, u64)>, 30 | pub start_failed: Option, 31 | } 32 | 33 | impl HostSection { 34 | pub fn get_action(&mut self, id: ActionId) -> Result<&mut ActionSection> { 35 | let action = self 36 | .actions 37 | .iter_mut() 38 | .rev() 39 | .find(|a| a.id == id) 40 | .ok_or_else(|| anyhow!("can't find action"))?; 41 | Ok(action) 42 | } 43 | 44 | fn render(&mut self, area: Rect, buf: &mut Buffer) { 45 | let status_area = Rect::new( 46 | area.left() + 1, 47 | area.bottom() - 1, 48 | area.width.saturating_sub(2), 49 | 1, 50 | ); 51 | 52 | { 53 | let width = status_area.width; 54 | let completed = self 55 | .actions 56 | .iter() 57 | .filter(|a| a.output.success == Some(true)) 58 | .count(); 59 | let total = self.actions.len(); 60 | 61 | let width = if total == 0 { 62 | 0 63 | } else { 64 | ((completed * width as usize) / total) as u16 65 | }; 66 | buf.set_style( 67 | Rect::new(status_area.left(), status_area.top(), width, 1), 68 | Style::default().bg(Color::Green), 69 | ); 70 | 71 | ratatui::widgets::Widget::render( 72 | Paragraph::new(format!("{completed} / {total}")).alignment(Alignment::Center), 73 | status_area, 74 | buf, 75 | ); 76 | } 77 | 78 | let area = Rect::new( 79 | area.left(), 80 | area.top(), 81 | area.width, 82 | area.height.saturating_sub(1), 83 | ); 84 | 85 | let block = Block::default() 86 | .title(Title::from(format!(" {} ", self.host)).alignment(Alignment::Center)) 87 | .borders(Borders::TOP | Borders::BOTTOM); 88 | ratatui::widgets::Widget::render(&block, area, buf); 89 | let area = block.inner(area); 90 | 91 | let area = Rect::new( 92 | area.left() + 1, 93 | area.top(), 94 | area.width.saturating_sub(2), 95 | area.height, 96 | ); 97 | 98 | let mut y = 0; 99 | let mut running_bottom = 0; 100 | 101 | let stop_if_outside_area = self.content_height.is_some(); 102 | if let Some(reason) = &self.start_failed { 103 | render_line( 104 | area, 105 | buf, 106 | &mut y, 107 | self.scroll, 108 | &format!("host start failed: {reason}"), 109 | Some(Color::Red), 110 | None, 111 | stop_if_outside_area, 112 | ); 113 | y += 1; 114 | } 115 | 116 | for action in &self.actions { 117 | action.render(area, buf, &mut y, self.scroll, stop_if_outside_area); 118 | y += 1; 119 | if action.output.started { 120 | running_bottom = y; 121 | } 122 | if stop_if_outside_area && y >= area.height + self.scroll { 123 | break; 124 | } 125 | } 126 | 127 | if self.content_height.is_none() { 128 | self.content_height = Some(y as usize); 129 | self.scroll = running_bottom.saturating_sub(area.height); 130 | self.scroll_state = self.scroll_state.position(self.scroll as usize); 131 | } 132 | self.viewport_height = area.height as usize; 133 | 134 | { 135 | let content_length = self.content_height.unwrap_or(y as usize); 136 | 137 | let area = Rect::new(area.x, area.y, area.width + 1, area.height); 138 | self.scroll_state = self 139 | .scroll_state 140 | .content_length(content_length.saturating_sub(area.height as usize)) 141 | .viewport_content_length(area.height as usize); 142 | Scrollbar::new(ScrollbarOrientation::VerticalRight).render( 143 | area, 144 | buf, 145 | &mut self.scroll_state, 146 | ); 147 | } 148 | } 149 | } 150 | 151 | pub struct ActionSection { 152 | pub id: ActionId, 153 | pub name: String, 154 | pub output: ActionOutput, 155 | pub folded: bool, 156 | } 157 | 158 | impl ActionSection { 159 | pub fn started(&mut self) { 160 | self.output.started = true; 161 | } 162 | 163 | pub fn output_line(&mut self, content: String, level: ActionOutputLevel) { 164 | self.output.lines.push(ActionOutputLine { content, level }); 165 | } 166 | 167 | pub fn success(&mut self, success: bool) { 168 | self.output.success = Some(success); 169 | } 170 | } 171 | 172 | pub struct RunPanel { 173 | pub id: Uuid, 174 | pub name: Option, 175 | pub active: usize, 176 | pub hosts: Vec, 177 | pub hosts_state: ListState, 178 | pub started: bool, 179 | pub success: Option, 180 | } 181 | 182 | impl RunPanel { 183 | pub fn new(id: Uuid, name: Option, hosts: Vec) -> Self { 184 | Self { 185 | id, 186 | name, 187 | active: 0, 188 | hosts, 189 | hosts_state: ListState::default().with_selected(Some(0)), 190 | started: false, 191 | success: None, 192 | } 193 | } 194 | 195 | pub fn get_active_host_mut(&mut self) -> Result<&mut HostSection> { 196 | let active = self.active.min(self.hosts.len().saturating_sub(1)); 197 | let host = self 198 | .hosts 199 | .get_mut(active) 200 | .ok_or_else(|| anyhow!("no host"))?; 201 | Ok(host) 202 | } 203 | 204 | pub fn get_active_host(&self) -> Result<&HostSection> { 205 | let active = self.active.min(self.hosts.len().saturating_sub(1)); 206 | let host = self.hosts.get(active).ok_or_else(|| anyhow!("no host"))?; 207 | Ok(host) 208 | } 209 | 210 | pub fn render(&mut self, area: Rect, buf: &mut Buffer) { 211 | if let Ok(host) = self.get_active_host_mut() { 212 | host.render(area, buf); 213 | } 214 | } 215 | 216 | pub fn render_hosts(&mut self, area: Rect, buf: &mut Buffer) { 217 | self.hosts_state.select(Some(self.active)); 218 | List::new(self.hosts.iter().map(|host| { 219 | let color = if host.start_failed.is_some() { 220 | Some(Color::Red) 221 | } else { 222 | host.success 223 | .map(|(success, _)| if success { Color::Green } else { Color::Red }) 224 | }; 225 | if let Some(color) = color { 226 | host.host.clone().fg(color) 227 | } else { 228 | host.host.clone().into() 229 | } 230 | })) 231 | .highlight_symbol(" > ") 232 | .block(Block::default().borders(Borders::RIGHT)) 233 | .render(area, buf, &mut self.hosts_state); 234 | } 235 | 236 | pub fn sort_hosts(&mut self) { 237 | let active_id = self.get_active_host().ok().map(|h| h.id); 238 | self.hosts.sort_by_key(|h| h.success); 239 | let active = if let Some(id) = active_id { 240 | self.hosts.iter().position(|h| h.id == id) 241 | } else { 242 | None 243 | }; 244 | if let Some(active) = active { 245 | self.active = active; 246 | self.hosts_state.select(Some(active)); 247 | } 248 | } 249 | } 250 | 251 | const fn get_line_offset(line_width: u16, text_area_width: u16, alignment: Alignment) -> u16 { 252 | match alignment { 253 | Alignment::Center => (text_area_width / 2).saturating_sub(line_width / 2), 254 | Alignment::Right => text_area_width.saturating_sub(line_width), 255 | Alignment::Left => 0, 256 | } 257 | } 258 | 259 | impl HostSection { 260 | pub fn new(id: Uuid, host: String, actions: Vec) -> Self { 261 | Self { 262 | id, 263 | host, 264 | actions, 265 | content_height: None, 266 | viewport_height: 0, 267 | scroll: 0, 268 | scroll_state: ScrollbarState::default(), 269 | success: None, 270 | start_failed: None, 271 | } 272 | } 273 | } 274 | 275 | impl ActionSection { 276 | pub fn new(id: ActionId, name: String) -> Self { 277 | Self { 278 | id, 279 | name, 280 | folded: false, 281 | output: ActionOutput::default(), 282 | } 283 | } 284 | 285 | fn render( 286 | &self, 287 | area: Rect, 288 | buf: &mut Buffer, 289 | y: &mut u16, 290 | scroll: u16, 291 | stop_if_outside_area: bool, 292 | ) { 293 | let (fg, bg) = if let Some(success) = self.output.success { 294 | let bg = if success { Color::Green } else { Color::Red }; 295 | (Some(Color::Black), bg) 296 | } else if self.output.started { 297 | (Some(Color::Black), Color::Yellow) 298 | } else { 299 | (Some(Color::Black), Color::Gray) 300 | }; 301 | render_line( 302 | area, 303 | buf, 304 | y, 305 | scroll, 306 | &self.name, 307 | fg, 308 | Some(bg), 309 | stop_if_outside_area, 310 | ); 311 | *y += 1; 312 | if self.folded { 313 | return; 314 | } 315 | if stop_if_outside_area && *y >= area.height + scroll { 316 | return; 317 | } 318 | for line in &self.output.lines { 319 | let fg = match line.level { 320 | ActionOutputLevel::Success => Some(Color::Green), 321 | ActionOutputLevel::Info => None, 322 | ActionOutputLevel::Warn => Some(Color::Yellow), 323 | ActionOutputLevel::Error => Some(Color::Red), 324 | }; 325 | render_line( 326 | area, 327 | buf, 328 | y, 329 | scroll, 330 | &line.content, 331 | fg, 332 | None, 333 | stop_if_outside_area, 334 | ); 335 | if stop_if_outside_area && *y >= area.height + scroll { 336 | return; 337 | } 338 | } 339 | } 340 | } 341 | 342 | #[allow(clippy::too_many_arguments)] 343 | fn render_line( 344 | area: Rect, 345 | buf: &mut Buffer, 346 | y: &mut u16, 347 | scroll: u16, 348 | line: &str, 349 | fg: Option, 350 | bg: Option, 351 | stop_if_outside_area: bool, 352 | ) { 353 | let style = Style::default(); 354 | let style = if let Some(fg) = fg { 355 | style.fg(fg) 356 | } else { 357 | style 358 | }; 359 | let mut line_composer = WordWrapper::new( 360 | vec![( 361 | line.graphemes(true) 362 | .map(move |g| StyledGrapheme { symbol: g, style }), 363 | Alignment::Left, 364 | )] 365 | .into_iter(), 366 | area.width, 367 | false, 368 | ); 369 | 370 | while let Some(WrappedLine { 371 | line: current_line, 372 | width: current_line_width, 373 | alignment: current_line_alignment, 374 | }) = line_composer.next_line() 375 | { 376 | if *y >= scroll && *y < area.height + scroll { 377 | if let Some(bg) = bg { 378 | let area = Rect::new(area.left(), area.top() + *y - scroll, area.width, 1); 379 | buf.set_style(area, Style::default().bg(bg)); 380 | } 381 | let mut x = get_line_offset(current_line_width, area.width, current_line_alignment); 382 | for StyledGrapheme { symbol, style } in current_line { 383 | let width = symbol.width(); 384 | if width == 0 { 385 | continue; 386 | } 387 | // If the symbol is empty, the last char which rendered last time will 388 | // leave on the line. It's a quick fix. 389 | let symbol = if symbol.is_empty() { " " } else { symbol }; 390 | buf.get_mut(area.left() + x, area.top() + *y - scroll) 391 | .set_symbol(symbol) 392 | .set_style(*style); 393 | x += width as u16; 394 | } 395 | } 396 | *y += 1; 397 | if stop_if_outside_area && *y >= area.height + scroll { 398 | break; 399 | } 400 | } 401 | } 402 | -------------------------------------------------------------------------------- /tiron/src/runbook.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, path::PathBuf}; 2 | 3 | use anyhow::{anyhow, Result}; 4 | use crossbeam_channel::Sender; 5 | use hcl::eval::{Context, Evaluate}; 6 | use hcl_edit::{ 7 | structure::{Block, BlockLabel, Structure}, 8 | Span, 9 | }; 10 | use tiron_common::{ 11 | action::{ActionData, ActionId}, 12 | error::{Error, Origin}, 13 | value::SpannedValue, 14 | }; 15 | use tiron_node::action::data::all_actions; 16 | use tiron_tui::event::AppEvent; 17 | use uuid::Uuid; 18 | 19 | use crate::{ 20 | group::{GroupConfig, HostOrGroup, HostOrGroupConfig}, 21 | job::Job, 22 | node::Node, 23 | run::Run, 24 | }; 25 | 26 | pub struct Runbook { 27 | groups: HashMap, 28 | pub jobs: HashMap, 29 | // the imported runbooks 30 | pub imports: HashMap, 31 | pub runs: Vec, 32 | // the origin data of the runbook 33 | pub origin: Origin, 34 | tx: Sender, 35 | // the imported level of the runbook, this is to detect circular imports 36 | level: usize, 37 | } 38 | 39 | impl Runbook { 40 | pub fn new(path: PathBuf, tx: Sender, level: usize) -> Result { 41 | let cwd = path.parent().ok_or_else(|| { 42 | Error::new(format!("can't find parent for {}", path.to_string_lossy())) 43 | })?; 44 | 45 | let data = std::fs::read_to_string(&path).map_err(|e| { 46 | Error::new(format!( 47 | "can't read runbook {} error: {e}", 48 | path.to_string_lossy() 49 | )) 50 | })?; 51 | 52 | let origin = Origin { 53 | cwd: cwd.to_path_buf(), 54 | path, 55 | data, 56 | }; 57 | let runbook = Self { 58 | origin, 59 | groups: HashMap::new(), 60 | jobs: HashMap::new(), 61 | imports: HashMap::new(), 62 | runs: Vec::new(), 63 | tx, 64 | level, 65 | }; 66 | 67 | Ok(runbook) 68 | } 69 | 70 | pub fn parse(&mut self, parse_run: bool) -> Result<(), Error> { 71 | let body = hcl_edit::parser::parse_body(&self.origin.data) 72 | .map_err(|e| Error::from_hcl(e, self.origin.path.clone()))?; 73 | 74 | for structure in body.iter() { 75 | if let Structure::Block(block) = structure { 76 | match block.ident.as_str() { 77 | "use" => { 78 | self.parse_use(block)?; 79 | } 80 | "group" => { 81 | self.parse_group(block)?; 82 | } 83 | "job" => { 84 | self.parse_job(block)?; 85 | } 86 | "run" => { 87 | if parse_run { 88 | // for imported runbook, we don't need to parse runs 89 | self.parse_run(block)?; 90 | } 91 | } 92 | _ => {} 93 | } 94 | } 95 | } 96 | 97 | Ok(()) 98 | } 99 | 100 | fn parse_run(&mut self, block: &Block) -> Result<(), Error> { 101 | let mut hosts: Vec = Vec::new(); 102 | if block.labels.is_empty() { 103 | return self 104 | .origin 105 | .error("You need put group name after run", &block.ident.span()) 106 | .err(); 107 | } 108 | if block.labels.len() > 1 { 109 | return self 110 | .origin 111 | .error( 112 | "You can only have one group name to run", 113 | &block.labels[1].span(), 114 | ) 115 | .err(); 116 | } 117 | let BlockLabel::String(name) = &block.labels[0] else { 118 | return self 119 | .origin 120 | .error("group name should be a string", &block.labels[0].span()) 121 | .err(); 122 | }; 123 | for node in self 124 | .hosts_from_name(name.as_str()) 125 | .map_err(|e| self.origin.error(e.to_string(), &block.labels[0].span()))? 126 | { 127 | if !hosts.iter().any(|n| n.host == node.host) { 128 | hosts.push(node); 129 | } 130 | } 131 | 132 | let hosts = if hosts.is_empty() { 133 | vec![Node { 134 | id: Uuid::new_v4(), 135 | host: "localhost".to_string(), 136 | vars: HashMap::new(), 137 | remote_user: None, 138 | become_: false, 139 | actions: Vec::new(), 140 | tx: self.tx.clone(), 141 | }] 142 | } else { 143 | hosts 144 | }; 145 | let run = Run::from_block(self, block, hosts)?; 146 | self.runs.push(run); 147 | Ok(()) 148 | } 149 | 150 | fn parse_group(&mut self, block: &Block) -> Result<(), Error> { 151 | if block.labels.is_empty() { 152 | return self 153 | .origin 154 | .error("group name doesn't exit", &block.ident.span()) 155 | .err(); 156 | } 157 | if block.labels.len() > 1 { 158 | return self 159 | .origin 160 | .error("group should only have one name", &block.labels[1].span()) 161 | .err(); 162 | } 163 | let BlockLabel::String(name) = &block.labels[0] else { 164 | return self 165 | .origin 166 | .error("group name should be a string", &block.labels[0].span()) 167 | .err(); 168 | }; 169 | 170 | if self.groups.contains_key(name.as_str()) { 171 | return self 172 | .origin 173 | .error("group name already exists", &block.labels[0].span()) 174 | .err(); 175 | } 176 | 177 | let mut group_config = GroupConfig { 178 | hosts: Vec::new(), 179 | vars: HashMap::new(), 180 | imported: None, 181 | }; 182 | 183 | let ctx = Context::new(); 184 | for structure in block.body.iter() { 185 | match structure { 186 | Structure::Attribute(a) => { 187 | let expr: hcl::Expression = a.value.to_owned().into(); 188 | let v: hcl::Value = expr 189 | .evaluate(&ctx) 190 | .map_err(|e| Error::new(e.to_string().replace('\n', " ")))?; 191 | group_config.vars.insert(a.key.to_string(), v); 192 | } 193 | Structure::Block(block) => { 194 | let host_or_group = self.parse_group_entry(name, block)?; 195 | group_config.hosts.push(host_or_group); 196 | } 197 | } 198 | } 199 | 200 | self.groups.insert(name.to_string(), group_config); 201 | 202 | Ok(()) 203 | } 204 | 205 | fn parse_group_entry( 206 | &self, 207 | group_name: &str, 208 | block: &Block, 209 | ) -> Result { 210 | let host_or_group = match block.ident.as_str() { 211 | "host" => { 212 | if block.labels.is_empty() { 213 | return self 214 | .origin 215 | .error("host name doesn't exit", &block.ident.span()) 216 | .err(); 217 | } 218 | if block.labels.len() > 1 { 219 | return self 220 | .origin 221 | .error("host should only have one name", &block.labels[1].span()) 222 | .err(); 223 | } 224 | 225 | let BlockLabel::String(name) = &block.labels[0] else { 226 | return self 227 | .origin 228 | .error("host name should be a string", &block.labels[0].span()) 229 | .err(); 230 | }; 231 | 232 | HostOrGroup::Host(name.to_string()) 233 | } 234 | "group" => { 235 | if block.labels.is_empty() { 236 | return self 237 | .origin 238 | .error("group name doesn't exit", &block.ident.span()) 239 | .err(); 240 | } 241 | if block.labels.len() > 1 { 242 | return self 243 | .origin 244 | .error("group should only have one name", &block.labels[1].span()) 245 | .err(); 246 | } 247 | 248 | let BlockLabel::String(name) = &block.labels[0] else { 249 | return self 250 | .origin 251 | .error("group name should be a string", &block.labels[0].span()) 252 | .err(); 253 | }; 254 | 255 | if name.as_str() == group_name { 256 | return self 257 | .origin 258 | .error("group can't point to itself", &block.labels[0].span()) 259 | .err(); 260 | } 261 | 262 | if !self.groups.contains_key(name.as_str()) { 263 | return self 264 | .origin 265 | .error( 266 | format!("group {} doesn't exist", name.as_str()), 267 | &block.labels[0].span(), 268 | ) 269 | .err(); 270 | } 271 | 272 | HostOrGroup::Group(name.to_string()) 273 | } 274 | _ => { 275 | return self 276 | .origin 277 | .error("you can only have host or group", &block.ident.span()) 278 | .err() 279 | } 280 | }; 281 | 282 | let mut host_config = HostOrGroupConfig { 283 | host: host_or_group, 284 | vars: HashMap::new(), 285 | }; 286 | 287 | let ctx = Context::new(); 288 | for structure in block.body.iter() { 289 | if let Structure::Attribute(a) = structure { 290 | let expr: hcl::Expression = a.value.to_owned().into(); 291 | let v: hcl::Value = expr 292 | .evaluate(&ctx) 293 | .map_err(|e| Error::new(e.to_string().replace('\n', " ")))?; 294 | host_config.vars.insert(a.key.to_string(), v); 295 | } 296 | } 297 | 298 | Ok(host_config) 299 | } 300 | 301 | fn parse_use(&mut self, block: &Block) -> Result<(), Error> { 302 | if block.labels.is_empty() { 303 | return self 304 | .origin 305 | .error("use needs a path", &block.ident.span()) 306 | .err(); 307 | } 308 | if block.labels.len() > 1 { 309 | return self 310 | .origin 311 | .error( 312 | "You can only have one path for use", 313 | &block.labels[1].span(), 314 | ) 315 | .err(); 316 | } 317 | let BlockLabel::String(name) = &block.labels[0] else { 318 | return self 319 | .origin 320 | .error("path should be a string", &block.labels[0].span()) 321 | .err(); 322 | }; 323 | 324 | let path = self.origin.cwd.join(name.as_str()); 325 | 326 | let mut runbook = Runbook::new(path, self.tx.clone(), self.level + 1)?; 327 | runbook.parse(false).map_err(|e| { 328 | let mut e = e; 329 | if e.location.is_none() { 330 | e = e.with_origin(&self.origin, &block.labels[0].span()); 331 | } 332 | e 333 | })?; 334 | 335 | let path = self 336 | .origin 337 | .cwd 338 | .join(name.as_str()) 339 | .canonicalize() 340 | .map_err(|e| { 341 | Error::new(format!("can't canonicalize path: {e}")) 342 | .with_origin(&self.origin, &block.labels[0].span()) 343 | })?; 344 | if self.imports.contains_key(&path) { 345 | return self 346 | .origin 347 | .error("path already imported", &block.labels[0].span()) 348 | .err(); 349 | } 350 | 351 | for structure in block.body.iter() { 352 | if let Structure::Block(block) = structure { 353 | match block.ident.as_str() { 354 | "job" => { 355 | self.parse_use_job(&runbook, block)?; 356 | } 357 | "group" => { 358 | self.parse_use_group(&runbook, block)?; 359 | } 360 | _ => {} 361 | } 362 | } 363 | } 364 | 365 | self.imports.insert(path, runbook); 366 | 367 | Ok(()) 368 | } 369 | 370 | fn parse_use_job(&mut self, imported: &Runbook, block: &Block) -> Result<(), Error> { 371 | if block.labels.is_empty() { 372 | return self 373 | .origin 374 | .error("use job needs a job name", &block.ident.span()) 375 | .err(); 376 | } 377 | if block.labels.len() > 1 { 378 | return self 379 | .origin 380 | .error("You can only use one job name", &block.labels[1].span()) 381 | .err(); 382 | } 383 | let BlockLabel::String(name) = &block.labels[0] else { 384 | return self 385 | .origin 386 | .error("job name should be a string", &block.labels[0].span()) 387 | .err(); 388 | }; 389 | 390 | let as_name = block.body.iter().find_map(|s| { 391 | s.as_attribute().and_then(|a| { 392 | if a.key.as_str() == "as" { 393 | Some(a.value.as_str()?) 394 | } else { 395 | None 396 | } 397 | }) 398 | }); 399 | 400 | let imported_name = as_name.unwrap_or(name.as_str()); 401 | if self.jobs.contains_key(imported_name) { 402 | return self 403 | .origin 404 | .error("job name already exists", &block.labels[0].span()) 405 | .err(); 406 | } 407 | 408 | let mut job = imported 409 | .jobs 410 | .get(name.as_str()) 411 | .ok_or_else(|| { 412 | self.origin.error( 413 | "job name can't be imported, it doesn't exit in the imported runbook", 414 | &block.labels[0].span(), 415 | ) 416 | })? 417 | .clone(); 418 | job.imported = Some(imported.origin.path.clone()); 419 | 420 | self.jobs.insert(imported_name.to_string(), job.to_owned()); 421 | 422 | Ok(()) 423 | } 424 | 425 | fn hosts_from_name(&self, name: &str) -> Result> { 426 | if self.groups.contains_key(name) { 427 | return self.hosts_from_group(name); 428 | } else { 429 | for group in self.groups.values() { 430 | for host in &group.hosts { 431 | if let HostOrGroup::Host(host_name) = &host.host { 432 | if host_name == name { 433 | return Ok(vec![Node::new( 434 | host_name.to_string(), 435 | host.vars.clone(), 436 | &self.tx, 437 | )]); 438 | } 439 | } 440 | } 441 | } 442 | } 443 | Err(anyhow!("can't find host with name {name}")) 444 | } 445 | 446 | fn parse_use_group(&mut self, imported: &Runbook, block: &Block) -> Result<(), Error> { 447 | if block.labels.is_empty() { 448 | return self 449 | .origin 450 | .error("use group needs a group name", &block.ident.span()) 451 | .err(); 452 | } 453 | if block.labels.len() > 1 { 454 | return self 455 | .origin 456 | .error("You can only use one group name", &block.labels[1].span()) 457 | .err(); 458 | } 459 | let BlockLabel::String(name) = &block.labels[0] else { 460 | return self 461 | .origin 462 | .error("group name should be a string", &block.labels[0].span()) 463 | .err(); 464 | }; 465 | 466 | let as_name = block.body.iter().find_map(|s| { 467 | s.as_attribute().and_then(|a| { 468 | if a.key.as_str() == "as" { 469 | Some(a.value.as_str()?) 470 | } else { 471 | None 472 | } 473 | }) 474 | }); 475 | 476 | let imported_name = as_name.unwrap_or(name.as_str()); 477 | if self.groups.contains_key(imported_name) { 478 | return self 479 | .origin 480 | .error("group name already exists", &block.labels[0].span()) 481 | .err(); 482 | } 483 | 484 | let mut group = imported 485 | .groups 486 | .get(name.as_str()) 487 | .ok_or_else(|| { 488 | self.origin.error( 489 | "group name can't be imported, it doesn't exit in the imported runbook", 490 | &block.labels[0].span(), 491 | ) 492 | })? 493 | .clone(); 494 | group.imported = Some(imported.origin.path.clone()); 495 | 496 | self.groups.insert(imported_name.to_string(), group); 497 | 498 | Ok(()) 499 | } 500 | 501 | fn hosts_from_group(&self, group: &str) -> Result> { 502 | let Some(group) = self.groups.get(group) else { 503 | return Err(anyhow!("hosts doesn't have group {group}")); 504 | }; 505 | 506 | let runbook = if let Some(imported) = &group.imported { 507 | self.imports 508 | .get(imported) 509 | .ok_or_else(|| anyhow!("can't find imported"))? 510 | } else { 511 | self 512 | }; 513 | 514 | let mut hosts = Vec::new(); 515 | for host_or_group in &group.hosts { 516 | let mut local_hosts = match &host_or_group.host { 517 | HostOrGroup::Host(name) => { 518 | vec![Node::new( 519 | name.to_string(), 520 | host_or_group.vars.clone(), 521 | &self.tx, 522 | )] 523 | } 524 | HostOrGroup::Group(group) => { 525 | let mut local_hosts = runbook.hosts_from_group(group)?; 526 | for host in local_hosts.iter_mut() { 527 | for (key, val) in &host_or_group.vars { 528 | if !host.vars.contains_key(key) { 529 | if key == "remote_user" && host.remote_user.is_none() { 530 | host.remote_user = if let hcl::Value::String(s) = val { 531 | Some(s.to_string()) 532 | } else { 533 | None 534 | }; 535 | } 536 | host.vars.insert(key.to_string(), val.clone()); 537 | } 538 | } 539 | } 540 | local_hosts 541 | } 542 | }; 543 | for host in local_hosts.iter_mut() { 544 | for (key, val) in &group.vars { 545 | if !host.vars.contains_key(key) { 546 | if key == "remote_user" && host.remote_user.is_none() { 547 | host.remote_user = if let hcl::Value::String(s) = val { 548 | Some(s.to_string()) 549 | } else { 550 | None 551 | }; 552 | } 553 | host.vars.insert(key.to_string(), val.clone()); 554 | } 555 | } 556 | } 557 | hosts.append(&mut local_hosts); 558 | } 559 | Ok(hosts) 560 | } 561 | 562 | fn parse_job(&mut self, block: &Block) -> Result<(), Error> { 563 | if block.labels.is_empty() { 564 | return Error::new("job needs a name").err(); 565 | } 566 | if block.labels.len() > 1 { 567 | return Error::new("You can only have one job name").err(); 568 | } 569 | let BlockLabel::String(name) = &block.labels[0] else { 570 | return Error::new("job name should be a string").err(); 571 | }; 572 | 573 | if self.jobs.contains_key(name.as_str()) { 574 | return Error::new("job name already exists").err(); 575 | } 576 | 577 | self.jobs.insert( 578 | name.to_string(), 579 | Job { 580 | block: block.to_owned(), 581 | imported: None, 582 | }, 583 | ); 584 | 585 | Ok(()) 586 | } 587 | 588 | pub fn parse_actions(&self, ctx: &Context, block: &Block) -> Result, Error> { 589 | let all_actions = all_actions(); 590 | 591 | let mut actions = Vec::new(); 592 | for s in block.body.iter() { 593 | if let Structure::Block(block) = s { 594 | if block.ident.as_str() == "action" { 595 | if block.labels.is_empty() { 596 | return self 597 | .origin 598 | .error("No action name", &block.ident.span()) 599 | .err(); 600 | } 601 | if block.labels.len() > 1 { 602 | return self 603 | .origin 604 | .error("You can only have one action name", &block.labels[1].span()) 605 | .err(); 606 | } 607 | let BlockLabel::String(action_name) = &block.labels[0] else { 608 | return self 609 | .origin 610 | .error("action name should be a string", &block.labels[0].span()) 611 | .err(); 612 | }; 613 | 614 | let params = block.body.iter().find_map(|s| { 615 | s.as_block() 616 | .filter(|&block| block.ident.as_str() == "params") 617 | }); 618 | 619 | let name = block.body.iter().find_map(|s| { 620 | s.as_attribute() 621 | .filter(|a| a.key.as_str() == "name") 622 | .map(|a| &a.value) 623 | }); 624 | let name = if let Some(name) = name { 625 | let name = 626 | SpannedValue::from_expression(&self.origin, ctx, name.to_owned())?; 627 | let SpannedValue::String(s) = name else { 628 | return self 629 | .origin 630 | .error("name should be a string", name.span()) 631 | .err(); 632 | }; 633 | Some(s.value().to_string()) 634 | } else { 635 | None 636 | }; 637 | 638 | let params = params.ok_or_else(|| { 639 | self.origin 640 | .error("action doesn't have params", &block.ident.span()) 641 | })?; 642 | 643 | let mut attrs = HashMap::new(); 644 | for s in params.body.iter() { 645 | if let Some(a) = s.as_attribute() { 646 | let v = SpannedValue::from_expression( 647 | &self.origin, 648 | ctx, 649 | a.value.to_owned(), 650 | )?; 651 | attrs.insert(a.key.to_string(), v); 652 | } 653 | } 654 | 655 | if action_name.as_str() == "job" { 656 | let job_name = attrs.get("name").ok_or_else(|| { 657 | self.origin 658 | .error("job doesn't have name in params", ¶ms.ident.span()) 659 | })?; 660 | let SpannedValue::String(job_name) = job_name else { 661 | return self 662 | .origin 663 | .error("job name should be a string", job_name.span()) 664 | .err(); 665 | }; 666 | let job = self.jobs.get(job_name.value()).ok_or_else(|| { 667 | self.origin.error("can't find job name", job_name.span()) 668 | })?; 669 | 670 | let runbook = if let Some(imported) = &job.imported { 671 | self.imports.get(imported).ok_or_else(|| { 672 | self.origin 673 | .error("can't find imported job", job_name.span()) 674 | })? 675 | } else { 676 | self 677 | }; 678 | 679 | actions.append(&mut runbook.parse_actions(ctx, &job.block)?); 680 | } else { 681 | let Some(action) = all_actions.get(action_name.as_str()) else { 682 | return self 683 | .origin 684 | .error( 685 | format!("action {} can't be found", action_name.as_str()), 686 | &block.labels[0].span(), 687 | ) 688 | .err(); 689 | }; 690 | 691 | let params = 692 | action 693 | .doc() 694 | .parse_attrs(&self.origin, &attrs) 695 | .map_err(|e| { 696 | let mut e = e; 697 | if e.location.is_none() { 698 | e = e.with_origin(&self.origin, ¶ms.ident.span()); 699 | } 700 | e 701 | })?; 702 | let input = action.input(params)?; 703 | actions.push(ActionData { 704 | id: ActionId::new(), 705 | name: name.unwrap_or_else(|| action_name.to_string()), 706 | action: action_name.to_string(), 707 | input, 708 | }); 709 | } 710 | } 711 | } 712 | } 713 | Ok(actions) 714 | } 715 | } 716 | --------------------------------------------------------------------------------