├── .dockerignore ├── .github ├── FUNDING.yml └── workflows │ └── build-image.yml ├── .gitignore ├── .rustfmt.toml ├── Cargo.lock ├── Cargo.toml ├── Dockerfile ├── LICENSE.md ├── Makefile ├── README.md ├── bootstrap.sh ├── compose.yml ├── docker-entrypoint.sh └── src ├── deploy_file.rs ├── main.rs └── secrets.rs /.dockerignore: -------------------------------------------------------------------------------- 1 | .* 2 | target 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: wez 2 | patreon: WezFurlong 3 | ko_fi: wezfurlong 4 | liberapay: wez 5 | -------------------------------------------------------------------------------- /.github/workflows/build-image.yml: -------------------------------------------------------------------------------- 1 | name: Build Image 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - "**/*.rs" 9 | - "Cargo.toml" 10 | - ".github/workflows/build-image.yml" 11 | - "Dockerfile" 12 | - "compose.yml" 13 | workflow_dispatch: 14 | 15 | jobs: 16 | build-image: 17 | runs-on: ubuntu-latest 18 | strategy: 19 | fail-fast: false 20 | permissions: 21 | contents: read 22 | packages: write 23 | steps: 24 | - uses: actions/checkout@v4 25 | - name: Set up Docker Buildx 26 | uses: docker/setup-buildx-action@v3 27 | - name: Log in to the Container registry 28 | if: ${{ github.event_name != 'pull_request' }} 29 | uses: docker/login-action@v3 30 | with: 31 | registry: https://ghcr.io 32 | username: ${{ github.repository_owner }} 33 | password: ${{ secrets.GITHUB_TOKEN }} 34 | - name: Build Image 35 | uses: docker/build-push-action@v6 36 | with: 37 | tags: ghcr.io/wez/docker-stack-deploy:latest 38 | push: ${{ github.event_name != 'pull_request' }} 39 | file: Dockerfile 40 | cache-from: type=gha 41 | cache-to: type=gha,mode=max 42 | 43 | 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /.rustfmt.toml: -------------------------------------------------------------------------------- 1 | # Please keep these in alphabetical order. 2 | # https://github.com/rust-lang/rustfmt/issues/3149 3 | edition = "2021" 4 | imports_granularity = "Module" 5 | tab_spaces = 4 6 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "adler2" 7 | version = "2.0.0" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" 10 | 11 | [[package]] 12 | name = "aes" 13 | version = "0.8.4" 14 | source = "registry+https://github.com/rust-lang/crates.io-index" 15 | checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" 16 | dependencies = [ 17 | "cfg-if", 18 | "cipher", 19 | "cpufeatures", 20 | ] 21 | 22 | [[package]] 23 | name = "aho-corasick" 24 | version = "1.1.3" 25 | source = "registry+https://github.com/rust-lang/crates.io-index" 26 | checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" 27 | dependencies = [ 28 | "memchr", 29 | ] 30 | 31 | [[package]] 32 | name = "android-tzdata" 33 | version = "0.1.1" 34 | source = "registry+https://github.com/rust-lang/crates.io-index" 35 | checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" 36 | 37 | [[package]] 38 | name = "android_system_properties" 39 | version = "0.1.5" 40 | source = "registry+https://github.com/rust-lang/crates.io-index" 41 | checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" 42 | dependencies = [ 43 | "libc", 44 | ] 45 | 46 | [[package]] 47 | name = "anstream" 48 | version = "0.6.15" 49 | source = "registry+https://github.com/rust-lang/crates.io-index" 50 | checksum = "64e15c1ab1f89faffbf04a634d5e1962e9074f2741eef6d97f3c4e322426d526" 51 | dependencies = [ 52 | "anstyle", 53 | "anstyle-parse", 54 | "anstyle-query", 55 | "anstyle-wincon", 56 | "colorchoice", 57 | "is_terminal_polyfill", 58 | "utf8parse", 59 | ] 60 | 61 | [[package]] 62 | name = "anstyle" 63 | version = "1.0.8" 64 | source = "registry+https://github.com/rust-lang/crates.io-index" 65 | checksum = "1bec1de6f59aedf83baf9ff929c98f2ad654b97c9510f4e70cf6f661d49fd5b1" 66 | 67 | [[package]] 68 | name = "anstyle-parse" 69 | version = "0.2.5" 70 | source = "registry+https://github.com/rust-lang/crates.io-index" 71 | checksum = "eb47de1e80c2b463c735db5b217a0ddc39d612e7ac9e2e96a5aed1f57616c1cb" 72 | dependencies = [ 73 | "utf8parse", 74 | ] 75 | 76 | [[package]] 77 | name = "anstyle-query" 78 | version = "1.1.1" 79 | source = "registry+https://github.com/rust-lang/crates.io-index" 80 | checksum = "6d36fc52c7f6c869915e99412912f22093507da8d9e942ceaf66fe4b7c14422a" 81 | dependencies = [ 82 | "windows-sys 0.52.0", 83 | ] 84 | 85 | [[package]] 86 | name = "anstyle-wincon" 87 | version = "3.0.4" 88 | source = "registry+https://github.com/rust-lang/crates.io-index" 89 | checksum = "5bf74e1b6e971609db8ca7a9ce79fd5768ab6ae46441c572e46cf596f59e57f8" 90 | dependencies = [ 91 | "anstyle", 92 | "windows-sys 0.52.0", 93 | ] 94 | 95 | [[package]] 96 | name = "anyhow" 97 | version = "1.0.89" 98 | source = "registry+https://github.com/rust-lang/crates.io-index" 99 | checksum = "86fdf8605db99b54d3cd748a44c6d04df638eb5dafb219b135d0149bd0db01f6" 100 | 101 | [[package]] 102 | name = "arrayref" 103 | version = "0.3.9" 104 | source = "registry+https://github.com/rust-lang/crates.io-index" 105 | checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" 106 | 107 | [[package]] 108 | name = "arrayvec" 109 | version = "0.7.6" 110 | source = "registry+https://github.com/rust-lang/crates.io-index" 111 | checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" 112 | 113 | [[package]] 114 | name = "autocfg" 115 | version = "1.4.0" 116 | source = "registry+https://github.com/rust-lang/crates.io-index" 117 | checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" 118 | 119 | [[package]] 120 | name = "base64" 121 | version = "0.21.7" 122 | source = "registry+https://github.com/rust-lang/crates.io-index" 123 | checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" 124 | 125 | [[package]] 126 | name = "base64" 127 | version = "0.22.1" 128 | source = "registry+https://github.com/rust-lang/crates.io-index" 129 | checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" 130 | 131 | [[package]] 132 | name = "bitflags" 133 | version = "2.6.0" 134 | source = "registry+https://github.com/rust-lang/crates.io-index" 135 | checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" 136 | 137 | [[package]] 138 | name = "blake2b_simd" 139 | version = "1.0.2" 140 | source = "registry+https://github.com/rust-lang/crates.io-index" 141 | checksum = "23285ad32269793932e830392f2fe2f83e26488fd3ec778883a93c8323735780" 142 | dependencies = [ 143 | "arrayref", 144 | "arrayvec", 145 | "constant_time_eq", 146 | ] 147 | 148 | [[package]] 149 | name = "block-buffer" 150 | version = "0.10.4" 151 | source = "registry+https://github.com/rust-lang/crates.io-index" 152 | checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" 153 | dependencies = [ 154 | "generic-array", 155 | ] 156 | 157 | [[package]] 158 | name = "block-modes" 159 | version = "0.9.1" 160 | source = "registry+https://github.com/rust-lang/crates.io-index" 161 | checksum = "9e2211b0817f061502a8dd9f11a37e879e79763e3c698d2418cf824d8cb2f21e" 162 | 163 | [[package]] 164 | name = "block-padding" 165 | version = "0.3.3" 166 | source = "registry+https://github.com/rust-lang/crates.io-index" 167 | checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" 168 | dependencies = [ 169 | "generic-array", 170 | ] 171 | 172 | [[package]] 173 | name = "bstr" 174 | version = "1.10.0" 175 | source = "registry+https://github.com/rust-lang/crates.io-index" 176 | checksum = "40723b8fb387abc38f4f4a37c09073622e41dd12327033091ef8950659e6dc0c" 177 | dependencies = [ 178 | "memchr", 179 | "regex-automata", 180 | "serde", 181 | ] 182 | 183 | [[package]] 184 | name = "bumpalo" 185 | version = "3.16.0" 186 | source = "registry+https://github.com/rust-lang/crates.io-index" 187 | checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" 188 | 189 | [[package]] 190 | name = "byteorder" 191 | version = "1.5.0" 192 | source = "registry+https://github.com/rust-lang/crates.io-index" 193 | checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" 194 | 195 | [[package]] 196 | name = "cbc" 197 | version = "0.1.2" 198 | source = "registry+https://github.com/rust-lang/crates.io-index" 199 | checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" 200 | dependencies = [ 201 | "cipher", 202 | ] 203 | 204 | [[package]] 205 | name = "cc" 206 | version = "1.1.28" 207 | source = "registry+https://github.com/rust-lang/crates.io-index" 208 | checksum = "2e80e3b6a3ab07840e1cae9b0666a63970dc28e8ed5ffbcdacbfc760c281bfc1" 209 | dependencies = [ 210 | "shlex", 211 | ] 212 | 213 | [[package]] 214 | name = "cfg-if" 215 | version = "1.0.0" 216 | source = "registry+https://github.com/rust-lang/crates.io-index" 217 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 218 | 219 | [[package]] 220 | name = "chacha20" 221 | version = "0.9.1" 222 | source = "registry+https://github.com/rust-lang/crates.io-index" 223 | checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" 224 | dependencies = [ 225 | "cfg-if", 226 | "cipher", 227 | "cpufeatures", 228 | ] 229 | 230 | [[package]] 231 | name = "chrono" 232 | version = "0.4.38" 233 | source = "registry+https://github.com/rust-lang/crates.io-index" 234 | checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" 235 | dependencies = [ 236 | "android-tzdata", 237 | "iana-time-zone", 238 | "num-traits", 239 | "serde", 240 | "windows-targets 0.52.6", 241 | ] 242 | 243 | [[package]] 244 | name = "cipher" 245 | version = "0.4.4" 246 | source = "registry+https://github.com/rust-lang/crates.io-index" 247 | checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" 248 | dependencies = [ 249 | "crypto-common", 250 | "inout", 251 | ] 252 | 253 | [[package]] 254 | name = "clap" 255 | version = "4.5.19" 256 | source = "registry+https://github.com/rust-lang/crates.io-index" 257 | checksum = "7be5744db7978a28d9df86a214130d106a89ce49644cbc4e3f0c22c3fba30615" 258 | dependencies = [ 259 | "clap_builder", 260 | "clap_derive", 261 | ] 262 | 263 | [[package]] 264 | name = "clap_builder" 265 | version = "4.5.19" 266 | source = "registry+https://github.com/rust-lang/crates.io-index" 267 | checksum = "a5fbc17d3ef8278f55b282b2a2e75ae6f6c7d4bb70ed3d0382375104bfafdb4b" 268 | dependencies = [ 269 | "anstream", 270 | "anstyle", 271 | "clap_lex", 272 | "strsim", 273 | ] 274 | 275 | [[package]] 276 | name = "clap_derive" 277 | version = "4.5.18" 278 | source = "registry+https://github.com/rust-lang/crates.io-index" 279 | checksum = "4ac6a0c7b1a9e9a5186361f67dfa1b88213572f427fb9ab038efb2bd8c582dab" 280 | dependencies = [ 281 | "heck", 282 | "proc-macro2", 283 | "quote", 284 | "syn", 285 | ] 286 | 287 | [[package]] 288 | name = "clap_lex" 289 | version = "0.7.2" 290 | source = "registry+https://github.com/rust-lang/crates.io-index" 291 | checksum = "1462739cb27611015575c0c11df5df7601141071f07518d56fcc1be504cbec97" 292 | 293 | [[package]] 294 | name = "colorchoice" 295 | version = "1.0.2" 296 | source = "registry+https://github.com/rust-lang/crates.io-index" 297 | checksum = "d3fd119d74b830634cea2a0f58bbd0d54540518a14397557951e79340abc28c0" 298 | 299 | [[package]] 300 | name = "constant_time_eq" 301 | version = "0.3.1" 302 | source = "registry+https://github.com/rust-lang/crates.io-index" 303 | checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" 304 | 305 | [[package]] 306 | name = "core-foundation-sys" 307 | version = "0.8.7" 308 | source = "registry+https://github.com/rust-lang/crates.io-index" 309 | checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" 310 | 311 | [[package]] 312 | name = "cpufeatures" 313 | version = "0.2.14" 314 | source = "registry+https://github.com/rust-lang/crates.io-index" 315 | checksum = "608697df725056feaccfa42cffdaeeec3fccc4ffc38358ecd19b243e716a78e0" 316 | dependencies = [ 317 | "libc", 318 | ] 319 | 320 | [[package]] 321 | name = "crc32fast" 322 | version = "1.4.2" 323 | source = "registry+https://github.com/rust-lang/crates.io-index" 324 | checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" 325 | dependencies = [ 326 | "cfg-if", 327 | ] 328 | 329 | [[package]] 330 | name = "crypto-common" 331 | version = "0.1.6" 332 | source = "registry+https://github.com/rust-lang/crates.io-index" 333 | checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" 334 | dependencies = [ 335 | "generic-array", 336 | "typenum", 337 | ] 338 | 339 | [[package]] 340 | name = "digest" 341 | version = "0.10.7" 342 | source = "registry+https://github.com/rust-lang/crates.io-index" 343 | checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" 344 | dependencies = [ 345 | "block-buffer", 346 | "crypto-common", 347 | "subtle", 348 | ] 349 | 350 | [[package]] 351 | name = "docker-stack-deploy" 352 | version = "0.1.0" 353 | dependencies = [ 354 | "anyhow", 355 | "clap", 356 | "env_logger", 357 | "filenamegen", 358 | "gethostname", 359 | "keepass", 360 | "log", 361 | "petgraph", 362 | "rpassword", 363 | "serde", 364 | "toml", 365 | ] 366 | 367 | [[package]] 368 | name = "env_filter" 369 | version = "0.1.2" 370 | source = "registry+https://github.com/rust-lang/crates.io-index" 371 | checksum = "4f2c92ceda6ceec50f43169f9ee8424fe2db276791afde7b2cd8bc084cb376ab" 372 | dependencies = [ 373 | "log", 374 | "regex", 375 | ] 376 | 377 | [[package]] 378 | name = "env_logger" 379 | version = "0.11.5" 380 | source = "registry+https://github.com/rust-lang/crates.io-index" 381 | checksum = "e13fa619b91fb2381732789fc5de83b45675e882f66623b7d8cb4f643017018d" 382 | dependencies = [ 383 | "anstream", 384 | "anstyle", 385 | "env_filter", 386 | "humantime", 387 | "log", 388 | ] 389 | 390 | [[package]] 391 | name = "equivalent" 392 | version = "1.0.1" 393 | source = "registry+https://github.com/rust-lang/crates.io-index" 394 | checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" 395 | 396 | [[package]] 397 | name = "errno" 398 | version = "0.3.9" 399 | source = "registry+https://github.com/rust-lang/crates.io-index" 400 | checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" 401 | dependencies = [ 402 | "libc", 403 | "windows-sys 0.52.0", 404 | ] 405 | 406 | [[package]] 407 | name = "filenamegen" 408 | version = "0.2.7" 409 | source = "registry+https://github.com/rust-lang/crates.io-index" 410 | checksum = "b57c1f17080e8d88a15dc3040f324d4ada892f5bc5f0dc605017f26c85dd0303" 411 | dependencies = [ 412 | "anyhow", 413 | "bstr", 414 | "regex", 415 | "walkdir", 416 | ] 417 | 418 | [[package]] 419 | name = "fixedbitset" 420 | version = "0.4.2" 421 | source = "registry+https://github.com/rust-lang/crates.io-index" 422 | checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" 423 | 424 | [[package]] 425 | name = "flate2" 426 | version = "1.0.34" 427 | source = "registry+https://github.com/rust-lang/crates.io-index" 428 | checksum = "a1b589b4dc103969ad3cf85c950899926ec64300a1a46d76c03a6072957036f0" 429 | dependencies = [ 430 | "crc32fast", 431 | "miniz_oxide", 432 | ] 433 | 434 | [[package]] 435 | name = "generic-array" 436 | version = "0.14.7" 437 | source = "registry+https://github.com/rust-lang/crates.io-index" 438 | checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" 439 | dependencies = [ 440 | "typenum", 441 | "version_check", 442 | ] 443 | 444 | [[package]] 445 | name = "gethostname" 446 | version = "0.5.0" 447 | source = "registry+https://github.com/rust-lang/crates.io-index" 448 | checksum = "dc3655aa6818d65bc620d6911f05aa7b6aeb596291e1e9f79e52df85583d1e30" 449 | dependencies = [ 450 | "rustix", 451 | "windows-targets 0.52.6", 452 | ] 453 | 454 | [[package]] 455 | name = "getrandom" 456 | version = "0.2.15" 457 | source = "registry+https://github.com/rust-lang/crates.io-index" 458 | checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" 459 | dependencies = [ 460 | "cfg-if", 461 | "libc", 462 | "wasi", 463 | ] 464 | 465 | [[package]] 466 | name = "hashbrown" 467 | version = "0.15.0" 468 | source = "registry+https://github.com/rust-lang/crates.io-index" 469 | checksum = "1e087f84d4f86bf4b218b927129862374b72199ae7d8657835f1e89000eea4fb" 470 | 471 | [[package]] 472 | name = "heck" 473 | version = "0.5.0" 474 | source = "registry+https://github.com/rust-lang/crates.io-index" 475 | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 476 | 477 | [[package]] 478 | name = "hex" 479 | version = "0.4.3" 480 | source = "registry+https://github.com/rust-lang/crates.io-index" 481 | checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" 482 | 483 | [[package]] 484 | name = "hex-literal" 485 | version = "0.4.1" 486 | source = "registry+https://github.com/rust-lang/crates.io-index" 487 | checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" 488 | 489 | [[package]] 490 | name = "hmac" 491 | version = "0.12.1" 492 | source = "registry+https://github.com/rust-lang/crates.io-index" 493 | checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" 494 | dependencies = [ 495 | "digest", 496 | ] 497 | 498 | [[package]] 499 | name = "humantime" 500 | version = "2.1.0" 501 | source = "registry+https://github.com/rust-lang/crates.io-index" 502 | checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" 503 | 504 | [[package]] 505 | name = "iana-time-zone" 506 | version = "0.1.61" 507 | source = "registry+https://github.com/rust-lang/crates.io-index" 508 | checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" 509 | dependencies = [ 510 | "android_system_properties", 511 | "core-foundation-sys", 512 | "iana-time-zone-haiku", 513 | "js-sys", 514 | "wasm-bindgen", 515 | "windows-core", 516 | ] 517 | 518 | [[package]] 519 | name = "iana-time-zone-haiku" 520 | version = "0.1.2" 521 | source = "registry+https://github.com/rust-lang/crates.io-index" 522 | checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" 523 | dependencies = [ 524 | "cc", 525 | ] 526 | 527 | [[package]] 528 | name = "indexmap" 529 | version = "2.6.0" 530 | source = "registry+https://github.com/rust-lang/crates.io-index" 531 | checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da" 532 | dependencies = [ 533 | "equivalent", 534 | "hashbrown", 535 | ] 536 | 537 | [[package]] 538 | name = "inout" 539 | version = "0.1.3" 540 | source = "registry+https://github.com/rust-lang/crates.io-index" 541 | checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5" 542 | dependencies = [ 543 | "block-padding", 544 | "generic-array", 545 | ] 546 | 547 | [[package]] 548 | name = "is_terminal_polyfill" 549 | version = "1.70.1" 550 | source = "registry+https://github.com/rust-lang/crates.io-index" 551 | checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" 552 | 553 | [[package]] 554 | name = "js-sys" 555 | version = "0.3.70" 556 | source = "registry+https://github.com/rust-lang/crates.io-index" 557 | checksum = "1868808506b929d7b0cfa8f75951347aa71bb21144b7791bae35d9bccfcfe37a" 558 | dependencies = [ 559 | "wasm-bindgen", 560 | ] 561 | 562 | [[package]] 563 | name = "keepass" 564 | version = "0.7.25" 565 | source = "registry+https://github.com/rust-lang/crates.io-index" 566 | checksum = "d5e66e6792ef565bd9be93dc5b4f688dcde717c3d64da2e1b55b5f05d66d97ba" 567 | dependencies = [ 568 | "aes", 569 | "base64 0.22.1", 570 | "block-modes", 571 | "byteorder", 572 | "cbc", 573 | "chacha20", 574 | "chrono", 575 | "cipher", 576 | "flate2", 577 | "getrandom", 578 | "hex", 579 | "hex-literal", 580 | "hmac", 581 | "rust-argon2", 582 | "salsa20", 583 | "secstr", 584 | "sha2", 585 | "thiserror", 586 | "twofish", 587 | "uuid", 588 | "xml-rs", 589 | "zeroize", 590 | ] 591 | 592 | [[package]] 593 | name = "libc" 594 | version = "0.2.159" 595 | source = "registry+https://github.com/rust-lang/crates.io-index" 596 | checksum = "561d97a539a36e26a9a5fad1ea11a3039a67714694aaa379433e580854bc3dc5" 597 | 598 | [[package]] 599 | name = "linux-raw-sys" 600 | version = "0.4.14" 601 | source = "registry+https://github.com/rust-lang/crates.io-index" 602 | checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" 603 | 604 | [[package]] 605 | name = "log" 606 | version = "0.4.22" 607 | source = "registry+https://github.com/rust-lang/crates.io-index" 608 | checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" 609 | 610 | [[package]] 611 | name = "memchr" 612 | version = "2.7.4" 613 | source = "registry+https://github.com/rust-lang/crates.io-index" 614 | checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" 615 | 616 | [[package]] 617 | name = "miniz_oxide" 618 | version = "0.8.0" 619 | source = "registry+https://github.com/rust-lang/crates.io-index" 620 | checksum = "e2d80299ef12ff69b16a84bb182e3b9df68b5a91574d3d4fa6e41b65deec4df1" 621 | dependencies = [ 622 | "adler2", 623 | ] 624 | 625 | [[package]] 626 | name = "num-traits" 627 | version = "0.2.19" 628 | source = "registry+https://github.com/rust-lang/crates.io-index" 629 | checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" 630 | dependencies = [ 631 | "autocfg", 632 | ] 633 | 634 | [[package]] 635 | name = "once_cell" 636 | version = "1.20.2" 637 | source = "registry+https://github.com/rust-lang/crates.io-index" 638 | checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" 639 | 640 | [[package]] 641 | name = "petgraph" 642 | version = "0.6.5" 643 | source = "registry+https://github.com/rust-lang/crates.io-index" 644 | checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" 645 | dependencies = [ 646 | "fixedbitset", 647 | "indexmap", 648 | ] 649 | 650 | [[package]] 651 | name = "proc-macro2" 652 | version = "1.0.86" 653 | source = "registry+https://github.com/rust-lang/crates.io-index" 654 | checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" 655 | dependencies = [ 656 | "unicode-ident", 657 | ] 658 | 659 | [[package]] 660 | name = "quote" 661 | version = "1.0.37" 662 | source = "registry+https://github.com/rust-lang/crates.io-index" 663 | checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" 664 | dependencies = [ 665 | "proc-macro2", 666 | ] 667 | 668 | [[package]] 669 | name = "regex" 670 | version = "1.11.0" 671 | source = "registry+https://github.com/rust-lang/crates.io-index" 672 | checksum = "38200e5ee88914975b69f657f0801b6f6dccafd44fd9326302a4aaeecfacb1d8" 673 | dependencies = [ 674 | "aho-corasick", 675 | "memchr", 676 | "regex-automata", 677 | "regex-syntax", 678 | ] 679 | 680 | [[package]] 681 | name = "regex-automata" 682 | version = "0.4.8" 683 | source = "registry+https://github.com/rust-lang/crates.io-index" 684 | checksum = "368758f23274712b504848e9d5a6f010445cc8b87a7cdb4d7cbee666c1288da3" 685 | dependencies = [ 686 | "aho-corasick", 687 | "memchr", 688 | "regex-syntax", 689 | ] 690 | 691 | [[package]] 692 | name = "regex-syntax" 693 | version = "0.8.5" 694 | source = "registry+https://github.com/rust-lang/crates.io-index" 695 | checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" 696 | 697 | [[package]] 698 | name = "rpassword" 699 | version = "7.3.1" 700 | source = "registry+https://github.com/rust-lang/crates.io-index" 701 | checksum = "80472be3c897911d0137b2d2b9055faf6eeac5b14e324073d83bc17b191d7e3f" 702 | dependencies = [ 703 | "libc", 704 | "rtoolbox", 705 | "windows-sys 0.48.0", 706 | ] 707 | 708 | [[package]] 709 | name = "rtoolbox" 710 | version = "0.0.2" 711 | source = "registry+https://github.com/rust-lang/crates.io-index" 712 | checksum = "c247d24e63230cdb56463ae328478bd5eac8b8faa8c69461a77e8e323afac90e" 713 | dependencies = [ 714 | "libc", 715 | "windows-sys 0.48.0", 716 | ] 717 | 718 | [[package]] 719 | name = "rust-argon2" 720 | version = "2.1.0" 721 | source = "registry+https://github.com/rust-lang/crates.io-index" 722 | checksum = "9d9848531d60c9cbbcf9d166c885316c24bc0e2a9d3eba0956bb6cbbd79bc6e8" 723 | dependencies = [ 724 | "base64 0.21.7", 725 | "blake2b_simd", 726 | "constant_time_eq", 727 | ] 728 | 729 | [[package]] 730 | name = "rustix" 731 | version = "0.38.37" 732 | source = "registry+https://github.com/rust-lang/crates.io-index" 733 | checksum = "8acb788b847c24f28525660c4d7758620a7210875711f79e7f663cc152726811" 734 | dependencies = [ 735 | "bitflags", 736 | "errno", 737 | "libc", 738 | "linux-raw-sys", 739 | "windows-sys 0.52.0", 740 | ] 741 | 742 | [[package]] 743 | name = "salsa20" 744 | version = "0.10.2" 745 | source = "registry+https://github.com/rust-lang/crates.io-index" 746 | checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" 747 | dependencies = [ 748 | "cipher", 749 | ] 750 | 751 | [[package]] 752 | name = "same-file" 753 | version = "1.0.6" 754 | source = "registry+https://github.com/rust-lang/crates.io-index" 755 | checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" 756 | dependencies = [ 757 | "winapi-util", 758 | ] 759 | 760 | [[package]] 761 | name = "secstr" 762 | version = "0.5.1" 763 | source = "registry+https://github.com/rust-lang/crates.io-index" 764 | checksum = "e04f657244f605c4cf38f6de5993e8bd050c8a303f86aeabff142d5c7c113e12" 765 | dependencies = [ 766 | "libc", 767 | ] 768 | 769 | [[package]] 770 | name = "serde" 771 | version = "1.0.210" 772 | source = "registry+https://github.com/rust-lang/crates.io-index" 773 | checksum = "c8e3592472072e6e22e0a54d5904d9febf8508f65fb8552499a1abc7d1078c3a" 774 | dependencies = [ 775 | "serde_derive", 776 | ] 777 | 778 | [[package]] 779 | name = "serde_derive" 780 | version = "1.0.210" 781 | source = "registry+https://github.com/rust-lang/crates.io-index" 782 | checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f" 783 | dependencies = [ 784 | "proc-macro2", 785 | "quote", 786 | "syn", 787 | ] 788 | 789 | [[package]] 790 | name = "serde_spanned" 791 | version = "0.6.8" 792 | source = "registry+https://github.com/rust-lang/crates.io-index" 793 | checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" 794 | dependencies = [ 795 | "serde", 796 | ] 797 | 798 | [[package]] 799 | name = "sha2" 800 | version = "0.10.8" 801 | source = "registry+https://github.com/rust-lang/crates.io-index" 802 | checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" 803 | dependencies = [ 804 | "cfg-if", 805 | "cpufeatures", 806 | "digest", 807 | ] 808 | 809 | [[package]] 810 | name = "shlex" 811 | version = "1.3.0" 812 | source = "registry+https://github.com/rust-lang/crates.io-index" 813 | checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 814 | 815 | [[package]] 816 | name = "strsim" 817 | version = "0.11.1" 818 | source = "registry+https://github.com/rust-lang/crates.io-index" 819 | checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 820 | 821 | [[package]] 822 | name = "subtle" 823 | version = "2.6.1" 824 | source = "registry+https://github.com/rust-lang/crates.io-index" 825 | checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" 826 | 827 | [[package]] 828 | name = "syn" 829 | version = "2.0.79" 830 | source = "registry+https://github.com/rust-lang/crates.io-index" 831 | checksum = "89132cd0bf050864e1d38dc3bbc07a0eb8e7530af26344d3d2bbbef83499f590" 832 | dependencies = [ 833 | "proc-macro2", 834 | "quote", 835 | "unicode-ident", 836 | ] 837 | 838 | [[package]] 839 | name = "thiserror" 840 | version = "1.0.64" 841 | source = "registry+https://github.com/rust-lang/crates.io-index" 842 | checksum = "d50af8abc119fb8bb6dbabcfa89656f46f84aa0ac7688088608076ad2b459a84" 843 | dependencies = [ 844 | "thiserror-impl", 845 | ] 846 | 847 | [[package]] 848 | name = "thiserror-impl" 849 | version = "1.0.64" 850 | source = "registry+https://github.com/rust-lang/crates.io-index" 851 | checksum = "08904e7672f5eb876eaaf87e0ce17857500934f4981c4a0ab2b4aa98baac7fc3" 852 | dependencies = [ 853 | "proc-macro2", 854 | "quote", 855 | "syn", 856 | ] 857 | 858 | [[package]] 859 | name = "toml" 860 | version = "0.8.19" 861 | source = "registry+https://github.com/rust-lang/crates.io-index" 862 | checksum = "a1ed1f98e3fdc28d6d910e6737ae6ab1a93bf1985935a1193e68f93eeb68d24e" 863 | dependencies = [ 864 | "serde", 865 | "serde_spanned", 866 | "toml_datetime", 867 | "toml_edit", 868 | ] 869 | 870 | [[package]] 871 | name = "toml_datetime" 872 | version = "0.6.8" 873 | source = "registry+https://github.com/rust-lang/crates.io-index" 874 | checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" 875 | dependencies = [ 876 | "serde", 877 | ] 878 | 879 | [[package]] 880 | name = "toml_edit" 881 | version = "0.22.22" 882 | source = "registry+https://github.com/rust-lang/crates.io-index" 883 | checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" 884 | dependencies = [ 885 | "indexmap", 886 | "serde", 887 | "serde_spanned", 888 | "toml_datetime", 889 | "winnow", 890 | ] 891 | 892 | [[package]] 893 | name = "twofish" 894 | version = "0.7.1" 895 | source = "registry+https://github.com/rust-lang/crates.io-index" 896 | checksum = "a78e83a30223c757c3947cd144a31014ff04298d8719ae10d03c31c0448c8013" 897 | dependencies = [ 898 | "cipher", 899 | ] 900 | 901 | [[package]] 902 | name = "typenum" 903 | version = "1.17.0" 904 | source = "registry+https://github.com/rust-lang/crates.io-index" 905 | checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" 906 | 907 | [[package]] 908 | name = "unicode-ident" 909 | version = "1.0.13" 910 | source = "registry+https://github.com/rust-lang/crates.io-index" 911 | checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" 912 | 913 | [[package]] 914 | name = "utf8parse" 915 | version = "0.2.2" 916 | source = "registry+https://github.com/rust-lang/crates.io-index" 917 | checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 918 | 919 | [[package]] 920 | name = "uuid" 921 | version = "1.10.0" 922 | source = "registry+https://github.com/rust-lang/crates.io-index" 923 | checksum = "81dfa00651efa65069b0b6b651f4aaa31ba9e3c3ce0137aaad053604ee7e0314" 924 | dependencies = [ 925 | "getrandom", 926 | "serde", 927 | ] 928 | 929 | [[package]] 930 | name = "version_check" 931 | version = "0.9.5" 932 | source = "registry+https://github.com/rust-lang/crates.io-index" 933 | checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" 934 | 935 | [[package]] 936 | name = "walkdir" 937 | version = "2.5.0" 938 | source = "registry+https://github.com/rust-lang/crates.io-index" 939 | checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" 940 | dependencies = [ 941 | "same-file", 942 | "winapi-util", 943 | ] 944 | 945 | [[package]] 946 | name = "wasi" 947 | version = "0.11.0+wasi-snapshot-preview1" 948 | source = "registry+https://github.com/rust-lang/crates.io-index" 949 | checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" 950 | 951 | [[package]] 952 | name = "wasm-bindgen" 953 | version = "0.2.93" 954 | source = "registry+https://github.com/rust-lang/crates.io-index" 955 | checksum = "a82edfc16a6c469f5f44dc7b571814045d60404b55a0ee849f9bcfa2e63dd9b5" 956 | dependencies = [ 957 | "cfg-if", 958 | "once_cell", 959 | "wasm-bindgen-macro", 960 | ] 961 | 962 | [[package]] 963 | name = "wasm-bindgen-backend" 964 | version = "0.2.93" 965 | source = "registry+https://github.com/rust-lang/crates.io-index" 966 | checksum = "9de396da306523044d3302746f1208fa71d7532227f15e347e2d93e4145dd77b" 967 | dependencies = [ 968 | "bumpalo", 969 | "log", 970 | "once_cell", 971 | "proc-macro2", 972 | "quote", 973 | "syn", 974 | "wasm-bindgen-shared", 975 | ] 976 | 977 | [[package]] 978 | name = "wasm-bindgen-macro" 979 | version = "0.2.93" 980 | source = "registry+https://github.com/rust-lang/crates.io-index" 981 | checksum = "585c4c91a46b072c92e908d99cb1dcdf95c5218eeb6f3bf1efa991ee7a68cccf" 982 | dependencies = [ 983 | "quote", 984 | "wasm-bindgen-macro-support", 985 | ] 986 | 987 | [[package]] 988 | name = "wasm-bindgen-macro-support" 989 | version = "0.2.93" 990 | source = "registry+https://github.com/rust-lang/crates.io-index" 991 | checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836" 992 | dependencies = [ 993 | "proc-macro2", 994 | "quote", 995 | "syn", 996 | "wasm-bindgen-backend", 997 | "wasm-bindgen-shared", 998 | ] 999 | 1000 | [[package]] 1001 | name = "wasm-bindgen-shared" 1002 | version = "0.2.93" 1003 | source = "registry+https://github.com/rust-lang/crates.io-index" 1004 | checksum = "c62a0a307cb4a311d3a07867860911ca130c3494e8c2719593806c08bc5d0484" 1005 | 1006 | [[package]] 1007 | name = "winapi-util" 1008 | version = "0.1.9" 1009 | source = "registry+https://github.com/rust-lang/crates.io-index" 1010 | checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" 1011 | dependencies = [ 1012 | "windows-sys 0.52.0", 1013 | ] 1014 | 1015 | [[package]] 1016 | name = "windows-core" 1017 | version = "0.52.0" 1018 | source = "registry+https://github.com/rust-lang/crates.io-index" 1019 | checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" 1020 | dependencies = [ 1021 | "windows-targets 0.52.6", 1022 | ] 1023 | 1024 | [[package]] 1025 | name = "windows-sys" 1026 | version = "0.48.0" 1027 | source = "registry+https://github.com/rust-lang/crates.io-index" 1028 | checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" 1029 | dependencies = [ 1030 | "windows-targets 0.48.5", 1031 | ] 1032 | 1033 | [[package]] 1034 | name = "windows-sys" 1035 | version = "0.52.0" 1036 | source = "registry+https://github.com/rust-lang/crates.io-index" 1037 | checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" 1038 | dependencies = [ 1039 | "windows-targets 0.52.6", 1040 | ] 1041 | 1042 | [[package]] 1043 | name = "windows-targets" 1044 | version = "0.48.5" 1045 | source = "registry+https://github.com/rust-lang/crates.io-index" 1046 | checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" 1047 | dependencies = [ 1048 | "windows_aarch64_gnullvm 0.48.5", 1049 | "windows_aarch64_msvc 0.48.5", 1050 | "windows_i686_gnu 0.48.5", 1051 | "windows_i686_msvc 0.48.5", 1052 | "windows_x86_64_gnu 0.48.5", 1053 | "windows_x86_64_gnullvm 0.48.5", 1054 | "windows_x86_64_msvc 0.48.5", 1055 | ] 1056 | 1057 | [[package]] 1058 | name = "windows-targets" 1059 | version = "0.52.6" 1060 | source = "registry+https://github.com/rust-lang/crates.io-index" 1061 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 1062 | dependencies = [ 1063 | "windows_aarch64_gnullvm 0.52.6", 1064 | "windows_aarch64_msvc 0.52.6", 1065 | "windows_i686_gnu 0.52.6", 1066 | "windows_i686_gnullvm", 1067 | "windows_i686_msvc 0.52.6", 1068 | "windows_x86_64_gnu 0.52.6", 1069 | "windows_x86_64_gnullvm 0.52.6", 1070 | "windows_x86_64_msvc 0.52.6", 1071 | ] 1072 | 1073 | [[package]] 1074 | name = "windows_aarch64_gnullvm" 1075 | version = "0.48.5" 1076 | source = "registry+https://github.com/rust-lang/crates.io-index" 1077 | checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" 1078 | 1079 | [[package]] 1080 | name = "windows_aarch64_gnullvm" 1081 | version = "0.52.6" 1082 | source = "registry+https://github.com/rust-lang/crates.io-index" 1083 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 1084 | 1085 | [[package]] 1086 | name = "windows_aarch64_msvc" 1087 | version = "0.48.5" 1088 | source = "registry+https://github.com/rust-lang/crates.io-index" 1089 | checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" 1090 | 1091 | [[package]] 1092 | name = "windows_aarch64_msvc" 1093 | version = "0.52.6" 1094 | source = "registry+https://github.com/rust-lang/crates.io-index" 1095 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 1096 | 1097 | [[package]] 1098 | name = "windows_i686_gnu" 1099 | version = "0.48.5" 1100 | source = "registry+https://github.com/rust-lang/crates.io-index" 1101 | checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" 1102 | 1103 | [[package]] 1104 | name = "windows_i686_gnu" 1105 | version = "0.52.6" 1106 | source = "registry+https://github.com/rust-lang/crates.io-index" 1107 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 1108 | 1109 | [[package]] 1110 | name = "windows_i686_gnullvm" 1111 | version = "0.52.6" 1112 | source = "registry+https://github.com/rust-lang/crates.io-index" 1113 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 1114 | 1115 | [[package]] 1116 | name = "windows_i686_msvc" 1117 | version = "0.48.5" 1118 | source = "registry+https://github.com/rust-lang/crates.io-index" 1119 | checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" 1120 | 1121 | [[package]] 1122 | name = "windows_i686_msvc" 1123 | version = "0.52.6" 1124 | source = "registry+https://github.com/rust-lang/crates.io-index" 1125 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 1126 | 1127 | [[package]] 1128 | name = "windows_x86_64_gnu" 1129 | version = "0.48.5" 1130 | source = "registry+https://github.com/rust-lang/crates.io-index" 1131 | checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" 1132 | 1133 | [[package]] 1134 | name = "windows_x86_64_gnu" 1135 | version = "0.52.6" 1136 | source = "registry+https://github.com/rust-lang/crates.io-index" 1137 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 1138 | 1139 | [[package]] 1140 | name = "windows_x86_64_gnullvm" 1141 | version = "0.48.5" 1142 | source = "registry+https://github.com/rust-lang/crates.io-index" 1143 | checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" 1144 | 1145 | [[package]] 1146 | name = "windows_x86_64_gnullvm" 1147 | version = "0.52.6" 1148 | source = "registry+https://github.com/rust-lang/crates.io-index" 1149 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 1150 | 1151 | [[package]] 1152 | name = "windows_x86_64_msvc" 1153 | version = "0.48.5" 1154 | source = "registry+https://github.com/rust-lang/crates.io-index" 1155 | checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" 1156 | 1157 | [[package]] 1158 | name = "windows_x86_64_msvc" 1159 | version = "0.52.6" 1160 | source = "registry+https://github.com/rust-lang/crates.io-index" 1161 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 1162 | 1163 | [[package]] 1164 | name = "winnow" 1165 | version = "0.6.20" 1166 | source = "registry+https://github.com/rust-lang/crates.io-index" 1167 | checksum = "36c1fec1a2bb5866f07c25f68c26e565c4c200aebb96d7e55710c19d3e8ac49b" 1168 | dependencies = [ 1169 | "memchr", 1170 | ] 1171 | 1172 | [[package]] 1173 | name = "xml-rs" 1174 | version = "0.8.22" 1175 | source = "registry+https://github.com/rust-lang/crates.io-index" 1176 | checksum = "af4e2e2f7cba5a093896c1e150fbfe177d1883e7448200efb81d40b9d339ef26" 1177 | 1178 | [[package]] 1179 | name = "zeroize" 1180 | version = "1.8.1" 1181 | source = "registry+https://github.com/rust-lang/crates.io-index" 1182 | checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" 1183 | dependencies = [ 1184 | "zeroize_derive", 1185 | ] 1186 | 1187 | [[package]] 1188 | name = "zeroize_derive" 1189 | version = "1.4.2" 1190 | source = "registry+https://github.com/rust-lang/crates.io-index" 1191 | checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" 1192 | dependencies = [ 1193 | "proc-macro2", 1194 | "quote", 1195 | "syn", 1196 | ] 1197 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "docker-stack-deploy" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | anyhow = "1" 8 | clap = { version = "4", features = ["derive"] } 9 | env_logger = "0.11" 10 | filenamegen = "0.2" 11 | gethostname = "0.5.0" 12 | keepass = "0.7" 13 | log = "0.4" 14 | petgraph = "0.6.5" 15 | rpassword = "7" 16 | serde = {version="1.0", features=["derive"]} 17 | toml = "0.8" 18 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM rust:latest AS rust 2 | 3 | WORKDIR /app 4 | COPY . . 5 | 6 | RUN apt update && apt install -y musl musl-tools 7 | RUN rustup target add x86_64-unknown-linux-musl 8 | RUN --mount=type=ssh \ 9 | --mount=type=cache,target=/root/.cargo/registry \ 10 | --mount=type=cache,target=/root/.cargo/git \ 11 | --mount=type=cache,target=/app/target \ 12 | cargo build --target x86_64-unknown-linux-musl --release && \ 13 | cp /app/target/x86_64-unknown-linux-musl/release/docker-stack-deploy /app/docker-stack-deploy 14 | 15 | FROM alpine:latest 16 | 17 | # Install essential dependencies, and remove cache and unnecessary files 18 | RUN apk --no-cache add \ 19 | git \ 20 | curl \ 21 | bash \ 22 | docker-cli \ 23 | docker-compose && \ 24 | rm -rf /var/cache/apk/* /tmp/* 25 | 26 | COPY --from=rust /app/docker-stack-deploy /usr/bin/docker-stack-deploy 27 | COPY docker-entrypoint.sh /entrypoint.sh 28 | 29 | STOPSIGNAL SIGINT 30 | CMD ["/bin/bash", "/entrypoint.sh"] 31 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024-Present Wez Furlong 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all fmt check test 2 | 3 | all: check 4 | 5 | test: 6 | cargo nextest run 7 | 8 | check: 9 | cargo check 10 | 11 | fmt: 12 | cargo +nightly fmt 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Docker-Stack-Deploy 2 | 3 | This repo is the home of a small but powerful utility that helps you to do 4 | gitops and maintain your docker deployments by checking in the corresponding 5 | configuration files into a private git repo and then running `git push` for 6 | them to take effect. 7 | 8 | ## Architecture 9 | 10 | There are a couple of pieces: 11 | 12 | * A private git repo that holds your infrastructure definition, 13 | essentially a set of directories with docker `compose.yml` files, 14 | and an encrypted keepass database file that holds any secrets 15 | that might be required by those containers. Each of these directories 16 | if referred to as a "stack". 17 | * One or more hosts on which you are running docker 18 | * The `docker-stack-deploy` container that runs persistently 19 | on each of those docker hosts 20 | 21 | ## Getting Started 22 | 23 | ### Infrastructure Repo 24 | 25 | If you don't already have a repo suitable for this purpose, create a new 26 | private one on GitHub. 27 | 28 | `docker-stack-deploy` is fairly un-opinionated about directory layout, but 29 | for the sake of getting started: 30 | 31 | * You will need to create a KeePass database to hold your encrypted secrets. 32 | On macOS you might want to look at [Strongbox](https://strongboxsafe.com), 33 | which can be used for free for this purpose, but you can also use 34 | [KeePassXC](https://keepassxc.org) for free on macOS or any other OS. 35 | 36 | * Clone your infra repo locally 37 | * Create a new vault/database in the root of your infra repo, and name 38 | it `.secrets.kdbx`. If you have the option to select the file format 39 | version, select `Keepass password database 2.x KDBX` in order to 40 | be compatible with the [rust keepass crate](https://docs.rs/keepass/latest/keepass/) 41 | * Use a passphrase, rather than a key file, when you configure this database. 42 | * You will need that passphrase when you edit the database later, and 43 | also to bootstrap a docker host. 44 | 45 | * You can now create a directory for each of your stack(s). They can be 46 | anywhere in the repo; they will be located based on the `stack-deploy.toml` 47 | file. I personally have some stacks deployed under `hosts/HOSTNAME/STACKNAME` 48 | and some under `services/STACKNAME`. You can pick whatever organization makes 49 | sense to you, but each individual stack must live in its own directory. 50 | 51 | * In the stack directory you will need at least two files: 52 | * `compose.yml` - the docker stack definition 53 | * `stack-deploy.toml` - the definition for stack-deploy 54 | 55 | #### Example Stack 56 | 57 | In your infra repo, create a `minecraft` directory, and populate it: 58 | 59 | Put this in `minecraft/compose.yml`: 60 | 61 | ```yml 62 | services: 63 | minecraft: 64 | image: itzg/minecraft-server 65 | ports: 66 | - "25565:25565" 67 | environment: 68 | EULA: "TRUE" 69 | deploy: 70 | resources: 71 | limits: 72 | memory: 1.5G 73 | volumes: 74 | - minecraft_data:/data 75 | 76 | volumes: 77 | minecraft_data: 78 | ``` 79 | 80 | > [!IMPORTANT] 81 | > Avoid using local directories for mutable state, as you want to avoid dirtying 82 | > your infrastructure repo checkout with files created by docker and potentially 83 | > cause permission problems and potentially causing conflicts with future changes 84 | > in your Git repo. I recommend using docker volumes, such as the `minecraft_data` 85 | > volume in the example above, to hold the mutable state. 86 | > Read-only mounts using config files in your repo are fine, and I used those 87 | > often. 88 | 89 | And this in `minecraft/stack-deploy.toml`: 90 | 91 | ```toml 92 | # The name of the stack. Used for dependency purposes 93 | name = "minecraft" 94 | 95 | # Lists the hosts on which this stack should run. 96 | # It should match the hostname of your docker host. 97 | runs_on = ["mydockerhostname"] 98 | ``` 99 | 100 | Add and commit that to your infra repo and push it to github. 101 | 102 | ## Bootstrapping 103 | 104 | You need to deploy `docker-stack-deploy` to your docker host. This is done 105 | via a one-time bootstrap procedure. 106 | 107 | You will need: 108 | 109 | * The infra repo URL 110 | * A PAT that grants access to read from the infra repo 111 | * The passphrase for your infra keepass secrets db 112 | * Access to the docker host 113 | 114 | First, login to the docker host, and run the bootstrap command. 115 | You must run this as a user that has privileges to talk to docker: 116 | 117 | ```console 118 | $ docker run --rm -it \ 119 | -v /var/run/docker.sock:/var/run/docker.sock \ 120 | -v /var/lib/docker-stack-deploy:/var/lib/docker-stack-deploy \ 121 | ghcr.io/wez/docker-stack-deploy \ 122 | docker-stack-deploy bootstrap \ 123 | --project-dir /var/lib/docker-stack-deploy \ 124 | --git-url https://github.com/YOURNAME/REPO.git 125 | Github Token: 126 | KeePass Passphrase: 127 | ``` 128 | 129 | This will pull the deploy image and run it, and it will then prompt you 130 | for your github token and keepass passphrase. 131 | 132 | With that done, you can now see what is happening with the deployment: 133 | 134 | ```console 135 | $ docker logs docker-stack-deploy --tail 100 --follow 136 | ``` 137 | 138 | after a few moments, it should have pulled and launched the minecraft 139 | container. 140 | 141 | `docker-stack-deploy` will pull your infra repo every 5 minutes 142 | to look for changes. If any files have changed, it will run through 143 | and deploy each stack. 144 | 145 | The deploy command that gets run for each stack is: 146 | 147 | ``` 148 | docker compose up --detach --wait --remove-orphans 149 | ``` 150 | 151 | with the environment populated as described in the *Secrets* section below. 152 | 153 | ## Secrets 154 | 155 | The standard easy way to manage secrets with docker compose is to put 156 | them into an `.env` file in the stack directory. While you can 157 | do that here, it isn't ideal to check in clear-text secrets. This is 158 | where the KeePass database comes into play. 159 | 160 | You can record the relevant secrets in this database and it will be 161 | stored encrypted on disk. With a sufficiently strong passphrase 162 | this is a significant upgrade over clear text `.env` files. 163 | 164 | Secrets are selectively exposed to a stack based on the instructions 165 | in your `stack-deploy.toml` file for that stack. 166 | 167 | For example, if you have this in `gitea/compose.yml`: 168 | 169 | ```yml 170 | services: 171 | gitea: 172 | image: gitea/gitea:latest 173 | environment: 174 | - DB_TYPE=postgres 175 | - DB_HOST=db:5432 176 | - DB_NAME=gitea 177 | - DB_USER=gitea 178 | - DB_PASSWD=${DB_PASSWD} 179 | restart: always 180 | volumes: 181 | - git_data:/data 182 | ports: 183 | - 3000:3000 184 | db: 185 | image: postgres:alpine 186 | environment: 187 | - POSTGRES_USER=gitea 188 | - POSTGRES_PASSWORD=gitea 189 | - POSTGRES_DB=${DB_PASSWD} 190 | restart: always 191 | volumes: 192 | - db_data:/var/lib/postgresql/data 193 | expose: 194 | - 5432 195 | volumes: 196 | db_data: 197 | git_data: 198 | ``` 199 | 200 | and this in `gitead/stack-deploy.toml`: 201 | 202 | ```toml 203 | name = "gitea" 204 | 205 | [secret_env] 206 | DB_PASSWD = 'Database/Gitea Postgres DB/password' 207 | ``` 208 | 209 | Then create an entry in your secrets DB called `Gitea Postgres DB`, this stack 210 | now securely holds the relevant credential in the secrets database. At deploy 211 | time only the credentials listed in the `secret_env` section will be decrypted 212 | and set in the environment when `docker compose` is run. 213 | 214 | `docker-stack-deploy` doesn't create or modify a `.env` file; those environment 215 | variables are set only in the context of the docker invocation. 216 | 217 | ## Stack Dependencies 218 | 219 | You can express dependencies between stacks on the same host. For example: 220 | 221 | ```toml 222 | # This is the homepage stack 223 | name = "homepage" 224 | # It runs on the docker1 host 225 | runs_on = ["docker1"] 226 | # It requires that the traefik stack on docker1 be deployed first 227 | depends_on = ["traefik"] 228 | ``` 229 | 230 | The stacks are topologically sorted based on their dependencies and then 231 | started in that order. 232 | 233 | It is not possible to depend on stacks that are running on other hosts. 234 | 235 | ## Stopping and removing a Stack 236 | 237 | This is a two phase process: 238 | 239 | * First you must edit the `compose.yml` and add `scale: 0` to each service in 240 | the compose file, then commit and push that and wait 5 minutes or so for 241 | the change to take effect. It tells docker to scale down to 0 and stop 242 | the service. 243 | 244 | * Once the service has stopped on all hosts, you can then `git rm` the stack 245 | directory, commit and push. 246 | 247 | ## How do I force deployment to run? 248 | 249 | If you don't want to wait 5 minutes for it to happen naturally, you can 250 | ssh into your docker host and run `docker restart docker-stack-deploy`. 251 | That will cause it to pull the repo immediately and run through the 252 | deploy commands. 253 | 254 | ## Troubleshooting 255 | 256 | You can use `docker compose ls` to review the stacks that are running. 257 | It might look something like this: 258 | 259 | ```console 260 | $ docker compose ls 261 | NAME STATUS CONFIG FILES 262 | docker-stack-deploy running(1) /var/lib/docker-stack-deploy/compose.yml 263 | dockerproxy running(1) /var/lib/docker-stack-deploy/repo/services/dockerproxy/compose.yml 264 | frigate running(1) /var/lib/docker-stack-deploy/repo/hosts/huge/frigate/compose.yml 265 | immich running(4) /var/lib/docker-stack-deploy/repo/hosts/huge/immich/compose.yml 266 | jellyfin running(2) /var/lib/docker-stack-deploy/repo/hosts/huge/jellyfin/compose.yml 267 | ``` 268 | 269 | The `/var/lib/docker-stack-deploy` directory is the location where `docker-stack-deploy` 270 | maintains its state. 271 | 272 | In that directory: 273 | 274 | * The `compose.yml` file was created from the [compose.yml](compose.yml) file 275 | present in this repository when the docker-stack-deploy image was build by 276 | my CI. 277 | * There is a `.env` file that captures the secrets from your bootstrap invocation. 278 | * The `repo` directory is where your infrastructure repo is checked out 279 | 280 | ### To stop a stack 281 | 282 | If I wanted to stop frigate: 283 | 284 | ```console 285 | $ cd /var/lib/docker-stack-deploy/repo/hosts/huge/frigate/ 286 | $ docker compose down 287 | ``` 288 | 289 | To bring it back up again, `docker restart docker-stack-deploy`. 290 | 291 | -------------------------------------------------------------------------------- /bootstrap.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -x 3 | 4 | docker run --rm -it \ 5 | -v /var/run/docker.sock:/var/run/docker.sock \ 6 | -v /var/lib/docker-stack-deploy:/var/lib/docker-stack-deploy \ 7 | ghcr.io/wez/docker-stack-deploy docker-stack-deploy \ 8 | bootstrap --project-dir /var/lib/docker-stack-deploy \ 9 | "$@" 10 | 11 | -------------------------------------------------------------------------------- /compose.yml: -------------------------------------------------------------------------------- 1 | name: docker-stack-deploy 2 | services: 3 | deployer: 4 | image: ghcr.io/wez/docker-stack-deploy 5 | container_name: docker-stack-deploy 6 | restart: always 7 | # we need the hostname of the host to correctly match runs_on rules 8 | uts: host 9 | environment: 10 | # required: the git repo url to clone, username and password 11 | - GITHUB_URL=${GITHUB_URL} 12 | - GITHUB_USERNAME=${GITHUB_USERNAME} 13 | - GITHUB_TOKEN=${GITHUB_TOKEN} 14 | # required: passphrase to unlock secrets database in your repo 15 | - STACK_KDBX_PASS=${STACK_KDBX_PASS} 16 | # optional: how many seconds between git pulls 17 | - POLL_INTERVAL=${POLL_INTERVAL} 18 | - STACK_REPO_DIR=/var/lib/docker-stack-deploy/repo 19 | volumes: 20 | # required to enable spawning containers on the host 21 | - /var/run/docker.sock:/var/run/docker.sock 22 | - /var/lib/docker-stack-deploy:/var/lib/docker-stack-deploy 23 | -------------------------------------------------------------------------------- /docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | export GITHUB_URL=${GITHUB_URL} 4 | export POLL_INTERVAL=${POLL_INTERVAL:-300} 5 | export GITHUB_USERNAME=${GITHUB_USERNAME} 6 | export GITHUB_TOKEN=${GITHUB_TOKEN} 7 | 8 | exec /usr/bin/docker-stack-deploy \ 9 | --kdbx /app/repo/.secrets.kdbc \ 10 | run \ 11 | --poll-interval "${POLL_INTERVAL}" \ 12 | --repo-dir "${STACK_REPO_DIR}" \ 13 | --repo-url "${GITHUB_URL}" 14 | -------------------------------------------------------------------------------- /src/deploy_file.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Context; 2 | use filenamegen::Glob; 3 | use petgraph::prelude::DiGraphMap; 4 | use serde::Deserialize; 5 | use std::collections::BTreeMap; 6 | use std::path::{Path, PathBuf}; 7 | 8 | #[derive(Debug, Clone)] 9 | pub struct DeployFile { 10 | pub path: PathBuf, 11 | pub deploy: StackDeploy, 12 | } 13 | 14 | #[derive(Deserialize, Debug, Clone)] 15 | #[serde(deny_unknown_fields)] 16 | pub struct StackDeploy { 17 | /// Name of this stack 18 | pub name: String, 19 | 20 | /// List of stacks that should be deployed before this one 21 | #[serde(default)] 22 | pub depends_on: Vec, 23 | 24 | /// Map of environment variables that should be expanded 25 | /// from the keepass db when running docker compose. 26 | #[serde(default)] 27 | pub secret_env: BTreeMap, 28 | 29 | // TODO: secret_file 30 | /// List of host names on which to run this service 31 | pub runs_on: Vec, 32 | } 33 | 34 | impl DeployFile {} 35 | 36 | /// Load stacks from the specified root and/or list of files. 37 | /// The result is returned in dependency order, such that stacks that depend 38 | /// on others will be ordered after those dependencies. 39 | pub fn load_stacks(root: &str, files: &[PathBuf]) -> anyhow::Result> { 40 | let files_specified = !files.is_empty(); 41 | let files = if files.is_empty() { 42 | let glob = Glob::new("**/stack-deploy.toml")?; 43 | glob.walk(root) 44 | .into_iter() 45 | .map(|relative| Path::new(root).join(relative)) 46 | .collect() 47 | } else { 48 | files.to_vec() 49 | }; 50 | let hostname = gethostname::gethostname() 51 | .to_str() 52 | .map(|s| s.to_string()) 53 | .unwrap_or_else(|| "localhost".to_string()); 54 | println!("my hostname is {hostname}"); 55 | 56 | let mut stacks = BTreeMap::new(); 57 | 58 | for path in files { 59 | let toml_text = 60 | std::fs::read_to_string(&path).with_context(|| format!("failed to read {path:?}"))?; 61 | let deploy: StackDeploy = toml::from_str(&toml_text) 62 | .with_context(|| format!("failed to parse {path:?} as toml"))?; 63 | println!("{deploy:#?}"); 64 | 65 | if deploy.runs_on.contains(&hostname) { 66 | anyhow::ensure!( 67 | !stacks.contains_key(&deploy.name), 68 | "multiple stacks have the same name {}", 69 | deploy.name 70 | ); 71 | 72 | stacks.insert( 73 | deploy.name.to_string(), 74 | DeployFile { 75 | path: path.to_path_buf(), 76 | deploy, 77 | }, 78 | ); 79 | } else { 80 | log::info!( 81 | "Skipping {path:?} because my hostname {hostname} is not in runs_on: {:?}", 82 | deploy.runs_on 83 | ); 84 | } 85 | } 86 | 87 | let mut graph = DiGraphMap::new(); 88 | for (name, entry) in stacks.iter() { 89 | graph.add_node(name); 90 | for dep in &entry.deploy.depends_on { 91 | if !stacks.contains_key(dep) { 92 | if files_specified { 93 | anyhow::bail!("{name} depends on {dep}, but {dep} is not present in any of the specified stack deploy files"); 94 | } 95 | anyhow::bail!( 96 | "{name} depends on {dep}, but {dep} is not present in any stack deploy file" 97 | ); 98 | } 99 | graph.add_edge(name, dep, ()); 100 | } 101 | } 102 | 103 | let mut sorted = petgraph::algo::toposort(&graph, None) 104 | .map_err(|err| anyhow::anyhow!("Dependency cycle detected for {}", err.node_id()))?; 105 | 106 | // Reverse the order, so that it is sequenced from ~start to finish 107 | sorted.reverse(); 108 | 109 | let mut result = vec![]; 110 | for name in sorted { 111 | match stacks.get(name).cloned() { 112 | Some(entry) => result.push(entry), 113 | None if files_specified => { 114 | anyhow::bail!("dependency {name} was not found in the list of files provided") 115 | } 116 | None => { 117 | anyhow::bail!("dependency {name} was not found in any of the stack-deploy files") 118 | } 119 | } 120 | } 121 | Ok(result) 122 | } 123 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use crate::deploy_file::*; 2 | use crate::secrets::*; 3 | use anyhow::Context; 4 | use clap::Parser; 5 | use log::LevelFilter; 6 | use std::path::{Path, PathBuf}; 7 | 8 | mod deploy_file; 9 | mod secrets; 10 | 11 | #[derive(Parser)] 12 | struct Args { 13 | /// Path to a KeePass .kdbx file containing secrets 14 | #[arg(long)] 15 | kdbx: Option, 16 | 17 | /// Password that can be used to decrypt the kdbx file 18 | #[arg(long)] 19 | password: Option, 20 | 21 | /// Prompt for missing information 22 | #[arg(long)] 23 | interactive: bool, 24 | 25 | #[command(subcommand)] 26 | cmd: Command, 27 | } 28 | 29 | #[derive(Parser)] 30 | enum Command { 31 | GetSecret { 32 | path: String, 33 | }, 34 | StackDeploy { 35 | /// Path to the root of the project. 36 | /// This path will be recursively searched for stack-deploy.toml 37 | /// files 38 | #[arg(long, default_value = ".")] 39 | root: String, 40 | 41 | /// Instead of searching for a deploy file, specify its path. 42 | /// Can be used multiple times 43 | #[arg(long = "file")] 44 | files: Vec, 45 | }, 46 | StackStop { 47 | /// Path to the root of the project. 48 | /// This path will be recursively searched for stack-deploy.toml 49 | /// files 50 | #[arg(long, default_value = ".")] 51 | root: String, 52 | 53 | /// Instead of searching for a deploy file, specify its path. 54 | /// Can be used multiple times 55 | #[arg(long = "file")] 56 | files: Vec, 57 | }, 58 | Run { 59 | /// Local path into which the repo should be cloned 60 | #[arg(long)] 61 | repo_dir: String, 62 | 63 | /// URL from which the repo should be cloned 64 | #[arg(long)] 65 | repo_url: String, 66 | 67 | /// How many seconds to wait between checking the repo for updates 68 | #[arg(long, default_value = "300")] 69 | poll_interval: u64, 70 | }, 71 | Bootstrap { 72 | /// Where to place the compose.yml and .env 73 | #[arg(long)] 74 | project_dir: String, 75 | 76 | /// The repo that should be cloned 77 | #[arg(long)] 78 | git_url: String, 79 | 80 | /// The git username to use 81 | #[arg(long, default_value = "oauth2")] 82 | git_username: String, 83 | 84 | /// How many seconds between git pulls 85 | #[arg(long, default_value = "300")] 86 | poll_interval: u32, 87 | }, 88 | } 89 | 90 | impl Args { 91 | fn open_kdbx(&self) -> anyhow::Result { 92 | let kdbx = self 93 | .kdbx 94 | .as_ref() 95 | .ok_or_else(|| anyhow::anyhow!("no --kdbx file was specified"))?; 96 | self.open_kdbx_path(&kdbx) 97 | } 98 | 99 | fn open_kdbx_path(&self, path: &str) -> anyhow::Result { 100 | let password = if let Some(pwd) = self.password.as_ref().map(Clone::clone) { 101 | pwd 102 | } else if let Ok(s) = std::env::var("STACK_KDBX_PASS") { 103 | s 104 | } else if self.interactive { 105 | rpassword::prompt_password("Password:")? 106 | } else { 107 | anyhow::bail!( 108 | "Missing --password and $STACK_KDBX_PASS env var value \ 109 | and --interactive is not set" 110 | ); 111 | }; 112 | 113 | KeePassDB::open_with_password(path, &password) 114 | } 115 | } 116 | 117 | fn do_compose_down(path: &Path) -> anyhow::Result<()> { 118 | let mut cmd = std::process::Command::new("docker"); 119 | cmd.args(["compose", "down", "--remove-orphans"]); 120 | cmd.current_dir( 121 | path.parent() 122 | .ok_or_else(|| anyhow::anyhow!("path {path:?} has no parent!?"))?, 123 | ); 124 | 125 | let status = cmd 126 | .status() 127 | .with_context(|| format!("failed to run docker compose down in directory of {path:?}"))?; 128 | anyhow::ensure!(status.success(), "exit status is {status:?}"); 129 | Ok(()) 130 | } 131 | 132 | fn do_compose_up(db: &KeePassDB, path: &Path, deploy: &StackDeploy) -> anyhow::Result<()> { 133 | let mut cmd = std::process::Command::new("docker"); 134 | cmd.args(["compose", "up", "--remove-orphans", "--detach", "--wait"]); 135 | cmd.current_dir( 136 | path.parent() 137 | .ok_or_else(|| anyhow::anyhow!("path {path:?} has no parent!?"))?, 138 | ); 139 | 140 | let mut failed = false; 141 | for (k, v) in deploy.secret_env.iter() { 142 | match db.resolve_value(&v) { 143 | Some(v) => { 144 | cmd.env(k, v); 145 | } 146 | None => { 147 | log::error!("secret_env {k}: {v} was not found in database"); 148 | failed = true; 149 | } 150 | } 151 | } 152 | 153 | anyhow::ensure!( 154 | !failed, 155 | "Cannot deploy {path:?} because of the errors above" 156 | ); 157 | 158 | let status = cmd 159 | .status() 160 | .with_context(|| format!("failed to run docker compose up in directory of {path:?}"))?; 161 | anyhow::ensure!(status.success(), "exit status is {status:?}"); 162 | Ok(()) 163 | } 164 | 165 | fn run_deploy(args: &Args, repo_dir: &str) -> anyhow::Result<()> { 166 | let secrets_path = format!("{repo_dir}/.secrets.kdbx"); 167 | let db = args.open_kdbx_path(&secrets_path)?; 168 | 169 | let sorted = load_stacks(repo_dir, &[])?; 170 | 171 | for entry in sorted { 172 | match do_compose_up(&db, &entry.path, &entry.deploy) { 173 | Ok(()) => { 174 | log::info!("Deployed {:?}!", entry.path); 175 | } 176 | Err(err) => { 177 | log::error!("Failed to deploy {:?}: {err:#}", entry.path); 178 | } 179 | } 180 | } 181 | 182 | Ok(()) 183 | } 184 | 185 | fn main() -> anyhow::Result<()> { 186 | let args = Args::parse(); 187 | 188 | env_logger::builder().filter_level(LevelFilter::Info).init(); 189 | 190 | match &args.cmd { 191 | Command::GetSecret { path } => { 192 | let db = args.open_kdbx()?; 193 | match db.resolve_value(&path) { 194 | Some(v) => { 195 | println!("{v}"); 196 | } 197 | None => { 198 | log::error!("{path} not found in {:?}", args.kdbx); 199 | std::process::exit(1); 200 | } 201 | } 202 | } 203 | Command::StackDeploy { root, files } => { 204 | let db = args.open_kdbx()?; 205 | let sorted = load_stacks(root, files)?; 206 | 207 | for entry in sorted { 208 | match do_compose_up(&db, &entry.path, &entry.deploy) { 209 | Ok(()) => { 210 | log::info!("Deployed {:?}!", entry.path); 211 | } 212 | Err(err) => { 213 | log::error!("Failed to deploy {:?}: {err:#}", entry.path); 214 | } 215 | } 216 | } 217 | } 218 | Command::StackStop { root, files } => { 219 | let mut sorted = load_stacks(root, files)?; 220 | // Go in reverse order when stopping 221 | sorted.reverse(); 222 | 223 | for entry in sorted { 224 | match do_compose_down(&entry.path) { 225 | Ok(()) => { 226 | log::info!("Deployed {:?}!", entry.path); 227 | } 228 | Err(err) => { 229 | log::error!("Failed to deploy {:?}: {err:#}", entry.path); 230 | } 231 | } 232 | } 233 | } 234 | Command::Run { 235 | repo_dir, 236 | repo_url, 237 | poll_interval, 238 | } => { 239 | let interval = std::time::Duration::from_secs(*poll_interval); 240 | let mut first_run = true; 241 | 242 | loop { 243 | let hash = clone_or_update(repo_url, repo_dir)?; 244 | log::debug!("hash is {hash:?}"); 245 | if hash.updated() || first_run { 246 | log::info!("Running a deploy {hash:?}"); 247 | if let Err(err) = run_deploy(&args, repo_dir) { 248 | log::error!("Error running deploy: {err:#}"); 249 | } 250 | } 251 | first_run = false; 252 | std::thread::sleep(interval); 253 | } 254 | } 255 | Command::Bootstrap { 256 | project_dir, 257 | git_url, 258 | git_username, 259 | poll_interval, 260 | } => { 261 | std::fs::create_dir_all(project_dir) 262 | .with_context(|| format!("failed to create_dir_all {project_dir}"))?; 263 | 264 | let github_token = rpassword::prompt_password("Github Token:")?; 265 | let db_password = rpassword::prompt_password("KeePass Passphrase:")?; 266 | 267 | let compose_yml = include_str!("../compose.yml"); 268 | let compose_file = format!("{project_dir}/compose.yml"); 269 | std::fs::write(&compose_file, compose_yml) 270 | .with_context(|| format!("failed to write {compose_file}"))?; 271 | let env_file = format!("{project_dir}/.env"); 272 | std::fs::write( 273 | &env_file, 274 | format!( 275 | "GITHUB_URL=\"{git_url}\"\n\ 276 | GITHUB_USERNAME=\"{git_username}\"\n\ 277 | GITHUB_TOKEN=\"{github_token}\"\n\ 278 | STACK_KDBX_PASS=\"{db_password}\"\n\ 279 | POLL_INTERVAL=\"{poll_interval}\"\n" 280 | ), 281 | ) 282 | .with_context(|| format!("failed to write {env_file}"))?; 283 | 284 | let mut cmd = std::process::Command::new("docker"); 285 | cmd.args(["compose", "up", "--remove-orphans", "--detach", "--wait"]); 286 | cmd.current_dir(project_dir); 287 | 288 | let status = cmd 289 | .status() 290 | .with_context(|| format!("failed to run docker compose up in {project_dir}"))?; 291 | anyhow::ensure!(status.success(), "exit status is {status:?}"); 292 | } 293 | } 294 | 295 | Ok(()) 296 | } 297 | 298 | fn getenv(name: &str) -> anyhow::Result { 299 | std::env::var(name).with_context(|| format!("env var {name} not found")) 300 | } 301 | 302 | #[derive(Debug)] 303 | #[allow(unused)] 304 | enum RepoUpdateStatus { 305 | Cloned(String), 306 | Updated(String), 307 | Same(String), 308 | } 309 | 310 | impl RepoUpdateStatus { 311 | pub fn updated(&self) -> bool { 312 | match self { 313 | Self::Cloned(_) | Self::Updated(_) => true, 314 | Self::Same(_) => false, 315 | } 316 | } 317 | } 318 | 319 | fn get_repo_commit_hash(repo_dir: &str) -> anyhow::Result { 320 | let mut cmd = std::process::Command::new("git"); 321 | cmd.current_dir(repo_dir); 322 | cmd.args(["rev-parse", "HEAD"]); 323 | let output = cmd 324 | .output() 325 | .with_context(|| format!("failed to get current commit hash of git repo {repo_dir}"))?; 326 | anyhow::ensure!( 327 | output.status.success(), 328 | "exit status is {:?}", 329 | output.status 330 | ); 331 | 332 | Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) 333 | } 334 | 335 | fn clone_or_update(repo_url: &str, repo_dir: &str) -> anyhow::Result { 336 | let dot_git = format!("{repo_dir}/.git"); 337 | 338 | let recreate = match std::fs::metadata(&dot_git) { 339 | Ok(meta) => !meta.is_dir(), 340 | Err(err) => { 341 | log::warn!("Error getting metadata for {dot_git}: {err:#}"); 342 | true 343 | } 344 | }; 345 | 346 | let mut cmd = std::process::Command::new("git"); 347 | // TODO: if we have the repo checked out, we could try to read current 348 | // versions of these creds from the secrets file, which would allow 349 | // managing token expiration without redeploying the redeployer. 350 | let username = getenv("GITHUB_USERNAME")?; 351 | let password = getenv("GITHUB_TOKEN")?; 352 | 353 | // We want to avoid baking the PAT from the time we clone the repo 354 | // into the repo so that we can update the token over time. 355 | // These ad-hoc config overrides facilitate passing in the creds 356 | // 357 | cmd.args(["-c", &format!("credential.username={username}")]); 358 | cmd.args([ 359 | "-c", 360 | "credential.helper=!f(){ test \"$1\" = get && echo \"password=${GITHUB_TOKEN}\"; }; f", 361 | ]); 362 | cmd.env("GITHUB_TOKEN", password); 363 | 364 | let mut hash_before = None; 365 | 366 | if recreate { 367 | if let Err(err) = std::fs::remove_dir_all(&repo_dir) { 368 | log::warn!("Error removing {repo_dir}: {err:#}"); 369 | } 370 | 371 | cmd.args(["clone", &repo_url, repo_dir]); 372 | } else { 373 | hash_before = get_repo_commit_hash(repo_dir).ok(); 374 | 375 | cmd.current_dir(repo_dir); 376 | cmd.args(["pull", "--rebase"]); 377 | } 378 | 379 | let status = cmd 380 | .status() 381 | .with_context(|| format!("failed to update git repo {repo_dir} from {repo_url}"))?; 382 | anyhow::ensure!(status.success(), "exit status is {status:?}"); 383 | 384 | let hash_after = get_repo_commit_hash(repo_dir)?; 385 | 386 | Ok(match (hash_before, hash_after) { 387 | (Some(before), after) if before == after => RepoUpdateStatus::Same(after), 388 | (Some(_before), after) => RepoUpdateStatus::Updated(after), 389 | (None, after) => RepoUpdateStatus::Cloned(after), 390 | }) 391 | } 392 | -------------------------------------------------------------------------------- /src/secrets.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Context; 2 | use keepass::db::NodeRef; 3 | use keepass::{Database, DatabaseKey}; 4 | 5 | pub struct KeePassDB { 6 | db: Database, 7 | } 8 | 9 | impl KeePassDB { 10 | pub fn open_with_password(path: &str, password: &str) -> anyhow::Result { 11 | let mut db_file = std::fs::File::open(path) 12 | .with_context(|| format!("failed to open kdbx file {path}"))?; 13 | let key = DatabaseKey::new().with_password(password); 14 | log::debug!("Opening database"); 15 | let db = Database::open(&mut db_file, key)?; 16 | log::debug!("Database opened"); 17 | 18 | Ok(Self { db }) 19 | } 20 | 21 | /// Given a path like "Database/group/group/entryname/fieldname" 22 | /// returns the string value of the field. 23 | /// The path elements are case insensitive. 24 | pub fn resolve_value(&self, path: &str) -> Option { 25 | fn resolve(parent: NodeRef, path: &[&str]) -> Option { 26 | let element = path.get(0)?; 27 | 28 | match parent { 29 | NodeRef::Group(group) => { 30 | if !group.name.eq_ignore_ascii_case(*element) { 31 | return None; 32 | } 33 | for child in &group.children { 34 | match resolve(child.as_ref(), &path[1..]) { 35 | Some(node) => return Some(node), 36 | None => {} 37 | } 38 | } 39 | None 40 | } 41 | NodeRef::Entry(entry) => { 42 | if !entry 43 | .get_title() 44 | .map(|title| title.eq_ignore_ascii_case(*element)) 45 | .unwrap_or(false) 46 | { 47 | return None; 48 | } 49 | 50 | let element = path.get(1)?; 51 | 52 | if path.len() > 2 { 53 | // Path is too long 54 | return None; 55 | } 56 | 57 | // We iterate the elements so that we can do a case 58 | // insensitive comparison 59 | for k in entry.fields.keys() { 60 | if k.eq_ignore_ascii_case(*element) { 61 | return entry.get(k).map(|s| s.to_string()); 62 | } 63 | } 64 | 65 | None 66 | } 67 | } 68 | } 69 | 70 | let elements: Vec<&str> = path.split('/').collect(); 71 | resolve(NodeRef::Group(&self.db.root), &elements) 72 | } 73 | } 74 | --------------------------------------------------------------------------------