auto deployment
Some checks failed
Test (production install and build) / production (20.16.0) (pull_request) Successful in 1m17s
Publish Docker image / Build (pull_request) Successful in 4m30s
Test (production install and build) / production (20.16.0) (push) Successful in 1m18s
Publish Docker image / Build (push) Has been cancelled
Some checks failed
Test (production install and build) / production (20.16.0) (pull_request) Successful in 1m17s
Publish Docker image / Build (pull_request) Successful in 4m30s
Test (production install and build) / production (20.16.0) (push) Successful in 1m18s
Publish Docker image / Build (push) Has been cancelled
Signed-off-by: eternal-flame-AD <yume@yumechi.jp>
This commit is contained in:
parent
c154154645
commit
616ec50435
7 changed files with 1538 additions and 1 deletions
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
|
@ -14,5 +14,6 @@
|
|||
"editor.formatOnSave": false,
|
||||
"rust-analyzer.linkedProjects": [
|
||||
"yume-mods/nyuukyou/Cargo.toml",
|
||||
"yume-mods/misskey-auto-deploy/Cargo.toml",
|
||||
]
|
||||
}
|
||||
|
|
2
yume-mods/misskey-auto-deploy/.gitignore
vendored
Normal file
2
yume-mods/misskey-auto-deploy/.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
/target
|
||||
|
1147
yume-mods/misskey-auto-deploy/Cargo.lock
generated
Normal file
1147
yume-mods/misskey-auto-deploy/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
15
yume-mods/misskey-auto-deploy/Cargo.toml
Normal file
15
yume-mods/misskey-auto-deploy/Cargo.toml
Normal file
|
@ -0,0 +1,15 @@
|
|||
[package]
|
||||
name = "misskey-auto-deploy"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
axum = { version = "0.7.7", features = ["macros"] }
|
||||
bcrypt = "0.15.1"
|
||||
chrono = { version = "0.4.38", features = ["serde"] }
|
||||
clap = { version = "4.5.20", features = ["derive"] }
|
||||
libc = "0.2.162"
|
||||
serde = { version = "1.0.214", features = ["derive"] }
|
||||
serde_json = "1.0.132"
|
||||
tokio = { version = "1.41.1", features = ["rt", "rt-multi-thread", "macros", "process", "signal"] }
|
||||
toml = "0.8.19"
|
291
yume-mods/misskey-auto-deploy/src/lib.rs
Normal file
291
yume-mods/misskey-auto-deploy/src/lib.rs
Normal file
|
@ -0,0 +1,291 @@
|
|||
use std::{collections::HashMap, fs::OpenOptions, path::PathBuf, process::Stdio, sync::Arc};
|
||||
|
||||
use axum::{extract::{Query, State, Json}, http::{HeaderMap, StatusCode}, response::{IntoResponse, Response}};
|
||||
use chrono::NaiveDateTime;
|
||||
use libc::SIGTERM;
|
||||
use tokio::{process::Command, sync::RwLock};
|
||||
|
||||
pub struct App;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub bearer_secret: String,
|
||||
pub refs: HashMap<String, Arc<RefState>>,
|
||||
}
|
||||
|
||||
pub struct RefState {
|
||||
pub config: RefConfig,
|
||||
pub status: RwLock<RefStatus>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
|
||||
pub struct RefStatus {
|
||||
pub status: Status,
|
||||
pub triggered: Option<NaiveDateTime>,
|
||||
pub succeeded: Option<NaiveDateTime>,
|
||||
pub last_payload: Option<WebhookPayload>,
|
||||
pub child_pid: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Status {
|
||||
Never,
|
||||
Deploying,
|
||||
Validating,
|
||||
Success,
|
||||
Fatal(String),
|
||||
Error(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, serde::Serialize)]
|
||||
pub struct RefConfig {
|
||||
#[serde(rename = "ref")]
|
||||
pub ref_: String,
|
||||
pub stdout: Option<PathBuf>,
|
||||
pub stderr: Option<PathBuf>,
|
||||
pub working_dir: PathBuf,
|
||||
pub compose_flags: Option<Vec<String>>,
|
||||
pub env: Option<HashMap<String, String>>,
|
||||
pub uid: u32,
|
||||
pub gid: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
|
||||
pub struct ApiError {
|
||||
pub code: u16,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
(StatusCode::from_u16(self.code).unwrap(), Json(&self)).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
|
||||
pub struct WebhookPayload {
|
||||
#[serde(rename = "ref")]
|
||||
pub ref_: String,
|
||||
|
||||
pub before: String,
|
||||
pub after: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct GetStatusQuery {
|
||||
#[serde(rename = "ref")]
|
||||
pub ref_: Option<String>,
|
||||
|
||||
pub branch: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
fn require_bearer(
|
||||
bcrypted: &str,
|
||||
headers: &HeaderMap) -> Result<(), ApiError> {
|
||||
let bearer = headers
|
||||
.get("authorization")
|
||||
.ok_or_else(|| ApiError {
|
||||
code: 401,
|
||||
message: "Unauthorized".to_string(),
|
||||
})?
|
||||
.to_str()
|
||||
.map_err(|_| ApiError {
|
||||
code: 400,
|
||||
message: "Invalid Authorization header".to_string(),
|
||||
})?;
|
||||
let bearer = bearer.trim_start_matches("Bearer ").trim_start_matches("bearer ");
|
||||
|
||||
if !bcrypt::verify(bearer, bcrypted).map_err(|_| ApiError {
|
||||
code: 401,
|
||||
message: "Unauthorized".to_string(),
|
||||
})? {
|
||||
return Err(ApiError {
|
||||
code: 401,
|
||||
message: "Unauthorized".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
impl App {
|
||||
pub async fn post_deploy(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<WebhookPayload>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
require_bearer(&state.bearer_secret, &headers)?;
|
||||
|
||||
let matched_ref = state.refs.get(&payload.ref_).ok_or_else(|| ApiError {
|
||||
code: 404,
|
||||
message: "Ref not found".to_string(),
|
||||
})?.clone();
|
||||
|
||||
let mut status = matched_ref.status.write().await;
|
||||
|
||||
status.last_payload = Some(payload.clone());
|
||||
|
||||
if status.status == Status::Deploying {
|
||||
if status.child_pid == 0 {
|
||||
return Err(ApiError {
|
||||
code: 500,
|
||||
message: "Child PID is 0 while status is InProgress".to_string(),
|
||||
});
|
||||
}
|
||||
if unsafe { libc::kill(status.child_pid as i32, SIGTERM) } != 0 {
|
||||
return Err(ApiError {
|
||||
code: 500,
|
||||
message: "Failed to send SIGTERM to existing process".to_string(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if !Command::new("git")
|
||||
.arg("fetch")
|
||||
.arg("origin")
|
||||
.uid(matched_ref.config.uid)
|
||||
.gid(matched_ref.config.gid)
|
||||
.current_dir(matched_ref.config.working_dir.clone())
|
||||
.spawn()
|
||||
.map_err(|_| ApiError {
|
||||
code: 500,
|
||||
message: "Failed to spawn command".to_string(),
|
||||
})?
|
||||
.wait()
|
||||
.await
|
||||
.map_err(|_| ApiError {
|
||||
code: 500,
|
||||
message: "Failed to wait for command".to_string(),
|
||||
})?.success() {
|
||||
status.status = Status::Error("Failed to fetch".to_string());
|
||||
return Ok(StatusCode::OK);
|
||||
}
|
||||
|
||||
if !Command::new("git")
|
||||
.arg("reset")
|
||||
.arg("--hard")
|
||||
.arg(payload.after)
|
||||
.uid(matched_ref.config.uid)
|
||||
.gid(matched_ref.config.gid)
|
||||
.current_dir(matched_ref.config.working_dir.clone())
|
||||
.spawn()
|
||||
.map_err(|_| ApiError {
|
||||
code: 500,
|
||||
message: "Failed to spawn command".to_string(),
|
||||
})?
|
||||
.wait()
|
||||
.await
|
||||
.map_err(|_| ApiError {
|
||||
code: 500,
|
||||
message: "Failed to wait for command".to_string(),
|
||||
})?.success() {
|
||||
status.status = Status::Error("Failed to reset".to_string());
|
||||
return Ok(StatusCode::OK);
|
||||
}
|
||||
|
||||
|
||||
let mut command = Command::new("docker")
|
||||
.arg("compose")
|
||||
.arg("up")
|
||||
.arg("--detach")
|
||||
.arg("--build")
|
||||
.args(matched_ref.config.compose_flags.as_ref().unwrap_or(&Vec::new()))
|
||||
.current_dir(matched_ref.config.working_dir.clone())
|
||||
.kill_on_drop(true)
|
||||
.envs(matched_ref.config.env.as_ref().unwrap_or(&HashMap::new()).iter())
|
||||
.stdout(
|
||||
match matched_ref.config.stdout.as_ref() {
|
||||
Some(path) => {
|
||||
let file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(path)
|
||||
.map_err(|_| ApiError {
|
||||
code: 500,
|
||||
message: "Failed to open stdout file".to_string(),
|
||||
})?;
|
||||
|
||||
Stdio::from(file)
|
||||
}
|
||||
None => Stdio::null(),
|
||||
}
|
||||
)
|
||||
.spawn()
|
||||
.map_err(|_| ApiError {
|
||||
code: 500,
|
||||
message: "Failed to spawn command".to_string(),
|
||||
})?;
|
||||
|
||||
status.status = Status::Deploying;
|
||||
status.triggered = Some(chrono::Utc::now().naive_utc());
|
||||
|
||||
status.child_pid = command.id().ok_or_else( ||
|
||||
ApiError {
|
||||
code: 500,
|
||||
message: "Failed to get child PID".to_string(),
|
||||
}
|
||||
)?;
|
||||
|
||||
let matched_ref = matched_ref.clone();
|
||||
tokio::spawn(async move {
|
||||
match command.wait().await {
|
||||
Ok(exit) => {
|
||||
let mut status = matched_ref.status.write().await;
|
||||
status.status = if exit.success() {
|
||||
status.succeeded = Some(chrono::Utc::now().naive_utc());
|
||||
Status::Success
|
||||
} else {
|
||||
Status::Error(format!("Exit code: {}", exit.code().unwrap_or(-1)))
|
||||
};
|
||||
status.child_pid = 0;
|
||||
}
|
||||
Err(e) => {
|
||||
let mut status = matched_ref.status.write().await;
|
||||
status.status = Status::Fatal(format!("Subprocess error: {}", e));
|
||||
status.child_pid = 0;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
pub async fn summary(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<HashMap<String, RefStatus>>, ApiError> {
|
||||
let mut statuses = HashMap::new();
|
||||
for (ref_, ref_state) in state.refs.iter() {
|
||||
let status = ref_state.status.read().await;
|
||||
statuses.insert(ref_.clone(), status.clone());
|
||||
}
|
||||
|
||||
Ok(Json(statuses))
|
||||
}
|
||||
|
||||
pub async fn get_status(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<GetStatusQuery>,
|
||||
) -> Result<Json<RefStatus>, ApiError> {
|
||||
let ref_ = match (&query.ref_, &query.branch) {
|
||||
(Some(ref_), None) => ref_.clone(),
|
||||
(None, Some(branch)) => format!("refs/heads/{}", branch),
|
||||
_ => return Err(ApiError {
|
||||
code: 400,
|
||||
message: "Either ref or branch must be specified".to_string(),
|
||||
}),
|
||||
};
|
||||
|
||||
let matched_ref = state.refs.get(&ref_).ok_or_else(|| ApiError {
|
||||
code: 404,
|
||||
message: "Ref not found".to_string(),
|
||||
})?.clone();
|
||||
|
||||
let status = matched_ref.status.read().await;
|
||||
|
||||
Ok(Json(status.clone()))
|
||||
}
|
||||
}
|
81
yume-mods/misskey-auto-deploy/src/main.rs
Normal file
81
yume-mods/misskey-auto-deploy/src/main.rs
Normal file
|
@ -0,0 +1,81 @@
|
|||
use std::{collections::HashMap, sync::Arc};
|
||||
use axum::Router;
|
||||
use tokio::{net::TcpListener, sync::RwLock};
|
||||
use clap::Parser;
|
||||
|
||||
use misskey_auto_deploy::{App, AppState, RefConfig, RefState, RefStatus, Status};
|
||||
|
||||
#[derive(Parser)]
|
||||
struct Args {
|
||||
#[clap(short, long)]
|
||||
listen: Option<String>,
|
||||
#[clap(short, long)]
|
||||
config: String,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct Config {
|
||||
pub service: ServiceConfig,
|
||||
pub auth: AuthConfig,
|
||||
pub refs: HashMap<String, RefConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct AuthConfig {
|
||||
pub secret: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct ServiceConfig {
|
||||
pub listen: Option<String>,
|
||||
}
|
||||
|
||||
impl From<Config> for AppState {
|
||||
fn from(val: Config) -> Self {
|
||||
let refs = val.refs.into_iter().map(|(ref_, config)| {
|
||||
let status = RefStatus{
|
||||
status: Status::Never,
|
||||
last_payload: None,
|
||||
triggered: None,
|
||||
succeeded: None,
|
||||
child_pid: 0,
|
||||
};
|
||||
let status = RwLock::new(status);
|
||||
|
||||
(ref_, Arc::new(RefState { config, status }))
|
||||
}).collect();
|
||||
AppState {
|
||||
bearer_secret: val.auth.secret,
|
||||
refs,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let args = Args::parse();
|
||||
|
||||
let config:Config = toml::from_str(&std::fs::read_to_string(args.config).expect("Failed to read config file")).expect("Failed to parse config file");
|
||||
|
||||
let listen = match (&args.listen, &config.service.listen) {
|
||||
(Some(listen), _) => listen.clone(),
|
||||
(None, Some(listen)) => listen.clone(),
|
||||
(None, None) => panic!("No listen address provided"),
|
||||
};
|
||||
|
||||
let app_state: AppState = config.into();
|
||||
|
||||
let app = Router::new()
|
||||
.route("/deploy",
|
||||
axum::routing::post(App::post_deploy)
|
||||
.get(App::get_status)
|
||||
)
|
||||
.route("/summary", axum::routing::get(App::summary))
|
||||
.with_state(app_state);
|
||||
|
||||
let listener = TcpListener::bind(listen).await.expect("Failed to bind listener");
|
||||
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
}
|
2
yume-mods/nyuukyou/.gitignore
vendored
2
yume-mods/nyuukyou/.gitignore
vendored
|
@ -1 +1 @@
|
|||
target/
|
||||
/target
|
||||
|
|
Loading…
Reference in a new issue