├── src ├── lib.rs ├── constants.rs ├── models │ ├── api_token.rs │ ├── name.rs │ ├── phase.rs │ ├── image_name.rs │ ├── reference.rs │ ├── build_target.rs │ ├── project_path.rs │ ├── ssh_url_prefix.rs │ ├── api_url_prefix.rs │ ├── commit_sha.rs │ ├── mod.rs │ ├── command.rs │ └── ssh_user_host.rs ├── main.rs ├── simple_deploy.rs ├── front_develop.rs ├── front_deploy.rs ├── simple_control.rs ├── back_develop.rs ├── front_control.rs ├── back_control.rs ├── back_deploy.rs ├── cli.rs └── functions.rs ├── .github ├── dependabot.yml └── workflows │ ├── ci-version.yml │ └── ci.yml ├── Makefile ├── LICENSE ├── Cargo.toml ├── rustfmt.toml ├── README.md └── .gitignore /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! # GitLab Deploy 2 | //! This tool is used for deploying software projects to multiple hosts during different phases. 3 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" -------------------------------------------------------------------------------- /src/constants.rs: -------------------------------------------------------------------------------- 1 | pub(crate) const SERVICE_DIRECTORY: &str = "services"; 2 | pub(crate) const PROJECT_DIRECTORY: &str = "projects"; 3 | pub(crate) const PHASE_DIRECTORY: &str = "phases"; 4 | -------------------------------------------------------------------------------- /src/models/api_token.rs: -------------------------------------------------------------------------------- 1 | use validators::prelude::*; 2 | 3 | #[derive(Debug, Clone, Validator)] 4 | #[validator(regex(regex(r"^\S+$")))] 5 | pub(crate) struct ApiToken(String); 6 | 7 | impl AsRef for ApiToken { 8 | #[inline] 9 | fn as_ref(&self) -> &str { 10 | self.0.as_str() 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/models/name.rs: -------------------------------------------------------------------------------- 1 | use validators::prelude::*; 2 | 3 | #[derive(Debug, Clone, Validator)] 4 | #[validator(regex(regex(r"^[a-zA-Z0-9\-_.]{1,80}$")))] 5 | pub(crate) struct Name(String); 6 | 7 | impl AsRef for Name { 8 | #[inline] 9 | fn as_ref(&self) -> &str { 10 | self.0.as_str() 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/models/phase.rs: -------------------------------------------------------------------------------- 1 | use validators::prelude::*; 2 | 3 | #[derive(Debug, Clone, Validator)] 4 | #[validator(regex(regex(r"^[a-zA-Z0-9\-_.]{1,80}$")))] 5 | pub(crate) struct Phase(String); 6 | 7 | impl AsRef for Phase { 8 | #[inline] 9 | fn as_ref(&self) -> &str { 10 | self.0.as_str() 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/models/image_name.rs: -------------------------------------------------------------------------------- 1 | use validators::prelude::*; 2 | 3 | #[derive(Debug, Validator)] 4 | #[validator(regex(regex(r"^[a-z0-9\-_]{1,80}$")))] 5 | pub(crate) struct ImageName(String); 6 | 7 | impl AsRef for ImageName { 8 | #[inline] 9 | fn as_ref(&self) -> &str { 10 | self.0.as_str() 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/models/reference.rs: -------------------------------------------------------------------------------- 1 | use validators::prelude::*; 2 | 3 | #[derive(Debug, Clone, Validator)] 4 | #[validator(line(char_length(trimmed_min = 1)))] 5 | pub(crate) struct Reference(String); 6 | 7 | impl AsRef for Reference { 8 | #[inline] 9 | fn as_ref(&self) -> &str { 10 | self.0.as_str() 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/models/build_target.rs: -------------------------------------------------------------------------------- 1 | use validators::prelude::*; 2 | 3 | #[derive(Debug, Clone, Validator)] 4 | #[validator(regex(regex(r"^[a-z0-9\-_]{1,80}$")))] 5 | pub(crate) struct BuildTarget(String); 6 | 7 | impl AsRef for BuildTarget { 8 | #[inline] 9 | fn as_ref(&self) -> &str { 10 | self.0.as_str() 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/models/project_path.rs: -------------------------------------------------------------------------------- 1 | use validators::prelude::*; 2 | 3 | #[derive(Debug, Clone, Validator)] 4 | #[validator(line(char_length(trimmed_min = 1)))] 5 | pub(crate) struct ProjectPath(String); 6 | 7 | impl AsRef for ProjectPath { 8 | #[inline] 9 | fn as_ref(&self) -> &str { 10 | self.0.as_str() 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/models/ssh_url_prefix.rs: -------------------------------------------------------------------------------- 1 | use validators::prelude::*; 2 | 3 | #[derive(Debug, Clone, Validator)] 4 | #[validator(regex(regex(r"^(ssh://)?[^/\s]+@[^/\s:]+(?::[^/\s]+)?$")))] 5 | pub(crate) struct SshUrlPrefix(String); 6 | 7 | impl AsRef for SshUrlPrefix { 8 | #[inline] 9 | fn as_ref(&self) -> &str { 10 | self.0.as_str() 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/models/api_url_prefix.rs: -------------------------------------------------------------------------------- 1 | use slash_formatter::delete_end_slash; 2 | use validators::prelude::*; 3 | use validators_prelude::url; 4 | 5 | #[derive(Debug, Clone, Validator)] 6 | #[validator(http_url(local(Allow)))] 7 | pub(crate) struct ApiUrlPrefix { 8 | url: url::Url, 9 | #[allow(dead_code)] 10 | is_https: bool, 11 | } 12 | 13 | impl AsRef for ApiUrlPrefix { 14 | #[inline] 15 | fn as_ref(&self) -> &str { 16 | delete_end_slash(self.url.as_str()) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/models/commit_sha.rs: -------------------------------------------------------------------------------- 1 | use validators::prelude::*; 2 | 3 | #[derive(Debug, Clone, Validator)] 4 | #[validator(regex(regex("^[a-zA-Z0-9]{40}$")))] 5 | pub(crate) struct CommitSha(String); 6 | 7 | impl CommitSha { 8 | #[inline] 9 | pub(crate) fn get_sha(&self) -> &str { 10 | self.0.as_str() 11 | } 12 | 13 | #[inline] 14 | pub(crate) fn get_short_sha(&self) -> &str { 15 | &self.get_sha()[..8] 16 | } 17 | } 18 | 19 | impl AsRef for CommitSha { 20 | #[inline] 21 | fn as_ref(&self) -> &str { 22 | self.get_sha() 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/models/mod.rs: -------------------------------------------------------------------------------- 1 | mod api_token; 2 | mod api_url_prefix; 3 | mod build_target; 4 | mod command; 5 | mod commit_sha; 6 | mod image_name; 7 | mod name; 8 | mod phase; 9 | mod project_path; 10 | mod reference; 11 | mod ssh_url_prefix; 12 | mod ssh_user_host; 13 | 14 | pub(crate) use api_token::*; 15 | pub(crate) use api_url_prefix::*; 16 | pub(crate) use build_target::*; 17 | pub(crate) use command::*; 18 | pub(crate) use commit_sha::*; 19 | pub(crate) use image_name::*; 20 | pub(crate) use name::*; 21 | pub(crate) use phase::*; 22 | pub(crate) use project_path::*; 23 | pub(crate) use reference::*; 24 | pub(crate) use ssh_url_prefix::*; 25 | pub(crate) use ssh_user_host::*; 26 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | EXECUTABLE_NAME := gitlab-deploy 2 | 3 | all: ./target/x86_64-unknown-linux-musl/release/$(EXECUTABLE_NAME) 4 | 5 | ./target/x86_64-unknown-linux-musl/release/$(EXECUTABLE_NAME): $(shell find . -type f -iname '*.rs' -o -name 'Cargo.toml' | sed 's/ /\\ /g') 6 | cargo build --release --target x86_64-unknown-linux-musl 7 | 8 | install: 9 | $(MAKE) 10 | sudo cp ./target/x86_64-unknown-linux-musl/release/$(EXECUTABLE_NAME) /usr/local/bin/$(EXECUTABLE_NAME) 11 | sudo chown root: /usr/local/bin/$(EXECUTABLE_NAME) 12 | sudo chmod 0755 /usr/local/bin/$(EXECUTABLE_NAME) 13 | 14 | uninstall: 15 | sudo rm /usr/local/bin/$(EXECUTABLE_NAME) 16 | 17 | test: 18 | cargo test --verbose 19 | 20 | clean: 21 | cargo clean 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 magiclen.org (Ron Li) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "gitlab-deploy" 3 | version = "0.2.0" 4 | authors = ["Magic Len "] 5 | edition = "2021" 6 | rust-version = "1.74" 7 | repository = "https://github.com/magiclen/gitlab-deploy" 8 | homepage = "https://magiclen.org/gitlab-deploy" 9 | keywords = ["deploy"] 10 | categories = ["command-line-utilities"] 11 | description = "This tool is used for deploying software projects to multiple hosts during different phases." 12 | license = "MIT" 13 | include = ["src/**/*", "Cargo.toml", "README.md", "LICENSE"] 14 | 15 | [profile.release] 16 | lto = true 17 | codegen-units = 1 18 | panic = "abort" 19 | strip = true 20 | 21 | [dependencies] 22 | clap = { version = "4", features = ["derive", "env"] } 23 | concat-with = "0.2" 24 | terminal_size = "0.3" 25 | 26 | anyhow = "1" 27 | 28 | execute = "0.2" 29 | 30 | once_cell = "1" 31 | regex = "1" 32 | 33 | trim-in-place = "0.1" 34 | slash-formatter = "3" 35 | tempfile = "3" 36 | scanner-rust = "2" 37 | chrono = "0.4" 38 | 39 | log = "0.4" 40 | simplelog = "0.12" 41 | 42 | [dependencies.validators] 43 | version = "0.25" 44 | default-features = false 45 | features = ["derive", "regex", "line", "http_url"] 46 | -------------------------------------------------------------------------------- /src/models/command.rs: -------------------------------------------------------------------------------- 1 | #[derive(Debug, Clone, Eq, PartialEq)] 2 | pub(crate) enum Command { 3 | Up, 4 | Stop, 5 | Down, 6 | Logs, 7 | DownAndUp, 8 | } 9 | 10 | impl Command { 11 | #[inline] 12 | pub(crate) fn parse_str>(s: S) -> Result { 13 | let s = s.as_ref(); 14 | 15 | let command = match s.to_ascii_lowercase().as_str() { 16 | "start" | "up" => Command::Up, 17 | "stop" => Command::Stop, 18 | "down" => Command::Down, 19 | "log" | "logs" => Command::Logs, 20 | "down_up" | "restart" => Command::DownAndUp, 21 | _ => return Err(()), 22 | }; 23 | 24 | Ok(command) 25 | } 26 | 27 | #[inline] 28 | pub(crate) fn as_str(&self) -> &'static str { 29 | match self { 30 | Self::Up => "up", 31 | Self::Stop => "stop", 32 | Self::Down => "down", 33 | Self::Logs => "logs", 34 | Self::DownAndUp => "down_up", 35 | } 36 | } 37 | 38 | #[inline] 39 | pub(crate) fn get_command_str(&self) -> &'static str { 40 | match self { 41 | Self::Up | Self::DownAndUp => { 42 | "docker compose up -d --build && (timeout 10 docker compose logs -f || true)" 43 | }, 44 | Self::Stop => "docker compose stop", 45 | Self::Down => "docker compose down", 46 | Self::Logs => "docker compose logs", 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/models/ssh_user_host.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::{self, Display, Formatter}; 2 | 3 | use regex::Regex; 4 | 5 | #[derive(Debug, Clone, Ord, PartialOrd, Eq, PartialEq, Hash)] 6 | pub(crate) struct SshUserHost { 7 | user: String, 8 | host: String, 9 | port: u16, 10 | } 11 | 12 | impl SshUserHost { 13 | pub(crate) fn parse_str>(s: S) -> Result { 14 | let s = s.as_ref(); 15 | 16 | let regex = Regex::new(r"^([^/\s]+)@([^/\s:]+)(?::([0-9]{1,5}))?$").unwrap(); 17 | 18 | let result = regex.captures(s).ok_or(())?; 19 | 20 | let user = result.get(1).unwrap().as_str(); 21 | let host = result.get(2).unwrap().as_str(); 22 | let port = match result.get(3) { 23 | Some(port) => Some(port.as_str().parse::().map_err(|_| ())?), 24 | None => None, 25 | }; 26 | 27 | Ok(SshUserHost { 28 | user: String::from(user), 29 | host: String::from(host), 30 | port: port.unwrap_or(22), 31 | }) 32 | } 33 | } 34 | 35 | impl SshUserHost { 36 | #[allow(dead_code)] 37 | #[inline] 38 | pub(crate) fn get_user(&self) -> &str { 39 | self.user.as_str() 40 | } 41 | 42 | #[allow(dead_code)] 43 | #[inline] 44 | pub(crate) fn get_host(&self) -> &str { 45 | self.host.as_str() 46 | } 47 | 48 | #[inline] 49 | pub(crate) fn get_port(&self) -> u16 { 50 | self.port 51 | } 52 | 53 | #[inline] 54 | pub(crate) fn user_host(&self) -> String { 55 | format!("{}@{}", self.user, self.host) 56 | } 57 | } 58 | 59 | impl Display for SshUserHost { 60 | #[inline] 61 | fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), fmt::Error> { 62 | if self.port != 22 { 63 | f.write_fmt(format_args!("{}@{}:{}", self.user, self.host, self.port)) 64 | } else { 65 | f.write_fmt(format_args!("{}@{}", self.user, self.host)) 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | # array_width = 60 2 | # attr_fn_like_width = 70 3 | binop_separator = "Front" 4 | blank_lines_lower_bound = 0 5 | blank_lines_upper_bound = 1 6 | brace_style = "PreferSameLine" 7 | # chain_width = 60 8 | color = "Auto" 9 | # comment_width = 100 10 | condense_wildcard_suffixes = true 11 | control_brace_style = "AlwaysSameLine" 12 | empty_item_single_line = true 13 | enum_discrim_align_threshold = 80 14 | error_on_line_overflow = false 15 | error_on_unformatted = false 16 | # fn_call_width = 60 17 | fn_params_layout = "Tall" 18 | fn_single_line = false 19 | force_explicit_abi = true 20 | force_multiline_blocks = false 21 | format_code_in_doc_comments = true 22 | doc_comment_code_block_width = 80 23 | format_generated_files = true 24 | format_macro_matchers = true 25 | format_macro_bodies = true 26 | skip_macro_invocations = [] 27 | format_strings = true 28 | hard_tabs = false 29 | hex_literal_case = "Upper" 30 | imports_indent = "Block" 31 | imports_layout = "Mixed" 32 | indent_style = "Block" 33 | inline_attribute_width = 0 34 | match_arm_blocks = true 35 | match_arm_leading_pipes = "Never" 36 | match_block_trailing_comma = true 37 | max_width = 100 38 | merge_derives = true 39 | imports_granularity = "Crate" 40 | newline_style = "Unix" 41 | normalize_comments = false 42 | normalize_doc_attributes = true 43 | overflow_delimited_expr = true 44 | remove_nested_parens = true 45 | reorder_impl_items = true 46 | reorder_imports = true 47 | group_imports = "StdExternalCrate" 48 | reorder_modules = true 49 | short_array_element_width_threshold = 10 50 | # single_line_if_else_max_width = 50 51 | space_after_colon = true 52 | space_before_colon = false 53 | spaces_around_ranges = false 54 | struct_field_align_threshold = 80 55 | struct_lit_single_line = false 56 | # struct_lit_width = 18 57 | # struct_variant_width = 35 58 | tab_spaces = 4 59 | trailing_comma = "Vertical" 60 | trailing_semicolon = true 61 | type_punctuation_density = "Wide" 62 | use_field_init_shorthand = true 63 | use_small_heuristics = "Max" 64 | use_try_shorthand = true 65 | where_single_line = false 66 | wrap_comments = false -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | mod cli; 2 | 3 | mod constants; 4 | mod functions; 5 | mod models; 6 | 7 | mod back_control; 8 | mod back_deploy; 9 | mod back_develop; 10 | mod front_control; 11 | mod front_deploy; 12 | mod front_develop; 13 | mod simple_control; 14 | mod simple_deploy; 15 | 16 | use back_control::*; 17 | use back_deploy::*; 18 | use back_develop::*; 19 | use cli::*; 20 | use front_control::*; 21 | use front_deploy::*; 22 | use front_develop::*; 23 | use simple_control::*; 24 | use simple_deploy::*; 25 | 26 | fn main() -> anyhow::Result<()> { 27 | let args = get_args(); 28 | 29 | let mut log_config = simplelog::ConfigBuilder::new(); 30 | 31 | log_config.set_time_level(simplelog::LevelFilter::Debug); 32 | 33 | simplelog::TermLogger::init( 34 | simplelog::LevelFilter::Info, 35 | log_config.build(), 36 | simplelog::TerminalMode::Mixed, 37 | simplelog::ColorChoice::Auto, 38 | ) 39 | .unwrap(); 40 | 41 | match &args.command { 42 | CLICommands::FrontendDevelop { 43 | .. 44 | } => { 45 | front_develop(args)?; 46 | }, 47 | CLICommands::FrontendDeploy { 48 | .. 49 | } => { 50 | front_deploy(args)?; 51 | }, 52 | CLICommands::FrontendControl { 53 | .. 54 | } => { 55 | front_control(args)?; 56 | }, 57 | CLICommands::BackendDevelop { 58 | .. 59 | } => { 60 | back_develop(args)?; 61 | }, 62 | CLICommands::BackendDeploy { 63 | .. 64 | } => { 65 | back_deploy(args)?; 66 | }, 67 | CLICommands::BackendControl { 68 | .. 69 | } => { 70 | back_control(args)?; 71 | }, 72 | CLICommands::SimpleDeploy { 73 | .. 74 | } => { 75 | simple_deploy(args)?; 76 | }, 77 | CLICommands::SimpleControl { 78 | .. 79 | } => { 80 | simple_control(args)?; 81 | }, 82 | } 83 | 84 | Ok(()) 85 | } 86 | -------------------------------------------------------------------------------- /.github/workflows/ci-version.yml: -------------------------------------------------------------------------------- 1 | name: CI-version 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | env: 9 | CARGO_TERM_COLOR: always 10 | 11 | jobs: 12 | tests: 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | os: 17 | - ubuntu-latest 18 | toolchain: 19 | - stable 20 | - nightly 21 | target: 22 | - x86_64-unknown-linux-gnu 23 | - x86_64-unknown-linux-musl 24 | features: 25 | - 26 | name: Test ${{ matrix.toolchain }} on ${{ matrix.os }} to ${{ matrix.target }} (${{ matrix.features }}) 27 | runs-on: ${{ matrix.os }} 28 | steps: 29 | - name: Install musl-tools (Linux) 30 | run: | 31 | sudo apt update 32 | sudo apt install musl-tools 33 | if: matrix.target == 'x86_64-unknown-linux-musl' 34 | - uses: actions/checkout@v4 35 | - uses: actions-rust-lang/setup-rust-toolchain@v1 36 | with: 37 | toolchain: ${{ matrix.toolchain }} 38 | target: ${{ matrix.target }} 39 | - run: cargo test --release --target ${{ matrix.target }} ${{ matrix.features }} 40 | - run: cargo doc --release --target ${{ matrix.target }} ${{ matrix.features }} 41 | 42 | MSRV: 43 | strategy: 44 | fail-fast: false 45 | matrix: 46 | os: 47 | - ubuntu-latest 48 | toolchain: 49 | - 1.74 50 | target: 51 | - x86_64-unknown-linux-gnu 52 | - x86_64-unknown-linux-musl 53 | features: 54 | - 55 | name: Test ${{ matrix.toolchain }} on ${{ matrix.os }} to ${{ matrix.target }} (${{ matrix.features }}) 56 | runs-on: ${{ matrix.os }} 57 | steps: 58 | - name: Install musl-tools (Linux) 59 | run: | 60 | sudo apt update 61 | sudo apt install musl-tools 62 | if: matrix.target == 'x86_64-unknown-linux-musl' 63 | - uses: actions/checkout@v4 64 | - uses: actions-rust-lang/setup-rust-toolchain@v1 65 | with: 66 | toolchain: ${{ matrix.toolchain }} 67 | target: ${{ matrix.target }} 68 | - run: cargo test --release --lib --bins --target ${{ matrix.target }} ${{ matrix.features }} -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [ push, pull_request ] 4 | 5 | env: 6 | CARGO_TERM_COLOR: always 7 | 8 | jobs: 9 | rustfmt: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions-rust-lang/setup-rust-toolchain@v1 14 | with: 15 | toolchain: nightly 16 | components: rustfmt 17 | - uses: actions-rust-lang/rustfmt@v1 18 | 19 | clippy: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v4 23 | - uses: actions-rust-lang/setup-rust-toolchain@v1 24 | with: 25 | components: clippy 26 | - run: cargo clippy --all-targets --all-features -- -D warnings 27 | 28 | tests: 29 | strategy: 30 | fail-fast: false 31 | matrix: 32 | os: 33 | - ubuntu-latest 34 | toolchain: 35 | - stable 36 | - nightly 37 | target: 38 | - x86_64-unknown-linux-gnu 39 | - x86_64-unknown-linux-musl 40 | features: 41 | - 42 | name: Test ${{ matrix.toolchain }} on ${{ matrix.os }} to ${{ matrix.target }} (${{ matrix.features }}) 43 | runs-on: ${{ matrix.os }} 44 | steps: 45 | - name: Install musl-tools (Linux) 46 | run: | 47 | sudo apt update 48 | sudo apt install musl-tools 49 | if: matrix.target == 'x86_64-unknown-linux-musl' 50 | - uses: actions/checkout@v4 51 | - uses: actions-rust-lang/setup-rust-toolchain@v1 52 | with: 53 | toolchain: ${{ matrix.toolchain }} 54 | target: ${{ matrix.target }} 55 | - run: cargo test --target ${{ matrix.target }} ${{ matrix.features }} 56 | - run: cargo doc --target ${{ matrix.target }} ${{ matrix.features }} 57 | 58 | MSRV: 59 | strategy: 60 | fail-fast: false 61 | matrix: 62 | os: 63 | - ubuntu-latest 64 | toolchain: 65 | - 1.74 66 | target: 67 | - x86_64-unknown-linux-gnu 68 | - x86_64-unknown-linux-musl 69 | features: 70 | - 71 | name: Test ${{ matrix.toolchain }} on ${{ matrix.os }} to ${{ matrix.target }} (${{ matrix.features }}) 72 | runs-on: ${{ matrix.os }} 73 | steps: 74 | - name: Install musl-tools (Linux) 75 | run: | 76 | sudo apt update 77 | sudo apt install musl-tools 78 | if: matrix.target == 'x86_64-unknown-linux-musl' 79 | - uses: actions/checkout@v4 80 | - uses: actions-rust-lang/setup-rust-toolchain@v1 81 | with: 82 | toolchain: ${{ matrix.toolchain }} 83 | target: ${{ matrix.target }} 84 | - run: cargo test --lib --bins --target ${{ matrix.target }} ${{ matrix.features }} -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Gitlab Deploy 2 | ==================== 3 | 4 | [![CI](https://github.com/magiclen/gitlab-deploy/actions/workflows/ci.yml/badge.svg)](https://github.com/magiclen/gitlab-deploy/actions/workflows/ci.yml) 5 | 6 | GitLab Deploy is used for deploying software projects to multiple hosts during different phases. This program should be run on Linux. 7 | 8 | ## Setup 9 | 10 | TBD 11 | 12 | ## Help 13 | 14 | ``` 15 | EXAMPLES: 16 | gitlab-deploy frontend-develop --gitlab-project-id 123 --commit-sha 0b14cd4fdec3bdffffdaf1de6fe13aaa01c4827f --build-target develop 17 | gitlab-deploy frontend-deploy --gitlab-project-id 123 --commit-sha 0b14cd4fdec3bdffffdaf1de6fe13aaa01c4827f --project-name website --reference-name pre-release --phase test --build-target test 18 | gitlab-deploy frontend-control --gitlab-project-id 123 --commit-sha 0b14cd4fdec3bdffffdaf1de6fe13aaa01c4827f --project-name website --reference-name pre-release --phase test 19 | gitlab-deploy backend-develop --gitlab-project-id 123 --gitlab-project-path website-api --project-name website --reference develop 20 | gitlab-deploy backend-deploy --gitlab-project-id 123 --commit-sha 0b14cd4fdec3bdffffdaf1de6fe13aaa01c4827f --project-name website --reference-name pre-release --phase test 21 | gitlab-deploy backend-control --gitlab-project-id 123 --commit-sha 0b14cd4fdec3bdffffdaf1de6fe13aaa01c4827f --project-name website --reference-name pre-release --phase test --command up 22 | gitlab-deploy simple-deploy --gitlab-project-id 123 --commit-sha 0b14cd4fdec3bdffffdaf1de6fe13aaa01c4827f --project-name website --reference-name pre-release --phase test 23 | gitlab-deploy simple-control --gitlab-project-id 123 --commit-sha 0b14cd4fdec3bdffffdaf1de6fe13aaa01c4827f --project-name website --reference-name pre-release --phase test sudo /usr/local/bin/apply-nginx.sh dev.env 24 | 25 | Usage: gitlab-deploy 26 | 27 | Commands: 28 | frontend-develop Fetch the project via GitLab API and then build it and use the public static files on a development host 29 | frontend-deploy Fetch the project via GitLab API and then build it and deploy the archive of public static files on multiple hosts according to the phase 30 | frontend-control Control the project on multiple hosts according to the phase 31 | backend-develop Fetch the project via Git and checkout to a specific branch and then start up the service on a development host 32 | backend-deploy Fetch the project via GitLab API and then build it and deploy the docker image on multiple hosts according to the phase 33 | backend-control Control the project on multiple hosts according to the phase 34 | simple-deploy Fetch the project via GitLab API and deploy the project files on multiple hosts according to the phase 35 | simple-control Control the project on multiple hosts according to the phase 36 | help Print this message or the help of the given subcommand(s) 37 | 38 | Options: 39 | -h, --help Print help 40 | -V, --version Print version 41 | ``` 42 | 43 | ## License 44 | 45 | [MIT](LICENSE) -------------------------------------------------------------------------------- /src/simple_deploy.rs: -------------------------------------------------------------------------------- 1 | use std::{fmt::Write, fs::File}; 2 | 3 | use anyhow::anyhow; 4 | use execute::Execute; 5 | use tempfile::tempdir; 6 | 7 | use crate::{ 8 | cli::{CLIArgs, CLICommands}, 9 | constants::*, 10 | functions::*, 11 | }; 12 | 13 | pub(crate) fn simple_deploy(cli_args: CLIArgs) -> anyhow::Result<()> { 14 | debug_assert!(matches!(cli_args.command, CLICommands::SimpleDeploy { .. })); 15 | 16 | if let CLICommands::SimpleDeploy { 17 | gitlab_project_id: project_id, 18 | commit_sha, 19 | project_name, 20 | reference_name, 21 | phase, 22 | gitlab_api_url_prefix: api_url_prefix, 23 | gitlab_api_token: api_token, 24 | } = cli_args.command 25 | { 26 | check_ssh()?; 27 | check_wget()?; 28 | check_docker()?; 29 | 30 | let ssh_user_hosts = find_ssh_user_hosts(phase, project_id)?; 31 | 32 | if ssh_user_hosts.is_empty() { 33 | log::warn!("No hosts to deploy!"); 34 | return Ok(()); 35 | } 36 | 37 | let temp_dir = tempdir()?; 38 | 39 | let archive_file_path = 40 | download_archive(&temp_dir, api_url_prefix, api_token, project_id, &commit_sha)?; 41 | 42 | for ssh_user_host in ssh_user_hosts.iter() { 43 | log::info!("Deploying to {ssh_user_host}"); 44 | 45 | let ssh_root = { 46 | let mut ssh_home = get_ssh_home(ssh_user_host)?; 47 | 48 | ssh_home.write_fmt(format_args!("/{PROJECT_DIRECTORY}",))?; 49 | 50 | ssh_home 51 | }; 52 | 53 | let ssh_project = format!( 54 | "{ssh_root}/{project_name}-{project_id}/{reference_name}-{commit_sha}", 55 | project_name = project_name.as_ref(), 56 | reference_name = reference_name.as_ref(), 57 | commit_sha = commit_sha.get_short_sha(), 58 | ); 59 | 60 | { 61 | let mut command = 62 | create_ssh_command(ssh_user_host, format!("mkdir -p {ssh_project:?}")); 63 | 64 | let status = command.execute()?; 65 | 66 | if let Some(0) = status { 67 | // do nothing 68 | } else { 69 | return Err(anyhow!( 70 | "Cannot create the directory {ssh_project:?} for storing the project \ 71 | files.", 72 | )); 73 | } 74 | } 75 | 76 | log::info!("Unpacking the archive file"); 77 | 78 | { 79 | let mut command = create_ssh_command( 80 | ssh_user_host, 81 | format!("tar --strip-components 1 -x -v -f - -C {ssh_project:?}"), 82 | ); 83 | 84 | let status = 85 | command.execute_input_reader(&mut File::open(archive_file_path.as_path())?)?; 86 | 87 | if let Some(0) = status { 88 | // do nothing 89 | } else { 90 | return Err(anyhow!("Cannot deploy the project")); 91 | } 92 | } 93 | } 94 | 95 | log::info!("Successfully!"); 96 | } 97 | 98 | Ok(()) 99 | } 100 | -------------------------------------------------------------------------------- /src/front_develop.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Write as FmtWrite; 2 | 3 | use anyhow::anyhow; 4 | use execute::{command_args, Execute}; 5 | use tempfile::tempdir; 6 | 7 | use crate::{ 8 | cli::{CLIArgs, CLICommands}, 9 | constants::*, 10 | functions::*, 11 | }; 12 | 13 | pub(crate) fn front_develop(cli_args: CLIArgs) -> anyhow::Result<()> { 14 | debug_assert!(matches!(cli_args.command, CLICommands::FrontendDevelop { .. })); 15 | 16 | if let CLICommands::FrontendDevelop { 17 | gitlab_project_id: project_id, 18 | commit_sha, 19 | build_target, 20 | gitlab_api_url_prefix: api_url_prefix, 21 | gitlab_api_token: api_token, 22 | develop_ssh_user_host: ssh_user_host, 23 | } = cli_args.command 24 | { 25 | check_zstd()?; 26 | check_ssh()?; 27 | check_wget()?; 28 | check_tar()?; 29 | check_bash()?; 30 | 31 | let temp_dir = tempdir()?; 32 | 33 | download_and_extract_archive( 34 | &temp_dir, 35 | api_url_prefix, 36 | api_token, 37 | project_id, 38 | &commit_sha, 39 | )?; 40 | 41 | let public_name = check_front_deploy(&temp_dir)?; 42 | 43 | run_front_build(&temp_dir, build_target)?; 44 | 45 | log::info!("Deploying to {ssh_user_host}"); 46 | 47 | let ssh_root = { 48 | let mut ssh_home = get_ssh_home(&ssh_user_host)?; 49 | 50 | ssh_home.write_fmt(format_args!( 51 | "/{SERVICE_DIRECTORY}/www/{public_name}", 52 | public_name = public_name.as_ref() 53 | ))?; 54 | 55 | ssh_home 56 | }; 57 | 58 | let ssh_html_path = format!("{ssh_root}/html"); 59 | 60 | { 61 | let mut command = create_ssh_command( 62 | &ssh_user_host, 63 | format!( 64 | "mkdir -p {ssh_root:?} && ((test -d {ssh_html_path:?} && rm -r \ 65 | {ssh_html_path:?}) || true) && mkdir -p {ssh_html_path:?}", 66 | ), 67 | ); 68 | 69 | let status = command.execute()?; 70 | 71 | if let Some(0) = status { 72 | // do nothing 73 | } else { 74 | return Err(anyhow!( 75 | "Cannot create the directory {ssh_html_path:?} for storing the public static \ 76 | files." 77 | )); 78 | } 79 | } 80 | 81 | let tarball_path = 82 | format!("deploy/{public_name}.tar.zst", public_name = public_name.as_ref()); 83 | 84 | { 85 | let mut command1 = command_args!("zstd", "-T0", "-d", "-c", tarball_path); 86 | 87 | command1.current_dir(temp_dir.path()); 88 | 89 | let mut command2 = 90 | create_ssh_command(&ssh_user_host, format!("tar -xf - -C {ssh_html_path:?}")); 91 | 92 | log::info!("Extracting {tarball_path}"); 93 | 94 | let result = command1.execute_multiple(&mut [&mut command2])?; 95 | 96 | if let Some(0) = result { 97 | // do nothing 98 | } else { 99 | return Err(anyhow!("Extract failed.")); 100 | } 101 | } 102 | 103 | log::info!("Listing the public static files..."); 104 | 105 | list_ssh_files(&ssh_user_host, ssh_html_path)?; 106 | 107 | log::info!("Successfully!"); 108 | } 109 | 110 | Ok(()) 111 | } 112 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Intellij+all ### 2 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 3 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 4 | 5 | # User-specific stuff 6 | .idea/**/workspace.xml 7 | .idea/**/tasks.xml 8 | .idea/**/usage.statistics.xml 9 | .idea/**/dictionaries 10 | .idea/**/shelf 11 | 12 | # AWS User-specific 13 | .idea/**/aws.xml 14 | 15 | # Generated files 16 | .idea/**/contentModel.xml 17 | 18 | # Sensitive or high-churn files 19 | .idea/**/dataSources/ 20 | .idea/**/dataSources.ids 21 | .idea/**/dataSources.local.xml 22 | .idea/**/sqlDataSources.xml 23 | .idea/**/dynamic.xml 24 | .idea/**/uiDesigner.xml 25 | .idea/**/dbnavigator.xml 26 | 27 | # Gradle 28 | .idea/**/gradle.xml 29 | .idea/**/libraries 30 | 31 | # Gradle and Maven with auto-import 32 | # When using Gradle or Maven with auto-import, you should exclude module files, 33 | # since they will be recreated, and may cause churn. Uncomment if using 34 | # auto-import. 35 | # .idea/artifacts 36 | # .idea/compiler.xml 37 | # .idea/jarRepositories.xml 38 | # .idea/modules.xml 39 | # .idea/*.iml 40 | # .idea/modules 41 | # *.iml 42 | # *.ipr 43 | 44 | # CMake 45 | cmake-build-*/ 46 | 47 | # Mongo Explorer plugin 48 | .idea/**/mongoSettings.xml 49 | 50 | # File-based project format 51 | *.iws 52 | 53 | # IntelliJ 54 | out/ 55 | 56 | # mpeltonen/sbt-idea plugin 57 | .idea_modules/ 58 | 59 | # JIRA plugin 60 | atlassian-ide-plugin.xml 61 | 62 | # Cursive Clojure plugin 63 | .idea/replstate.xml 64 | 65 | # SonarLint plugin 66 | .idea/sonarlint/ 67 | 68 | # Crashlytics plugin (for Android Studio and IntelliJ) 69 | com_crashlytics_export_strings.xml 70 | crashlytics.properties 71 | crashlytics-build.properties 72 | fabric.properties 73 | 74 | # Editor-based Rest Client 75 | .idea/httpRequests 76 | 77 | # Android studio 3.1+ serialized cache file 78 | .idea/caches/build_file_checksums.ser 79 | 80 | ### Intellij+all Patch ### 81 | # Ignore everything but code style settings and run configurations 82 | # that are supposed to be shared within teams. 83 | 84 | .idea/* 85 | 86 | !.idea/codeStyles 87 | !.idea/runConfigurations 88 | 89 | ### Rust ### 90 | # Generated by Cargo 91 | # will have compiled files and executables 92 | debug/ 93 | target/ 94 | 95 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 96 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 97 | Cargo.lock 98 | 99 | # These are backup files generated by rustfmt 100 | **/*.rs.bk 101 | 102 | # MSVC Windows builds of rustc generate these, which store debugging information 103 | *.pdb 104 | 105 | ### Vim ### 106 | # Swap 107 | [._]*.s[a-v][a-z] 108 | !*.svg # comment out if you don't need vector files 109 | [._]*.sw[a-p] 110 | [._]s[a-rt-v][a-z] 111 | [._]ss[a-gi-z] 112 | [._]sw[a-p] 113 | 114 | # Session 115 | Session.vim 116 | Sessionx.vim 117 | 118 | # Temporary 119 | .netrwhist 120 | *~ 121 | # Auto-generated tag files 122 | tags 123 | # Persistent undo 124 | [._]*.un~ 125 | 126 | ### VisualStudioCode ### 127 | .vscode/* 128 | !.vscode/settings.json 129 | !.vscode/tasks.json 130 | !.vscode/launch.json 131 | !.vscode/extensions.json 132 | !.vscode/*.code-snippets 133 | 134 | # Local History for Visual Studio Code 135 | .history/ 136 | 137 | # Built Visual Studio Code Extensions 138 | *.vsix 139 | 140 | ### VisualStudioCode Patch ### 141 | # Ignore all local history of files 142 | .history 143 | .ionide -------------------------------------------------------------------------------- /src/front_deploy.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Write as FmtWrite; 2 | 3 | use anyhow::anyhow; 4 | use execute::Execute; 5 | use tempfile::tempdir; 6 | 7 | use crate::{ 8 | cli::{CLIArgs, CLICommands}, 9 | constants::*, 10 | functions::*, 11 | }; 12 | 13 | pub(crate) fn front_deploy(cli_args: CLIArgs) -> anyhow::Result<()> { 14 | debug_assert!(matches!(cli_args.command, CLICommands::FrontendDeploy { .. })); 15 | 16 | if let CLICommands::FrontendDeploy { 17 | gitlab_project_id: project_id, 18 | commit_sha, 19 | project_name, 20 | reference_name, 21 | build_target, 22 | phase, 23 | gitlab_api_url_prefix: api_url_prefix, 24 | gitlab_api_token: api_token, 25 | } = cli_args.command 26 | { 27 | check_zstd()?; 28 | check_ssh()?; 29 | check_wget()?; 30 | check_tar()?; 31 | check_bash()?; 32 | 33 | let ssh_user_hosts = find_ssh_user_hosts(phase, project_id)?; 34 | 35 | if ssh_user_hosts.is_empty() { 36 | log::warn!("No hosts to deploy!"); 37 | return Ok(()); 38 | } 39 | 40 | let temp_dir = tempdir()?; 41 | 42 | download_and_extract_archive( 43 | &temp_dir, 44 | api_url_prefix, 45 | api_token, 46 | project_id, 47 | &commit_sha, 48 | )?; 49 | 50 | let public_name = check_front_deploy(&temp_dir)?; 51 | 52 | run_front_build(&temp_dir, build_target)?; 53 | 54 | for ssh_user_host in ssh_user_hosts.iter() { 55 | log::info!("Deploying to {ssh_user_host}"); 56 | 57 | let ssh_root = { 58 | let mut ssh_home = get_ssh_home(ssh_user_host)?; 59 | 60 | ssh_home.write_fmt(format_args!("/{PROJECT_DIRECTORY}",))?; 61 | 62 | ssh_home 63 | }; 64 | 65 | let ssh_project = format!( 66 | "{ssh_root}/{project_name}-{project_id}/{reference_name}-{commit_sha}", 67 | project_name = project_name.as_ref(), 68 | reference_name = reference_name.as_ref(), 69 | commit_sha = commit_sha.get_short_sha(), 70 | ); 71 | 72 | { 73 | let mut command = 74 | create_ssh_command(ssh_user_host, format!("mkdir -p {ssh_project:?}")); 75 | 76 | let status = command.execute()?; 77 | 78 | if let Some(0) = status { 79 | // do nothing 80 | } else { 81 | return Err(anyhow!( 82 | "Cannot create the directory {ssh_project:?} for storing the archive of \ 83 | public static files." 84 | )); 85 | } 86 | } 87 | 88 | let tarball_path = 89 | format!("deploy/{public_name}.tar.zst", public_name = public_name.as_ref()); 90 | 91 | let ssh_tarball_path = 92 | format!("{ssh_project}/{public_name}.tar.zst", public_name = public_name.as_ref()); 93 | 94 | { 95 | let mut command = create_scp_command( 96 | ssh_user_host, 97 | tarball_path.as_str(), 98 | ssh_tarball_path.as_str(), 99 | ); 100 | 101 | command.current_dir(temp_dir.path()); 102 | 103 | let status = command.execute()?; 104 | 105 | if let Some(0) = status { 106 | // do nothing 107 | } else { 108 | return Err(anyhow!( 109 | "Cannot copy {tarball_path:?} to {ssh_user_host}:{ssh_tarball_path:?} \ 110 | ({ssh_user_host_port}).", 111 | ssh_user_host = ssh_user_host.user_host(), 112 | ssh_user_host_port = ssh_user_host.get_port(), 113 | )); 114 | } 115 | } 116 | } 117 | 118 | log::info!("Successfully!"); 119 | } 120 | 121 | Ok(()) 122 | } 123 | -------------------------------------------------------------------------------- /src/simple_control.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Write; 2 | 3 | use anyhow::anyhow; 4 | use execute::Execute; 5 | 6 | use crate::{ 7 | cli::{CLIArgs, CLICommands}, 8 | constants::*, 9 | functions::*, 10 | }; 11 | 12 | pub(crate) fn simple_control(cli_args: CLIArgs) -> anyhow::Result<()> { 13 | debug_assert!(matches!(cli_args.command, CLICommands::SimpleControl { .. })); 14 | 15 | if let CLICommands::SimpleControl { 16 | gitlab_project_id: project_id, 17 | commit_sha, 18 | project_name, 19 | reference_name, 20 | phase, 21 | gitlab_api_url_prefix: _, 22 | gitlab_api_token: _, 23 | inject_project_directory, 24 | command, 25 | } = cli_args.command 26 | { 27 | check_ssh()?; 28 | 29 | let command_string = command.join(" "); 30 | 31 | let ssh_user_hosts = find_ssh_user_hosts(phase, project_id)?; 32 | 33 | if ssh_user_hosts.is_empty() { 34 | log::warn!("No hosts to control!"); 35 | return Ok(()); 36 | } 37 | 38 | for ssh_user_host in ssh_user_hosts.iter() { 39 | log::info!("Controlling to {ssh_user_host} ({command_string})"); 40 | 41 | let ssh_root = { 42 | let mut ssh_home = get_ssh_home(ssh_user_host)?; 43 | 44 | ssh_home.write_fmt(format_args!("/{PROJECT_DIRECTORY}",))?; 45 | 46 | ssh_home 47 | }; 48 | 49 | let ssh_project = format!( 50 | "{ssh_root}/{project_name}-{project_id}/{reference_name}-{commit_sha}", 51 | project_name = project_name.as_ref(), 52 | reference_name = reference_name.as_ref(), 53 | commit_sha = commit_sha.get_short_sha(), 54 | ); 55 | 56 | { 57 | let command_in_ssh = if inject_project_directory { 58 | let mut command_in_ssh = 59 | String::with_capacity(command_string.len() + ssh_project.len() + 1); 60 | 61 | if command[0] == "sudo" { 62 | command_in_ssh.push_str("sudo "); 63 | 64 | if command.len() > 1 { 65 | command_in_ssh.push_str(&command[1]); 66 | command_in_ssh.write_fmt(format_args!(" {ssh_project:?} "))?; 67 | command_in_ssh.push_str(&command[2..].join(" ")); 68 | } 69 | } else { 70 | command_in_ssh.push_str(&command[0]); 71 | command_in_ssh.write_fmt(format_args!(" {ssh_project:?} "))?; 72 | command_in_ssh.push_str(&command[1..].join(" ")); 73 | } 74 | 75 | command_in_ssh 76 | } else { 77 | command_string.clone() 78 | }; 79 | 80 | let mut command = create_ssh_command(ssh_user_host, command_in_ssh); 81 | 82 | let output = command.execute_output()?; 83 | 84 | if !output.status.success() { 85 | return Err(anyhow!("Control failed!")); 86 | } 87 | } 88 | 89 | { 90 | let mut command = create_ssh_command( 91 | ssh_user_host, 92 | format!( 93 | "cd {ssh_project:?} && echo \"{timestamp} {command_string:?} \ 94 | {reference_name}-{commit_sha}\" >> {ssh_project:?}/../control.log", 95 | timestamp = current_timestamp(), 96 | reference_name = reference_name.as_ref(), 97 | commit_sha = commit_sha.get_short_sha(), 98 | ), 99 | ); 100 | 101 | let output = command.execute_output()?; 102 | 103 | if !output.status.success() { 104 | return Err(anyhow!("Control failed!")); 105 | } 106 | } 107 | } 108 | 109 | log::info!("Successfully!"); 110 | } 111 | 112 | Ok(()) 113 | } 114 | -------------------------------------------------------------------------------- /src/back_develop.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Write; 2 | 3 | use anyhow::anyhow; 4 | use execute::Execute; 5 | 6 | use crate::{ 7 | cli::{CLIArgs, CLICommands}, 8 | constants::*, 9 | functions::*, 10 | }; 11 | 12 | pub(crate) fn back_develop(cli_args: CLIArgs) -> anyhow::Result<()> { 13 | debug_assert!(matches!(cli_args.command, CLICommands::BackendDevelop { .. })); 14 | 15 | if let CLICommands::BackendDevelop { 16 | gitlab_project_id: project_id, 17 | project_name, 18 | gitlab_project_path: project_path, 19 | reference, 20 | gitlab_ssh_url_prefix: ssh_url_prefix, 21 | develop_ssh_user_host: ssh_user_host, 22 | } = cli_args.command 23 | { 24 | check_ssh()?; 25 | check_bash()?; 26 | 27 | log::info!("Deploying to {ssh_user_host}"); 28 | 29 | let ssh_root = { 30 | let mut ssh_home = get_ssh_home(&ssh_user_host)?; 31 | 32 | ssh_home.write_fmt(format_args!( 33 | "/{PROJECT_DIRECTORY}/{project_name}-{project_id}", 34 | project_name = project_name.as_ref(), 35 | ))?; 36 | 37 | ssh_home 38 | }; 39 | 40 | let git_path = format!("{ssh_root}/.git"); 41 | 42 | let exist = check_directory_exist(&ssh_user_host, git_path)?; 43 | 44 | if exist { 45 | log::info!("The project exists, trying to pull"); 46 | 47 | check_back_deploy_via_ssh(&ssh_user_host, ssh_root.as_str())?; 48 | 49 | log::info!("Running deploy/develop-down.sh"); 50 | 51 | { 52 | let mut command = create_ssh_command( 53 | &ssh_user_host, 54 | format!("cd {ssh_root:?} && bash 'deploy/develop-down.sh'",), 55 | ); 56 | 57 | command.execute_output()?; 58 | } 59 | 60 | log::info!( 61 | "Trying to checkout {reference:?} and pull the branch", 62 | reference = reference.as_ref() 63 | ); 64 | 65 | { 66 | let mut command = create_ssh_command( 67 | &ssh_user_host, 68 | format!( 69 | "cd {ssh_root:?} && git checkout {reference:?} && git pull origin \ 70 | {reference:?}", 71 | reference = reference.as_ref(), 72 | ), 73 | ); 74 | 75 | let output = command.execute_output()?; 76 | 77 | if !output.status.success() { 78 | return Err(anyhow!( 79 | "Cannot checkout out and pull {reference:?}", 80 | reference = reference.as_ref() 81 | )); 82 | } 83 | } 84 | } else { 85 | let ssh_url = format!( 86 | "{ssh_url_prefix}/{project_path}.git", 87 | ssh_url_prefix = ssh_url_prefix.as_ref(), 88 | project_path = project_path.as_ref() 89 | ); 90 | 91 | log::info!( 92 | "The project does not exist, trying to clone {ssh_url:?} and checkout \ 93 | {reference:?}", 94 | reference = reference.as_ref(), 95 | ); 96 | 97 | let mut command = create_ssh_command( 98 | &ssh_user_host, 99 | format!( 100 | "mkdir -p {ssh_root:?} && cd {ssh_root:?} && git clone --recursive \ 101 | {ssh_url:?} . && git checkout {reference:?}", 102 | ssh_root = ssh_root, 103 | reference = reference.as_ref(), 104 | ), 105 | ); 106 | 107 | let output = command.execute_output()?; 108 | 109 | if !output.status.success() { 110 | return Err(anyhow!( 111 | "Cannot clone {ssh_url:?} and checkout out {reference:?}", 112 | reference = reference.as_ref() 113 | )); 114 | } 115 | } 116 | 117 | check_back_deploy_via_ssh(&ssh_user_host, ssh_root.as_str())?; 118 | 119 | log::info!("Running deploy/develop-up.sh"); 120 | 121 | let mut command = create_ssh_command( 122 | &ssh_user_host, 123 | format!("cd {SSH_ROOT:?} && bash 'deploy/develop-up.sh'", SSH_ROOT = ssh_root), 124 | ); 125 | 126 | let output = command.execute_output()?; 127 | 128 | if !output.status.success() { 129 | return Err(anyhow!("Failed!")); 130 | } 131 | 132 | log::info!("Successfully!"); 133 | } 134 | 135 | Ok(()) 136 | } 137 | -------------------------------------------------------------------------------- /src/front_control.rs: -------------------------------------------------------------------------------- 1 | use std::{path::PathBuf, process::Stdio}; 2 | 3 | use anyhow::anyhow; 4 | use execute::Execute; 5 | use trim_in_place::TrimInPlace; 6 | 7 | use crate::{ 8 | cli::{CLIArgs, CLICommands}, 9 | constants::*, 10 | functions::*, 11 | }; 12 | 13 | pub(crate) fn front_control(cli_args: CLIArgs) -> anyhow::Result<()> { 14 | debug_assert!(matches!(cli_args.command, CLICommands::FrontendControl { .. })); 15 | 16 | if let CLICommands::FrontendControl { 17 | gitlab_project_id: project_id, 18 | commit_sha, 19 | project_name, 20 | reference_name, 21 | phase, 22 | } = cli_args.command 23 | { 24 | check_ssh()?; 25 | 26 | let ssh_user_hosts = find_ssh_user_hosts(phase, project_id)?; 27 | 28 | if ssh_user_hosts.is_empty() { 29 | log::warn!("No hosts to control!"); 30 | return Ok(()); 31 | } 32 | 33 | for ssh_user_host in ssh_user_hosts.iter() { 34 | log::info!("Controlling to {ssh_user_host} (apply)"); 35 | 36 | let ssh_home = get_ssh_home(ssh_user_host)?; 37 | 38 | let ssh_root = format!("{ssh_home}/{PROJECT_DIRECTORY}"); 39 | 40 | let ssh_project = format!( 41 | "{ssh_root}/{project_name}-{project_id}/{reference_name}-{commit_sha}", 42 | project_name = project_name.as_ref(), 43 | reference_name = reference_name.as_ref(), 44 | commit_sha = commit_sha.get_short_sha(), 45 | ); 46 | 47 | let tarball_path = { 48 | let mut command = create_ssh_command( 49 | ssh_user_host, 50 | format!( 51 | "find {ssh_project:?} -mindepth 1 -maxdepth 1 -iname '*.tar.zst' | head -1" 52 | ), 53 | ); 54 | 55 | command.stdout(Stdio::piped()); 56 | command.stderr(Stdio::piped()); 57 | 58 | let output = command.execute_output()?; 59 | 60 | if output.status.success() { 61 | let mut files = String::from_utf8(output.stdout)?; 62 | 63 | files.trim_in_place(); 64 | 65 | if files.is_empty() { 66 | return Err(anyhow!( 67 | "The archive file cannot be found in the project {ssh_project:?}", 68 | )); 69 | } 70 | 71 | PathBuf::from(files) 72 | } else { 73 | String::from_utf8_lossy(output.stderr.as_slice()).split('\n').for_each( 74 | |line| { 75 | if !line.is_empty() { 76 | log::error!("{line}"); 77 | } 78 | }, 79 | ); 80 | 81 | return Err(anyhow!( 82 | "The archive file cannot be found in the project {ssh_project:?}" 83 | )); 84 | } 85 | }; 86 | 87 | let tarball = tarball_path.file_name().unwrap().to_string_lossy(); 88 | let public_name = tarball.strip_suffix(".tar.zst").unwrap(); 89 | 90 | let ssh_html_path = format!("{ssh_home}/{SERVICE_DIRECTORY}/www/{public_name}/html"); 91 | 92 | { 93 | let mut command = create_ssh_command( 94 | ssh_user_host, 95 | format!( 96 | "cd {ssh_project:?} && (([ ! -d public ] && mkdir public) || true) && \ 97 | (zstd -T0 -d -c {tarball:?} | tar -xf - -C public) && mkdir -p \ 98 | {ssh_html_path:?} && (([ -d {ssh_html_path:?} ] && rm -r \ 99 | {ssh_html_path:?}) || true) && cp -r public {ssh_html_path:?} && rm -r \ 100 | public && echo \"{timestamp} apply {reference_name}-{commit_sha}\" >> \ 101 | {ssh_project:?}/../control.log", 102 | reference_name = reference_name.as_ref(), 103 | timestamp = current_timestamp(), 104 | commit_sha = commit_sha.get_short_sha(), 105 | ), 106 | ); 107 | 108 | let status = command.execute()?; 109 | 110 | if let Some(0) = status { 111 | // do nothing 112 | } else { 113 | return Err(anyhow!("Cannot apply the project")); 114 | } 115 | } 116 | 117 | log::info!("Listing the public static files..."); 118 | 119 | list_ssh_files(ssh_user_host, ssh_html_path)?; 120 | } 121 | 122 | log::info!("Successfully!"); 123 | } 124 | 125 | Ok(()) 126 | } 127 | -------------------------------------------------------------------------------- /src/back_control.rs: -------------------------------------------------------------------------------- 1 | use std::{fmt::Write, process::Stdio}; 2 | 3 | use anyhow::anyhow; 4 | use execute::Execute; 5 | use trim_in_place::TrimInPlace; 6 | 7 | use crate::{ 8 | cli::{CLIArgs, CLICommands}, 9 | constants::*, 10 | functions::*, 11 | models::*, 12 | }; 13 | 14 | pub(crate) fn back_control(cli_args: CLIArgs) -> anyhow::Result<()> { 15 | debug_assert!(matches!(cli_args.command, CLICommands::BackendControl { .. })); 16 | 17 | if let CLICommands::BackendControl { 18 | gitlab_project_id: project_id, 19 | commit_sha, 20 | project_name, 21 | reference_name, 22 | phase, 23 | command, 24 | } = cli_args.command 25 | { 26 | check_ssh()?; 27 | 28 | let ssh_user_hosts = find_ssh_user_hosts(phase, project_id)?; 29 | 30 | if ssh_user_hosts.is_empty() { 31 | log::warn!("No hosts to control!"); 32 | return Ok(()); 33 | } 34 | 35 | for ssh_user_host in ssh_user_hosts.iter() { 36 | log::info!("Controlling to {ssh_user_host} ({command})", command = command.as_str()); 37 | 38 | let ssh_root = { 39 | let mut ssh_home = get_ssh_home(ssh_user_host)?; 40 | 41 | ssh_home.write_fmt(format_args!("/{PROJECT_DIRECTORY}",))?; 42 | 43 | ssh_home 44 | }; 45 | 46 | let ssh_project = format!( 47 | "{ssh_root}/{project_name}-{project_id}/{reference_name}-{commit_sha}", 48 | project_name = project_name.as_ref(), 49 | reference_name = reference_name.as_ref(), 50 | commit_sha = commit_sha.get_short_sha(), 51 | ); 52 | 53 | let command_str = command.get_command_str(); 54 | 55 | if command == Command::DownAndUp { 56 | let mut command = 57 | create_ssh_command(ssh_user_host, format!("cat {ssh_project:?}/../last-up")); 58 | 59 | command.stdout(Stdio::piped()); 60 | command.stderr(Stdio::piped()); 61 | 62 | let output = command.execute_output()?; 63 | 64 | if output.status.success() { 65 | let mut folder = String::from_utf8(output.stdout)?; 66 | 67 | folder.trim_in_place(); 68 | 69 | log::info!("Trying to shut down {folder} first"); 70 | 71 | { 72 | let mut command = create_ssh_command( 73 | ssh_user_host, 74 | format!( 75 | "cd {ssh_project:?}/../{folder} && {command}", 76 | command = Command::Down.get_command_str(), 77 | ), 78 | ); 79 | 80 | let output = command.execute_output()?; 81 | 82 | if !output.status.success() { 83 | log::warn!("{folder} cannot be fully shut down"); 84 | } 85 | } 86 | } 87 | } 88 | 89 | { 90 | let mut command = create_ssh_command( 91 | ssh_user_host, 92 | format!( 93 | "cd {ssh_project:?} && echo \"{timestamp} {command} \ 94 | {reference_name}-{commit_sha}\" >> {ssh_project:?}/../control.log && \ 95 | {command_str}", 96 | reference_name = reference_name.as_ref(), 97 | timestamp = current_timestamp(), 98 | commit_sha = commit_sha.get_short_sha(), 99 | command = command.as_str(), 100 | ), 101 | ); 102 | 103 | let output = command.execute_output()?; 104 | 105 | if !output.status.success() { 106 | return Err(anyhow!("Control failed!")); 107 | } 108 | } 109 | 110 | if matches!(command, Command::Up | Command::DownAndUp) { 111 | let mut command = create_ssh_command( 112 | ssh_user_host, 113 | format!( 114 | "cd {ssh_project:?} && echo \"{reference_name}-{commit_sha}\" > \ 115 | {ssh_project:?}/../last-up", 116 | reference_name = reference_name.as_ref(), 117 | commit_sha = commit_sha.get_short_sha(), 118 | ), 119 | ); 120 | 121 | let status = command.execute()?; 122 | 123 | if let Some(0) = status { 124 | // do nothing 125 | } else { 126 | log::warn!("The latest version information cannot be written"); 127 | } 128 | } 129 | } 130 | 131 | log::info!("Successfully!"); 132 | } 133 | 134 | Ok(()) 135 | } 136 | -------------------------------------------------------------------------------- /src/back_deploy.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Write as FmtWrite; 2 | 3 | use anyhow::anyhow; 4 | use execute::{command_args, Execute}; 5 | use tempfile::tempdir; 6 | 7 | use crate::{ 8 | cli::{CLIArgs, CLICommands}, 9 | constants::*, 10 | functions::*, 11 | }; 12 | 13 | pub(crate) fn back_deploy(cli_args: CLIArgs) -> anyhow::Result<()> { 14 | debug_assert!(matches!(cli_args.command, CLICommands::BackendDeploy { .. })); 15 | 16 | if let CLICommands::BackendDeploy { 17 | gitlab_project_id: project_id, 18 | commit_sha, 19 | project_name, 20 | reference_name, 21 | build_target, 22 | phase, 23 | gitlab_api_url_prefix: api_url_prefix, 24 | gitlab_api_token: api_token, 25 | } = cli_args.command 26 | { 27 | check_zstd()?; 28 | check_ssh()?; 29 | check_wget()?; 30 | check_tar()?; 31 | check_bash()?; 32 | check_docker()?; 33 | 34 | let ssh_user_hosts = find_ssh_user_hosts(phase, project_id)?; 35 | 36 | if ssh_user_hosts.is_empty() { 37 | log::warn!("No hosts to deploy!"); 38 | return Ok(()); 39 | } 40 | 41 | let temp_dir = tempdir()?; 42 | 43 | download_and_extract_archive( 44 | &temp_dir, 45 | api_url_prefix, 46 | api_token, 47 | project_id, 48 | &commit_sha, 49 | )?; 50 | 51 | let (image_name, docker_compose) = 52 | check_back_deploy(&temp_dir, &commit_sha, build_target.as_ref())?; 53 | 54 | run_back_build(&temp_dir, &commit_sha, build_target.as_ref())?; 55 | 56 | for ssh_user_host in ssh_user_hosts.iter() { 57 | log::info!("Deploying to {ssh_user_host}"); 58 | 59 | let ssh_root = { 60 | let mut ssh_home = get_ssh_home(ssh_user_host)?; 61 | 62 | ssh_home.write_fmt(format_args!("/{PROJECT_DIRECTORY}",))?; 63 | 64 | ssh_home 65 | }; 66 | 67 | let ssh_project = format!( 68 | "{ssh_root}/{project_name}-{project_id}/{reference_name}-{commit_sha}", 69 | project_name = project_name.as_ref(), 70 | reference_name = reference_name.as_ref(), 71 | commit_sha = commit_sha.get_short_sha(), 72 | ); 73 | 74 | { 75 | let mut command = 76 | create_ssh_command(ssh_user_host, format!("mkdir -p {ssh_project:?}")); 77 | 78 | let status = command.execute()?; 79 | 80 | if let Some(0) = status { 81 | // do nothing 82 | } else { 83 | return Err(anyhow!( 84 | "Cannot create the directory {ssh_project:?} for storing the archive of \ 85 | public static files." 86 | )); 87 | } 88 | } 89 | 90 | let ssh_docker_compose_path = format!("{ssh_project}/docker-compose.yml"); 91 | 92 | { 93 | let mut command = 94 | create_ssh_command(ssh_user_host, format!("cat - > {ssh_docker_compose_path}")); 95 | 96 | let status = command.execute_input(docker_compose.as_str())?; 97 | 98 | if let Some(0) = status { 99 | // do nothing 100 | } else { 101 | return Err(anyhow!( 102 | "Cannot create the docker compose file {ssh_docker_compose_path:?}." 103 | )); 104 | } 105 | } 106 | 107 | let tarball_path = 108 | format!("deploy/{image_name}.tar.zst", image_name = image_name.as_ref()); 109 | 110 | let ssh_tarball_path = 111 | format!("{ssh_project}/{image_name}.tar.zst", image_name = image_name.as_ref()); 112 | 113 | { 114 | let mut command = create_scp_command( 115 | ssh_user_host, 116 | tarball_path.as_str(), 117 | ssh_tarball_path.as_str(), 118 | ); 119 | 120 | command.current_dir(temp_dir.path()); 121 | 122 | let status = command.execute()?; 123 | 124 | if let Some(0) = status { 125 | // do nothing 126 | } else { 127 | return Err(anyhow!( 128 | "Cannot copy {tarball_path:?} to {ssh_user_host}:{ssh_tarball_path:?} \ 129 | ({ssh_user_host_port}).", 130 | ssh_user_host = ssh_user_host.user_host(), 131 | ssh_user_host_port = ssh_user_host.get_port(), 132 | )); 133 | } 134 | } 135 | 136 | log::info!("Extracting {tarball_path}"); 137 | 138 | { 139 | let mut command1 = command_args!("zstd", "-d", "-c", "-f", tarball_path); 140 | 141 | command1.current_dir(temp_dir.path()); 142 | 143 | let mut command2 = create_ssh_command(ssh_user_host, "docker image load"); 144 | 145 | let status = command1.execute_multiple(&mut [&mut command2])?; 146 | 147 | if let Some(0) = status { 148 | // do nothing 149 | } else { 150 | return Err(anyhow!("Cannot deploy the docker image")); 151 | } 152 | } 153 | } 154 | 155 | log::info!("Successfully!"); 156 | } 157 | 158 | Ok(()) 159 | } 160 | -------------------------------------------------------------------------------- /src/cli.rs: -------------------------------------------------------------------------------- 1 | use anyhow::anyhow; 2 | use clap::{CommandFactory, FromArgMatches, Parser, Subcommand}; 3 | use concat_with::concat_line; 4 | use terminal_size::terminal_size; 5 | use validators::{ 6 | errors::{HttpURLError, LineError, RegexError}, 7 | prelude::*, 8 | }; 9 | 10 | use crate::models::*; 11 | 12 | const APP_NAME: &str = "Gitlab Deploy"; 13 | const CARGO_PKG_VERSION: &str = env!("CARGO_PKG_VERSION"); 14 | const CARGO_PKG_AUTHORS: &str = env!("CARGO_PKG_AUTHORS"); 15 | 16 | const AFTER_HELP: &str = "Enjoy it! https://magiclen.org"; 17 | 18 | const APP_ABOUT: &str = concat!( 19 | "GitLab Deploy is used for deploying software projects to multiple hosts during different \ 20 | phases\n\nEXAMPLES:\n", 21 | concat_line!(prefix "gitlab-deploy ", 22 | "frontend-develop --gitlab-project-id 123 --commit-sha 0b14cd4fdec3bdffffdaf1de6fe13aaa01c4827f --build-target develop", 23 | "frontend-deploy --gitlab-project-id 123 --commit-sha 0b14cd4fdec3bdffffdaf1de6fe13aaa01c4827f --project-name website --reference-name pre-release --phase test --build-target test", 24 | "frontend-control --gitlab-project-id 123 --commit-sha 0b14cd4fdec3bdffffdaf1de6fe13aaa01c4827f --project-name website --reference-name pre-release --phase test", 25 | "backend-develop --gitlab-project-id 123 --gitlab-project-path website-api --project-name website --reference develop", 26 | "backend-deploy --gitlab-project-id 123 --commit-sha 0b14cd4fdec3bdffffdaf1de6fe13aaa01c4827f --project-name website --reference-name pre-release --phase test", 27 | "backend-control --gitlab-project-id 123 --commit-sha 0b14cd4fdec3bdffffdaf1de6fe13aaa01c4827f --project-name website --reference-name pre-release --phase test --command up", 28 | "simple-deploy --gitlab-project-id 123 --commit-sha 0b14cd4fdec3bdffffdaf1de6fe13aaa01c4827f --project-name website --reference-name pre-release --phase test", 29 | "simple-control --gitlab-project-id 123 --commit-sha 0b14cd4fdec3bdffffdaf1de6fe13aaa01c4827f --project-name website --reference-name pre-release --phase test sudo /usr/local/bin/apply-nginx.sh dev.env", 30 | ) 31 | ); 32 | 33 | #[derive(Debug, Parser)] 34 | #[command(name = APP_NAME)] 35 | #[command(term_width = terminal_size().map(|(width, _)| width.0 as usize).unwrap_or(0))] 36 | #[command(version = CARGO_PKG_VERSION)] 37 | #[command(author = CARGO_PKG_AUTHORS)] 38 | #[command(after_help = AFTER_HELP)] 39 | pub struct CLIArgs { 40 | #[command(subcommand)] 41 | pub command: CLICommands, 42 | } 43 | 44 | #[derive(Debug, Subcommand)] 45 | pub enum CLICommands { 46 | #[command(about = "Fetch the project via GitLab API and then build it and use the public \ 47 | static files on a development host")] 48 | #[command(after_help = AFTER_HELP)] 49 | FrontendDevelop { 50 | #[arg(long, visible_aliases = ["project-id", "id"], env = "CI_PROJECT_ID")] 51 | #[arg(help = "Set the ID on GitLab of this project")] 52 | gitlab_project_id: u64, 53 | #[arg(long, visible_aliases = ["sha"], env = "CI_COMMIT_SHA")] 54 | #[arg(value_parser = parse_commit_sha)] 55 | #[arg(help = "Set the sha of the commit")] 56 | commit_sha: CommitSha, 57 | #[arg(long, visible_aliases = ["target"])] 58 | #[arg(value_parser = parse_build_target)] 59 | #[arg(help = "Set the target of this build")] 60 | build_target: BuildTarget, 61 | #[arg(long, visible_aliases = ["api-url-prefix"], env = "GITLAB_API_URL_PREFIX")] 62 | #[arg(value_parser = parse_api_url_prefix)] 63 | #[arg(help = "Set the URL prefix for GitLab APIs")] 64 | gitlab_api_url_prefix: ApiUrlPrefix, 65 | #[arg(long, visible_aliases = ["api-token"], env = "GITLAB_API_TOKEN")] 66 | #[arg(value_parser = parse_api_token)] 67 | #[arg(help = "Set the token of GitLab APIs")] 68 | gitlab_api_token: ApiToken, 69 | #[arg(long, visible_aliases = ["ssh-user-host"], env = "DEVELOP_SSH_HOST")] 70 | #[arg(value_parser = parse_ssh_user_host)] 71 | #[arg(help = "Set the SSH user, host and the optional port for development")] 72 | develop_ssh_user_host: SshUserHost, 73 | }, 74 | #[command(about = "Fetch the project via GitLab API and then build it and deploy the \ 75 | archive of public static files on multiple hosts according to the phase")] 76 | #[command(after_help = AFTER_HELP)] 77 | FrontendDeploy { 78 | #[arg(long, visible_aliases = ["project-id", "id"], env = "CI_PROJECT_ID")] 79 | #[arg(help = "Set the ID on GitLab of this project")] 80 | gitlab_project_id: u64, 81 | #[arg(long, visible_aliases = ["sha"], env = "CI_COMMIT_SHA")] 82 | #[arg(value_parser = parse_commit_sha)] 83 | #[arg(help = "Set the sha of the commit")] 84 | commit_sha: CommitSha, 85 | #[arg(long, env = "CI_PROJECT_NAME")] 86 | #[arg(value_parser = parse_name)] 87 | #[arg(help = "Set the name of this project")] 88 | project_name: Name, 89 | #[arg(long, env = "CI_COMMIT_REF_NAME")] 90 | #[arg(value_parser = parse_name)] 91 | #[arg(help = "Set the reference name of the commit")] 92 | reference_name: Name, 93 | #[arg(long, visible_aliases = ["target"])] 94 | #[arg(value_parser = parse_build_target)] 95 | #[arg(help = "Set the target of this build")] 96 | build_target: BuildTarget, 97 | #[arg(long, visible_aliases = ["phase"])] 98 | #[arg(value_parser = parse_phase)] 99 | #[arg(help = "Set the phase")] 100 | phase: Phase, 101 | #[arg(long, visible_aliases = ["api-url-prefix"], env = "GITLAB_API_URL_PREFIX")] 102 | #[arg(value_parser = parse_api_url_prefix)] 103 | #[arg(help = "Set the URL prefix for GitLab APIs")] 104 | gitlab_api_url_prefix: ApiUrlPrefix, 105 | #[arg(long, visible_aliases = ["api-token"], env = "GITLAB_API_TOKEN")] 106 | #[arg(value_parser = parse_api_token)] 107 | #[arg(help = "Set the token of GitLab APIs")] 108 | gitlab_api_token: ApiToken, 109 | }, 110 | #[command(about = "Control the project on multiple hosts according to the phase")] 111 | #[command(after_help = AFTER_HELP)] 112 | FrontendControl { 113 | #[arg(long, visible_aliases = ["project-id", "id"], env = "CI_PROJECT_ID")] 114 | #[arg(help = "Set the ID on GitLab of this project")] 115 | gitlab_project_id: u64, 116 | #[arg(long, visible_aliases = ["sha"], env = "CI_COMMIT_SHA")] 117 | #[arg(value_parser = parse_commit_sha)] 118 | #[arg(help = "Set the sha of the commit")] 119 | commit_sha: CommitSha, 120 | #[arg(long, env = "CI_PROJECT_NAME")] 121 | #[arg(value_parser = parse_name)] 122 | #[arg(help = "Set the name of this project")] 123 | project_name: Name, 124 | #[arg(long, env = "CI_COMMIT_REF_NAME")] 125 | #[arg(value_parser = parse_name)] 126 | #[arg(help = "Set the reference name of the commit")] 127 | reference_name: Name, 128 | #[arg(long, visible_aliases = ["phase"])] 129 | #[arg(value_parser = parse_phase)] 130 | #[arg(help = "Set the phase")] 131 | phase: Phase, 132 | }, 133 | #[command(about = "Fetch the project via Git and checkout to a specific branch and then \ 134 | start up the service on a development host")] 135 | #[command(after_help = AFTER_HELP)] 136 | BackendDevelop { 137 | #[arg(long, visible_aliases = ["project-id", "id"], env = "CI_PROJECT_ID")] 138 | #[arg(help = "Set the ID on GitLab of this project")] 139 | gitlab_project_id: u64, 140 | #[arg(long, env = "CI_PROJECT_NAME")] 141 | #[arg(value_parser = parse_name)] 142 | #[arg(help = "Set the name of this project")] 143 | project_name: Name, 144 | #[arg(long, visible_aliases = ["project-path"], env = "CI_PROJECT_PATH")] 145 | #[arg(value_parser = parse_project_path)] 146 | #[arg(help = "Set the path of this project on GitLab")] 147 | gitlab_project_path: ProjectPath, 148 | #[arg(long, visible_aliases = ["ref"], env = "CI_COMMIT_BRANCH")] 149 | #[arg(value_parser = parse_reference)] 150 | #[arg(help = "Set the reference of the commit")] 151 | reference: Reference, 152 | #[arg(long, visible_aliases = ["ssh-url-prefix"], env = "GITLAB_SSH_URL_PREFIX")] 153 | #[arg(value_parser = parse_ssh_url_prefix)] 154 | #[arg(help = "Set the SSH URL prefix")] 155 | gitlab_ssh_url_prefix: SshUrlPrefix, 156 | #[arg(long, visible_aliases = ["ssh-user-host"], env = "DEVELOP_SSH_HOST")] 157 | #[arg(value_parser = parse_ssh_user_host)] 158 | #[arg(help = "Set the SSH user, host and the optional port for development")] 159 | develop_ssh_user_host: SshUserHost, 160 | }, 161 | #[command(about = "Fetch the project via GitLab API and then build it and deploy the docker \ 162 | image on multiple hosts according to the phase")] 163 | #[command(after_help = AFTER_HELP)] 164 | BackendDeploy { 165 | #[arg(long, visible_aliases = ["project-id", "id"], env = "CI_PROJECT_ID")] 166 | #[arg(help = "Set the ID on GitLab of this project")] 167 | gitlab_project_id: u64, 168 | #[arg(long, visible_aliases = ["sha"], env = "CI_COMMIT_SHA")] 169 | #[arg(value_parser = parse_commit_sha)] 170 | #[arg(help = "Set the sha of the commit")] 171 | commit_sha: CommitSha, 172 | #[arg(long, env = "CI_PROJECT_NAME")] 173 | #[arg(value_parser = parse_name)] 174 | #[arg(help = "Set the name of this project")] 175 | project_name: Name, 176 | #[arg(long, env = "CI_COMMIT_REF_NAME")] 177 | #[arg(value_parser = parse_name)] 178 | #[arg(help = "Set the reference name of the commit")] 179 | reference_name: Name, 180 | #[arg(long, visible_aliases = ["target"])] 181 | #[arg(value_parser = parse_build_target)] 182 | #[arg(help = "Set the target of this build")] 183 | build_target: Option, 184 | #[arg(long, visible_aliases = ["phase"])] 185 | #[arg(value_parser = parse_phase)] 186 | #[arg(help = "Set the phase")] 187 | phase: Phase, 188 | #[arg(long, visible_aliases = ["api-url-prefix"], env = "GITLAB_API_URL_PREFIX")] 189 | #[arg(value_parser = parse_api_url_prefix)] 190 | #[arg(help = "Set the URL prefix for GitLab APIs")] 191 | gitlab_api_url_prefix: ApiUrlPrefix, 192 | #[arg(long, visible_aliases = ["api-token"], env = "GITLAB_API_TOKEN")] 193 | #[arg(value_parser = parse_api_token)] 194 | #[arg(help = "Set the token of GitLab APIs")] 195 | gitlab_api_token: ApiToken, 196 | }, 197 | #[command(about = "Control the project on multiple hosts according to the phase")] 198 | #[command(after_help = AFTER_HELP)] 199 | BackendControl { 200 | #[arg(long, visible_aliases = ["project-id", "id"], env = "CI_PROJECT_ID")] 201 | #[arg(help = "Set the ID on GitLab of this project")] 202 | gitlab_project_id: u64, 203 | #[arg(long, visible_aliases = ["sha"], env = "CI_COMMIT_SHA")] 204 | #[arg(value_parser = parse_commit_sha)] 205 | #[arg(help = "Set the sha of the commit")] 206 | commit_sha: CommitSha, 207 | #[arg(long, env = "CI_PROJECT_NAME")] 208 | #[arg(value_parser = parse_name)] 209 | #[arg(help = "Set the name of this project")] 210 | project_name: Name, 211 | #[arg(long, env = "CI_COMMIT_REF_NAME")] 212 | #[arg(value_parser = parse_name)] 213 | #[arg(help = "Set the reference name of the commit")] 214 | reference_name: Name, 215 | #[arg(long, visible_aliases = ["phase"])] 216 | #[arg(value_parser = parse_phase)] 217 | #[arg(help = "Set the phase")] 218 | phase: Phase, 219 | #[arg(long)] 220 | #[arg(value_parser = parse_command)] 221 | #[arg(help = "Set the command")] 222 | command: Command, 223 | }, 224 | #[command(about = "Fetch the project via GitLab API and deploy the project files on \ 225 | multiple hosts according to the phase")] 226 | #[command(after_help = AFTER_HELP)] 227 | SimpleDeploy { 228 | #[arg(long, visible_aliases = ["project-id", "id"], env = "CI_PROJECT_ID")] 229 | #[arg(help = "Set the ID on GitLab of this project")] 230 | gitlab_project_id: u64, 231 | #[arg(long, visible_aliases = ["sha"], env = "CI_COMMIT_SHA")] 232 | #[arg(value_parser = parse_commit_sha)] 233 | #[arg(help = "Set the sha of the commit")] 234 | commit_sha: CommitSha, 235 | #[arg(long, env = "CI_PROJECT_NAME")] 236 | #[arg(value_parser = parse_name)] 237 | #[arg(help = "Set the name of this project")] 238 | project_name: Name, 239 | #[arg(long, env = "CI_COMMIT_REF_NAME")] 240 | #[arg(value_parser = parse_name)] 241 | #[arg(help = "Set the reference name of the commit")] 242 | reference_name: Name, 243 | #[arg(long, visible_aliases = ["phase"])] 244 | #[arg(value_parser = parse_phase)] 245 | #[arg(help = "Set the phase")] 246 | phase: Phase, 247 | #[arg(long, visible_aliases = ["api-url-prefix"], env = "GITLAB_API_URL_PREFIX")] 248 | #[arg(value_parser = parse_api_url_prefix)] 249 | #[arg(help = "Set the URL prefix for GitLab APIs")] 250 | gitlab_api_url_prefix: ApiUrlPrefix, 251 | #[arg(long, visible_aliases = ["api-token"], env = "GITLAB_API_TOKEN")] 252 | #[arg(value_parser = parse_api_token)] 253 | #[arg(help = "Set the token of GitLab APIs")] 254 | gitlab_api_token: ApiToken, 255 | }, 256 | #[command(about = "Control the project on multiple hosts according to the phase")] 257 | #[command(after_help = AFTER_HELP)] 258 | SimpleControl { 259 | #[arg(long, visible_aliases = ["project-id", "id"], env = "CI_PROJECT_ID")] 260 | #[arg(help = "Set the ID on GitLab of this project")] 261 | gitlab_project_id: u64, 262 | #[arg(long, visible_aliases = ["sha"], env = "CI_COMMIT_SHA")] 263 | #[arg(value_parser = parse_commit_sha)] 264 | #[arg(help = "Set the sha of the commit")] 265 | commit_sha: CommitSha, 266 | #[arg(long, env = "CI_PROJECT_NAME")] 267 | #[arg(value_parser = parse_name)] 268 | #[arg(help = "Set the name of this project")] 269 | project_name: Name, 270 | #[arg(long, env = "CI_COMMIT_REF_NAME")] 271 | #[arg(value_parser = parse_name)] 272 | #[arg(help = "Set the reference name of the commit")] 273 | reference_name: Name, 274 | #[arg(long, visible_aliases = ["phase"])] 275 | #[arg(value_parser = parse_phase)] 276 | #[arg(help = "Set the phase")] 277 | phase: Phase, 278 | #[arg(long, visible_aliases = ["api-url-prefix"], env = "GITLAB_API_URL_PREFIX")] 279 | #[arg(value_parser = parse_api_url_prefix)] 280 | #[arg(help = "Set the URL prefix for GitLab APIs")] 281 | gitlab_api_url_prefix: ApiUrlPrefix, 282 | #[arg(long, visible_aliases = ["api-token"], env = "GITLAB_API_TOKEN")] 283 | #[arg(value_parser = parse_api_token)] 284 | #[arg(help = "Set the token of GitLab APIs")] 285 | gitlab_api_token: ApiToken, 286 | #[arg(long)] 287 | #[arg(help = "Inject the project directory as the first argument to the command")] 288 | inject_project_directory: bool, 289 | #[arg(required = true)] 290 | #[arg(last = true)] 291 | #[arg(value_hint = clap::ValueHint::CommandWithArguments)] 292 | #[arg(help = "Command to execute")] 293 | command: Vec, 294 | }, 295 | } 296 | 297 | #[inline] 298 | fn parse_commit_sha(arg: &str) -> Result { 299 | CommitSha::parse_str(arg) 300 | } 301 | 302 | #[inline] 303 | fn parse_name(arg: &str) -> Result { 304 | Name::parse_str(arg) 305 | } 306 | 307 | #[inline] 308 | fn parse_phase(arg: &str) -> Result { 309 | Phase::parse_str(arg) 310 | } 311 | 312 | #[inline] 313 | fn parse_api_url_prefix(arg: &str) -> Result { 314 | ApiUrlPrefix::parse_str(arg) 315 | } 316 | 317 | #[inline] 318 | fn parse_api_token(arg: &str) -> Result { 319 | ApiToken::parse_str(arg) 320 | } 321 | 322 | #[inline] 323 | fn parse_project_path(arg: &str) -> Result { 324 | ProjectPath::parse_str(arg) 325 | } 326 | 327 | #[inline] 328 | fn parse_reference(arg: &str) -> Result { 329 | Reference::parse_str(arg) 330 | } 331 | 332 | #[inline] 333 | fn parse_ssh_url_prefix(arg: &str) -> Result { 334 | SshUrlPrefix::parse_str(arg) 335 | } 336 | 337 | #[inline] 338 | fn parse_ssh_user_host(arg: &str) -> anyhow::Result { 339 | SshUserHost::parse_str(arg).map_err(|_| anyhow!("{arg:?} is not a correct SSH user and host")) 340 | } 341 | 342 | #[inline] 343 | fn parse_build_target(arg: &str) -> Result { 344 | BuildTarget::parse_str(arg) 345 | } 346 | 347 | #[inline] 348 | fn parse_command(arg: &str) -> anyhow::Result { 349 | Command::parse_str(arg).map_err(|_| anyhow!("{arg:?} is not a correct command")) 350 | } 351 | 352 | pub fn get_args() -> CLIArgs { 353 | let args = CLIArgs::command(); 354 | 355 | let about = format!("{APP_NAME} {CARGO_PKG_VERSION}\n{CARGO_PKG_AUTHORS}\n{APP_ABOUT}"); 356 | 357 | let args = args.about(about); 358 | 359 | let matches = args.get_matches(); 360 | 361 | match CLIArgs::from_arg_matches(&matches) { 362 | Ok(args) => args, 363 | Err(err) => { 364 | err.exit(); 365 | }, 366 | } 367 | } 368 | -------------------------------------------------------------------------------- /src/functions.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | borrow::Cow, 3 | collections::{HashMap, HashSet}, 4 | env, 5 | fs::{self, File}, 6 | io::{BufRead, BufReader, ErrorKind}, 7 | path::{Path, PathBuf}, 8 | process::{Command, Stdio}, 9 | }; 10 | 11 | use anyhow::anyhow; 12 | use chrono::{ 13 | format::{DelayedFormat, StrftimeItems}, 14 | Local, 15 | }; 16 | use execute::{command, command_args, Execute}; 17 | use regex::Regex; 18 | use scanner_rust::{ScannerError, ScannerStr}; 19 | use slash_formatter::delete_end_slash_in_place; 20 | use tempfile::TempDir; 21 | use trim_in_place::TrimInPlace; 22 | use validators::prelude::*; 23 | 24 | use crate::{constants::*, models::*}; 25 | 26 | #[inline] 27 | pub(crate) fn check_zstd() -> anyhow::Result<()> { 28 | let mut command = command!("zstd --version"); 29 | 30 | if command.execute_check_exit_status_code(0).is_err() { 31 | return Err(anyhow!("Cannot find zstd.")); 32 | } 33 | 34 | Ok(()) 35 | } 36 | 37 | #[inline] 38 | pub(crate) fn check_ssh() -> anyhow::Result<()> { 39 | // scp should also be checked implicitly 40 | let mut command = command!("ssh -V"); 41 | 42 | if command.execute_check_exit_status_code(0).is_err() { 43 | return Err(anyhow!("Cannot find ssh.")); 44 | } 45 | 46 | Ok(()) 47 | } 48 | 49 | #[inline] 50 | pub(crate) fn check_wget() -> anyhow::Result<()> { 51 | let mut command = command!("wget --version"); 52 | 53 | if command.execute_check_exit_status_code(0).is_err() { 54 | return Err(anyhow!("Cannot find wget.")); 55 | } 56 | 57 | Ok(()) 58 | } 59 | 60 | #[inline] 61 | pub(crate) fn check_tar() -> anyhow::Result<()> { 62 | let mut command = command!("tar --version"); 63 | 64 | if command.execute_check_exit_status_code(0).is_err() { 65 | return Err(anyhow!("Cannot find tar.")); 66 | } 67 | 68 | Ok(()) 69 | } 70 | 71 | #[inline] 72 | pub(crate) fn check_bash() -> anyhow::Result<()> { 73 | let mut command = command!("bash --version"); 74 | 75 | if command.execute_check_exit_status_code(0).is_err() { 76 | return Err(anyhow!("Cannot find bash.")); 77 | } 78 | 79 | Ok(()) 80 | } 81 | 82 | #[inline] 83 | pub(crate) fn check_docker() -> anyhow::Result<()> { 84 | let mut command = command!("docker --version"); 85 | 86 | if command.execute_check_exit_status_code(0).is_err() { 87 | return Err(anyhow!("Cannot find docker.")); 88 | } 89 | 90 | Ok(()) 91 | } 92 | 93 | pub(crate) fn check_front_deploy(temp_dir: &TempDir) -> anyhow::Result { 94 | let deploy_dir = temp_dir.path().join("deploy"); 95 | 96 | if !deploy_dir.join("build.sh").is_file() { 97 | return Err(anyhow!("deploy/build.sh cannot be found in the project.")); 98 | } 99 | 100 | let public_name = match fs::read_to_string(deploy_dir.join("public-name.txt")) { 101 | Ok(mut public_name) => { 102 | public_name.trim_in_place(); 103 | 104 | match Name::parse_string(public_name) { 105 | Ok(public_name) => public_name, 106 | Err(_) => { 107 | return Err(anyhow!("deploy/public-name.txt is not correct")); 108 | }, 109 | } 110 | }, 111 | Err(ref error) if error.kind() == ErrorKind::NotFound => { 112 | return Err(anyhow!("deploy/public-name.txt cannot be found in the project.")); 113 | }, 114 | Err(error) => return Err(error.into()), 115 | }; 116 | 117 | Ok(public_name) 118 | } 119 | 120 | pub(crate) fn check_back_deploy( 121 | temp_dir: &TempDir, 122 | commit_sha: &CommitSha, 123 | build_target: Option<&BuildTarget>, 124 | ) -> anyhow::Result<(ImageName, String)> { 125 | let deploy_dir = temp_dir.path().join("deploy"); 126 | 127 | if !deploy_dir.join("build.sh").is_file() { 128 | return Err(anyhow!("deploy/build.sh cannot be found in the project.")); 129 | } 130 | 131 | if !deploy_dir.join("develop-up.sh").is_file() { 132 | return Err(anyhow!("deploy/develop-up.sh cannot be found in the project.")); 133 | } 134 | 135 | if !deploy_dir.join("develop-down.sh").is_file() { 136 | return Err(anyhow!("deploy/develop-down.sh cannot be found in the project.")); 137 | } 138 | 139 | let image_name = match fs::read_to_string(deploy_dir.join("image-name.txt")) { 140 | Ok(mut image_name) => { 141 | image_name.trim_in_place(); 142 | 143 | match ImageName::parse_string(image_name) { 144 | Ok(image_name) => image_name, 145 | Err(_) => { 146 | return Err(anyhow!("deploy/image-name.txt is not correct")); 147 | }, 148 | } 149 | }, 150 | Err(ref error) if error.kind() == ErrorKind::NotFound => { 151 | return Err(anyhow!("deploy/image-name.txt cannot be found in the project.")); 152 | }, 153 | Err(error) => return Err(error.into()), 154 | }; 155 | 156 | let docker_compose_name = if let Some(build_target) = build_target { 157 | Cow::Owned(format!( 158 | "docker-compose.{build_target}.yml", 159 | build_target = build_target.as_ref() 160 | )) 161 | } else { 162 | Cow::Borrowed("docker-compose.yml") 163 | }; 164 | 165 | let docker_compose = match fs::read_to_string(deploy_dir.join(docker_compose_name.as_ref())) { 166 | Ok(mut docker_compose) => { 167 | docker_compose.trim_in_place(); 168 | 169 | docker_compose 170 | }, 171 | Err(ref error) if error.kind() == ErrorKind::NotFound => { 172 | return Err(anyhow!("deploy/{docker_compose_name} cannot be found in the project.")); 173 | }, 174 | Err(error) => return Err(error.into()), 175 | }; 176 | 177 | let regex = 178 | Regex::new(&format!("(?m)^( *image: +{image_name}) *$", image_name = image_name.as_ref())) 179 | .unwrap(); 180 | 181 | if !regex.is_match(docker_compose.as_str()) { 182 | return Err(anyhow!("deploy/{docker_compose_name} or deploy/image-name.txt cannot match")); 183 | } 184 | 185 | let docker_compose = regex 186 | .replace_all( 187 | docker_compose.as_str(), 188 | format!("$1:{commit_sha}", commit_sha = commit_sha.get_short_sha()), 189 | ) 190 | .into_owned(); 191 | 192 | Ok((image_name, docker_compose)) 193 | } 194 | 195 | pub(crate) fn check_back_deploy_via_ssh>( 196 | ssh_user_host: &SshUserHost, 197 | ssh_root: S, 198 | ) -> anyhow::Result<()> { 199 | let deploy_path = format!("{ssh_root}/deploy", ssh_root = ssh_root.as_ref()); 200 | 201 | if !check_file_exist(ssh_user_host, format!("{deploy_path}/develop-up.sh"))? { 202 | return Err(anyhow!("deploy/develop-up.sh cannot be found in the project.")); 203 | } 204 | 205 | if !check_file_exist(ssh_user_host, format!("{deploy_path}/develop-down.sh"))? { 206 | return Err(anyhow!("deploy/develop-down.sh cannot be found in the project.")); 207 | } 208 | 209 | Ok(()) 210 | } 211 | 212 | pub(crate) fn run_front_build(temp_dir: &TempDir, target: BuildTarget) -> anyhow::Result<()> { 213 | log::info!("Running deploy/build.sh"); 214 | 215 | let mut command: Command = command_args!("bash", "deploy/build.sh", target.as_ref()); 216 | 217 | command.current_dir(temp_dir.path()); 218 | 219 | let output = command.execute_output()?; 220 | 221 | if !output.status.success() { 222 | return Err(anyhow!("Build failed")); 223 | } 224 | 225 | Ok(()) 226 | } 227 | 228 | pub(crate) fn run_back_build( 229 | temp_dir: &TempDir, 230 | commit_sha: &CommitSha, 231 | build_target: Option<&BuildTarget>, 232 | ) -> anyhow::Result<()> { 233 | log::info!("Running deploy/build.sh"); 234 | 235 | let mut command: Command = command_args!("bash", "deploy/build.sh", commit_sha.get_short_sha()); 236 | 237 | if let Some(build_target) = build_target { 238 | command.arg(build_target.as_ref()); 239 | } 240 | 241 | command.current_dir(temp_dir.path()); 242 | 243 | let output = command.execute_output()?; 244 | 245 | if !output.status.success() { 246 | return Err(anyhow!("Build failed")); 247 | } 248 | 249 | Ok(()) 250 | } 251 | 252 | #[inline] 253 | pub(crate) fn create_ssh_command>( 254 | ssh_user_host: &SshUserHost, 255 | command: S, 256 | ) -> Command { 257 | command_args!( 258 | "ssh", 259 | "-o", 260 | "StrictHostKeyChecking=no", 261 | "-o", 262 | "BatchMode=yes", 263 | "-p", 264 | ssh_user_host.get_port().to_string(), 265 | ssh_user_host.user_host(), 266 | command.as_ref() 267 | ) 268 | } 269 | 270 | #[inline] 271 | pub(crate) fn create_scp_command, T: AsRef>( 272 | ssh_user_host: &SshUserHost, 273 | from: F, 274 | to: T, 275 | ) -> Command { 276 | command_args!( 277 | "scp", 278 | "-o", 279 | "StrictHostKeyChecking=no", 280 | "-o", 281 | "BatchMode=yes", 282 | "-P", 283 | ssh_user_host.get_port().to_string(), 284 | from.as_ref(), 285 | format!( 286 | "{ssh_user_host}:{to}", 287 | ssh_user_host = ssh_user_host.user_host(), 288 | to = to.as_ref() 289 | ), 290 | ) 291 | } 292 | 293 | pub(crate) fn get_ssh_home(ssh_user_host: &SshUserHost) -> anyhow::Result { 294 | let mut command = create_ssh_command(ssh_user_host, "echo $HOME"); 295 | 296 | command.stdout(Stdio::piped()); 297 | command.stderr(Stdio::piped()); 298 | 299 | let output = command.execute_output()?; 300 | 301 | if !output.status.success() { 302 | String::from_utf8_lossy(output.stderr.as_slice()).split('\n').for_each(|line| { 303 | if !line.is_empty() { 304 | log::error!("{line}"); 305 | } 306 | }); 307 | 308 | return Err(anyhow!("Cannot get the home directory of {ssh_user_host}")); 309 | } 310 | 311 | let mut home = String::from_utf8(output.stdout)?; 312 | 313 | home.trim_in_place(); 314 | 315 | delete_end_slash_in_place(&mut home); 316 | 317 | Ok(home) 318 | } 319 | 320 | pub(crate) fn list_ssh_files>( 321 | ssh_user_host: &SshUserHost, 322 | path: S, 323 | ) -> anyhow::Result<()> { 324 | let mut command = 325 | create_ssh_command(ssh_user_host, format!("ls {path:?}", path = path.as_ref(),)); 326 | 327 | command.stderr(Stdio::piped()); 328 | 329 | let output = command.execute_output()?; 330 | 331 | if !output.status.success() { 332 | String::from_utf8_lossy(output.stderr.as_slice()).split('\n').for_each(|line| { 333 | if !line.is_empty() { 334 | log::warn!("{line}"); 335 | } 336 | }); 337 | } 338 | 339 | Ok(()) 340 | } 341 | 342 | pub(crate) fn check_file_exist>( 343 | ssh_user_host: &SshUserHost, 344 | path: S, 345 | ) -> anyhow::Result { 346 | let mut command = 347 | create_ssh_command(ssh_user_host, format!("test -f {PATH:?}", PATH = path.as_ref(),)); 348 | 349 | command.stdout(Stdio::piped()); 350 | command.stderr(Stdio::piped()); 351 | 352 | let output = command.execute_output()?; 353 | 354 | if let Some(code) = output.status.code() { 355 | match code { 356 | 0 => return Ok(true), 357 | 1 => return Ok(false), 358 | _ => (), 359 | } 360 | } 361 | 362 | String::from_utf8_lossy(output.stderr.as_slice()).split('\n').for_each(|line| { 363 | if !line.is_empty() { 364 | log::error!("{}", line); 365 | } 366 | }); 367 | 368 | Err(anyhow!("Cannot check the existence of {:?} of {}", path.as_ref(), ssh_user_host)) 369 | } 370 | 371 | pub(crate) fn check_directory_exist>( 372 | ssh_user_host: &SshUserHost, 373 | path: S, 374 | ) -> anyhow::Result { 375 | let mut command = 376 | create_ssh_command(ssh_user_host, format!("test -d {PATH:?}", PATH = path.as_ref(),)); 377 | 378 | command.stdout(Stdio::piped()); 379 | command.stderr(Stdio::piped()); 380 | 381 | let output = command.execute_output()?; 382 | 383 | if let Some(code) = output.status.code() { 384 | match code { 385 | 0 => return Ok(true), 386 | 1 => return Ok(false), 387 | _ => (), 388 | } 389 | } 390 | 391 | String::from_utf8_lossy(output.stderr.as_slice()).split('\n').for_each(|line| { 392 | if !line.is_empty() { 393 | log::error!("{}", line); 394 | } 395 | }); 396 | 397 | Err(anyhow!("Cannot check the existence of {:?} of {}", path.as_ref(), ssh_user_host)) 398 | } 399 | 400 | pub(crate) fn download_archive( 401 | temp_dir: &TempDir, 402 | api_url_prefix: ApiUrlPrefix, 403 | api_token: ApiToken, 404 | project_id: u64, 405 | commit_sha: &CommitSha, 406 | ) -> anyhow::Result { 407 | let archive_url = format!( 408 | "{api_url_prefix}/projects/{project_id}/repository/archive.tar?sha={commit_sha}", 409 | api_url_prefix = api_url_prefix.as_ref(), 410 | commit_sha = commit_sha.as_ref() 411 | ); 412 | 413 | let archive_save_path = temp_dir.path().join("archive.tar"); 414 | 415 | log::info!("Fetching project from {archive_url:?}"); 416 | 417 | { 418 | let mut command = command_args!( 419 | "wget", 420 | "--no-check-certificate", 421 | archive_url, 422 | "--header", 423 | format!("PRIVATE-TOKEN: {api_token}", api_token = api_token.as_ref()), 424 | "-O", 425 | archive_save_path, 426 | ); 427 | 428 | let output = command.execute()?; 429 | 430 | if let Some(0) = output { 431 | log::info!("Fetched successfully."); 432 | } else { 433 | return Err(anyhow!("Fetched unsuccessfully!")); 434 | } 435 | } 436 | 437 | Ok(archive_save_path) 438 | } 439 | 440 | pub(crate) fn download_and_extract_archive( 441 | temp_dir: &TempDir, 442 | api_url_prefix: ApiUrlPrefix, 443 | api_token: ApiToken, 444 | project_id: u64, 445 | commit_sha: &CommitSha, 446 | ) -> anyhow::Result<()> { 447 | let archive_url = format!( 448 | "{api_url_prefix}/projects/{project_id}/repository/archive?sha={commit_sha}", 449 | api_url_prefix = api_url_prefix.as_ref(), 450 | commit_sha = commit_sha.as_ref() 451 | ); 452 | 453 | log::info!("Fetching project from {archive_url:?}"); 454 | 455 | { 456 | let mut command1 = command_args!( 457 | "wget", 458 | "--no-check-certificate", 459 | archive_url, 460 | "--header", 461 | format!("PRIVATE-TOKEN: {api_token}", api_token = api_token.as_ref()), 462 | "-O", 463 | "-", 464 | ); 465 | 466 | let mut command2: Command = command!("tar --strip-components 1 -z -x -v -f -"); 467 | 468 | command2.current_dir(temp_dir.path()); 469 | 470 | let output = command1.execute_multiple(&mut [&mut command2])?; 471 | 472 | if let Some(0) = output { 473 | log::info!("Fetched successfully."); 474 | } else { 475 | return Err(anyhow!("Fetched unsuccessfully!")); 476 | } 477 | } 478 | 479 | Ok(()) 480 | } 481 | 482 | pub(crate) fn find_ssh_user_hosts( 483 | phase: Phase, 484 | project_id: u64, 485 | ) -> anyhow::Result> { 486 | let mut home = env::var("HOME")?; 487 | 488 | delete_end_slash_in_place(&mut home); 489 | 490 | let phase_path = Path::new(home.as_str()).join(PHASE_DIRECTORY).join(phase.as_ref()); 491 | 492 | let file = match File::open(phase_path.as_path()) { 493 | Ok(f) => f, 494 | Err(ref err) if err.kind() == ErrorKind::NotFound => { 495 | return Err(anyhow!("{:?} is not a supported phase!", phase.as_ref())); 496 | }, 497 | Err(err) => return Err(err.into()), 498 | }; 499 | 500 | let mut reader = BufReader::new(file); 501 | 502 | let mut map: HashMap> = HashMap::new(); 503 | 504 | let mut line_number = 0; 505 | 506 | let mut line = String::new(); 507 | 508 | let mut last_project_id: Option = None; 509 | 510 | loop { 511 | line.clear(); 512 | line_number += 1; 513 | 514 | let c = reader.read_line(&mut line)?; 515 | 516 | if c == 0 { 517 | break; 518 | } 519 | 520 | if let Some(index) = line.find('#') { 521 | unsafe { 522 | line.as_mut_vec().set_len(index); 523 | } 524 | } 525 | 526 | let mut sc = ScannerStr::new(&line); 527 | 528 | let project_id = match sc.next_u64() { 529 | Ok(r) => match r { 530 | Some(r) => r, 531 | None => continue, 532 | }, 533 | Err(err) => match err { 534 | ScannerError::ParseIntError(_) => { 535 | return Err(anyhow!( 536 | "In {phase_path:?} at line {line_number}, cannot read the project id: \ 537 | {err:?}", 538 | )) 539 | }, 540 | ScannerError::IOError(err) => return Err(err.into()), 541 | ScannerError::ParseFloatError(_) => unreachable!(), 542 | }, 543 | }; 544 | 545 | let mut set: HashSet = HashSet::with_capacity(1); 546 | 547 | while let Some(user_host) = sc.next()? { 548 | if set.is_empty() && user_host == "." { 549 | if sc.next()?.is_some() { 550 | return Err(anyhow!( 551 | "In {phase_path:?} at line {line_number}, it is not correct", 552 | )); 553 | } 554 | 555 | match last_project_id { 556 | Some(last_project_id) => { 557 | set.extend(map.get(&last_project_id).unwrap().iter().cloned()); 558 | break; 559 | }, 560 | None => { 561 | return Err(anyhow!( 562 | "In {phase_path:?} at line {line_number}, should be written after the \ 563 | line that you want to reference", 564 | )) 565 | }, 566 | } 567 | } 568 | 569 | let ssh_user_host = match SshUserHost::parse_str(user_host) { 570 | Ok(ssh_user_host) => ssh_user_host, 571 | Err(_) => { 572 | return Err(anyhow!( 573 | "In {phase_path:?} at line {line_number}, the format of {user_host:?} is \ 574 | not correct", 575 | )) 576 | }, 577 | }; 578 | 579 | if !set.insert(ssh_user_host) { 580 | return Err(anyhow!( 581 | "In {phase_path:?} at line {line_number}, {user_host:?} is duplicated", 582 | )); 583 | } 584 | } 585 | 586 | map.insert(project_id, set); 587 | last_project_id = Some(project_id); 588 | } 589 | 590 | if let Some(set) = map.remove(&project_id) { 591 | Ok(set) 592 | } else { 593 | Err(anyhow!("The project {project_id} is not set in {phase_path:?}",)) 594 | } 595 | } 596 | 597 | #[inline] 598 | pub(crate) fn current_timestamp() -> DelayedFormat> { 599 | Local::now().format("[%Y-%m-%d-%H-%M-%S]") 600 | } 601 | --------------------------------------------------------------------------------