Compare commits
5
Commits
6ad1720fcb
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d259feae6 | ||
|
|
d194890315 | ||
|
|
97eea5e064 | ||
|
|
eca349a706 | ||
|
|
95c88fe5d9 |
@@ -14,42 +14,66 @@
|
|||||||
cargo install --path .
|
cargo install --path .
|
||||||
```
|
```
|
||||||
|
|
||||||
Требуется Rust 1.88+. Кроссплатформенно: Windows (MSVC), Linux, macOS.
|
Бинарник называется **`xd`**. Требуется Rust 1.88+. Кроссплатформенно:
|
||||||
|
Windows (MSVC), Linux, macOS.
|
||||||
|
|
||||||
## Быстрый старт
|
## Команды
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
xboct-deploy --list # показать проекты из конфига
|
xd list # показать проекты из конфига
|
||||||
xboct-deploy --list-servers # показать серверы из конфига
|
xd list-servers # показать серверы из конфига
|
||||||
xboct-deploy --pick # интерактивно выбрать проект (нечёткий поиск)
|
xd pick # интерактивно выбрать проект (нечёткий поиск)
|
||||||
xboct-deploy --dry-run demo # напечатать план деплоя без выполнения
|
xd deploy my-node-app # задеплоить проект my-node-app
|
||||||
xboct-deploy demo # задеплоить проект demo
|
xd deploy # задеплоить проект текущей папки (workdir == cwd)
|
||||||
xboct-deploy --this # задеплоить проект текущей папки (глобальный конфиг)
|
xd --dry-run deploy my-node-app # напечатать план деплоя без выполнения
|
||||||
|
xd edit-config # открыть глобальный конфиг в $EDITOR
|
||||||
|
xd # статус: какой проект соответствует текущей папке
|
||||||
|
# (вне проекта — список проектов из конфига)
|
||||||
```
|
```
|
||||||
|
|
||||||
Вывод:
|
Глобальные флаги:
|
||||||
|
|
||||||
```txt
|
```txt
|
||||||
deploying 'demo'
|
-c, --config <PATH> явный путь к конфигу (работает и внутри подкоманд)
|
||||||
|
-g, --global использовать только глобальный конфиг (конфликтует с -c)
|
||||||
|
--dry-run печать плана без выполнения; НЕ глобальный — ставится
|
||||||
|
до подкоманды: `xd --dry-run deploy name`
|
||||||
|
```
|
||||||
|
|
||||||
|
`xd deploy` без имени ищет проект, чей `workdir` совпадает с текущей папкой,
|
||||||
|
и падает с ошибкой, если такого нет. `xd pick` требует реальный терминал —
|
||||||
|
в скриптах используйте `xd list`.
|
||||||
|
|
||||||
|
Вывод деплоя:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
[Git] on branch 'main' ✓
|
||||||
|
deploying 'my-node-app'
|
||||||
[Local] running: npm run build... Success! (1.2s)
|
[Local] running: npm run build... Success! (1.2s)
|
||||||
[SFTP] transferring dist to /var/www/pages... Done! (14 file(s), 231.5 KiB) (829ms)
|
connecting...
|
||||||
|
[SFTP] transferring dist to /var/www/pages/my-node-app... Done! (14 file(s), 231.5 KiB) (829ms)
|
||||||
[Remote] running: pm2 restart pages... Success! (102ms)
|
[Remote] running: pm2 restart pages... Success! (102ms)
|
||||||
|
[LocalAfter] running: curl -s -X POST https://example.com/hook... Success! (45ms)
|
||||||
Deploy successful! (total 2.3s)
|
Deploy successful! (total 2.3s)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Строка `[Git]` появляется только при заданном `branch`, `connecting...` — перед
|
||||||
|
первым SSH-шагом (sync/remote), `[LocalAfter]` — шаги `local_after`.
|
||||||
|
|
||||||
## Конфигурация
|
## Конфигурация
|
||||||
|
|
||||||
Файл ищется по цепочке:
|
Файл ищется по цепочке:
|
||||||
|
|
||||||
1. `--config PATH`, если указан явно;
|
1. `-c/--config PATH`, если указан явно (конфликтует с `-g`);
|
||||||
2. `./deploy.toml` в текущей папке — удобно держать рядом с проектом и в тестах;
|
2. `./deploy.toml` в текущей папке — удобно держать рядом с проектом и в тестах;
|
||||||
3. глобальный `~/.config/xboct-deploy/deploy.toml` — запуск «из любой папки».
|
3. глобальный `~/.config/xboct-deploy/deploy.toml` — запуск «из любой папки».
|
||||||
|
|
||||||
`--this` деплоит проект, чей `workdir` совпадает с текущей папкой; конфиг
|
Флаг `-g/--global` исключает шаг 2: используется только глобальный конфиг.
|
||||||
ищется по обычной цепочке, так что временный локальный `./deploy.toml`
|
Деплой проекта текущей папки (`xd deploy` без имени) ищет конфиг по этой же
|
||||||
переопределяет глобальный — создали файл, задеплоились, удалили.
|
цепочке, так что временный локальный `./deploy.toml` переопределяет глобальный —
|
||||||
|
создали файл, задеплоились, удалили.
|
||||||
|
|
||||||
Схема:
|
Схема — **один проект = один блок**, все параметры точечными ключами:
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[servers.my-vps] # профиль сервера: описывается один раз
|
[servers.my-vps] # профиль сервера: описывается один раз
|
||||||
@@ -60,29 +84,65 @@ key_path = "~/.ssh/id_ed25519"
|
|||||||
base_dir = "/var/www/pages" # корень для относительных sync target
|
base_dir = "/var/www/pages" # корень для относительных sync target
|
||||||
|
|
||||||
[projects.my-node-app]
|
[projects.my-node-app]
|
||||||
server = "my-vps" # ссылка на профиль
|
server = "my-vps" # ссылка на профиль SSH
|
||||||
workdir = "~/code/my-node-app" # где выполнять локальные команды (~ разворачивается)
|
workdir = 'C:\code\my-node-app' # где выполнять локальные команды (~ разворачивается)
|
||||||
|
branch = "main" # опционально: защита от деплоя чужой ветки
|
||||||
[projects.my-node-app.env] # переменные для локальных команд сборки
|
env.PUBLIC_BASE_PATH = "/my-node-app" # переменные для локальной сборки
|
||||||
PUBLIC_BASE_PATH = "/my-node-app"
|
local.commands = ["npm ci", "npm run build"] # шаги сборки на вашей машине
|
||||||
|
sync = "dist" # залить dist -> <base_dir>/my-node-app
|
||||||
[projects.my-node-app.local] # шаги сборки на вашей машине
|
remote.commands = ["pm2 restart pages"] # команды управления по SSH
|
||||||
commands = ["npm ci", "npm run build"]
|
|
||||||
|
|
||||||
[[projects.my-node-app.sync]] # что заливать на сервер
|
|
||||||
source = "dist" # файл или папка (относительно workdir)
|
|
||||||
target = "my-node-app" # относительный -> base_dir сервера;
|
|
||||||
# абсолютный "/path" используется как есть
|
|
||||||
|
|
||||||
[projects.my-node-app.remote] # команды управления на сервере
|
|
||||||
commands = ["pm2 restart pages"]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Поле `sync`
|
||||||
|
|
||||||
|
Три формы — выбирай по смыслу:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
sync = "dist" # один источник; target = имя проекта
|
||||||
|
sync = ["dist", "static"] # несколько источников; target = имя проекта
|
||||||
|
sync = [{ source = "web/build", target = "/opt/static" }] # полные правила
|
||||||
|
```
|
||||||
|
|
||||||
|
Во всех формах `target` без `/` на конце резолвится через `base_dir` сервера
|
||||||
|
(`"my-node-app"` → `/var/www/pages/my-node-app`). Абсолютный target
|
||||||
|
(`"/opt/static"`) используется как есть. Отдельных блоков `[[...sync]]`,
|
||||||
|
`[projects.x.env]`, `[projects.x.local]`, `[projects.x.remote]` больше нет —
|
||||||
|
это не нужно помнить и копировать, всё в блоке проекта.
|
||||||
|
|
||||||
Проект может задать параметры подключения и напрямую (`host`, `user`, `port`,
|
Проект может задать параметры подключения и напрямую (`host`, `user`, `port`,
|
||||||
`key_path` вместо `server`) — тогда они имеют приоритет над профилем.
|
`key_path` вместо `server`) — тогда они имеют приоритет над профилем.
|
||||||
|
|
||||||
Приоритет параметров SSH: проект → `[servers.<name>]` → `~/.ssh/config` → дефолт.
|
Приоритет параметров SSH: проект → `[servers.<name>]` → `~/.ssh/config` → дефолт.
|
||||||
|
|
||||||
|
### Локальные команды после деплоя (`local_after`)
|
||||||
|
|
||||||
|
Выполняются на вашей машине **после** sync + remote и **всегда** — и при
|
||||||
|
успехе, и после любого упавшего шага (deploy всё равно упадёт). Для очистки
|
||||||
|
артефактов или вебхука с результатом:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[projects.my-node-app]
|
||||||
|
server = "my-vps"
|
||||||
|
workdir = 'C:\code\my-node-app'
|
||||||
|
local.commands = ["npm run build"]
|
||||||
|
sync = "dist"
|
||||||
|
remote.commands = ["pm2 restart pages"]
|
||||||
|
|
||||||
|
[projects.my-node-app.local_after]
|
||||||
|
commands = [
|
||||||
|
'del /q dist',
|
||||||
|
'curl -s -X POST https://example.com/hook -d "result=%XD_DEPLOY_RESULT%"',
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Внутри доступны переменные (в cmd — `%VAR%`, в sh — `$VAR`):
|
||||||
|
|
||||||
|
- `XD_DEPLOY_RESULT` — `success` либо `failed`;
|
||||||
|
- `XD_ERROR` — текст ошибки (только при `failed`).
|
||||||
|
|
||||||
|
Эти же переменные доступны в `--dry-run`, где показываются в плане без
|
||||||
|
выполнения.
|
||||||
|
|
||||||
### Windows-нюанс TOML
|
### Windows-нюанс TOML
|
||||||
|
|
||||||
В basic-строках `"..."` бэкслеши — escape-символы, путь `C:\Users` сломает парсинг.
|
В basic-строках `"..."` бэкслеши — escape-символы, путь `C:\Users` сломает парсинг.
|
||||||
@@ -125,6 +185,10 @@ export DB_URL=postgres://localhost/app
|
|||||||
## Разработка
|
## Разработка
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
just test # fmt + clippy -D warnings + cargo test
|
just test # fmt + clippy -D warnings + cargo test
|
||||||
just run # деплой из test/deploy.toml (в git не входит)
|
just build # cargo build
|
||||||
|
just install # cargo install --path . (бинарник xd)
|
||||||
|
just run # деплой проекта github-tracker из test/deploy.toml (в git не входит)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Схема деплоя и планы — в `docs/`, текущие задачи — в `backlog.md`.
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ pub enum Commands {
|
|||||||
|
|
||||||
/// List all available servers
|
/// List all available servers
|
||||||
ListServers,
|
ListServers,
|
||||||
|
|
||||||
|
/// Open global config in $EDITOR
|
||||||
|
EditConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Args, Debug)]
|
#[derive(Args, Debug)]
|
||||||
|
|||||||
+147
-34
@@ -29,18 +29,20 @@ pub struct Server {
|
|||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct Project {
|
pub struct Project {
|
||||||
pub workdir: PathBuf,
|
|
||||||
pub branch: Option<String>,
|
|
||||||
pub server: Option<String>,
|
pub server: Option<String>,
|
||||||
pub host: Option<String>,
|
pub host: Option<String>,
|
||||||
pub user: Option<String>,
|
pub user: Option<String>,
|
||||||
pub port: Option<u16>,
|
pub port: Option<u16>,
|
||||||
pub key_path: Option<PathBuf>,
|
pub key_path: Option<PathBuf>,
|
||||||
|
pub workdir: PathBuf,
|
||||||
|
pub branch: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub env: BTreeMap<String, String>,
|
pub env: BTreeMap<String, String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub local: Option<Commands>,
|
pub local: Option<Commands>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub local_after: Option<Commands>,
|
||||||
|
#[serde(default, deserialize_with = "de_sync")]
|
||||||
pub sync: Vec<SyncRule>,
|
pub sync: Vec<SyncRule>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub remote: Option<Commands>,
|
pub remote: Option<Commands>,
|
||||||
@@ -56,9 +58,40 @@ pub struct Commands {
|
|||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct SyncRule {
|
pub struct SyncRule {
|
||||||
pub source: PathBuf,
|
pub source: PathBuf,
|
||||||
|
#[serde(default)]
|
||||||
pub target: PathBuf,
|
pub target: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
enum SyncSpec {
|
||||||
|
Source(PathBuf),
|
||||||
|
Sources(Vec<PathBuf>),
|
||||||
|
Rule(SyncRule),
|
||||||
|
Rules(Vec<SyncRule>),
|
||||||
|
}
|
||||||
|
|
||||||
|
fn de_sync<'de, D>(deserializer: D) -> Result<Vec<SyncRule>, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::de::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
Ok(match SyncSpec::deserialize(deserializer)? {
|
||||||
|
SyncSpec::Source(source) => vec![SyncRule {
|
||||||
|
source,
|
||||||
|
target: PathBuf::new(),
|
||||||
|
}],
|
||||||
|
SyncSpec::Sources(sources) => sources
|
||||||
|
.into_iter()
|
||||||
|
.map(|source| SyncRule {
|
||||||
|
source,
|
||||||
|
target: PathBuf::new(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
SyncSpec::Rule(rule) => vec![rule],
|
||||||
|
SyncSpec::Rules(rules) => rules,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
pub fn validate(mut self) -> Result<Self> {
|
pub fn validate(mut self) -> Result<Self> {
|
||||||
if self.projects.is_empty() {
|
if self.projects.is_empty() {
|
||||||
@@ -105,6 +138,7 @@ impl Config {
|
|||||||
}
|
}
|
||||||
for (phase, cmds) in [
|
for (phase, cmds) in [
|
||||||
("local", &mut project.local),
|
("local", &mut project.local),
|
||||||
|
("local_after", &mut project.local_after),
|
||||||
("remote", &mut project.remote),
|
("remote", &mut project.remote),
|
||||||
] {
|
] {
|
||||||
if let Some(c) = cmds
|
if let Some(c) = cmds
|
||||||
@@ -119,6 +153,11 @@ impl Config {
|
|||||||
*kp = expand_tilde(kp)
|
*kp = expand_tilde(kp)
|
||||||
.with_context(|| format!("project '{name}': invalid key_path"))?;
|
.with_context(|| format!("project '{name}': invalid key_path"))?;
|
||||||
}
|
}
|
||||||
|
for rule in &mut project.sync {
|
||||||
|
if rule.target.as_os_str().is_empty() {
|
||||||
|
rule.target = PathBuf::from(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
for rule in &mut project.sync {
|
for rule in &mut project.sync {
|
||||||
rule.source = expand_tilde(&rule.source)
|
rule.source = expand_tilde(&rule.source)
|
||||||
.with_context(|| format!("project '{name}': invalid sync source"))?;
|
.with_context(|| format!("project '{name}': invalid sync source"))?;
|
||||||
@@ -184,11 +223,17 @@ pub fn display(path: &Path) -> String {
|
|||||||
format!("~/{}", rest.to_string_lossy().replace('\\', "/"))
|
format!("~/{}", rest.to_string_lossy().replace('\\', "/"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn global_config_path(home: Option<&Path>) -> Option<PathBuf> {
|
||||||
|
Some(
|
||||||
|
home?
|
||||||
|
.join(".config")
|
||||||
|
.join(GLOBAL_DIR)
|
||||||
|
.join(CONFIG_FILE_NAME),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn global_path(home: Option<&Path>) -> Option<PathBuf> {
|
pub fn global_path(home: Option<&Path>) -> Option<PathBuf> {
|
||||||
let path = home?
|
let path = global_config_path(home)?;
|
||||||
.join(".config")
|
|
||||||
.join(GLOBAL_DIR)
|
|
||||||
.join(CONFIG_FILE_NAME);
|
|
||||||
path.is_file().then_some(path)
|
path.is_file().then_some(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,18 +288,12 @@ mod tests {
|
|||||||
|
|
||||||
const VALID: &str = r#"
|
const VALID: &str = r#"
|
||||||
[projects.web]
|
[projects.web]
|
||||||
workdir = "~/code/web"
|
|
||||||
host = "vps"
|
host = "vps"
|
||||||
|
workdir = "~/code/web"
|
||||||
[projects.web.local]
|
env.PUBLIC_BASE_PATH = "/web"
|
||||||
commands = ["npm run build"]
|
local.commands = ["npm run build"]
|
||||||
|
sync = [{ source = "dist", target = "/var/www/web" }]
|
||||||
[[projects.web.sync]]
|
remote.commands = ["pm2 restart web"]
|
||||||
source = "dist"
|
|
||||||
target = "/var/www/web"
|
|
||||||
|
|
||||||
[projects.web.remote]
|
|
||||||
commands = ["pm2 restart web"]
|
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -342,7 +381,7 @@ hostt = "typo"
|
|||||||
host = "10.0.0.9"
|
host = "10.0.0.9"
|
||||||
port = 2327
|
port = 2327
|
||||||
user = "deploy"
|
user = "deploy"
|
||||||
key_path = "~/.ssh/xbp_key"
|
key_path = "~/.ssh/xd_key"
|
||||||
|
|
||||||
[projects.web]
|
[projects.web]
|
||||||
workdir = "."
|
workdir = "."
|
||||||
@@ -362,7 +401,7 @@ port = 2222
|
|||||||
assert_eq!(web.port, Some(2327));
|
assert_eq!(web.port, Some(2327));
|
||||||
assert_eq!(web.user.as_deref(), Some("deploy"));
|
assert_eq!(web.user.as_deref(), Some("deploy"));
|
||||||
let key = web.key_path.as_ref().unwrap();
|
let key = web.key_path.as_ref().unwrap();
|
||||||
assert!(key.ends_with("xbp_key"), "{}", key.display());
|
assert!(key.ends_with("xd_key"), "{}", key.display());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -402,16 +441,12 @@ host = "10.0.0.9"
|
|||||||
base_dir = "/var/www/pages/"
|
base_dir = "/var/www/pages/"
|
||||||
|
|
||||||
[projects.web]
|
[projects.web]
|
||||||
workdir = "."
|
|
||||||
server = "main"
|
server = "main"
|
||||||
|
workdir = "."
|
||||||
[[projects.web.sync]]
|
sync = [
|
||||||
source = "dist"
|
{ source = "dist", target = "web-app" },
|
||||||
target = "web-app"
|
{ source = "dist/favicon.ico", target = "/opt/static/favicon.ico" },
|
||||||
|
]
|
||||||
[[projects.web.sync]]
|
|
||||||
source = "dist/favicon.ico"
|
|
||||||
target = "/opt/static/favicon.ico"
|
|
||||||
"#;
|
"#;
|
||||||
let cfg = parse(s).unwrap();
|
let cfg = parse(s).unwrap();
|
||||||
let sync = &cfg.projects.get("web").unwrap().sync;
|
let sync = &cfg.projects.get("web").unwrap().sync;
|
||||||
@@ -425,10 +460,7 @@ target = "/opt/static/favicon.ico"
|
|||||||
[projects.web]
|
[projects.web]
|
||||||
workdir = "."
|
workdir = "."
|
||||||
host = "10.0.0.9"
|
host = "10.0.0.9"
|
||||||
|
sync = [{ source = "dist", target = "web-app" }]
|
||||||
[[projects.web.sync]]
|
|
||||||
source = "dist"
|
|
||||||
target = "web-app"
|
|
||||||
"#;
|
"#;
|
||||||
let err = parse(s).unwrap_err();
|
let err = parse(s).unwrap_err();
|
||||||
assert!(err.to_string().contains("base_dir"), "{err:#}");
|
assert!(err.to_string().contains("base_dir"), "{err:#}");
|
||||||
@@ -440,12 +472,93 @@ target = "web-app"
|
|||||||
[projects.web]
|
[projects.web]
|
||||||
workdir = "."
|
workdir = "."
|
||||||
host = "vps"
|
host = "vps"
|
||||||
[projects.web.remote]
|
remote.commands = []
|
||||||
commands = []
|
|
||||||
"#;
|
"#;
|
||||||
assert!(parse(s).is_err());
|
assert!(parse(s).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn short_sync_uses_project_name_as_target() {
|
||||||
|
let s = r#"
|
||||||
|
[servers.main]
|
||||||
|
host = "10.0.0.9"
|
||||||
|
base_dir = "/var/www/pages"
|
||||||
|
|
||||||
|
[projects.web]
|
||||||
|
server = "main"
|
||||||
|
workdir = "."
|
||||||
|
sync = "dist"
|
||||||
|
"#;
|
||||||
|
let cfg = parse(s).unwrap();
|
||||||
|
let rule = &cfg.projects.get("web").unwrap().sync[0];
|
||||||
|
assert_eq!(rule.source.to_string_lossy(), "dist");
|
||||||
|
assert_eq!(rule.target.to_string_lossy(), "/var/www/pages/web");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_source_list_repeats_project_name_as_target() {
|
||||||
|
let s = r#"
|
||||||
|
[servers.main]
|
||||||
|
host = "10.0.0.9"
|
||||||
|
base_dir = "/var/www/pages"
|
||||||
|
|
||||||
|
[projects.web]
|
||||||
|
server = "main"
|
||||||
|
workdir = "."
|
||||||
|
sync = ["dist", "static"]
|
||||||
|
"#;
|
||||||
|
let cfg = parse(s).unwrap();
|
||||||
|
let sync = &cfg.projects.get("web").unwrap().sync;
|
||||||
|
assert_eq!(sync.len(), 2);
|
||||||
|
for rule in sync {
|
||||||
|
assert_eq!(rule.target.to_string_lossy(), "/var/www/pages/web");
|
||||||
|
}
|
||||||
|
assert_eq!(sync[0].source.to_string_lossy(), "dist");
|
||||||
|
assert_eq!(sync[1].source.to_string_lossy(), "static");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_table_without_target_uses_project_name() {
|
||||||
|
let s = r#"
|
||||||
|
[servers.main]
|
||||||
|
host = "10.0.0.9"
|
||||||
|
base_dir = "/var/www/pages"
|
||||||
|
|
||||||
|
[projects.web]
|
||||||
|
server = "main"
|
||||||
|
workdir = "."
|
||||||
|
sync = [{ source = "web/build" }]
|
||||||
|
"#;
|
||||||
|
let cfg = parse(s).unwrap();
|
||||||
|
let rule = &cfg.projects.get("web").unwrap().sync[0];
|
||||||
|
assert_eq!(rule.source.to_string_lossy(), "web/build");
|
||||||
|
assert_eq!(rule.target.to_string_lossy(), "/var/www/pages/web");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn local_after_parses_and_validates() {
|
||||||
|
let s = r#"
|
||||||
|
[projects.web]
|
||||||
|
workdir = "."
|
||||||
|
host = "vps"
|
||||||
|
local_after.commands = ["del /q dist"]
|
||||||
|
"#;
|
||||||
|
let cfg = parse(s).unwrap();
|
||||||
|
let project = cfg.projects.get("web").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
project.local_after.as_ref().unwrap().commands,
|
||||||
|
vec!["del /q dist".to_string()]
|
||||||
|
);
|
||||||
|
|
||||||
|
let empty = r#"
|
||||||
|
[projects.web]
|
||||||
|
workdir = "."
|
||||||
|
host = "vps"
|
||||||
|
local_after.commands = []
|
||||||
|
"#;
|
||||||
|
assert!(parse(empty).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tilde_expands_to_home() {
|
fn tilde_expands_to_home() {
|
||||||
let home = home::home_dir().unwrap();
|
let home = home::home_dir().unwrap();
|
||||||
|
|||||||
+141
-10
@@ -21,19 +21,51 @@ pub fn deploy(name: &str, project: &Project, dry_run: bool) -> Result<()> {
|
|||||||
.remote
|
.remote
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map_or([].as_slice(), |c| c.commands.as_slice());
|
.map_or([].as_slice(), |c| c.commands.as_slice());
|
||||||
|
let local_after_cmds = project
|
||||||
|
.local_after
|
||||||
|
.as_ref()
|
||||||
|
.map_or([].as_slice(), |c| c.commands.as_slice());
|
||||||
|
|
||||||
if dry_run {
|
if dry_run {
|
||||||
print_plan(project, local_cmds, remote_cmds);
|
print_plan(project, local_cmds, remote_cmds, local_after_cmds);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
if local_cmds.is_empty() && project.sync.is_empty() && remote_cmds.is_empty() {
|
if local_cmds.is_empty()
|
||||||
println!("nothing to do: no local commands, sync rules or remote commands");
|
&& project.sync.is_empty()
|
||||||
|
&& remote_cmds.is_empty()
|
||||||
|
&& local_after_cmds.is_empty()
|
||||||
|
{
|
||||||
|
println!(
|
||||||
|
"nothing to do: no local commands, sync rules, remote commands or post-deploy commands"
|
||||||
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let total = Instant::now();
|
let total = Instant::now();
|
||||||
|
|
||||||
|
let primary = run_deploy_steps(project, local_cmds, remote_cmds);
|
||||||
|
let after = run_local_after(project, &primary);
|
||||||
|
|
||||||
|
match (primary.is_ok(), after.is_ok()) {
|
||||||
|
(true, true) => {
|
||||||
|
println!(
|
||||||
|
"{} (total {})",
|
||||||
|
"Deploy successful!".green().bold(),
|
||||||
|
fmt_dur(total.elapsed())
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
(false, _) => Err(primary.unwrap_err()),
|
||||||
|
(true, false) => Err(after.unwrap_err()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_deploy_steps(
|
||||||
|
project: &Project,
|
||||||
|
local_cmds: &[String],
|
||||||
|
remote_cmds: &[String],
|
||||||
|
) -> Result<()> {
|
||||||
for cmd in local_cmds {
|
for cmd in local_cmds {
|
||||||
let step = Instant::now();
|
let step = Instant::now();
|
||||||
if let Err(err) = local::execute(cmd, &project.workdir, &project.env) {
|
if let Err(err) = local::execute(cmd, &project.workdir, &project.env) {
|
||||||
@@ -90,11 +122,40 @@ pub fn deploy(name: &str, project: &Project, dry_run: bool) -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
println!(
|
Ok(())
|
||||||
"{} (total {})",
|
}
|
||||||
"Deploy successful!".green().bold(),
|
|
||||||
fmt_dur(total.elapsed())
|
fn run_local_after(project: &Project, primary: &Result<()>) -> Result<()> {
|
||||||
);
|
let Some(cmds) = &project.local_after else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let mut env = project.env.clone();
|
||||||
|
match primary {
|
||||||
|
Ok(()) => {
|
||||||
|
env.insert("XD_DEPLOY_RESULT".to_string(), "success".to_string());
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
env.insert("XD_DEPLOY_RESULT".to_string(), "failed".to_string());
|
||||||
|
env.insert("XD_ERROR".to_string(), format!("{err:#}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for cmd in &cmds.commands {
|
||||||
|
let step = Instant::now();
|
||||||
|
if let Err(err) = local::execute(cmd, &project.workdir, &env) {
|
||||||
|
fail_line(
|
||||||
|
"[LocalAfter]",
|
||||||
|
&format!("running: {cmd}..."),
|
||||||
|
err.to_string(),
|
||||||
|
);
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
success_line(
|
||||||
|
"[LocalAfter]",
|
||||||
|
&format!("running: {cmd}..."),
|
||||||
|
"Success!",
|
||||||
|
step.elapsed(),
|
||||||
|
);
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,7 +182,12 @@ fn verify_branch(name: &str, project: &Project, expected: &str) -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn print_plan(project: &Project, local_cmds: &[String], remote_cmds: &[String]) {
|
fn print_plan(
|
||||||
|
project: &Project,
|
||||||
|
local_cmds: &[String],
|
||||||
|
remote_cmds: &[String],
|
||||||
|
local_after_cmds: &[String],
|
||||||
|
) {
|
||||||
println!("dry-run plan:");
|
println!("dry-run plan:");
|
||||||
for cmd in local_cmds {
|
for cmd in local_cmds {
|
||||||
println!(" [Local] {cmd}");
|
println!(" [Local] {cmd}");
|
||||||
@@ -136,6 +202,9 @@ fn print_plan(project: &Project, local_cmds: &[String], remote_cmds: &[String])
|
|||||||
for cmd in remote_cmds {
|
for cmd in remote_cmds {
|
||||||
println!(" [Remote] {cmd}");
|
println!(" [Remote] {cmd}");
|
||||||
}
|
}
|
||||||
|
for cmd in local_after_cmds {
|
||||||
|
println!(" [LocalAfter] {cmd}");
|
||||||
|
}
|
||||||
println!("{}", "nothing executed".dimmed());
|
println!("{}", "nothing executed".dimmed());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,7 +254,7 @@ fn fmt_dur(d: Duration) -> String {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::config::Project;
|
use crate::config::{Commands, Project};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
@@ -238,11 +307,73 @@ mod tests {
|
|||||||
key_path: None,
|
key_path: None,
|
||||||
env: Default::default(),
|
env: Default::default(),
|
||||||
local: None,
|
local: None,
|
||||||
|
local_after: None,
|
||||||
sync: Vec::new(),
|
sync: Vec::new(),
|
||||||
remote: None,
|
remote: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn result_echo_cmd(out: &Path) -> String {
|
||||||
|
if cfg!(windows) {
|
||||||
|
format!("echo %XD_DEPLOY_RESULT%> {}", out.display())
|
||||||
|
} else {
|
||||||
|
format!("echo $XD_DEPLOY_RESULT > {}", out.display())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fail_cmd() -> String {
|
||||||
|
if cfg!(windows) {
|
||||||
|
"exit /b 3".to_string()
|
||||||
|
} else {
|
||||||
|
"false".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn local_after_runs_on_success_with_result() {
|
||||||
|
let dir = tmpdir("afterok");
|
||||||
|
let out = dir.join("result.txt");
|
||||||
|
let mut project = project_with_branch(dir.clone(), None);
|
||||||
|
project.local_after = Some(Commands {
|
||||||
|
commands: vec![result_echo_cmd(&out)],
|
||||||
|
});
|
||||||
|
deploy("p", &project, false).unwrap();
|
||||||
|
let content = fs::read_to_string(&out).unwrap();
|
||||||
|
assert_eq!(content.trim(), "success");
|
||||||
|
let _ = fs::remove_dir_all(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn local_after_runs_on_failure_with_result() {
|
||||||
|
let dir = tmpdir("afterfail");
|
||||||
|
let out = dir.join("result.txt");
|
||||||
|
let mut project = project_with_branch(dir.clone(), None);
|
||||||
|
project.local = Some(Commands {
|
||||||
|
commands: vec![fail_cmd()],
|
||||||
|
});
|
||||||
|
project.local_after = Some(Commands {
|
||||||
|
commands: vec![result_echo_cmd(&out)],
|
||||||
|
});
|
||||||
|
let err = deploy("p", &project, false).unwrap_err();
|
||||||
|
assert!(err.to_string().contains("FAILED"), "{err:#}");
|
||||||
|
let content = fs::read_to_string(&out).unwrap();
|
||||||
|
assert_eq!(content.trim(), "failed");
|
||||||
|
let _ = fs::remove_dir_all(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dry_run_lists_local_after_but_does_not_execute() {
|
||||||
|
let dir = tmpdir("afterdry");
|
||||||
|
let out = dir.join("result.txt");
|
||||||
|
let mut project = project_with_branch(dir.clone(), None);
|
||||||
|
project.local_after = Some(Commands {
|
||||||
|
commands: vec![result_echo_cmd(&out)],
|
||||||
|
});
|
||||||
|
deploy("p", &project, true).unwrap();
|
||||||
|
assert!(!out.exists());
|
||||||
|
let _ = fs::remove_dir_all(dir);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn no_branch_deploys_without_check() {
|
fn no_branch_deploys_without_check() {
|
||||||
let dir = tmpdir("nobranch");
|
let dir = tmpdir("nobranch");
|
||||||
|
|||||||
+7
-7
@@ -212,11 +212,11 @@ mod tests {
|
|||||||
fn env_vars_visible_inside_local_commands() {
|
fn env_vars_visible_inside_local_commands() {
|
||||||
let workdir = tmpdir("dotenv-exec");
|
let workdir = tmpdir("dotenv-exec");
|
||||||
let echo_var = if cfg!(windows) {
|
let echo_var = if cfg!(windows) {
|
||||||
"echo %XBP_TEST_VAR%"
|
"echo %XD_TEST_VAR%"
|
||||||
} else {
|
} else {
|
||||||
"echo $XBP_TEST_VAR"
|
"echo $XD_TEST_VAR"
|
||||||
};
|
};
|
||||||
fs::write(workdir.join(".env"), "XBP_TEST_VAR=hello-env\n").unwrap();
|
fs::write(workdir.join(".env"), "XD_TEST_VAR=hello-env\n").unwrap();
|
||||||
let mut command = shell_command(echo_var);
|
let mut command = shell_command(echo_var);
|
||||||
command.current_dir(&workdir);
|
command.current_dir(&workdir);
|
||||||
for (k, v) in load_dotenv(&workdir).unwrap() {
|
for (k, v) in load_dotenv(&workdir).unwrap() {
|
||||||
@@ -233,13 +233,13 @@ mod tests {
|
|||||||
fn config_env_overrides_dotenv() {
|
fn config_env_overrides_dotenv() {
|
||||||
let workdir = tmpdir("dotenv-precedence");
|
let workdir = tmpdir("dotenv-precedence");
|
||||||
let echo_var = if cfg!(windows) {
|
let echo_var = if cfg!(windows) {
|
||||||
"echo %XBP_P_VAR%"
|
"echo %XD_P_VAR%"
|
||||||
} else {
|
} else {
|
||||||
"echo $XBP_P_VAR"
|
"echo $XD_P_VAR"
|
||||||
};
|
};
|
||||||
fs::write(workdir.join(".env"), "XBP_P_VAR=from-dotenv\n").unwrap();
|
fs::write(workdir.join(".env"), "XD_P_VAR=from-dotenv\n").unwrap();
|
||||||
let mut extra = BTreeMap::new();
|
let mut extra = BTreeMap::new();
|
||||||
extra.insert("XBP_P_VAR".to_string(), "from-config".to_string());
|
extra.insert("XD_P_VAR".to_string(), "from-config".to_string());
|
||||||
let mut command = shell_command(echo_var);
|
let mut command = shell_command(echo_var);
|
||||||
command.current_dir(&workdir);
|
command.current_dir(&workdir);
|
||||||
for (k, v) in load_dotenv(&workdir).unwrap() {
|
for (k, v) in load_dotenv(&workdir).unwrap() {
|
||||||
|
|||||||
+63
-12
@@ -6,7 +6,7 @@ mod local;
|
|||||||
mod ssh;
|
mod ssh;
|
||||||
mod sync;
|
mod sync;
|
||||||
|
|
||||||
use std::{path::Path, process::ExitCode};
|
use std::{fs, path::Path, process::ExitCode};
|
||||||
|
|
||||||
use anyhow::{Context, Result, bail};
|
use anyhow::{Context, Result, bail};
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
@@ -42,20 +42,70 @@ fn handle_deploy(cli: &Cli, args: &DeployArgs, cfg: &config::Config) -> Result<(
|
|||||||
|
|
||||||
let project = &cfg.projects[name];
|
let project = &cfg.projects[name];
|
||||||
|
|
||||||
deploy(&cli, &name, project)
|
deploy(cli, name, project)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_list(path: &Path, cfg: &config::Config) -> Result<()> {
|
fn handle_list(path: &Path, cfg: &config::Config) -> Result<()> {
|
||||||
println!("Config: {}", config::display(&path));
|
println!("Config: {}", config::display(path));
|
||||||
println!("Configured projects:");
|
println!("Configured projects:");
|
||||||
for name in cfg.projects.keys() {
|
for name in cfg.projects.keys() {
|
||||||
println!(" - {name}");
|
println!(" - {name}");
|
||||||
}
|
}
|
||||||
return Ok(());
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_edit_config() -> Result<()> {
|
||||||
|
let path = config::global_config_path(home::home_dir().as_deref())
|
||||||
|
.context("cannot determine home directory")?;
|
||||||
|
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)
|
||||||
|
.with_context(|| format!("cannot create {}", parent.display()))?;
|
||||||
|
}
|
||||||
|
if !path.exists() {
|
||||||
|
fs::write(&path, "").with_context(|| format!("cannot create {}", path.display()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let editor = std::env::var("EDITOR")
|
||||||
|
.or_else(|_| std::env::var("VISUAL"))
|
||||||
|
.unwrap_or_else(|_| {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
"notepad".to_string()
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
{
|
||||||
|
"vi".to_string()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let cmd = format!("{editor} {}", path.display());
|
||||||
|
let mut process = {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
let mut c = std::process::Command::new("cmd");
|
||||||
|
c.arg("/C").arg(&cmd);
|
||||||
|
c
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
{
|
||||||
|
let mut c = std::process::Command::new("sh");
|
||||||
|
c.arg("-c").arg(&cmd);
|
||||||
|
c
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let status = process
|
||||||
|
.status()
|
||||||
|
.with_context(|| format!("failed to launch editor: {editor}"))?;
|
||||||
|
|
||||||
|
if !status.success() {
|
||||||
|
std::process::exit(status.code().unwrap_or(1));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_list_servers(path: &Path, cfg: &config::Config) -> Result<()> {
|
fn handle_list_servers(path: &Path, cfg: &config::Config) -> Result<()> {
|
||||||
println!("config: {}", config::display(&path));
|
println!("config: {}", config::display(path));
|
||||||
println!("configured servers:");
|
println!("configured servers:");
|
||||||
for (name, server) in &cfg.servers {
|
for (name, server) in &cfg.servers {
|
||||||
let mut line = format!(" - {name} {}", server.host);
|
let mut line = format!(" - {name} {}", server.host);
|
||||||
@@ -67,11 +117,11 @@ fn handle_list_servers(path: &Path, cfg: &config::Config) -> Result<()> {
|
|||||||
}
|
}
|
||||||
println!("{line}");
|
println!("{line}");
|
||||||
}
|
}
|
||||||
return Ok(());
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_pick(cli: &Cli, path: &Path, cfg: &config::Config) -> Result<()> {
|
fn handle_pick(cli: &Cli, path: &Path, cfg: &config::Config) -> Result<()> {
|
||||||
let name = match pick_project(&cfg)? {
|
let name = match pick_project(cfg)? {
|
||||||
Some(name) => name,
|
Some(name) => name,
|
||||||
None => return Ok(()),
|
None => return Ok(()),
|
||||||
};
|
};
|
||||||
@@ -81,7 +131,7 @@ fn handle_pick(cli: &Cli, path: &Path, cfg: &config::Config) -> Result<()> {
|
|||||||
.get(&name)
|
.get(&name)
|
||||||
.with_context(|| format!("project '{name}' not found in {}", path.display()))?;
|
.with_context(|| format!("project '{name}' not found in {}", path.display()))?;
|
||||||
|
|
||||||
deploy(&cli, &name, project)
|
deploy(cli, &name, project)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_default(path: &Path, cfg: &config::Config) -> Result<()> {
|
fn handle_default(path: &Path, cfg: &config::Config) -> Result<()> {
|
||||||
@@ -100,7 +150,7 @@ fn handle_default(path: &Path, cfg: &config::Config) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn deploy(cli: &Cli, name: &str, project: &Project) -> Result<()> {
|
fn deploy(cli: &Cli, name: &str, project: &Project) -> Result<()> {
|
||||||
deploy::deploy(&name, project, cli.dry_run)
|
deploy::deploy(name, project, cli.dry_run)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run(cli: &Cli) -> Result<()> {
|
fn run(cli: &Cli) -> Result<()> {
|
||||||
@@ -108,10 +158,11 @@ fn run(cli: &Cli) -> Result<()> {
|
|||||||
let cfg = config::load(&path)?;
|
let cfg = config::load(&path)?;
|
||||||
|
|
||||||
match &cli.command {
|
match &cli.command {
|
||||||
Some(Commands::Deploy(args)) => handle_deploy(&cli, args, &cfg),
|
Some(Commands::Deploy(args)) => handle_deploy(cli, args, &cfg),
|
||||||
Some(Commands::Pick) => handle_pick(&cli, &path, &cfg),
|
Some(Commands::Pick) => handle_pick(cli, &path, &cfg),
|
||||||
Some(Commands::List) => handle_list(&path, &cfg),
|
Some(Commands::List) => handle_list(&path, &cfg),
|
||||||
Some(Commands::ListServers) => handle_list_servers(&path, &cfg),
|
Some(Commands::ListServers) => handle_list_servers(&path, &cfg),
|
||||||
|
Some(Commands::EditConfig) => handle_edit_config(),
|
||||||
None => handle_default(&path, &cfg),
|
None => handle_default(&path, &cfg),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -141,7 +192,7 @@ fn pick_project(cfg: &config::Config) -> Result<Option<String>> {
|
|||||||
|
|
||||||
fn get_project_name_by_cwd(cfg: &Config) -> Result<&str> {
|
fn get_project_name_by_cwd(cfg: &Config) -> Result<&str> {
|
||||||
let cwd = std::env::current_dir().context("cannot determine current directory")?;
|
let cwd = std::env::current_dir().context("cannot determine current directory")?;
|
||||||
match config::find_by_cwd(&cfg, &cwd) {
|
match config::find_by_cwd(cfg, &cwd) {
|
||||||
Some(name) => Ok(name),
|
Some(name) => Ok(name),
|
||||||
None => bail!("no project found in {}", cwd.display()),
|
None => bail!("no project found in {}", cwd.display()),
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user