Compare commits
8 Commits
chore/ui-h
...
662d5a2cf8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
662d5a2cf8 | ||
|
|
fc8d473416 | ||
|
|
c73e3c80c0 | ||
|
|
f147665157 | ||
|
|
e4851b3deb | ||
|
|
5d08974876 | ||
|
|
ca278bddc8 | ||
|
|
7b50047730 |
353
Cargo.lock
generated
353
Cargo.lock
generated
@@ -40,6 +40,56 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstream"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"anstyle-parse",
|
||||||
|
"anstyle-query",
|
||||||
|
"anstyle-wincon",
|
||||||
|
"colorchoice",
|
||||||
|
"is_terminal_polyfill",
|
||||||
|
"utf8parse",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle"
|
||||||
|
version = "1.0.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-parse"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
|
||||||
|
dependencies = [
|
||||||
|
"utf8parse",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-query"
|
||||||
|
version = "1.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-wincon"
|
||||||
|
version = "3.0.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"once_cell_polyfill",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anyhow"
|
name = "anyhow"
|
||||||
version = "1.0.102"
|
version = "1.0.102"
|
||||||
@@ -68,6 +118,21 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "assert_cmd"
|
||||||
|
version = "2.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"bstr",
|
||||||
|
"libc",
|
||||||
|
"predicates",
|
||||||
|
"predicates-core",
|
||||||
|
"predicates-tree",
|
||||||
|
"wait-timeout",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-trait"
|
name = "async-trait"
|
||||||
version = "0.1.89"
|
version = "0.1.89"
|
||||||
@@ -236,6 +301,17 @@ dependencies = [
|
|||||||
"generic-array",
|
"generic-array",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bstr"
|
||||||
|
version = "1.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
"regex-automata",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bumpalo"
|
name = "bumpalo"
|
||||||
version = "3.20.3"
|
version = "3.20.3"
|
||||||
@@ -302,6 +378,52 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap"
|
||||||
|
version = "4.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
|
||||||
|
dependencies = [
|
||||||
|
"clap_builder",
|
||||||
|
"clap_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_builder"
|
||||||
|
version = "4.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
|
||||||
|
dependencies = [
|
||||||
|
"anstream",
|
||||||
|
"anstyle",
|
||||||
|
"clap_lex",
|
||||||
|
"strsim",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_derive"
|
||||||
|
version = "4.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
|
||||||
|
dependencies = [
|
||||||
|
"heck",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_lex"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorchoice"
|
||||||
|
version = "1.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "concurrent-queue"
|
name = "concurrent-queue"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
@@ -440,6 +562,12 @@ version = "0.1.13"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
|
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "difflib"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "digest"
|
name = "digest"
|
||||||
version = "0.10.7"
|
version = "0.10.7"
|
||||||
@@ -452,6 +580,27 @@ dependencies = [
|
|||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "directories"
|
||||||
|
version = "5.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35"
|
||||||
|
dependencies = [
|
||||||
|
"dirs-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dirs-sys"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"option-ext",
|
||||||
|
"redox_users",
|
||||||
|
"windows-sys 0.48.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "displaydoc"
|
name = "displaydoc"
|
||||||
version = "0.2.5"
|
version = "0.2.5"
|
||||||
@@ -516,6 +665,12 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fastrand"
|
||||||
|
version = "2.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "figment"
|
name = "figment"
|
||||||
version = "0.10.19"
|
version = "0.10.19"
|
||||||
@@ -536,6 +691,15 @@ version = "0.1.9"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "float-cmp"
|
||||||
|
version = "0.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "flume"
|
name = "flume"
|
||||||
version = "0.11.1"
|
version = "0.11.1"
|
||||||
@@ -1010,6 +1174,12 @@ version = "2.12.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
|
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "is_terminal_polyfill"
|
||||||
|
version = "1.70.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "1.0.18"
|
version = "1.0.18"
|
||||||
@@ -1077,6 +1247,12 @@ dependencies = [
|
|||||||
"vcpkg",
|
"vcpkg",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linux-raw-sys"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "litemap"
|
name = "litemap"
|
||||||
version = "0.8.2"
|
version = "0.8.2"
|
||||||
@@ -1161,6 +1337,12 @@ dependencies = [
|
|||||||
"spin 0.5.2",
|
"spin 0.5.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "normalize-line-endings"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nu-ansi-term"
|
name = "nu-ansi-term"
|
||||||
version = "0.50.3"
|
version = "0.50.3"
|
||||||
@@ -1231,6 +1413,18 @@ dependencies = [
|
|||||||
"portable-atomic",
|
"portable-atomic",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "once_cell_polyfill"
|
||||||
|
version = "1.70.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "option-ext"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "parking"
|
name = "parking"
|
||||||
version = "2.2.1"
|
version = "2.2.1"
|
||||||
@@ -1335,6 +1529,27 @@ dependencies = [
|
|||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "picloud-cli"
|
||||||
|
version = "0.6.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"assert_cmd",
|
||||||
|
"chrono",
|
||||||
|
"clap",
|
||||||
|
"directories",
|
||||||
|
"libc",
|
||||||
|
"picloud-shared",
|
||||||
|
"predicates",
|
||||||
|
"reqwest",
|
||||||
|
"rpassword",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tempfile",
|
||||||
|
"tokio",
|
||||||
|
"toml",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud-executor"
|
name = "picloud-executor"
|
||||||
version = "0.6.0"
|
version = "0.6.0"
|
||||||
@@ -1509,6 +1724,36 @@ dependencies = [
|
|||||||
"zerocopy",
|
"zerocopy",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "predicates"
|
||||||
|
version = "3.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"difflib",
|
||||||
|
"float-cmp",
|
||||||
|
"normalize-line-endings",
|
||||||
|
"predicates-core",
|
||||||
|
"regex",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "predicates-core"
|
||||||
|
version = "1.0.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "predicates-tree"
|
||||||
|
version = "1.0.13"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2"
|
||||||
|
dependencies = [
|
||||||
|
"predicates-core",
|
||||||
|
"termtree",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pretty_assertions"
|
name = "pretty_assertions"
|
||||||
version = "1.4.1"
|
version = "1.4.1"
|
||||||
@@ -1704,6 +1949,29 @@ dependencies = [
|
|||||||
"bitflags",
|
"bitflags",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "redox_users"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.2.17",
|
||||||
|
"libredox",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "regex"
|
||||||
|
version = "1.12.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
|
||||||
|
dependencies = [
|
||||||
|
"aho-corasick",
|
||||||
|
"memchr",
|
||||||
|
"regex-automata",
|
||||||
|
"regex-syntax",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "regex-automata"
|
name = "regex-automata"
|
||||||
version = "0.4.14"
|
version = "0.4.14"
|
||||||
@@ -1729,7 +1997,9 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
@@ -1812,6 +2082,17 @@ dependencies = [
|
|||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rpassword"
|
||||||
|
version = "7.5.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "835a57a69104632d64deb0df2e09a69945cd7a6eab4070fc9b1d7e50cf6c3edc"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"rtoolbox",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rsa"
|
name = "rsa"
|
||||||
version = "0.9.10"
|
version = "0.9.10"
|
||||||
@@ -1832,6 +2113,16 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rtoolbox"
|
||||||
|
version = "0.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys 0.59.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rust-multipart-rfc7578_2"
|
name = "rust-multipart-rfc7578_2"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
@@ -1853,6 +2144,19 @@ version = "2.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
|
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustix"
|
||||||
|
version = "1.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
"linux-raw-sys",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls"
|
name = "rustls"
|
||||||
version = "0.23.40"
|
version = "0.23.40"
|
||||||
@@ -2327,6 +2631,12 @@ dependencies = [
|
|||||||
"unicode-properties",
|
"unicode-properties",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strsim"
|
||||||
|
version = "0.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "subtle"
|
name = "subtle"
|
||||||
version = "2.6.1"
|
version = "2.6.1"
|
||||||
@@ -2364,6 +2674,25 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tempfile"
|
||||||
|
version = "3.27.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||||
|
dependencies = [
|
||||||
|
"fastrand",
|
||||||
|
"getrandom 0.4.2",
|
||||||
|
"once_cell",
|
||||||
|
"rustix",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "termtree"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thin-vec"
|
name = "thin-vec"
|
||||||
version = "0.2.18"
|
version = "0.2.18"
|
||||||
@@ -2783,6 +3112,12 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf8parse"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uuid"
|
name = "uuid"
|
||||||
version = "1.23.1"
|
version = "1.23.1"
|
||||||
@@ -2813,6 +3148,15 @@ version = "0.9.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wait-timeout"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "want"
|
name = "want"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -3066,6 +3410,15 @@ dependencies = [
|
|||||||
"windows-targets 0.52.6",
|
"windows-targets 0.52.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.59.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.60.2"
|
version = "0.60.2"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ members = [
|
|||||||
"crates/picloud-manager",
|
"crates/picloud-manager",
|
||||||
"crates/picloud-orchestrator",
|
"crates/picloud-orchestrator",
|
||||||
"crates/picloud-executor",
|
"crates/picloud-executor",
|
||||||
|
"crates/picloud-cli",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
|
|||||||
42
crates/picloud-cli/Cargo.toml
Normal file
42
crates/picloud-cli/Cargo.toml
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
[package]
|
||||||
|
name = "picloud-cli"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
description = "PiCloud command-line client"
|
||||||
|
# Each top-level `tests/*.rs` would otherwise auto-discover as its own
|
||||||
|
# test binary, respawning picloud once per file. We want one binary
|
||||||
|
# with module sub-files (auth.rs, apps.rs, …) so the LazyLock fixture
|
||||||
|
# is genuinely shared.
|
||||||
|
autotests = false
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "pic"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[[test]]
|
||||||
|
name = "cli"
|
||||||
|
path = "tests/cli.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
picloud-shared.workspace = true
|
||||||
|
reqwest = { workspace = true, features = ["json"] }
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
toml = "0.8"
|
||||||
|
directories = "5"
|
||||||
|
rpassword = "7"
|
||||||
|
anyhow = "1"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
assert_cmd = "2"
|
||||||
|
predicates = "3"
|
||||||
|
tempfile = "3"
|
||||||
|
reqwest = { workspace = true, features = ["json", "blocking"] }
|
||||||
|
libc = "0.2"
|
||||||
501
crates/picloud-cli/src/client.rs
Normal file
501
crates/picloud-cli/src/client.rs
Normal file
@@ -0,0 +1,501 @@
|
|||||||
|
//! Reqwest-backed HTTP client + minimal wire DTOs.
|
||||||
|
//!
|
||||||
|
//! The CLI deliberately re-declares small request/response structs here
|
||||||
|
//! rather than depending on `manager-core` (and pulling its Postgres
|
||||||
|
//! transitive surface). Fields kept to what the CLI actually sends or
|
||||||
|
//! reads.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use picloud_shared::{
|
||||||
|
AdminUserId, ApiKeyId, App, AppId, AppRole, ExecutionLog, InstanceRole, Scope, Script,
|
||||||
|
};
|
||||||
|
use reqwest::{header, Method, RequestBuilder, StatusCode};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::config::Credentials;
|
||||||
|
|
||||||
|
pub struct Client {
|
||||||
|
http: reqwest::Client,
|
||||||
|
url: String,
|
||||||
|
token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Client {
|
||||||
|
pub fn from_creds(creds: &Credentials) -> Result<Self> {
|
||||||
|
Self::new(&creds.url, &creds.token)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new(url: &str, token: &str) -> Result<Self> {
|
||||||
|
let http = reqwest::Client::builder()
|
||||||
|
.user_agent(concat!("pic/", env!("CARGO_PKG_VERSION")))
|
||||||
|
.build()
|
||||||
|
.context("building HTTP client")?;
|
||||||
|
Ok(Self {
|
||||||
|
http,
|
||||||
|
url: url.trim_end_matches('/').to_string(),
|
||||||
|
token: token.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)] // used by the trailing-slash unit test below.
|
||||||
|
pub fn url(&self) -> &str {
|
||||||
|
&self.url
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request(&self, method: Method, path: &str) -> RequestBuilder {
|
||||||
|
self.http
|
||||||
|
.request(method, format!("{}{path}", self.url))
|
||||||
|
.header(header::AUTHORIZATION, format!("Bearer {}", self.token))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/v1/admin/auth/me`
|
||||||
|
pub async fn auth_me(&self) -> Result<AuthMeDto> {
|
||||||
|
let resp = self
|
||||||
|
.request(Method::GET, "/api/v1/admin/auth/me")
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
decode(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/v1/admin/apps`
|
||||||
|
pub async fn apps_list(&self) -> Result<Vec<App>> {
|
||||||
|
let resp = self
|
||||||
|
.request(Method::GET, "/api/v1/admin/apps")
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
decode(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/v1/admin/apps/{id_or_slug}` — slug or UUID accepted.
|
||||||
|
pub async fn apps_get(&self, ident: &str) -> Result<AppLookupDto> {
|
||||||
|
let resp = self
|
||||||
|
.request(Method::GET, &format!("/api/v1/admin/apps/{ident}"))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
decode(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/v1/admin/apps`
|
||||||
|
pub async fn apps_create(&self, body: &CreateAppBody<'_>) -> Result<App> {
|
||||||
|
let resp = self
|
||||||
|
.request(Method::POST, "/api/v1/admin/apps")
|
||||||
|
.json(body)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
decode(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/v1/admin/scripts?app={ident}`
|
||||||
|
pub async fn scripts_list_by_app(&self, ident: &str) -> Result<Vec<Script>> {
|
||||||
|
let resp = self
|
||||||
|
.request(
|
||||||
|
Method::GET,
|
||||||
|
&format!("/api/v1/admin/scripts?app={}", urlencoded(ident)),
|
||||||
|
)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
decode(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/v1/admin/scripts` — every script the caller can see
|
||||||
|
/// (server filters by membership for `Member`). Lets `pic scripts ls`
|
||||||
|
/// (no `--app`) collapse what used to be an N+1 per-app walk into a
|
||||||
|
/// single request that can't be partially-broken by a concurrent app
|
||||||
|
/// delete.
|
||||||
|
pub async fn scripts_list_all(&self) -> Result<Vec<Script>> {
|
||||||
|
let resp = self
|
||||||
|
.request(Method::GET, "/api/v1/admin/scripts")
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
decode(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `DELETE /api/v1/admin/apps/{id_or_slug}` with optional `?force=true`.
|
||||||
|
/// Server requires `AppAdmin` capability; without `force`, returns
|
||||||
|
/// 409 if the app still has scripts.
|
||||||
|
pub async fn apps_delete(&self, ident: &str, force: bool) -> Result<()> {
|
||||||
|
let path = if force {
|
||||||
|
format!("/api/v1/admin/apps/{ident}?force=true")
|
||||||
|
} else {
|
||||||
|
format!("/api/v1/admin/apps/{ident}")
|
||||||
|
};
|
||||||
|
let resp = self.request(Method::DELETE, &path).send().await?;
|
||||||
|
decode_status(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `DELETE /api/v1/admin/scripts/{id}` — requires `AppAdmin` on the
|
||||||
|
/// owning app (stricter than the edit endpoints, by design).
|
||||||
|
pub async fn scripts_delete(&self, id: &str) -> Result<()> {
|
||||||
|
let resp = self
|
||||||
|
.request(Method::DELETE, &format!("/api/v1/admin/scripts/{id}"))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
decode_status(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/v1/admin/scripts`
|
||||||
|
pub async fn scripts_create(&self, body: &CreateScriptBody<'_>) -> Result<Script> {
|
||||||
|
let resp = self
|
||||||
|
.request(Method::POST, "/api/v1/admin/scripts")
|
||||||
|
.json(body)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
decode(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `PUT /api/v1/admin/scripts/{id}` — matches the dashboard, which
|
||||||
|
/// uses PUT despite the field-level update semantics.
|
||||||
|
pub async fn scripts_update_source(&self, id: &str, source: &str) -> Result<Script> {
|
||||||
|
let body = UpdateScriptBody { source };
|
||||||
|
let resp = self
|
||||||
|
.request(Method::PUT, &format!("/api/v1/admin/scripts/{id}"))
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
decode(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/v1/execute/{id}` — returns the raw HTTP status, headers,
|
||||||
|
/// and JSON body (the orchestrator marshals the script's output as
|
||||||
|
/// the HTTP response itself, not a wrapper object).
|
||||||
|
pub async fn execute(
|
||||||
|
&self,
|
||||||
|
id: &str,
|
||||||
|
body: Value,
|
||||||
|
headers: &[(String, String)],
|
||||||
|
) -> Result<ExecuteResponse> {
|
||||||
|
let mut req = self
|
||||||
|
.request(Method::POST, &format!("/api/v1/execute/{id}"))
|
||||||
|
.json(&body);
|
||||||
|
for (k, v) in headers {
|
||||||
|
req = req.header(k, v);
|
||||||
|
}
|
||||||
|
let resp = req.send().await?;
|
||||||
|
let status = resp.status().as_u16();
|
||||||
|
let mut headers_out: BTreeMap<String, String> = BTreeMap::new();
|
||||||
|
for (k, v) in resp.headers() {
|
||||||
|
if let Ok(val) = v.to_str() {
|
||||||
|
headers_out.insert(k.as_str().to_string(), val.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let bytes = resp.bytes().await.context("reading execute response")?;
|
||||||
|
let body_json: Value = if bytes.is_empty() {
|
||||||
|
Value::Null
|
||||||
|
} else {
|
||||||
|
serde_json::from_slice(&bytes)
|
||||||
|
.unwrap_or(Value::String(String::from_utf8_lossy(&bytes).into_owned()))
|
||||||
|
};
|
||||||
|
Ok(ExecuteResponse {
|
||||||
|
status_code: status,
|
||||||
|
headers: headers_out,
|
||||||
|
body: body_json,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/v1/admin/scripts/{id}/logs?limit=N`
|
||||||
|
pub async fn logs_list(&self, script_id: &str, limit: u32) -> Result<Vec<ExecutionLog>> {
|
||||||
|
let resp = self
|
||||||
|
.request(
|
||||||
|
Method::GET,
|
||||||
|
&format!("/api/v1/admin/scripts/{script_id}/logs?limit={limit}"),
|
||||||
|
)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
decode(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/v1/admin/auth/logout` — best-effort: server returns
|
||||||
|
/// 204 whether or not the token matched a live session, so we just
|
||||||
|
/// fire and discard the body. Caller still wipes the local creds.
|
||||||
|
pub async fn auth_logout(&self) -> Result<()> {
|
||||||
|
let resp = self
|
||||||
|
.request(Method::POST, "/api/v1/admin/auth/logout")
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
decode_status(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/v1/admin/api-keys` — caller's keys only (server filters
|
||||||
|
/// by user_id, no cross-user enumeration).
|
||||||
|
pub async fn apikeys_list(&self) -> Result<Vec<ApiKeyDto>> {
|
||||||
|
let resp = self
|
||||||
|
.request(Method::GET, "/api/v1/admin/api-keys")
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
decode(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/v1/admin/api-keys` — `raw_token` is in the response
|
||||||
|
/// **once** and never appears in `GET /api-keys` afterward.
|
||||||
|
pub async fn apikeys_mint(&self, body: &MintApiKeyBody<'_>) -> Result<MintApiKeyResponseDto> {
|
||||||
|
let resp = self
|
||||||
|
.request(Method::POST, "/api/v1/admin/api-keys")
|
||||||
|
.json(body)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
decode(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `DELETE /api/v1/admin/api-keys/{id}` — 404 covers both "doesn't
|
||||||
|
/// exist" and "not yours" (server flattens to avoid enumeration).
|
||||||
|
pub async fn apikeys_delete(&self, id: &str) -> Result<()> {
|
||||||
|
let resp = self
|
||||||
|
.request(Method::DELETE, &format!("/api/v1/admin/api-keys/{id}"))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
decode_status(resp).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/v1/admin/auth/login` — sits outside the `Client` because
|
||||||
|
/// it runs before any token exists. Mirrors the dashboard's login.ts
|
||||||
|
/// wire shape (see `manager-core/src/auth_api.rs:49-60`).
|
||||||
|
pub async fn auth_login(url: &str, username: &str, password: &str) -> Result<LoginResponseDto> {
|
||||||
|
let http = reqwest::Client::builder()
|
||||||
|
.user_agent(concat!("pic/", env!("CARGO_PKG_VERSION")))
|
||||||
|
.build()
|
||||||
|
.context("building HTTP client")?;
|
||||||
|
let body = LoginRequestBody { username, password };
|
||||||
|
let resp = http
|
||||||
|
.post(format!(
|
||||||
|
"{}/api/v1/admin/auth/login",
|
||||||
|
url.trim_end_matches('/')
|
||||||
|
))
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
decode(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- DTOs (CLI-local, wire-shape-matched) ----------
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct AuthMeDto {
|
||||||
|
// Part of the wire shape (and kept for symmetry with the dashboard's
|
||||||
|
// MeDto), even though the CLI never displays it.
|
||||||
|
pub id: String,
|
||||||
|
pub username: String,
|
||||||
|
pub instance_role: InstanceRole,
|
||||||
|
#[serde(default)]
|
||||||
|
pub email: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct AppLookupDto {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub app: App,
|
||||||
|
// Not surfaced yet — `pic apps ls` only shows what `apps_list` returns.
|
||||||
|
// Kept on the DTO so future `pic apps inspect <slug>` work is one-line.
|
||||||
|
#[serde(default)]
|
||||||
|
pub my_role: Option<AppRole>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct CreateAppBody<'a> {
|
||||||
|
pub slug: &'a str,
|
||||||
|
pub name: &'a str,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub description: Option<&'a str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct CreateScriptBody<'a> {
|
||||||
|
pub app_id: AppId,
|
||||||
|
pub name: &'a str,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub description: Option<&'a str>,
|
||||||
|
pub source: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct UpdateScriptBody<'a> {
|
||||||
|
source: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct LoginRequestBody<'a> {
|
||||||
|
username: &'a str,
|
||||||
|
password: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct LoginResponseDto {
|
||||||
|
pub user: LoginUserDto,
|
||||||
|
pub token: String,
|
||||||
|
pub expires_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct LoginUserDto {
|
||||||
|
pub id: AdminUserId,
|
||||||
|
pub username: String,
|
||||||
|
pub instance_role: InstanceRole,
|
||||||
|
#[serde(default)]
|
||||||
|
pub email: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct MintApiKeyBody<'a> {
|
||||||
|
pub name: &'a str,
|
||||||
|
pub scopes: &'a [Scope],
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub app_id: Option<AppId>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub expires_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fresh-mint response. The `raw_token` field is the one and only
|
||||||
|
/// chance to capture the bearer string; subsequent `GET /api-keys`
|
||||||
|
/// returns the `ApiKeyDto` portion without it.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct MintApiKeyResponseDto {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub key: ApiKeyDto,
|
||||||
|
pub raw_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ApiKeyDto {
|
||||||
|
pub id: ApiKeyId,
|
||||||
|
pub prefix: String,
|
||||||
|
pub name: String,
|
||||||
|
pub scopes: Vec<Scope>,
|
||||||
|
pub app_id: Option<AppId>,
|
||||||
|
pub expires_at: Option<DateTime<Utc>>,
|
||||||
|
pub last_used_at: Option<DateTime<Utc>>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ExecuteResponse {
|
||||||
|
pub status_code: u16,
|
||||||
|
// Captured for completeness; not displayed today, but `pic invoke -v`
|
||||||
|
// could surface them later without changing this struct.
|
||||||
|
pub headers: BTreeMap<String, String>,
|
||||||
|
pub body: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- helpers ----------
|
||||||
|
|
||||||
|
/// Parse `-H "Key: value"` or `-H "Key=value"` into a `(name, value)`
|
||||||
|
/// pair. Trims surrounding whitespace on both sides.
|
||||||
|
pub fn parse_kv_header(raw: &str) -> Result<(String, String), String> {
|
||||||
|
let (k, v) = raw
|
||||||
|
.split_once(':')
|
||||||
|
.or_else(|| raw.split_once('='))
|
||||||
|
.ok_or_else(|| format!("expected `Key: value` or `Key=value`, got {raw:?}"))?;
|
||||||
|
let k = k.trim();
|
||||||
|
let v = v.trim();
|
||||||
|
if k.is_empty() {
|
||||||
|
return Err(format!("empty header name in {raw:?}"));
|
||||||
|
}
|
||||||
|
Ok((k.to_string(), v.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn urlencoded(s: &str) -> String {
|
||||||
|
// Minimal pass: percent-encode the few chars that break the query.
|
||||||
|
// Slugs and UUIDs don't contain them in practice, but be safe.
|
||||||
|
let mut out = String::with_capacity(s.len());
|
||||||
|
for ch in s.chars() {
|
||||||
|
match ch {
|
||||||
|
'&' | '=' | '?' | '#' | ' ' => {
|
||||||
|
out.push_str(&format!("%{:02X}", u32::from(ch)));
|
||||||
|
}
|
||||||
|
_ => out.push(ch),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn decode<T: for<'de> Deserialize<'de>>(resp: reqwest::Response) -> Result<T> {
|
||||||
|
if resp.status().is_success() {
|
||||||
|
return resp.json::<T>().await.context("parsing response body");
|
||||||
|
}
|
||||||
|
Err(server_error(resp).await)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like `decode` but for endpoints whose 2xx response has no body
|
||||||
|
/// (204 No Content) — DELETE handlers, logout.
|
||||||
|
async fn decode_status(resp: reqwest::Response) -> Result<()> {
|
||||||
|
if resp.status().is_success() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(server_error(resp).await)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn server_error(resp: reqwest::Response) -> anyhow::Error {
|
||||||
|
let status = resp.status();
|
||||||
|
let body = resp.text().await.unwrap_or_default();
|
||||||
|
let msg = parse_error_body(&body).unwrap_or(body);
|
||||||
|
let hint = role_hint(status);
|
||||||
|
if hint.is_empty() {
|
||||||
|
anyhow!("HTTP {}: {}", status.as_u16(), msg)
|
||||||
|
} else {
|
||||||
|
anyhow!("HTTP {}: {} ({})", status.as_u16(), msg, hint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_error_body(s: &str) -> Option<String> {
|
||||||
|
let v: Value = serde_json::from_str(s).ok()?;
|
||||||
|
let obj = v.as_object()?;
|
||||||
|
if let Some(m) = obj.get("message").and_then(Value::as_str) {
|
||||||
|
return Some(m.to_string());
|
||||||
|
}
|
||||||
|
if let Some(e) = obj.get("error").and_then(Value::as_str) {
|
||||||
|
return Some(e.to_string());
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn role_hint(status: StatusCode) -> &'static str {
|
||||||
|
match status {
|
||||||
|
StatusCode::FORBIDDEN => "your role may lack the required capability; check `pic whoami`",
|
||||||
|
StatusCode::UNAUTHORIZED => "token rejected; re-run `pic login`",
|
||||||
|
_ => "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_kv_colon() {
|
||||||
|
let (k, v) = parse_kv_header("X-Foo: bar").unwrap();
|
||||||
|
assert_eq!(k, "X-Foo");
|
||||||
|
assert_eq!(v, "bar");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_kv_equals() {
|
||||||
|
let (k, v) = parse_kv_header("X-Foo=bar").unwrap();
|
||||||
|
assert_eq!(k, "X-Foo");
|
||||||
|
assert_eq!(v, "bar");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_kv_rejects_no_separator() {
|
||||||
|
assert!(parse_kv_header("X-Foo").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_kv_rejects_empty_name() {
|
||||||
|
assert!(parse_kv_header(": bar").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn url_strip_trailing_slash() {
|
||||||
|
let c = Client::new("http://localhost:8000/", "pic_x").unwrap();
|
||||||
|
assert_eq!(c.url(), "http://localhost:8000");
|
||||||
|
}
|
||||||
|
}
|
||||||
201
crates/picloud-cli/src/cmds/api_keys.rs
Normal file
201
crates/picloud-cli/src/cmds/api_keys.rs
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
//! `pic api-keys` — long-lived bearer-key management.
|
||||||
|
//!
|
||||||
|
//! Server semantics (mirrored from `manager-core/src/api_keys_api.rs`):
|
||||||
|
//! * `raw_token` is returned **once** on mint and never again.
|
||||||
|
//! * `app_id` (optional `--app`) binds the key to one app; instance
|
||||||
|
//! scopes (`instance:*`) are rejected when `--app` is also set.
|
||||||
|
//! * `scopes` is a `text[]` in the wire form (`script:read`, …).
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use picloud_shared::Scope;
|
||||||
|
|
||||||
|
use crate::client::{Client, MintApiKeyBody};
|
||||||
|
use crate::config;
|
||||||
|
use crate::output::{KvBlock, OutputMode, Table};
|
||||||
|
|
||||||
|
pub async fn mint(
|
||||||
|
name: &str,
|
||||||
|
scope_strs: &[String],
|
||||||
|
app_ident: Option<&str>,
|
||||||
|
expires: Option<&str>,
|
||||||
|
mode: OutputMode,
|
||||||
|
) -> Result<()> {
|
||||||
|
let creds = config::resolve()?;
|
||||||
|
let client = Client::from_creds(&creds)?;
|
||||||
|
|
||||||
|
let scopes = parse_scopes(scope_strs)?;
|
||||||
|
let expires_at = expires.map(parse_expires).transpose()?;
|
||||||
|
let app_id = match app_ident {
|
||||||
|
Some(ident) => Some(client.apps_get(ident).await?.app.id),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let body = MintApiKeyBody {
|
||||||
|
name,
|
||||||
|
scopes: &scopes,
|
||||||
|
app_id,
|
||||||
|
expires_at,
|
||||||
|
};
|
||||||
|
let resp = client.apikeys_mint(&body).await?;
|
||||||
|
|
||||||
|
let mut block = KvBlock::new();
|
||||||
|
block
|
||||||
|
.field("id", resp.key.id.to_string())
|
||||||
|
.field("name", resp.key.name.clone())
|
||||||
|
.field("prefix", resp.key.prefix.clone())
|
||||||
|
.field(
|
||||||
|
"scopes",
|
||||||
|
resp.key
|
||||||
|
.scopes
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.as_str())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(","),
|
||||||
|
)
|
||||||
|
.field(
|
||||||
|
"app_id",
|
||||||
|
resp.key
|
||||||
|
.app_id
|
||||||
|
.map(|a| a.to_string())
|
||||||
|
.unwrap_or_else(|| "-".into()),
|
||||||
|
)
|
||||||
|
.field(
|
||||||
|
"expires_at",
|
||||||
|
resp.key
|
||||||
|
.expires_at
|
||||||
|
.map(|t| t.to_rfc3339())
|
||||||
|
.unwrap_or_else(|| "-".into()),
|
||||||
|
)
|
||||||
|
.field("token", resp.raw_token.clone());
|
||||||
|
block.print(mode);
|
||||||
|
if matches!(mode, OutputMode::Tsv) {
|
||||||
|
// The token row is human-easy-to-miss in a wall of metadata;
|
||||||
|
// call it out exactly once on the human path. Skip on JSON
|
||||||
|
// since machine consumers don't need the nudge.
|
||||||
|
eprintln!("Save this token — it will not be shown again.");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ls(mode: OutputMode) -> Result<()> {
|
||||||
|
let creds = config::resolve()?;
|
||||||
|
let client = Client::from_creds(&creds)?;
|
||||||
|
let keys = client.apikeys_list().await?;
|
||||||
|
let mut table = Table::new([
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"prefix",
|
||||||
|
"scopes",
|
||||||
|
"app_id",
|
||||||
|
"expires_at",
|
||||||
|
"last_used_at",
|
||||||
|
"created_at",
|
||||||
|
]);
|
||||||
|
for k in keys {
|
||||||
|
table.row([
|
||||||
|
k.id.to_string(),
|
||||||
|
k.name,
|
||||||
|
k.prefix,
|
||||||
|
k.scopes
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.as_str())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(","),
|
||||||
|
k.app_id
|
||||||
|
.map(|a| a.to_string())
|
||||||
|
.unwrap_or_else(|| "-".into()),
|
||||||
|
k.expires_at
|
||||||
|
.map(|t| t.to_rfc3339())
|
||||||
|
.unwrap_or_else(|| "-".into()),
|
||||||
|
k.last_used_at
|
||||||
|
.map(|t| t.to_rfc3339())
|
||||||
|
.unwrap_or_else(|| "-".into()),
|
||||||
|
k.created_at.to_rfc3339(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
table.print(mode);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn rm(id: &str) -> Result<()> {
|
||||||
|
let creds = config::resolve()?;
|
||||||
|
let client = Client::from_creds(&creds)?;
|
||||||
|
client.apikeys_delete(id).await?;
|
||||||
|
println!("Revoked api-key {id}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_scopes(raw: &[String]) -> Result<Vec<Scope>> {
|
||||||
|
if raw.is_empty() {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"at least one `--scope` is required (e.g. --scope script:read)"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
raw.iter()
|
||||||
|
.map(|s| Scope::from_wire(s).ok_or_else(|| anyhow!("unknown scope: {s}")))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `--expires` accepts either RFC 3339 (`2026-12-31T23:59:59Z`) or a
|
||||||
|
/// shorthand `<N>d` / `<N>h` / `<N>m` (days / hours / minutes from now).
|
||||||
|
/// Shorthand wins for the common "key good for 30 days" case; full
|
||||||
|
/// RFC 3339 keeps the door open for precise cutoffs.
|
||||||
|
fn parse_expires(raw: &str) -> Result<DateTime<Utc>> {
|
||||||
|
if let Some(spec) = raw.strip_suffix('d') {
|
||||||
|
let days: i64 = spec.parse().map_err(|_| anyhow!("bad days: {raw}"))?;
|
||||||
|
return Ok(Utc::now() + chrono::Duration::days(days));
|
||||||
|
}
|
||||||
|
if let Some(spec) = raw.strip_suffix('h') {
|
||||||
|
let hours: i64 = spec.parse().map_err(|_| anyhow!("bad hours: {raw}"))?;
|
||||||
|
return Ok(Utc::now() + chrono::Duration::hours(hours));
|
||||||
|
}
|
||||||
|
if let Some(spec) = raw.strip_suffix('m') {
|
||||||
|
let mins: i64 = spec.parse().map_err(|_| anyhow!("bad minutes: {raw}"))?;
|
||||||
|
return Ok(Utc::now() + chrono::Duration::minutes(mins));
|
||||||
|
}
|
||||||
|
DateTime::parse_from_rfc3339(raw)
|
||||||
|
.map(|d| d.with_timezone(&Utc))
|
||||||
|
.map_err(|e| anyhow!("expected RFC 3339 or `<N>d/h/m`, got {raw:?}: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_scopes_accepts_wire_form() {
|
||||||
|
let scopes = parse_scopes(&["script:read".into(), "log:read".into()]).unwrap();
|
||||||
|
assert_eq!(scopes, vec![Scope::ScriptRead, Scope::LogRead]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_scopes_rejects_empty() {
|
||||||
|
let err = parse_scopes(&[]).unwrap_err();
|
||||||
|
assert!(format!("{err}").contains("at least one"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_scopes_rejects_unknown() {
|
||||||
|
let err = parse_scopes(&["script:nope".into()]).unwrap_err();
|
||||||
|
assert!(format!("{err}").contains("unknown scope"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_expires_days_shorthand() {
|
||||||
|
let d = parse_expires("7d").unwrap();
|
||||||
|
let diff = (d - Utc::now()).num_days();
|
||||||
|
assert!((6..=7).contains(&diff), "got {diff}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_expires_rfc3339_passes_through() {
|
||||||
|
let d = parse_expires("2030-01-01T00:00:00Z").unwrap();
|
||||||
|
assert_eq!(d.timestamp(), 1893456000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_expires_garbage_errors() {
|
||||||
|
assert!(parse_expires("tomorrow").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
84
crates/picloud-cli/src/cmds/apps.rs
Normal file
84
crates/picloud-cli/src/cmds/apps.rs
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
//! `pic apps` subcommands: `ls`, `create`, `show`, `delete`.
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use picloud_shared::AppRole;
|
||||||
|
|
||||||
|
use crate::client::{Client, CreateAppBody};
|
||||||
|
use crate::config;
|
||||||
|
use crate::output::{KvBlock, OutputMode, Table};
|
||||||
|
|
||||||
|
pub async fn ls(mode: OutputMode) -> Result<()> {
|
||||||
|
let creds = config::resolve()?;
|
||||||
|
let client = Client::from_creds(&creds)?;
|
||||||
|
let apps = client.apps_list().await?;
|
||||||
|
let mut table = Table::new(["slug", "name", "my_role", "created_at"]);
|
||||||
|
for app in apps {
|
||||||
|
// The list endpoint returns App without my_role. We do a per-app
|
||||||
|
// lookup only on demand; for `ls` we leave the column dashed so
|
||||||
|
// the call stays cheap (one HTTP request).
|
||||||
|
table.row([
|
||||||
|
app.slug.clone(),
|
||||||
|
app.name.clone(),
|
||||||
|
"-".to_string(),
|
||||||
|
app.created_at.to_rfc3339(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
table.print(mode);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create(slug: &str, name: Option<&str>, description: Option<&str>) -> Result<()> {
|
||||||
|
let creds = config::resolve()?;
|
||||||
|
let client = Client::from_creds(&creds)?;
|
||||||
|
let body = CreateAppBody {
|
||||||
|
slug,
|
||||||
|
name: name.unwrap_or(slug),
|
||||||
|
description,
|
||||||
|
};
|
||||||
|
let app = client.apps_create(&body).await?;
|
||||||
|
println!("Created app {}", app.slug);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `pic apps show <slug>` — single-app inspect using the lookup
|
||||||
|
/// endpoint, which carries `my_role` for the caller (the `ls` endpoint
|
||||||
|
/// doesn't).
|
||||||
|
pub async fn show(ident: &str, mode: OutputMode) -> Result<()> {
|
||||||
|
let creds = config::resolve()?;
|
||||||
|
let client = Client::from_creds(&creds)?;
|
||||||
|
let lookup = client.apps_get(ident).await?;
|
||||||
|
let mut block = KvBlock::new();
|
||||||
|
block
|
||||||
|
.field("id", lookup.app.id.to_string())
|
||||||
|
.field("slug", lookup.app.slug.clone())
|
||||||
|
.field("name", lookup.app.name.clone())
|
||||||
|
.field(
|
||||||
|
"description",
|
||||||
|
lookup.app.description.clone().unwrap_or_else(|| "-".into()),
|
||||||
|
)
|
||||||
|
.field("my_role", role_label(lookup.my_role.as_ref()))
|
||||||
|
.field("created_at", lookup.app.created_at.to_rfc3339())
|
||||||
|
.field("updated_at", lookup.app.updated_at.to_rfc3339());
|
||||||
|
block.print(mode);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `pic apps delete <slug> [--force]`. Without `--force` the server
|
||||||
|
/// returns 409 if the app still owns scripts — surface that as a
|
||||||
|
/// useful error rather than swallowing.
|
||||||
|
pub async fn delete(ident: &str, force: bool) -> Result<()> {
|
||||||
|
let creds = config::resolve()?;
|
||||||
|
let client = Client::from_creds(&creds)?;
|
||||||
|
client.apps_delete(ident, force).await?;
|
||||||
|
println!("Deleted app {ident}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn role_label(role: Option<&AppRole>) -> String {
|
||||||
|
// Use the wire form so the CLI label matches what the dashboard
|
||||||
|
// shows and what the membership APIs accept.
|
||||||
|
match role {
|
||||||
|
Some(r) => r.as_str().to_string(),
|
||||||
|
None => "-".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
129
crates/picloud-cli/src/cmds/login.rs
Normal file
129
crates/picloud-cli/src/cmds/login.rs
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
//! `pic login` — primary auth entry point.
|
||||||
|
//!
|
||||||
|
//! Two flows:
|
||||||
|
//! * **username + password** (default, interactive): POST
|
||||||
|
//! `/api/v1/admin/auth/login` with the credentials and persist the
|
||||||
|
//! returned session token. Mirrors the dashboard's login form.
|
||||||
|
//! * **paste-a-token** (`--token <T>`, or `PICLOUD_TOKEN` env): skip
|
||||||
|
//! the credential exchange and persist a bearer string directly.
|
||||||
|
//! Used by CI and by anyone using a long-lived API key minted via
|
||||||
|
//! `pic api-keys mint`. Validated against `/auth/me` before save.
|
||||||
|
//!
|
||||||
|
//! `--url <U>` (or `PICLOUD_URL`) overrides the URL prompt non-interactively.
|
||||||
|
|
||||||
|
use std::io::{self, BufRead, Write};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use picloud_shared::InstanceRole;
|
||||||
|
|
||||||
|
use crate::client::{self, Client};
|
||||||
|
use crate::config::{save, Credentials};
|
||||||
|
|
||||||
|
const DEFAULT_URL: &str = "http://localhost:8000";
|
||||||
|
|
||||||
|
pub async fn run(url_arg: Option<&str>, token_arg: Option<&str>) -> Result<()> {
|
||||||
|
let url = resolve_url(url_arg)?;
|
||||||
|
let token_from_env = std::env::var("PICLOUD_TOKEN")
|
||||||
|
.ok()
|
||||||
|
.filter(|s| !s.is_empty());
|
||||||
|
let bearer_token = token_arg.map(str::to_string).or(token_from_env);
|
||||||
|
|
||||||
|
let (token, username, role) = match bearer_token {
|
||||||
|
Some(t) => login_with_bearer(&url, &t).await?,
|
||||||
|
None => login_with_password(&url).await?,
|
||||||
|
};
|
||||||
|
|
||||||
|
let creds = Credentials {
|
||||||
|
url: url.clone(),
|
||||||
|
token,
|
||||||
|
username: username.clone(),
|
||||||
|
};
|
||||||
|
save(&creds)?;
|
||||||
|
println!(
|
||||||
|
"Logged in as {username} ({}) at {url}",
|
||||||
|
instance_role_label(&role)
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn login_with_password(url: &str) -> Result<(String, String, InstanceRole)> {
|
||||||
|
let username = prompt_line("Username: ")?;
|
||||||
|
if username.is_empty() {
|
||||||
|
anyhow::bail!("username is required");
|
||||||
|
}
|
||||||
|
let password = read_password()?;
|
||||||
|
let resp = client::auth_login(url, &username, &password).await?;
|
||||||
|
Ok((resp.token, resp.user.username, resp.user.instance_role))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a password without echoing it where possible. Falls back to a
|
||||||
|
/// plain stdin read when no controlling terminal is attached — CI
|
||||||
|
/// systems and `cargo test`'s piped stdin both land here, and dying
|
||||||
|
/// outright would block scripted use entirely. The fallback is louder
|
||||||
|
/// (visible characters), but it's that or no functioning login.
|
||||||
|
fn read_password() -> Result<String> {
|
||||||
|
match rpassword::prompt_password("Password: ") {
|
||||||
|
Ok(p) => Ok(p),
|
||||||
|
Err(_) => {
|
||||||
|
eprint!("Password: ");
|
||||||
|
io::stderr().flush()?;
|
||||||
|
let mut buf = String::new();
|
||||||
|
io::stdin()
|
||||||
|
.lock()
|
||||||
|
.read_line(&mut buf)
|
||||||
|
.context("reading password from stdin")?;
|
||||||
|
Ok(buf.trim_end_matches(['\r', '\n']).to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bearer-token path: validate against `/auth/me` so a typo doesn't get
|
||||||
|
/// persisted, then trust the username the server reports rather than
|
||||||
|
/// whatever the user typed (which they didn't type at all in this mode).
|
||||||
|
async fn login_with_bearer(url: &str, token: &str) -> Result<(String, String, InstanceRole)> {
|
||||||
|
let client = Client::new(url, token)?;
|
||||||
|
let me = client.auth_me().await?;
|
||||||
|
Ok((token.to_string(), me.username, me.instance_role))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn instance_role_label(role: &InstanceRole) -> &'static str {
|
||||||
|
match role {
|
||||||
|
InstanceRole::Owner => "owner",
|
||||||
|
InstanceRole::Admin => "admin",
|
||||||
|
InstanceRole::Member => "member",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_url(url_arg: Option<&str>) -> Result<String> {
|
||||||
|
if let Some(u) = url_arg {
|
||||||
|
return Ok(u.trim_end_matches('/').to_string());
|
||||||
|
}
|
||||||
|
if let Ok(env_url) = std::env::var("PICLOUD_URL") {
|
||||||
|
if !env_url.is_empty() {
|
||||||
|
return Ok(env_url.trim_end_matches('/').to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let typed = prompt_with_default("PiCloud URL", DEFAULT_URL)?;
|
||||||
|
Ok(typed.trim_end_matches('/').to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prompt_line(label: &str) -> Result<String> {
|
||||||
|
print!("{label}");
|
||||||
|
io::stdout().flush()?;
|
||||||
|
let mut buf = String::new();
|
||||||
|
io::stdin().lock().read_line(&mut buf)?;
|
||||||
|
Ok(buf.trim().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prompt_with_default(label: &str, default: &str) -> Result<String> {
|
||||||
|
print!("{label} [{default}]: ");
|
||||||
|
io::stdout().flush()?;
|
||||||
|
let mut buf = String::new();
|
||||||
|
io::stdin().lock().read_line(&mut buf)?;
|
||||||
|
let trimmed = buf.trim();
|
||||||
|
Ok(if trimmed.is_empty() {
|
||||||
|
default.to_string()
|
||||||
|
} else {
|
||||||
|
trimmed.to_string()
|
||||||
|
})
|
||||||
|
}
|
||||||
29
crates/picloud-cli/src/cmds/logout.rs
Normal file
29
crates/picloud-cli/src/cmds/logout.rs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
//! `pic logout` — revoke the saved session server-side, then wipe the
|
||||||
|
//! local credentials file.
|
||||||
|
//!
|
||||||
|
//! Idempotent: if the file doesn't exist or the server already forgot
|
||||||
|
//! the session, we still succeed. The point is leaving the user in a
|
||||||
|
//! clean "no token" state, not enforcing that a session existed.
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
use crate::client::Client;
|
||||||
|
use crate::config;
|
||||||
|
|
||||||
|
pub async fn run() -> Result<()> {
|
||||||
|
// Load before delete so we have a token to POST /logout with; if
|
||||||
|
// there's no creds file there's also nothing to revoke server-side.
|
||||||
|
let creds = config::load().ok();
|
||||||
|
|
||||||
|
if let Some(creds) = creds {
|
||||||
|
let client = Client::from_creds(&creds)?;
|
||||||
|
// Best-effort: a 4xx (token already invalid) or network error
|
||||||
|
// shouldn't block the local wipe. The whole point of logout is
|
||||||
|
// leaving no credentials on disk.
|
||||||
|
let _ = client.auth_logout().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
config::delete()?;
|
||||||
|
println!("Logged out");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
79
crates/picloud-cli/src/cmds/logs.rs
Normal file
79
crates/picloud-cli/src/cmds/logs.rs
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
//! `pic logs <script-id>` — print recent execution log rows.
|
||||||
|
//!
|
||||||
|
//! In TSV mode emits a header + truncated-summary rows (`pic logs` was
|
||||||
|
//! previously headerless — inconsistent with `apps ls` / `scripts ls`).
|
||||||
|
//! In JSON mode emits the raw `ExecutionLog` array (no truncation),
|
||||||
|
//! letting `jq` consumers see request/response bodies in full.
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use picloud_shared::{ExecutionLog, ExecutionStatus};
|
||||||
|
|
||||||
|
use crate::client::Client;
|
||||||
|
use crate::config;
|
||||||
|
use crate::output::{OutputMode, Table};
|
||||||
|
|
||||||
|
pub async fn run(script_id: &str, limit: u32, mode: OutputMode) -> Result<()> {
|
||||||
|
let creds = config::resolve()?;
|
||||||
|
let client = Client::from_creds(&creds)?;
|
||||||
|
let entries = client.logs_list(script_id, limit).await?;
|
||||||
|
match mode {
|
||||||
|
OutputMode::Tsv => render_tsv(&entries),
|
||||||
|
OutputMode::Json => render_json(&entries),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_tsv(entries: &[ExecutionLog]) {
|
||||||
|
let mut table = Table::new(["created_at", "status", "summary"]);
|
||||||
|
for e in entries {
|
||||||
|
let summary = summarize(&e.response_body, &e.script_logs);
|
||||||
|
table.row([
|
||||||
|
e.created_at.to_rfc3339(),
|
||||||
|
status_label(&e.status).to_string(),
|
||||||
|
truncate(&summary, 120),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
table.print(OutputMode::Tsv);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_json(entries: &[ExecutionLog]) {
|
||||||
|
// Pretty for human jq-piping; consumers that want compact can pipe
|
||||||
|
// through `jq -c`.
|
||||||
|
let s = serde_json::to_string_pretty(entries).unwrap_or_else(|_| "[]".to_string());
|
||||||
|
println!("{s}");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status_label(s: &ExecutionStatus) -> &'static str {
|
||||||
|
match s {
|
||||||
|
ExecutionStatus::Success => "success",
|
||||||
|
ExecutionStatus::Error => "error",
|
||||||
|
ExecutionStatus::Timeout => "timeout",
|
||||||
|
ExecutionStatus::BudgetExceeded => "budget_exceeded",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn summarize(response_body: &Option<serde_json::Value>, script_logs: &serde_json::Value) -> String {
|
||||||
|
// Prefer the last script-side log line (often the most useful for
|
||||||
|
// grepping). Fall back to the response body.
|
||||||
|
if let Some(arr) = script_logs.as_array() {
|
||||||
|
if let Some(last) = arr.last() {
|
||||||
|
if let Some(msg) = last.get("message").and_then(|m| m.as_str()) {
|
||||||
|
return msg.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response_body
|
||||||
|
.as_ref()
|
||||||
|
.map(ToString::to_string)
|
||||||
|
.unwrap_or_else(|| "-".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate(s: &str, n: usize) -> String {
|
||||||
|
let normalized = s.replace('\n', " ");
|
||||||
|
if normalized.chars().count() <= n {
|
||||||
|
normalized
|
||||||
|
} else {
|
||||||
|
let head: String = normalized.chars().take(n).collect();
|
||||||
|
format!("{head}…")
|
||||||
|
}
|
||||||
|
}
|
||||||
7
crates/picloud-cli/src/cmds/mod.rs
Normal file
7
crates/picloud-cli/src/cmds/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
pub mod api_keys;
|
||||||
|
pub mod apps;
|
||||||
|
pub mod login;
|
||||||
|
pub mod logout;
|
||||||
|
pub mod logs;
|
||||||
|
pub mod scripts;
|
||||||
|
pub mod whoami;
|
||||||
197
crates/picloud-cli/src/cmds/scripts.rs
Normal file
197
crates/picloud-cli/src/cmds/scripts.rs
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
//! `pic scripts ls | deploy | invoke | delete`.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::io::{self, Read, Write};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
use picloud_shared::AppId;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::client::{Client, CreateScriptBody};
|
||||||
|
use crate::config;
|
||||||
|
use crate::output::{OutputMode, Table};
|
||||||
|
|
||||||
|
pub async fn ls(app: Option<&str>, mode: OutputMode) -> Result<()> {
|
||||||
|
let creds = config::resolve()?;
|
||||||
|
let client = Client::from_creds(&creds)?;
|
||||||
|
|
||||||
|
let mut table = Table::new(["id", "app_slug", "name", "version", "updated_at"]);
|
||||||
|
|
||||||
|
if let Some(ident) = app {
|
||||||
|
let app = client.apps_get(ident).await?;
|
||||||
|
let scripts = client.scripts_list_by_app(&app.app.slug).await?;
|
||||||
|
for s in scripts {
|
||||||
|
table.row([
|
||||||
|
s.id.to_string(),
|
||||||
|
app.app.slug.clone(),
|
||||||
|
s.name,
|
||||||
|
s.version.to_string(),
|
||||||
|
s.updated_at.to_rfc3339(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No filter → use the single `GET /admin/scripts` call. Server
|
||||||
|
// filters by membership for `Member`; for `Admin`/`Owner` it
|
||||||
|
// returns every script. Two requests total (apps + scripts) run
|
||||||
|
// in parallel; the per-app walk we used to do here aborted on
|
||||||
|
// the first 404 when another caller deleted an app mid-listing,
|
||||||
|
// and was the entire reason a 5× retry existed in the tests.
|
||||||
|
let (apps, scripts) = tokio::try_join!(client.apps_list(), client.scripts_list_all())?;
|
||||||
|
let slug_by_id: HashMap<AppId, String> = apps.into_iter().map(|a| (a.id, a.slug)).collect();
|
||||||
|
for s in scripts {
|
||||||
|
let app_slug = slug_by_id
|
||||||
|
.get(&s.app_id)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "-".to_string());
|
||||||
|
table.row([
|
||||||
|
s.id.to_string(),
|
||||||
|
app_slug,
|
||||||
|
s.name,
|
||||||
|
s.version.to_string(),
|
||||||
|
s.updated_at.to_rfc3339(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
table.print(mode);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn deploy(
|
||||||
|
file: &Path,
|
||||||
|
app_ident: &str,
|
||||||
|
name_override: Option<&str>,
|
||||||
|
description: Option<&str>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let creds = config::resolve()?;
|
||||||
|
let client = Client::from_creds(&creds)?;
|
||||||
|
|
||||||
|
let source =
|
||||||
|
std::fs::read_to_string(file).with_context(|| format!("reading {}", file.display()))?;
|
||||||
|
let name = match name_override {
|
||||||
|
Some(n) => n.to_string(),
|
||||||
|
None => file
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.map(str::to_string)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
anyhow!(
|
||||||
|
"could not derive script name from path {} (use --name)",
|
||||||
|
file.display()
|
||||||
|
)
|
||||||
|
})?,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Slug-or-id resolution: a single GET satisfies both lookups and
|
||||||
|
// gives us the canonical app_id needed for create.
|
||||||
|
let app = client.apps_get(app_ident).await?;
|
||||||
|
|
||||||
|
let existing = client.scripts_list_by_app(app_ident).await?;
|
||||||
|
if let Some(s) = existing.into_iter().find(|s| s.name == name) {
|
||||||
|
let updated = client
|
||||||
|
.scripts_update_source(&s.id.to_string(), &source)
|
||||||
|
.await?;
|
||||||
|
println!("Updated {} v{}", updated.name, updated.version);
|
||||||
|
} else {
|
||||||
|
let body = CreateScriptBody {
|
||||||
|
app_id: app.app.id,
|
||||||
|
name: &name,
|
||||||
|
description,
|
||||||
|
source: &source,
|
||||||
|
};
|
||||||
|
let created = client.scripts_create(&body).await?;
|
||||||
|
println!("Created {} v{}", created.name, created.version);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn invoke(id: &str, body_arg: Option<&str>, headers: &[(String, String)]) -> Result<()> {
|
||||||
|
let creds = config::resolve()?;
|
||||||
|
let client = Client::from_creds(&creds)?;
|
||||||
|
|
||||||
|
let body = parse_body_arg(body_arg)?;
|
||||||
|
let resp = client.execute(id, body, headers).await?;
|
||||||
|
// Status to stderr so stdout stays JSON for piping into jq.
|
||||||
|
let _ = writeln!(io::stderr(), "<- HTTP {}", resp.status_code);
|
||||||
|
let pretty = serde_json::to_string_pretty(&resp.body).unwrap_or_else(|_| resp.body.to_string());
|
||||||
|
println!("{pretty}");
|
||||||
|
if (200..400).contains(&resp.status_code) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(anyhow!("execute returned HTTP {}", resp.status_code))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `pic scripts delete <id>`. Requires `AppAdmin` on the owning app
|
||||||
|
/// server-side, which is stricter than the edit endpoints — Editor
|
||||||
|
/// can deploy/update but not destroy. Surfaces that as a 403 with the
|
||||||
|
/// usual role hint.
|
||||||
|
pub async fn delete(id: &str) -> Result<()> {
|
||||||
|
let creds = config::resolve()?;
|
||||||
|
let client = Client::from_creds(&creds)?;
|
||||||
|
client.scripts_delete(id).await?;
|
||||||
|
println!("Deleted script {id}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_body_arg(arg: Option<&str>) -> Result<Value> {
|
||||||
|
match arg {
|
||||||
|
None => Ok(Value::Object(serde_json::Map::new())),
|
||||||
|
Some("@-") => {
|
||||||
|
let mut buf = String::new();
|
||||||
|
io::stdin()
|
||||||
|
.read_to_string(&mut buf)
|
||||||
|
.context("reading stdin")?;
|
||||||
|
parse_or_string(&buf)
|
||||||
|
}
|
||||||
|
Some(raw) if raw.starts_with('@') => {
|
||||||
|
let path = &raw[1..];
|
||||||
|
let text = std::fs::read_to_string(path)
|
||||||
|
.with_context(|| format!("reading body file {path}"))?;
|
||||||
|
parse_or_string(&text)
|
||||||
|
}
|
||||||
|
Some(raw) => parse_or_string(raw),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_or_string(s: &str) -> Result<Value> {
|
||||||
|
let trimmed = s.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Ok(Value::Object(serde_json::Map::new()));
|
||||||
|
}
|
||||||
|
serde_json::from_str(trimmed)
|
||||||
|
.with_context(|| format!("body is not valid JSON: {}", truncate(trimmed, 80)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate(s: &str, n: usize) -> String {
|
||||||
|
if s.len() <= n {
|
||||||
|
s.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{}…", &s[..n])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_body_inline_json() {
|
||||||
|
let v = parse_body_arg(Some(r#"{"x":1}"#)).unwrap();
|
||||||
|
assert_eq!(v["x"], 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_body_none_is_empty_object() {
|
||||||
|
let v = parse_body_arg(None).unwrap();
|
||||||
|
assert!(v.is_object());
|
||||||
|
assert_eq!(v.as_object().unwrap().len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_body_invalid_json_reports() {
|
||||||
|
let err = parse_body_arg(Some("not-json{")).unwrap_err();
|
||||||
|
let msg = format!("{err:#}");
|
||||||
|
assert!(msg.contains("not valid JSON"), "got: {msg}");
|
||||||
|
}
|
||||||
|
}
|
||||||
34
crates/picloud-cli/src/cmds/whoami.rs
Normal file
34
crates/picloud-cli/src/cmds/whoami.rs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
//! `pic whoami` — re-validates the saved token by hitting `/auth/me`
|
||||||
|
//! every time. Cached username in the credentials file is for
|
||||||
|
//! display-only contexts; this command is the source of truth.
|
||||||
|
//!
|
||||||
|
//! TSV output uses `KvBlock` (aligned `key: value` rows), JSON output
|
||||||
|
//! is a flat object — both downstream-friendly without the user having
|
||||||
|
//! to parse a headerless tab-line.
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use picloud_shared::InstanceRole;
|
||||||
|
|
||||||
|
use crate::client::Client;
|
||||||
|
use crate::config;
|
||||||
|
use crate::output::{KvBlock, OutputMode};
|
||||||
|
|
||||||
|
pub async fn run(mode: OutputMode) -> Result<()> {
|
||||||
|
let creds = config::resolve()?;
|
||||||
|
let client = Client::from_creds(&creds)?;
|
||||||
|
let me = client.auth_me().await?;
|
||||||
|
let role = match me.instance_role {
|
||||||
|
InstanceRole::Owner => "owner",
|
||||||
|
InstanceRole::Admin => "admin",
|
||||||
|
InstanceRole::Member => "member",
|
||||||
|
};
|
||||||
|
let email = me.email.as_deref().unwrap_or("-");
|
||||||
|
let mut block = KvBlock::new();
|
||||||
|
block
|
||||||
|
.field("username", me.username)
|
||||||
|
.field("role", role)
|
||||||
|
.field("email", email)
|
||||||
|
.field("url", creds.url.clone());
|
||||||
|
block.print(mode);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
153
crates/picloud-cli/src/config.rs
Normal file
153
crates/picloud-cli/src/config.rs
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
//! On-disk credentials store.
|
||||||
|
//!
|
||||||
|
//! Path is resolved via `directories::ProjectDirs` so the file lives in
|
||||||
|
//! the platform-appropriate config dir (XDG on Linux, Library on macOS,
|
||||||
|
//! AppData on Windows). On POSIX the file is forced to mode 0600 so the
|
||||||
|
//! pasted bearer token isn't world-readable.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
use directories::ProjectDirs;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct Credentials {
|
||||||
|
pub url: String,
|
||||||
|
pub token: String,
|
||||||
|
pub username: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the credentials file path. Honors `PICLOUD_CONFIG_DIR` as an
|
||||||
|
/// override (used by tests to redirect to a tempdir) before falling
|
||||||
|
/// back to the platform default.
|
||||||
|
pub fn credentials_path() -> Result<PathBuf> {
|
||||||
|
if let Ok(dir) = std::env::var("PICLOUD_CONFIG_DIR") {
|
||||||
|
return Ok(PathBuf::from(dir).join("credentials"));
|
||||||
|
}
|
||||||
|
let dirs = ProjectDirs::from("dev", "picloud", "picloud")
|
||||||
|
.ok_or_else(|| anyhow!("could not determine config directory"))?;
|
||||||
|
Ok(dirs.config_dir().join("credentials"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load() -> Result<Credentials> {
|
||||||
|
let path = credentials_path()?;
|
||||||
|
let body = fs::read_to_string(&path).with_context(|| {
|
||||||
|
format!(
|
||||||
|
"no credentials at {}. run `pic login` first",
|
||||||
|
path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
toml::from_str(&body).with_context(|| format!("failed to parse {}", path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolution order used by every non-login command:
|
||||||
|
/// 1. If both `PICLOUD_URL` and `PICLOUD_TOKEN` are set (and non-empty),
|
||||||
|
/// use them directly. Matches gcloud/aws/kubectl semantics — env
|
||||||
|
/// wins so CI never accidentally reads a developer's stale file.
|
||||||
|
/// 2. Otherwise fall back to the on-disk credentials file.
|
||||||
|
///
|
||||||
|
/// Username is best-effort: env mode has no way to know the real one
|
||||||
|
/// (no round-trip to `/auth/me`), so it shows as `"-"` in `whoami`
|
||||||
|
/// output. Callers that need the canonical username re-fetch via
|
||||||
|
/// `Client::auth_me`.
|
||||||
|
pub fn resolve() -> Result<Credentials> {
|
||||||
|
if let (Ok(url), Ok(token)) = (std::env::var("PICLOUD_URL"), std::env::var("PICLOUD_TOKEN")) {
|
||||||
|
if !url.is_empty() && !token.is_empty() {
|
||||||
|
return Ok(Credentials {
|
||||||
|
url,
|
||||||
|
token,
|
||||||
|
username: "-".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete the on-disk credentials file. Idempotent — silently succeeds
|
||||||
|
/// if the file is already gone (the user already logged out, or never
|
||||||
|
/// logged in to begin with).
|
||||||
|
pub fn delete() -> Result<()> {
|
||||||
|
let path = credentials_path()?;
|
||||||
|
match fs::remove_file(&path) {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||||
|
Err(err) => Err(err).with_context(|| format!("removing {}", path.display())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(creds: &Credentials) -> Result<()> {
|
||||||
|
let path = credentials_path()?;
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
|
||||||
|
}
|
||||||
|
let body = toml::to_string(creds).context("serializing credentials")?;
|
||||||
|
write_private(&path, body.as_bytes())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn write_private(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||||
|
use std::os::unix::fs::OpenOptionsExt;
|
||||||
|
let mut f = fs::OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create(true)
|
||||||
|
.truncate(true)
|
||||||
|
.mode(0o600)
|
||||||
|
.open(path)
|
||||||
|
.with_context(|| format!("opening {}", path.display()))?;
|
||||||
|
f.write_all(bytes)
|
||||||
|
.with_context(|| format!("writing {}", path.display()))?;
|
||||||
|
// Belt-and-suspenders: re-set perms in case the file already existed
|
||||||
|
// with a wider mode (mode() on create doesn't downgrade existing).
|
||||||
|
let mut perms = fs::metadata(path)?.permissions();
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
perms.set_mode(0o600);
|
||||||
|
fs::set_permissions(path, perms)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
fn write_private(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||||
|
fs::write(path, bytes).with_context(|| format!("writing {}", path.display()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn roundtrip_toml() {
|
||||||
|
let creds = Credentials {
|
||||||
|
url: "http://localhost:8000".to_string(),
|
||||||
|
token: "pic_abc".to_string(),
|
||||||
|
username: "admin".to_string(),
|
||||||
|
};
|
||||||
|
let serialized = toml::to_string(&creds).unwrap();
|
||||||
|
let parsed: Credentials = toml::from_str(&serialized).unwrap();
|
||||||
|
assert_eq!(creds, parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn posix_mode_is_0600() {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
std::env::set_var("PICLOUD_CONFIG_DIR", dir.path());
|
||||||
|
let creds = Credentials {
|
||||||
|
url: "http://localhost:8000".to_string(),
|
||||||
|
token: "pic_secret".to_string(),
|
||||||
|
username: "admin".to_string(),
|
||||||
|
};
|
||||||
|
save(&creds).unwrap();
|
||||||
|
let path = credentials_path().unwrap();
|
||||||
|
let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
|
||||||
|
assert_eq!(mode, 0o600, "credentials must be readable only by owner");
|
||||||
|
std::env::remove_var("PICLOUD_CONFIG_DIR");
|
||||||
|
}
|
||||||
|
}
|
||||||
268
crates/picloud-cli/src/main.rs
Normal file
268
crates/picloud-cli/src/main.rs
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
//! PiCloud command-line client.
|
||||||
|
//!
|
||||||
|
//! Thin client over the existing admin + execute HTTP surface — the
|
||||||
|
//! server gains nothing for the CLI; the CLI is just a developer
|
||||||
|
//! ergonomics layer over endpoints the dashboard already uses.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::ExitCode;
|
||||||
|
|
||||||
|
use clap::{Args, Parser, Subcommand};
|
||||||
|
|
||||||
|
mod client;
|
||||||
|
mod cmds;
|
||||||
|
mod config;
|
||||||
|
mod output;
|
||||||
|
|
||||||
|
use crate::output::OutputMode;
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(name = "pic", version, about = "PiCloud command-line client")]
|
||||||
|
struct Cli {
|
||||||
|
/// Output format for `ls` / `show` / `whoami` / `logs` commands.
|
||||||
|
/// TSV stays pipe-friendly; JSON is `jq`-ready.
|
||||||
|
#[arg(long, value_enum, global = true, default_value_t = OutputMode::Tsv)]
|
||||||
|
output: OutputMode,
|
||||||
|
|
||||||
|
#[command(subcommand)]
|
||||||
|
cmd: Cmd,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum Cmd {
|
||||||
|
/// Authenticate with the server. Default flow prompts for username
|
||||||
|
/// + password and saves the returned session token; `--token` skips
|
||||||
|
/// the password exchange and persists a bearer string directly (use
|
||||||
|
/// this for long-lived API keys minted via `pic api-keys mint`).
|
||||||
|
Login(LoginArgs),
|
||||||
|
|
||||||
|
/// Revoke the saved session server-side and delete the local
|
||||||
|
/// credentials file. Idempotent.
|
||||||
|
Logout,
|
||||||
|
|
||||||
|
/// Print the principal the saved token resolves to.
|
||||||
|
Whoami,
|
||||||
|
|
||||||
|
/// App management.
|
||||||
|
Apps {
|
||||||
|
#[command(subcommand)]
|
||||||
|
cmd: AppsCmd,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Script management.
|
||||||
|
Scripts {
|
||||||
|
#[command(subcommand)]
|
||||||
|
cmd: ScriptsCmd,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Long-lived bearer API key management.
|
||||||
|
#[command(name = "api-keys")]
|
||||||
|
ApiKeys {
|
||||||
|
#[command(subcommand)]
|
||||||
|
cmd: ApiKeysCmd,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Tail recent execution logs for a script.
|
||||||
|
Logs(LogsArgs),
|
||||||
|
|
||||||
|
/// Top-level alias for `pic scripts invoke <id>`.
|
||||||
|
Invoke(InvokeArgs),
|
||||||
|
|
||||||
|
/// Top-level alias for `pic scripts deploy <file> --app <slug>`.
|
||||||
|
Deploy(DeployArgs),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args)]
|
||||||
|
struct LoginArgs {
|
||||||
|
/// Override the URL prompt non-interactively. Also reads
|
||||||
|
/// `PICLOUD_URL`.
|
||||||
|
#[arg(long)]
|
||||||
|
url: Option<String>,
|
||||||
|
|
||||||
|
/// Skip the username + password exchange and persist this bearer
|
||||||
|
/// directly (validated against `/auth/me` first). Also reads
|
||||||
|
/// `PICLOUD_TOKEN`.
|
||||||
|
#[arg(long)]
|
||||||
|
token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum AppsCmd {
|
||||||
|
/// List apps the caller can see.
|
||||||
|
Ls,
|
||||||
|
|
||||||
|
/// Create a new app.
|
||||||
|
Create {
|
||||||
|
slug: String,
|
||||||
|
#[arg(long)]
|
||||||
|
name: Option<String>,
|
||||||
|
#[arg(long)]
|
||||||
|
description: Option<String>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Show a single app, including the caller's role in it.
|
||||||
|
Show { ident: String },
|
||||||
|
|
||||||
|
/// Delete an app. Without `--force`, the server rejects if the app
|
||||||
|
/// still owns scripts.
|
||||||
|
Delete {
|
||||||
|
ident: String,
|
||||||
|
#[arg(long)]
|
||||||
|
force: bool,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum ScriptsCmd {
|
||||||
|
/// List scripts. With `--app`, scoped to one app; without, one
|
||||||
|
/// `GET /admin/scripts` for everything the caller can see.
|
||||||
|
Ls {
|
||||||
|
#[arg(long)]
|
||||||
|
app: Option<String>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Upload a `.rhai` file. Patches the existing script with the
|
||||||
|
/// matching name in `--app` if one exists, otherwise creates it.
|
||||||
|
Deploy(DeployArgs),
|
||||||
|
|
||||||
|
/// POST to `/api/v1/execute/{id}`. Body via `--body @path`,
|
||||||
|
/// `--body @-` for stdin, or inline JSON.
|
||||||
|
Invoke(InvokeArgs),
|
||||||
|
|
||||||
|
/// Delete a script. Requires AppAdmin on the owning app.
|
||||||
|
Delete { id: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args)]
|
||||||
|
struct DeployArgs {
|
||||||
|
file: PathBuf,
|
||||||
|
#[arg(long)]
|
||||||
|
app: String,
|
||||||
|
#[arg(long)]
|
||||||
|
name: Option<String>,
|
||||||
|
#[arg(long)]
|
||||||
|
description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args)]
|
||||||
|
struct InvokeArgs {
|
||||||
|
id: String,
|
||||||
|
#[arg(long)]
|
||||||
|
body: Option<String>,
|
||||||
|
#[arg(short = 'H', long = "header", value_parser = client::parse_kv_header)]
|
||||||
|
headers: Vec<(String, String)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum ApiKeysCmd {
|
||||||
|
/// Mint a new long-lived bearer key. Token printed exactly once.
|
||||||
|
Mint {
|
||||||
|
name: String,
|
||||||
|
/// Repeat for multiple scopes: `--scope script:read --scope log:read`.
|
||||||
|
#[arg(long = "scope", required = true)]
|
||||||
|
scopes: Vec<String>,
|
||||||
|
/// Bind the key to a single app (slug or id). Rejects
|
||||||
|
/// `instance:*` scopes when set.
|
||||||
|
#[arg(long)]
|
||||||
|
app: Option<String>,
|
||||||
|
/// Absolute RFC 3339 (`2026-12-31T23:59:59Z`) or shorthand
|
||||||
|
/// `<N>d`/`<N>h`/`<N>m`.
|
||||||
|
#[arg(long)]
|
||||||
|
expires: Option<String>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// List the caller's keys (no `raw_token` after mint).
|
||||||
|
Ls,
|
||||||
|
|
||||||
|
/// Revoke a key by id.
|
||||||
|
Rm { id: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args)]
|
||||||
|
struct LogsArgs {
|
||||||
|
script_id: String,
|
||||||
|
#[arg(long, default_value_t = 50)]
|
||||||
|
limit: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main(flavor = "current_thread")]
|
||||||
|
async fn main() -> ExitCode {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
let mode = cli.output;
|
||||||
|
let result = match cli.cmd {
|
||||||
|
Cmd::Login(args) => cmds::login::run(args.url.as_deref(), args.token.as_deref()).await,
|
||||||
|
Cmd::Logout => cmds::logout::run().await,
|
||||||
|
Cmd::Whoami => cmds::whoami::run(mode).await,
|
||||||
|
Cmd::Apps { cmd: AppsCmd::Ls } => cmds::apps::ls(mode).await,
|
||||||
|
Cmd::Apps {
|
||||||
|
cmd:
|
||||||
|
AppsCmd::Create {
|
||||||
|
slug,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
},
|
||||||
|
} => cmds::apps::create(&slug, name.as_deref(), description.as_deref()).await,
|
||||||
|
Cmd::Apps {
|
||||||
|
cmd: AppsCmd::Show { ident },
|
||||||
|
} => cmds::apps::show(&ident, mode).await,
|
||||||
|
Cmd::Apps {
|
||||||
|
cmd: AppsCmd::Delete { ident, force },
|
||||||
|
} => cmds::apps::delete(&ident, force).await,
|
||||||
|
Cmd::Scripts {
|
||||||
|
cmd: ScriptsCmd::Ls { app },
|
||||||
|
} => cmds::scripts::ls(app.as_deref(), mode).await,
|
||||||
|
Cmd::Scripts {
|
||||||
|
cmd: ScriptsCmd::Deploy(args),
|
||||||
|
} => {
|
||||||
|
cmds::scripts::deploy(
|
||||||
|
&args.file,
|
||||||
|
&args.app,
|
||||||
|
args.name.as_deref(),
|
||||||
|
args.description.as_deref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
Cmd::Scripts {
|
||||||
|
cmd: ScriptsCmd::Invoke(args),
|
||||||
|
} => cmds::scripts::invoke(&args.id, args.body.as_deref(), &args.headers).await,
|
||||||
|
Cmd::Scripts {
|
||||||
|
cmd: ScriptsCmd::Delete { id },
|
||||||
|
} => cmds::scripts::delete(&id).await,
|
||||||
|
Cmd::ApiKeys {
|
||||||
|
cmd:
|
||||||
|
ApiKeysCmd::Mint {
|
||||||
|
name,
|
||||||
|
scopes,
|
||||||
|
app,
|
||||||
|
expires,
|
||||||
|
},
|
||||||
|
} => cmds::api_keys::mint(&name, &scopes, app.as_deref(), expires.as_deref(), mode).await,
|
||||||
|
Cmd::ApiKeys {
|
||||||
|
cmd: ApiKeysCmd::Ls,
|
||||||
|
} => cmds::api_keys::ls(mode).await,
|
||||||
|
Cmd::ApiKeys {
|
||||||
|
cmd: ApiKeysCmd::Rm { id },
|
||||||
|
} => cmds::api_keys::rm(&id).await,
|
||||||
|
Cmd::Logs(LogsArgs { script_id, limit }) => cmds::logs::run(&script_id, limit, mode).await,
|
||||||
|
Cmd::Invoke(args) => {
|
||||||
|
cmds::scripts::invoke(&args.id, args.body.as_deref(), &args.headers).await
|
||||||
|
}
|
||||||
|
Cmd::Deploy(args) => {
|
||||||
|
cmds::scripts::deploy(
|
||||||
|
&args.file,
|
||||||
|
&args.app,
|
||||||
|
args.name.as_deref(),
|
||||||
|
args.description.as_deref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(()) => ExitCode::SUCCESS,
|
||||||
|
Err(err) => {
|
||||||
|
output::print_error(&err);
|
||||||
|
ExitCode::FAILURE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
252
crates/picloud-cli/src/output.rs
Normal file
252
crates/picloud-cli/src/output.rs
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
//! Output rendering for the CLI.
|
||||||
|
//!
|
||||||
|
//! Two formats:
|
||||||
|
//! * **TSV** (default): aligned columns separated by `\t`. Stays
|
||||||
|
//! pipe-friendly — `pic apps ls | awk -F'\t' '{print $1}'` works
|
||||||
|
//! without parsing box-drawing.
|
||||||
|
//! * **JSON**: array of `{column: value, …}` objects (for tables) or
|
||||||
|
//! a flat object (for single-row `show`/`whoami`). Designed to be
|
||||||
|
//! `jq`-friendly without escaping the table column names.
|
||||||
|
//!
|
||||||
|
//! Mode is set globally by the top-level `--output` flag and threaded
|
||||||
|
//! through every command. Single-row commands (`whoami`, `apps show`)
|
||||||
|
//! use `KvBlock`; everything plural uses `Table`.
|
||||||
|
|
||||||
|
use std::io::{self, Write};
|
||||||
|
|
||||||
|
use clap::ValueEnum;
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, ValueEnum)]
|
||||||
|
#[clap(rename_all = "lowercase")]
|
||||||
|
pub enum OutputMode {
|
||||||
|
#[default]
|
||||||
|
Tsv,
|
||||||
|
Json,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Table — list views (`apps ls`, `scripts ls`, `logs`)
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub struct Table {
|
||||||
|
headers: Vec<String>,
|
||||||
|
rows: Vec<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Table {
|
||||||
|
pub fn new<I, S>(headers: I) -> Self
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = S>,
|
||||||
|
S: Into<String>,
|
||||||
|
{
|
||||||
|
Self {
|
||||||
|
headers: headers.into_iter().map(Into::into).collect(),
|
||||||
|
rows: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn row<I, S>(&mut self, cells: I) -> &mut Self
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = S>,
|
||||||
|
S: Into<String>,
|
||||||
|
{
|
||||||
|
self.rows.push(cells.into_iter().map(Into::into).collect());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_tsv(&self) -> String {
|
||||||
|
let mut widths: Vec<usize> = self.headers.iter().map(String::len).collect();
|
||||||
|
for row in &self.rows {
|
||||||
|
for (i, cell) in row.iter().enumerate() {
|
||||||
|
if i >= widths.len() {
|
||||||
|
widths.push(cell.len());
|
||||||
|
} else if cell.len() > widths[i] {
|
||||||
|
widths[i] = cell.len();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out = String::new();
|
||||||
|
write_row(&mut out, &self.headers, &widths);
|
||||||
|
for row in &self.rows {
|
||||||
|
write_row(&mut out, row, &widths);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JSON form: `[{header: cell, …}, …]`. Cells go in as strings even
|
||||||
|
/// when they happen to look like numbers — the CLI doesn't carry
|
||||||
|
/// type information all the way through (e.g., `version` is already
|
||||||
|
/// `to_string`'d at the call site). Consumers that need typed
|
||||||
|
/// numbers should parse `jq -r '.[].version|tonumber'`.
|
||||||
|
pub fn render_json(&self) -> String {
|
||||||
|
let arr: Vec<Value> = self
|
||||||
|
.rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| {
|
||||||
|
let mut obj = Map::new();
|
||||||
|
for (i, header) in self.headers.iter().enumerate() {
|
||||||
|
let cell = row.get(i).cloned().unwrap_or_default();
|
||||||
|
obj.insert(header.clone(), Value::String(cell));
|
||||||
|
}
|
||||||
|
Value::Object(obj)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
serde_json::to_string_pretty(&Value::Array(arr)).unwrap_or_else(|_| "[]".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn print(&self, mode: OutputMode) {
|
||||||
|
let s = match mode {
|
||||||
|
OutputMode::Tsv => self.render_tsv(),
|
||||||
|
OutputMode::Json => {
|
||||||
|
let mut s = self.render_json();
|
||||||
|
s.push('\n');
|
||||||
|
s
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Best-effort write — broken pipe from `| head` etc. shouldn't
|
||||||
|
// surface as an error.
|
||||||
|
let _ = io::stdout().write_all(s.as_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_row(out: &mut String, row: &[String], widths: &[usize]) {
|
||||||
|
for (i, cell) in row.iter().enumerate() {
|
||||||
|
if i > 0 {
|
||||||
|
out.push('\t');
|
||||||
|
}
|
||||||
|
out.push_str(cell);
|
||||||
|
// Right-pad with spaces so tabs land on the column grid for
|
||||||
|
// human readers. Skip on the final column.
|
||||||
|
if i + 1 < row.len() {
|
||||||
|
let w = widths.get(i).copied().unwrap_or(cell.len());
|
||||||
|
for _ in cell.len()..w {
|
||||||
|
out.push(' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// KvBlock — single-row views (`whoami`, `apps show`)
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// One row's worth of fields, rendered as aligned `key: value` lines in
|
||||||
|
/// TSV mode (one line per field — easier on the eye than a 1-row table)
|
||||||
|
/// or a flat JSON object.
|
||||||
|
pub struct KvBlock {
|
||||||
|
fields: Vec<(String, String)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KvBlock {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self { fields: Vec::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn field(&mut self, key: impl Into<String>, value: impl Into<String>) -> &mut Self {
|
||||||
|
self.fields.push((key.into(), value.into()));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_tsv(&self) -> String {
|
||||||
|
let key_width = self.fields.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
|
||||||
|
let mut out = String::new();
|
||||||
|
for (k, v) in &self.fields {
|
||||||
|
out.push_str(k);
|
||||||
|
for _ in k.len()..key_width {
|
||||||
|
out.push(' ');
|
||||||
|
}
|
||||||
|
out.push('\t');
|
||||||
|
out.push_str(v);
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_json(&self) -> String {
|
||||||
|
let mut obj = Map::new();
|
||||||
|
for (k, v) in &self.fields {
|
||||||
|
obj.insert(k.clone(), Value::String(v.clone()));
|
||||||
|
}
|
||||||
|
serde_json::to_string_pretty(&Value::Object(obj)).unwrap_or_else(|_| "{}".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn print(&self, mode: OutputMode) {
|
||||||
|
let s = match mode {
|
||||||
|
OutputMode::Tsv => self.render_tsv(),
|
||||||
|
OutputMode::Json => {
|
||||||
|
let mut s = self.render_json();
|
||||||
|
s.push('\n');
|
||||||
|
s
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let _ = io::stdout().write_all(s.as_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Errors
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub fn print_error(err: &anyhow::Error) {
|
||||||
|
let mut stderr = io::stderr();
|
||||||
|
let _ = writeln!(stderr, "error: {err:#}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn table_aligns_columns_tsv() {
|
||||||
|
let mut t = Table::new(["slug", "name"]);
|
||||||
|
t.row(["a", "Alpha"]).row(["bravo", "B"]);
|
||||||
|
let out = t.render_tsv();
|
||||||
|
assert_eq!(out, "slug \tname\na \tAlpha\nbravo\tB\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn table_empty_rows_tsv() {
|
||||||
|
let t = Table::new(["a", "b"]);
|
||||||
|
assert_eq!(t.render_tsv(), "a\tb\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn table_render_json_is_array_of_objects() {
|
||||||
|
let mut t = Table::new(["slug", "name"]);
|
||||||
|
t.row(["a", "Alpha"]).row(["bravo", "B"]);
|
||||||
|
let raw = t.render_json();
|
||||||
|
let v: Value = serde_json::from_str(&raw).expect("valid JSON");
|
||||||
|
let arr = v.as_array().expect("array");
|
||||||
|
assert_eq!(arr.len(), 2);
|
||||||
|
assert_eq!(arr[0]["slug"], "a");
|
||||||
|
assert_eq!(arr[0]["name"], "Alpha");
|
||||||
|
assert_eq!(arr[1]["slug"], "bravo");
|
||||||
|
assert_eq!(arr[1]["name"], "B");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn kv_block_tsv_aligns_keys() {
|
||||||
|
let mut b = KvBlock::new();
|
||||||
|
b.field("username", "admin").field("role", "owner");
|
||||||
|
let out = b.render_tsv();
|
||||||
|
// username (8 chars) defines the key width.
|
||||||
|
assert_eq!(out, "username\tadmin\nrole \towner\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn kv_block_json_is_flat_object() {
|
||||||
|
let mut b = KvBlock::new();
|
||||||
|
b.field("username", "admin").field("role", "owner");
|
||||||
|
let raw = b.render_json();
|
||||||
|
let v: Value = serde_json::from_str(&raw).expect("valid JSON");
|
||||||
|
assert_eq!(v["username"], "admin");
|
||||||
|
assert_eq!(v["role"], "owner");
|
||||||
|
}
|
||||||
|
}
|
||||||
170
crates/picloud-cli/tests/api_keys.rs
Normal file
170
crates/picloud-cli/tests/api_keys.rs
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
//! `pic api-keys` — mint / ls / rm journeys.
|
||||||
|
//!
|
||||||
|
//! Server semantics asserted here:
|
||||||
|
//! * `mint` emits the `raw_token` *exactly once* and never on `ls`.
|
||||||
|
//! * A minted key is a valid bearer for `/auth/me`.
|
||||||
|
//! * After `rm`, the same token is rejected (401).
|
||||||
|
|
||||||
|
use predicates::prelude::*;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::common;
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn mint_prints_raw_token_once_and_ls_omits_it() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let name = format!("pic-cli-mint-{}", common::unique_slug("k"));
|
||||||
|
|
||||||
|
let out = common::pic_as(&env)
|
||||||
|
.args([
|
||||||
|
"--output",
|
||||||
|
"json",
|
||||||
|
"api-keys",
|
||||||
|
"mint",
|
||||||
|
&name,
|
||||||
|
"--scope",
|
||||||
|
"script:read",
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.expect("api-keys mint");
|
||||||
|
assert!(out.status.success(), "mint failed: {out:?}");
|
||||||
|
let body: Value = serde_json::from_slice(&out.stdout).expect("JSON");
|
||||||
|
let token = body["token"]
|
||||||
|
.as_str()
|
||||||
|
.expect("mint should expose `token`")
|
||||||
|
.to_string();
|
||||||
|
let key_id = body["id"]
|
||||||
|
.as_str()
|
||||||
|
.expect("mint should expose `id`")
|
||||||
|
.to_string();
|
||||||
|
assert!(
|
||||||
|
token.starts_with("pic_"),
|
||||||
|
"tokens are pic_-prefixed: {token}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// `ls` must NEVER carry the raw token. The key row should appear,
|
||||||
|
// identified by name, but `token` is mint-only.
|
||||||
|
let ls = common::pic_as(&env)
|
||||||
|
.args(["--output", "json", "api-keys", "ls"])
|
||||||
|
.output()
|
||||||
|
.expect("api-keys ls");
|
||||||
|
assert!(ls.status.success(), "ls failed: {ls:?}");
|
||||||
|
let ls_body: Value = serde_json::from_slice(&ls.stdout).expect("JSON");
|
||||||
|
let arr = ls_body.as_array().expect("array");
|
||||||
|
let row = arr
|
||||||
|
.iter()
|
||||||
|
.find(|r| r.get("id").and_then(Value::as_str) == Some(key_id.as_str()))
|
||||||
|
.expect("our key in ls");
|
||||||
|
assert!(
|
||||||
|
row.get("token").is_none(),
|
||||||
|
"ls must not expose raw_token: {row}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cleanup so we don't leak keys across runs.
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["api-keys", "rm", &key_id])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn minted_key_works_as_bearer() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let name = format!("pic-cli-bearer-{}", common::unique_slug("k"));
|
||||||
|
|
||||||
|
let mint = common::pic_as(&env)
|
||||||
|
.args([
|
||||||
|
"--output",
|
||||||
|
"json",
|
||||||
|
"api-keys",
|
||||||
|
"mint",
|
||||||
|
&name,
|
||||||
|
"--scope",
|
||||||
|
"script:read",
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.expect("mint");
|
||||||
|
assert!(mint.status.success());
|
||||||
|
let body: Value = serde_json::from_slice(&mint.stdout).unwrap();
|
||||||
|
let token = body["token"].as_str().unwrap().to_string();
|
||||||
|
let id = body["id"].as_str().unwrap().to_string();
|
||||||
|
|
||||||
|
// Drive whoami with the minted token — proves the bearer string we
|
||||||
|
// captured really is what the server stamped.
|
||||||
|
let key_env = common::custom_env(&fx.url, &token);
|
||||||
|
common::seed_credentials(&key_env, &fx.admin_username);
|
||||||
|
common::pic_as(&key_env)
|
||||||
|
.args(["whoami"])
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(predicate::str::contains(fx.admin_username.as_str()));
|
||||||
|
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["api-keys", "rm", &id])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// After `rm`, the bearer token is dead server-side: a follow-up
|
||||||
|
/// `whoami` driven by it must 401, not 500.
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn rm_revokes_the_token() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let name = format!("pic-cli-rm-{}", common::unique_slug("k"));
|
||||||
|
|
||||||
|
let mint = common::pic_as(&env)
|
||||||
|
.args([
|
||||||
|
"--output",
|
||||||
|
"json",
|
||||||
|
"api-keys",
|
||||||
|
"mint",
|
||||||
|
&name,
|
||||||
|
"--scope",
|
||||||
|
"script:read",
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.expect("mint");
|
||||||
|
let body: Value = serde_json::from_slice(&mint.stdout).unwrap();
|
||||||
|
let token = body["token"].as_str().unwrap().to_string();
|
||||||
|
let id = body["id"].as_str().unwrap().to_string();
|
||||||
|
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["api-keys", "rm", &id])
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(predicate::str::contains(format!("Revoked api-key {id}")));
|
||||||
|
|
||||||
|
let dead = common::custom_env(&fx.url, &token);
|
||||||
|
common::pic_as(&dead)
|
||||||
|
.args(["whoami"])
|
||||||
|
.assert()
|
||||||
|
.failure()
|
||||||
|
.stderr(predicate::str::contains("HTTP 401"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn mint_with_unknown_scope_is_rejected_client_side() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["api-keys", "mint", "doomed", "--scope", "script:nope"])
|
||||||
|
.assert()
|
||||||
|
.failure()
|
||||||
|
.stderr(predicate::str::contains("unknown scope"));
|
||||||
|
}
|
||||||
268
crates/picloud-cli/tests/apps.rs
Normal file
268
crates/picloud-cli/tests/apps.rs
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
//! `pic apps create` / `pic apps ls` edge cases. The integration smoke
|
||||||
|
//! test covers the happy path; this module covers conflict, validation,
|
||||||
|
//! and the persistence of the optional `--name` / `--description` flags
|
||||||
|
//! (which `apps ls` doesn't surface).
|
||||||
|
|
||||||
|
use predicates::prelude::*;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::common;
|
||||||
|
use crate::common::cleanup::AppGuard;
|
||||||
|
use crate::common::member;
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn create_with_name_and_description_persists() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let slug = common::unique_slug("apps-named");
|
||||||
|
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args([
|
||||||
|
"apps",
|
||||||
|
"create",
|
||||||
|
&slug,
|
||||||
|
"--name",
|
||||||
|
"Pretty Name",
|
||||||
|
"--description",
|
||||||
|
"test description",
|
||||||
|
])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||||
|
|
||||||
|
// `apps ls` only shows slug+name+role+created_at, so verify the
|
||||||
|
// persisted shape via the admin GET endpoint.
|
||||||
|
let client = reqwest::blocking::Client::new();
|
||||||
|
let resp = client
|
||||||
|
.get(format!("{}/api/v1/admin/apps/{}", env.url, slug))
|
||||||
|
.bearer_auth(&env.token)
|
||||||
|
.send()
|
||||||
|
.expect("GET app");
|
||||||
|
assert!(resp.status().is_success(), "GET app failed: {resp:?}");
|
||||||
|
let body: Value = resp.json().expect("app json");
|
||||||
|
assert_eq!(body["slug"].as_str(), Some(slug.as_str()));
|
||||||
|
assert_eq!(body["name"].as_str(), Some("Pretty Name"));
|
||||||
|
assert_eq!(body["description"].as_str(), Some("test description"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn create_duplicate_slug_conflicts() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let slug = common::unique_slug("apps-dup");
|
||||||
|
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["apps", "create", &slug])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||||
|
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["apps", "create", &slug])
|
||||||
|
.assert()
|
||||||
|
.failure()
|
||||||
|
.stderr(predicate::str::contains("409").or(predicate::str::contains("conflict")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn create_invalid_slug_rejected() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
|
||||||
|
// Server slug regex is `^[a-z0-9][a-z0-9-]{0,62}$` — uppercase
|
||||||
|
// breaks the rule on the very first char. The server returns 422
|
||||||
|
// (`InvalidSlug` → `UNPROCESSABLE_ENTITY`), not 400 — the previous
|
||||||
|
// `"HTTP 4"` predicate would have silently matched any other 4xx
|
||||||
|
// (a regressed 401 from broken auth, for example).
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["apps", "create", "NotALowerSlug"])
|
||||||
|
.assert()
|
||||||
|
.failure()
|
||||||
|
.stderr(predicate::str::contains("HTTP 422"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn ls_includes_created_app_with_expected_columns() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let slug = common::unique_slug("apps-ls");
|
||||||
|
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["apps", "create", &slug])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||||
|
|
||||||
|
let out = common::pic_as(&env)
|
||||||
|
.args(["apps", "ls"])
|
||||||
|
.output()
|
||||||
|
.expect("apps ls");
|
||||||
|
assert!(out.status.success(), "apps ls failed: {out:?}");
|
||||||
|
let stdout = String::from_utf8(out.stdout).expect("utf8 stdout");
|
||||||
|
let mut lines = stdout.lines();
|
||||||
|
let header = lines.next().expect("header row");
|
||||||
|
assert_eq!(
|
||||||
|
common::cells(header),
|
||||||
|
vec!["slug", "name", "my_role", "created_at"]
|
||||||
|
);
|
||||||
|
|
||||||
|
// The slug must appear in some data row and its row's my_role column
|
||||||
|
// is dashed (the ls endpoint doesn't compute it per-app).
|
||||||
|
let row = lines
|
||||||
|
.map(common::cells)
|
||||||
|
.find(|c| c.first().copied() == Some(slug.as_str()))
|
||||||
|
.unwrap_or_else(|| panic!("slug {slug} not in apps ls output: {stdout}"));
|
||||||
|
assert_eq!(row.len(), 4, "row should have 4 cells: {row:?}");
|
||||||
|
assert_eq!(row[2], "-", "my_role column should be dashed: {row:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn delete_removes_app_from_ls() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let slug = common::unique_slug("apps-del");
|
||||||
|
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["apps", "create", &slug])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["apps", "delete", &slug])
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(predicate::str::contains(format!("Deleted app {slug}")));
|
||||||
|
|
||||||
|
let out = common::pic_as(&env)
|
||||||
|
.args(["apps", "ls"])
|
||||||
|
.output()
|
||||||
|
.expect("apps ls");
|
||||||
|
assert!(out.status.success());
|
||||||
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
|
assert!(
|
||||||
|
!stdout.lines().any(|l| l.starts_with(&slug)),
|
||||||
|
"deleted slug should not appear in ls: {stdout}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn delete_with_scripts_errors_without_force() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let slug = common::unique_slug("apps-del-busy");
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["apps", "create", &slug])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
// AppGuard is the safety net: if the no-force delete fails (as
|
||||||
|
// expected) the app stays around; AppGuard force-deletes on drop.
|
||||||
|
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||||
|
|
||||||
|
let fixture = common::fixture_path("hello.rhai");
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args([
|
||||||
|
"scripts",
|
||||||
|
"deploy",
|
||||||
|
fixture.to_str().unwrap(),
|
||||||
|
"--app",
|
||||||
|
&slug,
|
||||||
|
])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["apps", "delete", &slug])
|
||||||
|
.assert()
|
||||||
|
.failure()
|
||||||
|
// Server `HasScripts` → 409 with a "scripts present" message.
|
||||||
|
.stderr(predicate::str::contains("HTTP 409"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn delete_with_scripts_succeeds_with_force() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let slug = common::unique_slug("apps-del-force");
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["apps", "create", &slug])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
|
||||||
|
let fixture = common::fixture_path("hello.rhai");
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args([
|
||||||
|
"scripts",
|
||||||
|
"deploy",
|
||||||
|
fixture.to_str().unwrap(),
|
||||||
|
"--app",
|
||||||
|
&slug,
|
||||||
|
])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["apps", "delete", &slug, "--force"])
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(predicate::str::contains(format!("Deleted app {slug}")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn show_prints_my_role_for_member() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let admin_env = common::admin_env(fx);
|
||||||
|
let slug = common::unique_slug("apps-show");
|
||||||
|
common::pic_as(&admin_env)
|
||||||
|
.args(["apps", "create", &slug])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
let _g = AppGuard::new(&admin_env.url, &admin_env.token, &slug);
|
||||||
|
|
||||||
|
let m = member::member_user(fx, &common::unique_username("show"));
|
||||||
|
member::grant_membership(fx, &slug, &m.id, "viewer");
|
||||||
|
|
||||||
|
let member_env = common::custom_env(&fx.url, &m.token);
|
||||||
|
common::seed_credentials(&member_env, &m.username);
|
||||||
|
|
||||||
|
let out = common::pic_as(&member_env)
|
||||||
|
.args(["apps", "show", &slug])
|
||||||
|
.output()
|
||||||
|
.expect("apps show");
|
||||||
|
assert!(out.status.success(), "apps show failed: {out:?}");
|
||||||
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
|
// KvBlock output: `my_role` row carries the wire form (`viewer`).
|
||||||
|
assert!(
|
||||||
|
stdout
|
||||||
|
.lines()
|
||||||
|
.any(|l| l.starts_with("my_role") && l.trim_end().ends_with("viewer")),
|
||||||
|
"show should surface my_role=viewer, got: {stdout}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
stdout.lines().any(|l| l.starts_with("slug")),
|
||||||
|
"show should include slug row: {stdout}"
|
||||||
|
);
|
||||||
|
}
|
||||||
288
crates/picloud-cli/tests/auth.rs
Normal file
288
crates/picloud-cli/tests/auth.rs
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
//! Login + whoami journeys beyond the happy path: bad tokens, missing
|
||||||
|
//! credentials file, stale on-disk creds, and the role-label rendered
|
||||||
|
//! by `pic login`.
|
||||||
|
|
||||||
|
use predicates::prelude::*;
|
||||||
|
|
||||||
|
use crate::common;
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn login_persists_credentials_with_correct_perms() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
|
||||||
|
common::pic_as(&env).args(["login"]).assert().success();
|
||||||
|
|
||||||
|
let creds_path = env.config_dir.path().join("credentials");
|
||||||
|
let body = std::fs::read_to_string(&creds_path).expect("credentials file");
|
||||||
|
assert!(
|
||||||
|
body.contains(&format!("url = \"{}\"", env.url)),
|
||||||
|
"creds missing url line: {body}",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
body.contains(&format!("token = \"{}\"", env.token)),
|
||||||
|
"creds missing token line: {body}",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
body.contains(&format!("username = \"{}\"", fx.admin_username)),
|
||||||
|
"creds missing username line: {body}",
|
||||||
|
);
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let mode = std::fs::metadata(&creds_path).unwrap().permissions().mode() & 0o777;
|
||||||
|
assert_eq!(mode, 0o600, "credentials file must be 0600, got {mode:o}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn login_rejects_bad_token() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::custom_env(&fx.url, "pic_garbage_token");
|
||||||
|
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["login"])
|
||||||
|
.assert()
|
||||||
|
.failure()
|
||||||
|
.stderr(predicate::str::contains("401").or(predicate::str::contains("token rejected")));
|
||||||
|
|
||||||
|
let creds_path = env.config_dir.path().join("credentials");
|
||||||
|
assert!(
|
||||||
|
!creds_path.exists(),
|
||||||
|
"failed login must not persist credentials"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn whoami_without_credentials_errors() {
|
||||||
|
let Some(_fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// Build a TestEnv directly so the config dir stays empty —
|
||||||
|
// `admin_env` would seed a credentials file, masking the bug
|
||||||
|
// this test is supposed to catch.
|
||||||
|
let env = common::TestEnv {
|
||||||
|
url: String::new(),
|
||||||
|
token: String::new(),
|
||||||
|
config_dir: tempfile::TempDir::new().unwrap(),
|
||||||
|
home: tempfile::TempDir::new().unwrap(),
|
||||||
|
};
|
||||||
|
|
||||||
|
common::pic_no_env(&env)
|
||||||
|
.args(["whoami"])
|
||||||
|
.assert()
|
||||||
|
.failure()
|
||||||
|
.stderr(predicate::str::contains("pic login"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn whoami_with_stale_token_errors() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
|
||||||
|
let body = format!(
|
||||||
|
"url = \"{}\"\ntoken = \"pic_stale_token\"\nusername = \"ghost\"\n",
|
||||||
|
env.url
|
||||||
|
);
|
||||||
|
std::fs::write(env.config_dir.path().join("credentials"), body).unwrap();
|
||||||
|
|
||||||
|
common::pic_no_env(&env)
|
||||||
|
.args(["whoami"])
|
||||||
|
.assert()
|
||||||
|
.failure()
|
||||||
|
.stderr(predicate::str::contains("401").or(predicate::str::contains("token rejected")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn login_prints_member_role_label() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let username = common::unique_username("auth");
|
||||||
|
let m = common::member::member_user(fx, &username);
|
||||||
|
let env = common::custom_env(&fx.url, &m.token);
|
||||||
|
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["login"])
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(predicate::str::contains(format!(
|
||||||
|
"Logged in as {} (member)",
|
||||||
|
m.username
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drive the real username+password flow end-to-end. `pic_no_env`
|
||||||
|
/// strips `PICLOUD_TOKEN` so login can't short-circuit through the
|
||||||
|
/// bearer path; stdin feeds `username\npassword\n` (the URL is supplied
|
||||||
|
/// via `--url` to avoid the third prompt).
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn login_with_username_and_password_persists() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let username = common::unique_username("lpw");
|
||||||
|
let m = common::member::member_user(fx, &username);
|
||||||
|
let env = common::custom_env(&fx.url, ""); // empty token — file gets written by login
|
||||||
|
|
||||||
|
let stdin_payload = format!("{}\n{}\n", m.username, common::member::MEMBER_PASSWORD);
|
||||||
|
common::pic_no_env(&env)
|
||||||
|
.args(["login", "--url", &fx.url])
|
||||||
|
.write_stdin(stdin_payload)
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(predicate::str::contains(format!(
|
||||||
|
"Logged in as {} (member)",
|
||||||
|
m.username
|
||||||
|
)));
|
||||||
|
|
||||||
|
let creds_path = env.config_dir.path().join("credentials");
|
||||||
|
let body = std::fs::read_to_string(&creds_path).expect("credentials file");
|
||||||
|
assert!(
|
||||||
|
body.contains(&format!("username = \"{}\"", m.username)),
|
||||||
|
"creds should carry the canonical username: {body}",
|
||||||
|
);
|
||||||
|
// The token persisted must be a real session token, not whatever
|
||||||
|
// the user typed — a regression where we accidentally saved the
|
||||||
|
// password as the token would fail this check.
|
||||||
|
assert!(
|
||||||
|
!body.contains(common::member::MEMBER_PASSWORD),
|
||||||
|
"password leaked into credentials file: {body}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn login_with_wrong_password_errors() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let username = common::unique_username("lpwbad");
|
||||||
|
let m = common::member::member_user(fx, &username);
|
||||||
|
let env = common::custom_env(&fx.url, "");
|
||||||
|
|
||||||
|
let stdin_payload = format!("{}\nwrong-password\n", m.username);
|
||||||
|
common::pic_no_env(&env)
|
||||||
|
.args(["login", "--url", &fx.url])
|
||||||
|
.write_stdin(stdin_payload)
|
||||||
|
.assert()
|
||||||
|
.failure()
|
||||||
|
.stderr(predicate::str::contains("HTTP 401"));
|
||||||
|
|
||||||
|
let creds_path = env.config_dir.path().join("credentials");
|
||||||
|
assert!(
|
||||||
|
!creds_path.exists(),
|
||||||
|
"failed login must not persist credentials"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn logout_clears_local_credentials() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// Use a member's token so we don't yank the admin session out from
|
||||||
|
// under parallel tests. The local-file cleanup is the same.
|
||||||
|
let username = common::unique_username("lout");
|
||||||
|
let m = common::member::member_user(fx, &username);
|
||||||
|
let env = common::custom_env(&fx.url, &m.token);
|
||||||
|
common::seed_credentials(&env, &m.username);
|
||||||
|
|
||||||
|
let creds_path = env.config_dir.path().join("credentials");
|
||||||
|
assert!(creds_path.exists(), "precondition: creds file seeded");
|
||||||
|
|
||||||
|
common::pic_no_env(&env)
|
||||||
|
.args(["logout"])
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(predicate::str::contains("Logged out"));
|
||||||
|
assert!(
|
||||||
|
!creds_path.exists(),
|
||||||
|
"credentials file should be removed after logout"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `pic logout` is meant to be idempotent: running it with no
|
||||||
|
/// credentials file present is not an error.
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn logout_is_idempotent_when_already_logged_out() {
|
||||||
|
let Some(_fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::TestEnv {
|
||||||
|
url: String::new(),
|
||||||
|
token: String::new(),
|
||||||
|
config_dir: tempfile::TempDir::new().unwrap(),
|
||||||
|
home: tempfile::TempDir::new().unwrap(),
|
||||||
|
};
|
||||||
|
common::pic_no_env(&env)
|
||||||
|
.args(["logout"])
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(predicate::str::contains("Logged out"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Server-side session invalidation: after `pic logout`, a subsequent
|
||||||
|
/// `pic whoami` driven by the same (now-stale) token must 401.
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn logout_invalidates_server_session() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let username = common::unique_username("lout2");
|
||||||
|
let m = common::member::member_user(fx, &username);
|
||||||
|
let env = common::custom_env(&fx.url, &m.token);
|
||||||
|
common::seed_credentials(&env, &m.username);
|
||||||
|
|
||||||
|
common::pic_no_env(&env).args(["logout"]).assert().success();
|
||||||
|
|
||||||
|
// Replay the member's old token explicitly — pic_no_env reads the
|
||||||
|
// (now-deleted) file, so we go back to env-driven mode with the
|
||||||
|
// stale bearer.
|
||||||
|
let stale = common::custom_env(&fx.url, &m.token);
|
||||||
|
common::pic_as(&stale)
|
||||||
|
.args(["whoami"])
|
||||||
|
.assert()
|
||||||
|
.failure()
|
||||||
|
.stderr(predicate::str::contains("HTTP 401"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Env vars must override the on-disk credentials file globally. Write
|
||||||
|
/// garbage into the file, set env to the real admin creds, and prove
|
||||||
|
/// every read-side command (here `whoami`) goes via env.
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn env_vars_override_credentials_file() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::custom_env(&fx.url, &fx.admin_token);
|
||||||
|
// Garbage in the file: would 401 if used.
|
||||||
|
let body = format!(
|
||||||
|
"url = \"{}\"\ntoken = \"pic_stale_garbage_token\"\nusername = \"ghost\"\n",
|
||||||
|
env.url
|
||||||
|
);
|
||||||
|
std::fs::write(env.config_dir.path().join("credentials"), body).unwrap();
|
||||||
|
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["whoami"])
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(predicate::str::contains(fx.admin_username.as_str()));
|
||||||
|
}
|
||||||
23
crates/picloud-cli/tests/cli.rs
Normal file
23
crates/picloud-cli/tests/cli.rs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
//! Integration-test binary for the `pic` CLI.
|
||||||
|
//!
|
||||||
|
//! Every `#[test]` in this binary routes through `common::fixture()`, a
|
||||||
|
//! `LazyLock` that spawns picloud once on a private port and reuses it
|
||||||
|
//! across all journey modules. Mirrors the dashboard Playwright suite,
|
||||||
|
//! which spins backend + Vite up once for 63 specs.
|
||||||
|
//!
|
||||||
|
//! Gated on `DATABASE_URL`. To run:
|
||||||
|
//!
|
||||||
|
//! docker compose up -d postgres
|
||||||
|
//! DATABASE_URL=postgres://picloud:picloud@127.0.0.1:15432/picloud \
|
||||||
|
//! cargo test -p picloud-cli --test cli -- --include-ignored
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
|
||||||
|
mod api_keys;
|
||||||
|
mod apps;
|
||||||
|
mod auth;
|
||||||
|
mod invoke;
|
||||||
|
mod logs;
|
||||||
|
mod output;
|
||||||
|
mod roles;
|
||||||
|
mod scripts;
|
||||||
61
crates/picloud-cli/tests/common/cleanup.rs
Normal file
61
crates/picloud-cli/tests/common/cleanup.rs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
//! RAII guards that delete server-side resources on `Drop`.
|
||||||
|
//!
|
||||||
|
//! Each guard owns the minimum it needs to issue a single DELETE: the
|
||||||
|
//! base URL, an admin bearer token, and the resource identifier.
|
||||||
|
//! Failures are swallowed because Drop runs during teardown — a panic
|
||||||
|
//! here would just mask the real failure that the test was reporting.
|
||||||
|
|
||||||
|
pub struct AppGuard {
|
||||||
|
url: String,
|
||||||
|
token: String,
|
||||||
|
slug: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppGuard {
|
||||||
|
pub fn new(url: &str, token: &str, slug: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
url: url.to_string(),
|
||||||
|
token: token.to_string(),
|
||||||
|
slug: slug.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for AppGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let client = reqwest::blocking::Client::new();
|
||||||
|
let _ = client
|
||||||
|
.delete(format!(
|
||||||
|
"{}/api/v1/admin/apps/{}?force=true",
|
||||||
|
self.url, self.slug
|
||||||
|
))
|
||||||
|
.bearer_auth(&self.token)
|
||||||
|
.send();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct UserGuard {
|
||||||
|
url: String,
|
||||||
|
token: String,
|
||||||
|
user_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserGuard {
|
||||||
|
pub fn new(url: &str, token: &str, user_id: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
url: url.to_string(),
|
||||||
|
token: token.to_string(),
|
||||||
|
user_id: user_id.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for UserGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let client = reqwest::blocking::Client::new();
|
||||||
|
let _ = client
|
||||||
|
.delete(format!("{}/api/v1/admin/admins/{}", self.url, self.user_id))
|
||||||
|
.bearer_auth(&self.token)
|
||||||
|
.send();
|
||||||
|
}
|
||||||
|
}
|
||||||
99
crates/picloud-cli/tests/common/member.rs
Normal file
99
crates/picloud-cli/tests/common/member.rs
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
//! Helpers for non-admin (`instance_role: Member`) user lifecycle plus
|
||||||
|
//! direct API calls for granting / updating app memberships.
|
||||||
|
//!
|
||||||
|
//! These talk to the manager HTTP surface directly instead of going
|
||||||
|
//! through the CLI, so role-gated tests can stage state without
|
||||||
|
//! requiring `pic` to grow new commands.
|
||||||
|
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
use super::cleanup::UserGuard;
|
||||||
|
use super::Fixture;
|
||||||
|
|
||||||
|
pub const MEMBER_PASSWORD: &str = "pic-cli-test-pw-12345678";
|
||||||
|
|
||||||
|
pub struct MemberUser {
|
||||||
|
pub id: String,
|
||||||
|
pub username: String,
|
||||||
|
pub token: String,
|
||||||
|
pub _guard: UserGuard,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mint a fresh `instance_role: Member` user, log them in for a bearer
|
||||||
|
/// token, and register a `UserGuard` for teardown.
|
||||||
|
pub fn member_user(fx: &Fixture, username: &str) -> MemberUser {
|
||||||
|
let client = reqwest::blocking::Client::new();
|
||||||
|
|
||||||
|
let create = client
|
||||||
|
.post(format!("{}/api/v1/admin/admins", fx.url))
|
||||||
|
.bearer_auth(&fx.admin_token)
|
||||||
|
.json(&json!({
|
||||||
|
"username": username,
|
||||||
|
"password": MEMBER_PASSWORD,
|
||||||
|
// InstanceRole / AppRole serialize via `rename_all =
|
||||||
|
// "snake_case"` — wire forms are always lowercase.
|
||||||
|
"instance_role": "member",
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.expect("create member user");
|
||||||
|
assert!(
|
||||||
|
create.status().is_success(),
|
||||||
|
"create member user failed: {} {}",
|
||||||
|
create.status(),
|
||||||
|
create.text().unwrap_or_default(),
|
||||||
|
);
|
||||||
|
let body: Value = create.json().expect("admin create json");
|
||||||
|
let id = body["id"]
|
||||||
|
.as_str()
|
||||||
|
.expect("admin create returns id")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Register cleanup before we attempt anything else that could fail.
|
||||||
|
let guard = UserGuard::new(&fx.url, &fx.admin_token, &id);
|
||||||
|
|
||||||
|
let token = super::server::login_for_bearer_token(&fx.url, username, MEMBER_PASSWORD);
|
||||||
|
|
||||||
|
MemberUser {
|
||||||
|
id,
|
||||||
|
username: username.to_string(),
|
||||||
|
token,
|
||||||
|
_guard: guard,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/v1/admin/apps/{slug}/members` — grant `role` to `user_id`.
|
||||||
|
pub fn grant_membership(fx: &Fixture, app_slug: &str, user_id: &str, role: &str) {
|
||||||
|
let client = reqwest::blocking::Client::new();
|
||||||
|
let resp = client
|
||||||
|
.post(format!("{}/api/v1/admin/apps/{}/members", fx.url, app_slug))
|
||||||
|
.bearer_auth(&fx.admin_token)
|
||||||
|
.json(&json!({ "user_id": user_id, "role": role }))
|
||||||
|
.send()
|
||||||
|
.expect("grant membership");
|
||||||
|
assert!(
|
||||||
|
resp.status().is_success(),
|
||||||
|
"grant membership failed: {} {}",
|
||||||
|
resp.status(),
|
||||||
|
resp.text().unwrap_or_default(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `PATCH /api/v1/admin/apps/{slug}/members/{user_id}` — promote/demote.
|
||||||
|
pub fn update_membership(fx: &Fixture, app_slug: &str, user_id: &str, role: &str) {
|
||||||
|
let client = reqwest::blocking::Client::new();
|
||||||
|
let resp = client
|
||||||
|
.patch(format!(
|
||||||
|
"{}/api/v1/admin/apps/{}/members/{}",
|
||||||
|
fx.url, app_slug, user_id
|
||||||
|
))
|
||||||
|
.bearer_auth(&fx.admin_token)
|
||||||
|
.json(&json!({ "role": role }))
|
||||||
|
.send()
|
||||||
|
.expect("update membership");
|
||||||
|
assert!(
|
||||||
|
resp.status().is_success(),
|
||||||
|
"update membership failed: {} {}",
|
||||||
|
resp.status(),
|
||||||
|
resp.text().unwrap_or_default(),
|
||||||
|
);
|
||||||
|
}
|
||||||
314
crates/picloud-cli/tests/common/mod.rs
Normal file
314
crates/picloud-cli/tests/common/mod.rs
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
//! Shared fixture and helpers for the CLI integration test binary.
|
||||||
|
//!
|
||||||
|
//! All tests in `tests/cli.rs` route through `fixture()`, a `LazyLock`
|
||||||
|
//! that spawns picloud on a private port the first time it's touched
|
||||||
|
//! and reuses that subprocess for every subsequent test. The dashboard
|
||||||
|
//! Playwright suite pays the same cost once for 63 tests; we do the
|
||||||
|
//! same here.
|
||||||
|
|
||||||
|
#![allow(dead_code)] // shared helpers — not every module uses every fn.
|
||||||
|
|
||||||
|
pub mod cleanup;
|
||||||
|
pub mod member;
|
||||||
|
pub mod server;
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::Child;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::sync::{LazyLock, Mutex, OnceLock};
|
||||||
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use assert_cmd::Command as AssertCommand;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------
|
||||||
|
// Fixture
|
||||||
|
// --------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub struct Fixture {
|
||||||
|
pub url: String,
|
||||||
|
pub admin_token: String,
|
||||||
|
pub admin_username: String,
|
||||||
|
// Held in a Mutex so Drop can kill it without UB; we never re-enter.
|
||||||
|
child: Mutex<Option<Child>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Fixture {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if let Ok(mut guard) = self.child.lock() {
|
||||||
|
if let Some(mut child) = guard.take() {
|
||||||
|
server::kill_subprocess(&mut child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static FIXTURE: LazyLock<Fixture> = LazyLock::new(init_fixture);
|
||||||
|
|
||||||
|
fn init_fixture() -> Fixture {
|
||||||
|
let database_url =
|
||||||
|
std::env::var("DATABASE_URL").expect("DATABASE_URL is required to spawn picloud");
|
||||||
|
let username = admin_username();
|
||||||
|
let password = admin_password();
|
||||||
|
let port = server::pick_free_port();
|
||||||
|
let url = format!("http://127.0.0.1:{port}");
|
||||||
|
let mut child = server::spawn_picloud(&database_url, port, &username, &password);
|
||||||
|
if let Err(e) = server::wait_for_health(&url, Duration::from_secs(60)) {
|
||||||
|
server::kill_subprocess(&mut child);
|
||||||
|
panic!("picloud failed to become healthy: {e}");
|
||||||
|
}
|
||||||
|
let token = server::login_for_bearer_token(&url, &username, &password);
|
||||||
|
Fixture {
|
||||||
|
url,
|
||||||
|
admin_token: token,
|
||||||
|
admin_username: username,
|
||||||
|
child: Mutex::new(Some(child)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the shared fixture, spawning the picloud subprocess on first
|
||||||
|
/// call. Returns `None` (and prints a skip message) when `DATABASE_URL`
|
||||||
|
/// is absent — matching the existing convention so the suite is a
|
||||||
|
/// no-op outside the integration environment.
|
||||||
|
pub fn fixture_or_skip() -> Option<&'static Fixture> {
|
||||||
|
if std::env::var("DATABASE_URL").is_err() {
|
||||||
|
eprintln!("skipping: DATABASE_URL not set");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(&FIXTURE)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------
|
||||||
|
// Per-test env
|
||||||
|
// --------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub struct TestEnv {
|
||||||
|
pub url: String,
|
||||||
|
pub token: String,
|
||||||
|
pub config_dir: TempDir,
|
||||||
|
pub home: TempDir,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-test env pre-loaded with the admin token, and a credentials
|
||||||
|
/// file already on disk so non-login commands ("pic apps create", …)
|
||||||
|
/// can run without first calling `pic login`. As of the env-var
|
||||||
|
/// consistency fix, `PICLOUD_URL`/`PICLOUD_TOKEN` (set by `pic_as`)
|
||||||
|
/// also work for *every* command, not just `login` — `config::resolve`
|
||||||
|
/// reads them first and falls back to the on-disk file.
|
||||||
|
pub fn admin_env(fx: &Fixture) -> TestEnv {
|
||||||
|
let env = TestEnv {
|
||||||
|
url: fx.url.clone(),
|
||||||
|
token: fx.admin_token.clone(),
|
||||||
|
config_dir: TempDir::new().expect("config tempdir"),
|
||||||
|
home: TempDir::new().expect("home tempdir"),
|
||||||
|
};
|
||||||
|
seed_credentials(&env, &fx.admin_username);
|
||||||
|
env
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-test env pre-loaded with a specific (URL, token) pair. Used by
|
||||||
|
/// tests that want a non-admin token, a bogus token, or an unreachable
|
||||||
|
/// URL. Does **not** seed a credentials file — call `seed_credentials`
|
||||||
|
/// explicitly when the test needs to run non-login commands.
|
||||||
|
pub fn custom_env(url: &str, token: &str) -> TestEnv {
|
||||||
|
TestEnv {
|
||||||
|
url: url.to_string(),
|
||||||
|
token: token.to_string(),
|
||||||
|
config_dir: TempDir::new().expect("config tempdir"),
|
||||||
|
home: TempDir::new().expect("home tempdir"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a valid credentials TOML into `env.config_dir` so subsequent
|
||||||
|
/// `pic_as(&env)` invocations can issue non-login subcommands. Mirrors
|
||||||
|
/// the file shape `pic login` produces (url/token/username). Tests that
|
||||||
|
/// exercise the "no credentials" / "stale token" error paths construct
|
||||||
|
/// `TestEnv` directly to keep the config dir empty.
|
||||||
|
pub fn seed_credentials(env: &TestEnv, username: &str) {
|
||||||
|
let body = format!(
|
||||||
|
"url = \"{}\"\ntoken = \"{}\"\nusername = \"{}\"\n",
|
||||||
|
env.url, env.token, username,
|
||||||
|
);
|
||||||
|
std::fs::write(env.config_dir.path().join("credentials"), body).expect("seed credentials file");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `pic` invocation with the env wired up — credentials dir, HOME, and
|
||||||
|
/// the `PICLOUD_URL`/`PICLOUD_TOKEN` shortcut env vars.
|
||||||
|
pub fn pic_as(env: &TestEnv) -> AssertCommand {
|
||||||
|
let mut cmd = AssertCommand::cargo_bin("pic").expect("pic binary");
|
||||||
|
cmd.env("PICLOUD_URL", &env.url)
|
||||||
|
.env("PICLOUD_TOKEN", &env.token)
|
||||||
|
.env("PICLOUD_CONFIG_DIR", env.config_dir.path())
|
||||||
|
.env("HOME", env.home.path());
|
||||||
|
cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `pic` invocation with `PICLOUD_URL`/`PICLOUD_TOKEN` *cleared*, so the
|
||||||
|
/// command sees only the on-disk credentials file (or lack thereof).
|
||||||
|
pub fn pic_no_env(env: &TestEnv) -> AssertCommand {
|
||||||
|
let mut cmd = AssertCommand::cargo_bin("pic").expect("pic binary");
|
||||||
|
cmd.env_remove("PICLOUD_URL")
|
||||||
|
.env_remove("PICLOUD_TOKEN")
|
||||||
|
.env("PICLOUD_CONFIG_DIR", env.config_dir.path())
|
||||||
|
.env("HOME", env.home.path());
|
||||||
|
cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------
|
||||||
|
// Unique slugs / usernames
|
||||||
|
// --------------------------------------------------------------------
|
||||||
|
|
||||||
|
static UNIQUE_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
|
pub fn unique_slug(prefix: &str) -> String {
|
||||||
|
let ms = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_millis();
|
||||||
|
let n = UNIQUE_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||||
|
format!("pic-cli-{prefix}-{ms}-{n:x}")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unique_username(prefix: &str) -> String {
|
||||||
|
// Server regex: [a-z0-9._-]{2,32}. Build out of lowercase
|
||||||
|
// alphanumerics only; "piccli" prefix keeps collisions with other
|
||||||
|
// test suites obvious. Caller's `prefix` must be ≤8 chars and
|
||||||
|
// already match the regex.
|
||||||
|
let ms = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_millis();
|
||||||
|
let n = UNIQUE_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let name = format!("piccli{prefix}{ms:x}{n:x}");
|
||||||
|
assert!(name.len() <= 32, "username overflow: {name}");
|
||||||
|
name
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------
|
||||||
|
// Misc helpers
|
||||||
|
// --------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub fn admin_username() -> String {
|
||||||
|
std::env::var("PICLOUD_CLI_E2E_USERNAME").unwrap_or_else(|_| "admin".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn admin_password() -> String {
|
||||||
|
std::env::var("PICLOUD_CLI_E2E_PASSWORD").unwrap_or_else(|_| "admin".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fixture_path(name: &str) -> PathBuf {
|
||||||
|
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("tests")
|
||||||
|
.join("fixtures")
|
||||||
|
.join(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a fresh app and deploy a `tests/fixtures/<fixture_name>` into
|
||||||
|
/// it. Returns the new script id plus the `AppGuard` that cleans the
|
||||||
|
/// app (and its scripts via `force=true`) on Drop. Used by invoke /
|
||||||
|
/// logs / output journeys that all need "deploy something, then drive
|
||||||
|
/// `pic` against it".
|
||||||
|
pub fn deploy_fixture(
|
||||||
|
env: &TestEnv,
|
||||||
|
app_label: &str,
|
||||||
|
fixture_name: &str,
|
||||||
|
) -> (String, cleanup::AppGuard) {
|
||||||
|
let slug = unique_slug(app_label);
|
||||||
|
pic_as(env)
|
||||||
|
.args(["apps", "create", &slug])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
let guard = cleanup::AppGuard::new(&env.url, &env.token, &slug);
|
||||||
|
|
||||||
|
let fixture = fixture_path(fixture_name);
|
||||||
|
pic_as(env)
|
||||||
|
.args([
|
||||||
|
"scripts",
|
||||||
|
"deploy",
|
||||||
|
fixture.to_str().unwrap(),
|
||||||
|
"--app",
|
||||||
|
&slug,
|
||||||
|
])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
|
||||||
|
let out = pic_as(env)
|
||||||
|
.args(["scripts", "ls", "--app", &slug])
|
||||||
|
.output()
|
||||||
|
.expect("scripts ls");
|
||||||
|
let id = parse_first_id(std::str::from_utf8(&out.stdout).unwrap())
|
||||||
|
.expect("scripts ls should produce one row");
|
||||||
|
(id, guard)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split a row from `pic apps ls` / `pic scripts ls` into trimmed
|
||||||
|
/// cells. The output writer space-pads each cell to its column's max
|
||||||
|
/// width before the tab, so raw `split('\t')` leaves trailing spaces;
|
||||||
|
/// this helper hides that detail from tests that only care about the
|
||||||
|
/// logical values.
|
||||||
|
pub fn cells(row: &str) -> Vec<&str> {
|
||||||
|
row.split('\t').map(str::trim).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// First data row's first tab-delimited cell, used to extract IDs from
|
||||||
|
/// `pic scripts ls` output. The header is expected to start with "id".
|
||||||
|
pub fn parse_first_id(table: &str) -> Option<String> {
|
||||||
|
let mut lines = table.lines().filter(|l| !l.trim().is_empty());
|
||||||
|
let header = lines.next()?;
|
||||||
|
if !header.starts_with("id") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let row = lines.next()?;
|
||||||
|
let first = row.split('\t').next()?.trim();
|
||||||
|
if first.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(first.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------
|
||||||
|
// Fixture-sharing sanity check
|
||||||
|
// --------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Two tests record `fixture().url` into the same `OnceLock` — if the
|
||||||
|
// fixture isn't actually shared, the second test sees a different URL
|
||||||
|
// and panics. Belt-and-suspenders: pointer identity on `&Fixture`.
|
||||||
|
|
||||||
|
static OBSERVED_URL: OnceLock<String> = OnceLock::new();
|
||||||
|
|
||||||
|
fn observe_fixture_url(label: &str) {
|
||||||
|
let Some(fx) = fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let url = fx.url.clone();
|
||||||
|
match OBSERVED_URL.get() {
|
||||||
|
Some(prev) => assert_eq!(
|
||||||
|
prev, &url,
|
||||||
|
"{label} observed a different fixture URL: prior={prev} now={url}"
|
||||||
|
),
|
||||||
|
None => {
|
||||||
|
let _ = OBSERVED_URL.set(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Same `&'static Fixture` from every call — proves the LazyLock is
|
||||||
|
// sharing, not respawning.
|
||||||
|
let a = fixture_or_skip().unwrap();
|
||||||
|
let b = fixture_or_skip().unwrap();
|
||||||
|
assert!(
|
||||||
|
std::ptr::eq(a, b),
|
||||||
|
"fixture_or_skip should return the same &'static Fixture"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn fixture_url_is_shared_a() {
|
||||||
|
observe_fixture_url("fixture_url_is_shared_a");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn fixture_url_is_shared_b() {
|
||||||
|
observe_fixture_url("fixture_url_is_shared_b");
|
||||||
|
}
|
||||||
166
crates/picloud-cli/tests/common/server.rs
Normal file
166
crates/picloud-cli/tests/common/server.rs
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
//! Picloud subprocess lifecycle for the CLI integration test binary.
|
||||||
|
//!
|
||||||
|
//! Mirrors what the original seed test did inline, lifted out so it can
|
||||||
|
//! be shared across modules via `common::fixture()`. The Fixture lives
|
||||||
|
//! in a `LazyLock` — and `LazyLock<T>` never drops, so we register an
|
||||||
|
//! `atexit` handler that SIGTERMs the child when the test binary exits
|
||||||
|
//! normally (which is the common case under `cargo test`).
|
||||||
|
//!
|
||||||
|
//! `PR_SET_PDEATHSIG` is intentionally *not* used: it fires when the
|
||||||
|
//! creating thread dies, and cargo runs each `#[test]` on its own
|
||||||
|
//! worker thread that exits as soon as the test returns — which would
|
||||||
|
//! kill picloud after the first test that triggered the LazyLock,
|
||||||
|
//! breaking every test after it.
|
||||||
|
//!
|
||||||
|
//! Abnormal exit (SIGKILL of the test binary) leaks the child; the
|
||||||
|
//! dashboard Playwright suite accepts the same tradeoff.
|
||||||
|
|
||||||
|
use std::io::{BufRead, BufReader};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::{Child, Command as StdCommand, Stdio};
|
||||||
|
use std::sync::atomic::{AtomicI32, Ordering};
|
||||||
|
use std::sync::mpsc;
|
||||||
|
use std::thread;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
pub fn pick_free_port() -> u16 {
|
||||||
|
let listener =
|
||||||
|
std::net::TcpListener::bind("127.0.0.1:0").expect("bind 127.0.0.1:0 to pick port");
|
||||||
|
listener.local_addr().expect("local addr").port()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn picloud_binary_path() -> PathBuf {
|
||||||
|
// The integration test binary lives at
|
||||||
|
// `<target>/debug/deps/cli-<hash>`. Walk up two levels to reach
|
||||||
|
// `<target>/debug` and look for `picloud` next to ourselves.
|
||||||
|
let exe = std::env::current_exe().expect("current_exe");
|
||||||
|
let debug_dir = exe
|
||||||
|
.parent()
|
||||||
|
.and_then(|p| p.parent())
|
||||||
|
.expect("test binary should live under target/debug/deps");
|
||||||
|
debug_dir.join(if cfg!(windows) {
|
||||||
|
"picloud.exe"
|
||||||
|
} else {
|
||||||
|
"picloud"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn spawn_picloud(database_url: &str, port: u16, admin_user: &str, admin_pass: &str) -> Child {
|
||||||
|
let binary = picloud_binary_path();
|
||||||
|
assert!(
|
||||||
|
binary.exists(),
|
||||||
|
"expected picloud binary at {}. Run `cargo build -p picloud` first \
|
||||||
|
(or use `cargo test --workspace -- --include-ignored` which builds it)",
|
||||||
|
binary.display()
|
||||||
|
);
|
||||||
|
let mut child = StdCommand::new(&binary)
|
||||||
|
.env("PICLOUD_BIND", format!("127.0.0.1:{port}"))
|
||||||
|
.env("DATABASE_URL", database_url)
|
||||||
|
.env("PICLOUD_ADMIN_USERNAME", admin_user)
|
||||||
|
.env("PICLOUD_ADMIN_PASSWORD", admin_pass)
|
||||||
|
.env("RUST_LOG", "warn")
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.expect("spawn picloud");
|
||||||
|
|
||||||
|
// Drain stderr in a side thread so the pipe buffer doesn't fill and
|
||||||
|
// block the server.
|
||||||
|
if let Some(err) = child.stderr.take().map(BufReader::new) {
|
||||||
|
let (tx, _rx) = mpsc::channel::<String>();
|
||||||
|
thread::spawn(move || {
|
||||||
|
for line in err.lines().map_while(Result::ok) {
|
||||||
|
let _ = tx.send(line);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
register_atexit_killer(child.id());
|
||||||
|
child
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------
|
||||||
|
// atexit-based child cleanup
|
||||||
|
// --------------------------------------------------------------------
|
||||||
|
|
||||||
|
static PICLOUD_PID: AtomicI32 = AtomicI32::new(0);
|
||||||
|
|
||||||
|
fn register_atexit_killer(pid: u32) {
|
||||||
|
// First spawn wins; subsequent spawns (none expected today) would
|
||||||
|
// overwrite, but the previous child would already be tracked via
|
||||||
|
// its Drop path on the Fixture.
|
||||||
|
PICLOUD_PID.store(pid as i32, Ordering::SeqCst);
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::sync::Once;
|
||||||
|
static REGISTERED: Once = Once::new();
|
||||||
|
REGISTERED.call_once(|| {
|
||||||
|
// SAFETY: atexit's contract is a `extern "C" fn()` callback;
|
||||||
|
// ours signals a child PID we own.
|
||||||
|
unsafe {
|
||||||
|
libc::atexit(kill_picloud_at_exit);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
extern "C" fn kill_picloud_at_exit() {
|
||||||
|
let pid = PICLOUD_PID.swap(0, Ordering::SeqCst);
|
||||||
|
if pid > 0 {
|
||||||
|
// SAFETY: SIGTERM to a PID we recorded; if PID has been reused
|
||||||
|
// we're killing the wrong process — accepted risk for a test
|
||||||
|
// helper.
|
||||||
|
unsafe {
|
||||||
|
libc::kill(pid, libc::SIGTERM);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn wait_for_health(url: &str, timeout: Duration) -> Result<(), String> {
|
||||||
|
let deadline = Instant::now() + timeout;
|
||||||
|
let client = reqwest::blocking::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(2))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
while Instant::now() < deadline {
|
||||||
|
if let Ok(resp) = client.get(format!("{url}/healthz")).send() {
|
||||||
|
if resp.status().is_success() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
thread::sleep(Duration::from_millis(250));
|
||||||
|
}
|
||||||
|
Err(format!("/healthz never returned 200 within {timeout:?}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn login_for_bearer_token(url: &str, username: &str, password: &str) -> String {
|
||||||
|
let client = reqwest::blocking::Client::new();
|
||||||
|
let resp = client
|
||||||
|
.post(format!("{url}/api/v1/admin/auth/login"))
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"username": username,
|
||||||
|
"password": password,
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.expect("login request");
|
||||||
|
assert!(
|
||||||
|
resp.status().is_success(),
|
||||||
|
"login should succeed, got {}: {}",
|
||||||
|
resp.status(),
|
||||||
|
resp.text().unwrap_or_default()
|
||||||
|
);
|
||||||
|
let v: Value = resp.json().expect("login json");
|
||||||
|
v["token"]
|
||||||
|
.as_str()
|
||||||
|
.expect("login returns token")
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn kill_subprocess(child: &mut Child) {
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
}
|
||||||
7
crates/picloud-cli/tests/fixtures/boom.rhai
vendored
Normal file
7
crates/picloud-cli/tests/fixtures/boom.rhai
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
// Returns a structured 500. The execution is still `Success` in the
|
||||||
|
// log because the script ran cleanly — for an `Error`-status log entry
|
||||||
|
// use throw.rhai instead.
|
||||||
|
#{
|
||||||
|
statusCode: 500,
|
||||||
|
body: #{ ok: false, why: "intentional" },
|
||||||
|
}
|
||||||
6
crates/picloud-cli/tests/fixtures/echo.rhai
vendored
Normal file
6
crates/picloud-cli/tests/fixtures/echo.rhai
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// Echoes the request body and headers back so invoke tests can verify
|
||||||
|
// that `--body` (inline / @file / @-) and `-H` flow through end-to-end.
|
||||||
|
#{
|
||||||
|
body: ctx.request.body,
|
||||||
|
headers: ctx.request.headers,
|
||||||
|
}
|
||||||
4
crates/picloud-cli/tests/fixtures/hello.rhai
vendored
Normal file
4
crates/picloud-cli/tests/fixtures/hello.rhai
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
// Smallest possible Rhai script for the integration test: returns a JSON
|
||||||
|
// object so the orchestrator wraps it as the HTTP response body.
|
||||||
|
let body = #{ ok: true, greeting: "hello from pic" };
|
||||||
|
body
|
||||||
5
crates/picloud-cli/tests/fixtures/loud.rhai
vendored
Normal file
5
crates/picloud-cli/tests/fixtures/loud.rhai
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// Logs a long line so the logs-truncation test has something to chew on.
|
||||||
|
// `pic logs` truncates the summary cell to 120 characters; this line is
|
||||||
|
// 240 chars after the prefix so the truncation is unambiguous.
|
||||||
|
log::info("xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx");
|
||||||
|
#{ ok: true }
|
||||||
4
crates/picloud-cli/tests/fixtures/throw.rhai
vendored
Normal file
4
crates/picloud-cli/tests/fixtures/throw.rhai
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
// Throws a Rhai runtime error. The orchestrator records this as
|
||||||
|
// `ExecutionStatus::Error` in the execution log (a structured 5xx
|
||||||
|
// response is recorded as `Success`).
|
||||||
|
throw "boom";
|
||||||
171
crates/picloud-cli/tests/invoke.rs
Normal file
171
crates/picloud-cli/tests/invoke.rs
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
//! `pic scripts invoke` — body sources (inline, `@file`, `@-`), header
|
||||||
|
//! propagation, exit-code semantics for non-2xx responses, and 404
|
||||||
|
//! handling for unknown ids.
|
||||||
|
|
||||||
|
use predicates::prelude::*;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::common;
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn invoke_with_inline_json_body_echoes() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let (id, _guard) = common::deploy_fixture(&env, "invoke-inline", "echo.rhai");
|
||||||
|
|
||||||
|
let out = common::pic_as(&env)
|
||||||
|
.args(["scripts", "invoke", &id, "--body", r#"{"x":1}"#])
|
||||||
|
.output()
|
||||||
|
.expect("invoke");
|
||||||
|
assert!(out.status.success(), "invoke failed: {out:?}");
|
||||||
|
let parsed: Value = serde_json::from_slice(&out.stdout).expect("stdout JSON");
|
||||||
|
assert_eq!(parsed["body"]["x"], 1, "echoed body: {parsed}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn invoke_with_file_body() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let (id, _guard) = common::deploy_fixture(&env, "invoke-file", "echo.rhai");
|
||||||
|
|
||||||
|
let tmp = tempfile::NamedTempFile::new().expect("tempfile");
|
||||||
|
std::fs::write(tmp.path(), r#"{"src":"file"}"#).unwrap();
|
||||||
|
let body_arg = format!("@{}", tmp.path().display());
|
||||||
|
|
||||||
|
let out = common::pic_as(&env)
|
||||||
|
.args(["scripts", "invoke", &id, "--body", &body_arg])
|
||||||
|
.output()
|
||||||
|
.expect("invoke");
|
||||||
|
assert!(out.status.success(), "invoke failed: {out:?}");
|
||||||
|
let parsed: Value = serde_json::from_slice(&out.stdout).expect("stdout JSON");
|
||||||
|
assert_eq!(parsed["body"]["src"], "file", "echoed body: {parsed}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn invoke_with_stdin_body() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let (id, _guard) = common::deploy_fixture(&env, "invoke-stdin", "echo.rhai");
|
||||||
|
|
||||||
|
let assert = common::pic_as(&env)
|
||||||
|
.args(["scripts", "invoke", &id, "--body", "@-"])
|
||||||
|
.write_stdin(r#"{"src":"stdin"}"#)
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
let out = assert.get_output();
|
||||||
|
let parsed: Value = serde_json::from_slice(&out.stdout).expect("stdout JSON");
|
||||||
|
assert_eq!(parsed["body"]["src"], "stdin", "echoed body: {parsed}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn invoke_propagates_headers() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let (id, _guard) = common::deploy_fixture(&env, "invoke-hdr", "echo.rhai");
|
||||||
|
|
||||||
|
let out = common::pic_as(&env)
|
||||||
|
.args([
|
||||||
|
"scripts",
|
||||||
|
"invoke",
|
||||||
|
&id,
|
||||||
|
"-H",
|
||||||
|
"X-Foo: bar",
|
||||||
|
"-H",
|
||||||
|
"X-Baz=qux",
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.expect("invoke");
|
||||||
|
assert!(out.status.success(), "invoke failed: {out:?}");
|
||||||
|
let parsed: Value = serde_json::from_slice(&out.stdout).expect("stdout JSON");
|
||||||
|
// HTTP normalises header names to lowercase.
|
||||||
|
assert_eq!(parsed["headers"]["x-foo"], "bar", "echoed: {parsed}");
|
||||||
|
assert_eq!(parsed["headers"]["x-baz"], "qux", "echoed: {parsed}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn invoke_unknown_script_id_errors() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
|
||||||
|
// Any well-formed UUID that doesn't exist server-side. The
|
||||||
|
// orchestrator's `/execute/{id}` handler returns 404 specifically
|
||||||
|
// for unknown ids — tighten the predicate so a regressed 401
|
||||||
|
// wouldn't sneak through.
|
||||||
|
let bogus = "00000000-0000-0000-0000-000000000000";
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["scripts", "invoke", bogus])
|
||||||
|
.assert()
|
||||||
|
.failure()
|
||||||
|
.stderr(predicate::str::contains("HTTP 404"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `pic invoke <id>` (top-level alias) and `pic scripts invoke <id>`
|
||||||
|
/// must hit the same handler and produce identical-shape stdout.
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn top_level_invoke_alias_works() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let (id, _guard) = common::deploy_fixture(&env, "inv-alias", "hello.rhai");
|
||||||
|
|
||||||
|
let nested = common::pic_as(&env)
|
||||||
|
.args(["scripts", "invoke", &id])
|
||||||
|
.output()
|
||||||
|
.expect("scripts invoke");
|
||||||
|
assert!(nested.status.success());
|
||||||
|
let nested_body: Value = serde_json::from_slice(&nested.stdout).unwrap();
|
||||||
|
|
||||||
|
let aliased = common::pic_as(&env)
|
||||||
|
.args(["invoke", &id])
|
||||||
|
.output()
|
||||||
|
.expect("invoke (top-level)");
|
||||||
|
assert!(aliased.status.success());
|
||||||
|
let aliased_body: Value = serde_json::from_slice(&aliased.stdout).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
nested_body, aliased_body,
|
||||||
|
"top-level alias should produce identical body to scripts invoke"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn invoke_non_2xx_exits_nonzero_but_prints_body() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let (id, _guard) = common::deploy_fixture(&env, "invoke-500", "boom.rhai");
|
||||||
|
|
||||||
|
let out = common::pic_as(&env)
|
||||||
|
.args(["scripts", "invoke", &id])
|
||||||
|
.output()
|
||||||
|
.expect("invoke");
|
||||||
|
assert!(!out.status.success(), "expected non-zero exit: {out:?}");
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
|
assert!(
|
||||||
|
stderr.contains("<- HTTP 500"),
|
||||||
|
"stderr should report HTTP 500: {stderr}"
|
||||||
|
);
|
||||||
|
let parsed: Value = serde_json::from_slice(&out.stdout)
|
||||||
|
.unwrap_or_else(|e| panic!("stdout was not JSON ({e}): {:?}", out.stdout));
|
||||||
|
assert_eq!(parsed["ok"], false, "boom body: {parsed}");
|
||||||
|
assert_eq!(parsed["why"], "intentional", "boom body: {parsed}");
|
||||||
|
}
|
||||||
179
crates/picloud-cli/tests/logs.rs
Normal file
179
crates/picloud-cli/tests/logs.rs
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
//! `pic logs <script-id>` — emptiness, status labels, `--limit`
|
||||||
|
//! clamping, error path for unknown ids, and the 120-char truncate
|
||||||
|
//! applied to the summary column.
|
||||||
|
|
||||||
|
use predicates::prelude::*;
|
||||||
|
|
||||||
|
use crate::common;
|
||||||
|
|
||||||
|
/// Pick out the data rows from `pic logs` TSV output — the header line
|
||||||
|
/// (`created_at\tstatus\tsummary`) is now always present, so the old
|
||||||
|
/// "no non-empty lines means no logs" check needs to skip it.
|
||||||
|
fn data_rows(stdout: &str) -> Vec<&str> {
|
||||||
|
stdout
|
||||||
|
.lines()
|
||||||
|
.filter(|l| !l.trim().is_empty())
|
||||||
|
.filter(|l| !l.starts_with("created_at"))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn logs_for_fresh_script_is_empty() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let (id, _guard) = common::deploy_fixture(&env, "logs-empty", "hello.rhai");
|
||||||
|
|
||||||
|
let out = common::pic_as(&env)
|
||||||
|
.args(["logs", &id])
|
||||||
|
.output()
|
||||||
|
.expect("logs");
|
||||||
|
assert!(out.status.success(), "logs failed: {out:?}");
|
||||||
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
|
assert!(
|
||||||
|
data_rows(&stdout).is_empty(),
|
||||||
|
"expected no log rows (header is allowed), got: {stdout}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn logs_after_invoke_records_success_row() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let (id, _guard) = common::deploy_fixture(&env, "logs-ok", "hello.rhai");
|
||||||
|
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["scripts", "invoke", &id])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
|
||||||
|
let out = common::pic_as(&env)
|
||||||
|
.args(["logs", &id])
|
||||||
|
.output()
|
||||||
|
.expect("logs");
|
||||||
|
assert!(out.status.success(), "logs failed: {out:?}");
|
||||||
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
|
let rows = data_rows(&stdout);
|
||||||
|
assert_eq!(rows.len(), 1, "expected 1 data row, got: {stdout}");
|
||||||
|
let cols: Vec<&str> = rows[0].split('\t').map(str::trim).collect();
|
||||||
|
assert_eq!(
|
||||||
|
cols.len(),
|
||||||
|
3,
|
||||||
|
"row should be 3 tab-delimited cells: {rows:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(cols[1], "success");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn logs_records_error_for_throwing_script() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let (id, _guard) = common::deploy_fixture(&env, "logs-err", "throw.rhai");
|
||||||
|
|
||||||
|
// The invoke is expected to fail — we only care that the execution
|
||||||
|
// gets recorded with `Error` status.
|
||||||
|
let _ = common::pic_as(&env)
|
||||||
|
.args(["scripts", "invoke", &id])
|
||||||
|
.output();
|
||||||
|
|
||||||
|
let out = common::pic_as(&env)
|
||||||
|
.args(["logs", &id])
|
||||||
|
.output()
|
||||||
|
.expect("logs");
|
||||||
|
assert!(out.status.success(), "logs failed: {out:?}");
|
||||||
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
|
let row = data_rows(&stdout)
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.expect("at least one data row");
|
||||||
|
let cols: Vec<&str> = row.split('\t').map(str::trim).collect();
|
||||||
|
assert_eq!(cols[1], "error", "expected error status, got row: {row}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn logs_respects_limit_flag() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let (id, _guard) = common::deploy_fixture(&env, "logs-limit", "hello.rhai");
|
||||||
|
|
||||||
|
for _ in 0..3 {
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["scripts", "invoke", &id])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
}
|
||||||
|
|
||||||
|
let out = common::pic_as(&env)
|
||||||
|
.args(["logs", &id, "--limit", "1"])
|
||||||
|
.output()
|
||||||
|
.expect("logs");
|
||||||
|
assert!(out.status.success(), "logs failed: {out:?}");
|
||||||
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
|
let rows = data_rows(&stdout).len();
|
||||||
|
assert_eq!(rows, 1, "expected --limit 1, got rows: {stdout}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn logs_for_unknown_id_errors() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
|
||||||
|
let bogus = "00000000-0000-0000-0000-000000000000";
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["logs", bogus])
|
||||||
|
.assert()
|
||||||
|
.failure()
|
||||||
|
// 404 specifically — same `NotFound(ScriptId)` path the get/edit
|
||||||
|
// endpoints use.
|
||||||
|
.stderr(predicate::str::contains("HTTP 404"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn logs_truncates_long_summary() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let (id, _guard) = common::deploy_fixture(&env, "logs-loud", "loud.rhai");
|
||||||
|
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["scripts", "invoke", &id])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
|
||||||
|
let out = common::pic_as(&env)
|
||||||
|
.args(["logs", &id])
|
||||||
|
.output()
|
||||||
|
.expect("logs");
|
||||||
|
assert!(out.status.success(), "logs failed: {out:?}");
|
||||||
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
|
let row = data_rows(&stdout)
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.expect("at least one data row");
|
||||||
|
let summary = row.split('\t').nth(2).expect("summary column");
|
||||||
|
assert!(
|
||||||
|
summary.ends_with('…'),
|
||||||
|
"summary should be truncated with `…`, got: {summary}"
|
||||||
|
);
|
||||||
|
let chars = summary.chars().count();
|
||||||
|
assert!(
|
||||||
|
chars <= 121,
|
||||||
|
"summary should be ≤120 chars + the truncation marker, got {chars}: {summary}"
|
||||||
|
);
|
||||||
|
}
|
||||||
289
crates/picloud-cli/tests/output.rs
Normal file
289
crates/picloud-cli/tests/output.rs
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
//! Output-shape invariants — the contracts downstream `jq`/`awk`
|
||||||
|
//! pipelines depend on: column headers, stdout-vs-stderr separation,
|
||||||
|
//! and RFC3339 timestamps.
|
||||||
|
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::common;
|
||||||
|
use crate::common::cleanup::AppGuard;
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn apps_ls_header_columns() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
|
||||||
|
let out = common::pic_as(&env)
|
||||||
|
.args(["apps", "ls"])
|
||||||
|
.output()
|
||||||
|
.expect("apps ls");
|
||||||
|
assert!(out.status.success());
|
||||||
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
|
let header = stdout.lines().next().expect("header row");
|
||||||
|
assert_eq!(
|
||||||
|
common::cells(header),
|
||||||
|
vec!["slug", "name", "my_role", "created_at"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn scripts_ls_header_columns() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let slug = common::unique_slug("out-ls");
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["apps", "create", &slug])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||||
|
|
||||||
|
let out = common::pic_as(&env)
|
||||||
|
.args(["scripts", "ls", "--app", &slug])
|
||||||
|
.output()
|
||||||
|
.expect("scripts ls");
|
||||||
|
assert!(out.status.success());
|
||||||
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
|
let header = stdout.lines().next().expect("header row");
|
||||||
|
assert_eq!(
|
||||||
|
common::cells(header),
|
||||||
|
vec!["id", "app_slug", "name", "version", "updated_at"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn invoke_separates_stdout_and_stderr() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let (id, _guard) = common::deploy_fixture(&env, "out-inv", "hello.rhai");
|
||||||
|
|
||||||
|
let out = common::pic_as(&env)
|
||||||
|
.args(["scripts", "invoke", &id])
|
||||||
|
.output()
|
||||||
|
.expect("invoke");
|
||||||
|
assert!(out.status.success());
|
||||||
|
|
||||||
|
let stderr = String::from_utf8(out.stderr).unwrap();
|
||||||
|
assert!(
|
||||||
|
stderr.starts_with("<- HTTP 200"),
|
||||||
|
"stderr should announce HTTP status: {stderr:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let parsed: Value = serde_json::from_slice(&out.stdout)
|
||||||
|
.expect("stdout should be JSON only, with no status prefix");
|
||||||
|
assert_eq!(parsed["ok"], true, "body: {parsed}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn error_goes_to_stderr_not_stdout() {
|
||||||
|
let Some(_fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// Use a pristine env (no credentials file) so `whoami` is guaranteed
|
||||||
|
// to fail at the `config::load` step — `admin_env` would pre-seed
|
||||||
|
// creds and the command would succeed.
|
||||||
|
let env = common::TestEnv {
|
||||||
|
url: String::new(),
|
||||||
|
token: String::new(),
|
||||||
|
config_dir: tempfile::TempDir::new().unwrap(),
|
||||||
|
home: tempfile::TempDir::new().unwrap(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let out = common::pic_no_env(&env)
|
||||||
|
.args(["whoami"])
|
||||||
|
.output()
|
||||||
|
.expect("whoami");
|
||||||
|
assert!(!out.status.success(), "expected failure, got: {out:?}");
|
||||||
|
assert!(
|
||||||
|
out.stdout.is_empty(),
|
||||||
|
"stdout should be empty on error, got: {:?}",
|
||||||
|
String::from_utf8_lossy(&out.stdout),
|
||||||
|
);
|
||||||
|
let stderr = String::from_utf8(out.stderr).unwrap();
|
||||||
|
assert!(
|
||||||
|
stderr.contains("error:"),
|
||||||
|
"stderr should be prefixed with `error:`: {stderr}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn apps_ls_created_at_is_rfc3339() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let slug = common::unique_slug("out-date");
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["apps", "create", &slug])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||||
|
|
||||||
|
let out = common::pic_as(&env)
|
||||||
|
.args(["apps", "ls"])
|
||||||
|
.output()
|
||||||
|
.expect("apps ls");
|
||||||
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
|
let row = stdout
|
||||||
|
.lines()
|
||||||
|
.map(common::cells)
|
||||||
|
.find(|c| c.first().copied() == Some(slug.as_str()))
|
||||||
|
.unwrap_or_else(|| panic!("slug {slug} missing in: {stdout}"));
|
||||||
|
let created_at = row.get(3).expect("created_at cell");
|
||||||
|
|
||||||
|
// Accept the RFC3339 shape without pulling in chrono — `YYYY-MM-DDTHH:MM:SS`
|
||||||
|
// with optional fraction + timezone is enough of a contract for the test.
|
||||||
|
assert!(
|
||||||
|
created_at.len() >= 20
|
||||||
|
&& created_at.as_bytes()[4] == b'-'
|
||||||
|
&& created_at.as_bytes()[7] == b'-'
|
||||||
|
&& created_at.as_bytes()[10] == b'T'
|
||||||
|
&& created_at.as_bytes()[13] == b':'
|
||||||
|
&& created_at.as_bytes()[16] == b':',
|
||||||
|
"created_at not RFC3339-shaped: {created_at}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `--output json` is the global pipeline-friendly format. Validates
|
||||||
|
/// `apps ls` returns a real JSON array (not a TSV-with-quotes hack).
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn apps_ls_json_output_is_valid_array() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let slug = common::unique_slug("out-json-apps");
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["apps", "create", &slug])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||||
|
|
||||||
|
let out = common::pic_as(&env)
|
||||||
|
.args(["--output", "json", "apps", "ls"])
|
||||||
|
.output()
|
||||||
|
.expect("apps ls --output json");
|
||||||
|
assert!(out.status.success(), "apps ls failed: {out:?}");
|
||||||
|
let v: Value = serde_json::from_slice(&out.stdout).expect("stdout should be JSON");
|
||||||
|
let arr = v.as_array().expect("apps ls JSON should be an array");
|
||||||
|
assert!(
|
||||||
|
arr.iter()
|
||||||
|
.any(|row| row.get("slug").and_then(Value::as_str) == Some(slug.as_str())),
|
||||||
|
"json should include created slug: {v}"
|
||||||
|
);
|
||||||
|
// The header row must NOT bleed into JSON output — the rendered
|
||||||
|
// objects use header *keys*, not data cells.
|
||||||
|
assert!(
|
||||||
|
arr.iter().all(|row| row.get("slug").is_some()),
|
||||||
|
"every row should have a `slug` key: {v}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn scripts_ls_json_output_has_app_slug() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let slug = common::unique_slug("out-json-scr");
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["apps", "create", &slug])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||||
|
let fixture = common::fixture_path("hello.rhai");
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args([
|
||||||
|
"scripts",
|
||||||
|
"deploy",
|
||||||
|
fixture.to_str().unwrap(),
|
||||||
|
"--app",
|
||||||
|
&slug,
|
||||||
|
])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
|
||||||
|
let out = common::pic_as(&env)
|
||||||
|
.args(["--output", "json", "scripts", "ls", "--app", &slug])
|
||||||
|
.output()
|
||||||
|
.expect("scripts ls --output json");
|
||||||
|
assert!(out.status.success());
|
||||||
|
let v: Value = serde_json::from_slice(&out.stdout).expect("stdout JSON");
|
||||||
|
let arr = v.as_array().expect("array");
|
||||||
|
let row = arr
|
||||||
|
.iter()
|
||||||
|
.find(|r| r.get("name").and_then(Value::as_str) == Some("hello"))
|
||||||
|
.expect("hello row");
|
||||||
|
assert_eq!(row["app_slug"].as_str(), Some(slug.as_str()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn logs_json_output_is_array_of_objects() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let (id, _guard) = common::deploy_fixture(&env, "out-json-log", "hello.rhai");
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["scripts", "invoke", &id])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
|
||||||
|
let out = common::pic_as(&env)
|
||||||
|
.args(["--output", "json", "logs", &id])
|
||||||
|
.output()
|
||||||
|
.expect("logs --output json");
|
||||||
|
assert!(out.status.success());
|
||||||
|
let v: Value = serde_json::from_slice(&out.stdout).expect("stdout JSON");
|
||||||
|
let arr = v.as_array().expect("array");
|
||||||
|
assert!(!arr.is_empty(), "expected at least one log");
|
||||||
|
// Schema: each row carries the raw `ExecutionLog`, not the
|
||||||
|
// truncated summary the TSV form uses.
|
||||||
|
assert!(
|
||||||
|
arr[0].get("status").is_some(),
|
||||||
|
"log row missing status: {arr:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TSV `whoami` used to be a single tab-separated line with no labels;
|
||||||
|
/// downstream tools couldn't tell which column was the role. Now it's
|
||||||
|
/// a key/value block with stable labels.
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn whoami_tsv_has_labeled_rows() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
|
||||||
|
let out = common::pic_as(&env)
|
||||||
|
.args(["whoami"])
|
||||||
|
.output()
|
||||||
|
.expect("whoami");
|
||||||
|
assert!(out.status.success());
|
||||||
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
|
let labels: Vec<&str> = stdout
|
||||||
|
.lines()
|
||||||
|
.filter_map(|l| l.split('\t').next())
|
||||||
|
.map(str::trim)
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
labels.contains(&"username"),
|
||||||
|
"missing username row: {stdout}"
|
||||||
|
);
|
||||||
|
assert!(labels.contains(&"role"), "missing role row: {stdout}");
|
||||||
|
assert!(labels.contains(&"email"), "missing email row: {stdout}");
|
||||||
|
assert!(labels.contains(&"url"), "missing url row: {stdout}");
|
||||||
|
}
|
||||||
146
crates/picloud-cli/tests/roles.rs
Normal file
146
crates/picloud-cli/tests/roles.rs
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
//! RBAC mirror of the dashboard's role-shadowing specs. A Member user
|
||||||
|
//! is minted via the admin API, granted (or denied) membership on an
|
||||||
|
//! app, then `pic` is driven against the member's bearer token to
|
||||||
|
//! confirm the server's capability gates surface as expected exit
|
||||||
|
//! codes / error messages.
|
||||||
|
|
||||||
|
use predicates::prelude::*;
|
||||||
|
|
||||||
|
use crate::common;
|
||||||
|
use crate::common::cleanup::AppGuard;
|
||||||
|
use crate::common::member;
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn member_apps_ls_only_shows_their_apps() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let admin_env = common::admin_env(fx);
|
||||||
|
|
||||||
|
let slug_visible = common::unique_slug("roles-visible");
|
||||||
|
let slug_hidden = common::unique_slug("roles-hidden");
|
||||||
|
common::pic_as(&admin_env)
|
||||||
|
.args(["apps", "create", &slug_visible])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
let _g1 = AppGuard::new(&admin_env.url, &admin_env.token, &slug_visible);
|
||||||
|
common::pic_as(&admin_env)
|
||||||
|
.args(["apps", "create", &slug_hidden])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
let _g2 = AppGuard::new(&admin_env.url, &admin_env.token, &slug_hidden);
|
||||||
|
|
||||||
|
let m = member::member_user(fx, &common::unique_username("rls"));
|
||||||
|
member::grant_membership(fx, &slug_visible, &m.id, "viewer");
|
||||||
|
let member_env = common::custom_env(&fx.url, &m.token);
|
||||||
|
common::seed_credentials(&member_env, &m.username);
|
||||||
|
|
||||||
|
let out = common::pic_as(&member_env)
|
||||||
|
.args(["apps", "ls"])
|
||||||
|
.output()
|
||||||
|
.expect("apps ls");
|
||||||
|
assert!(out.status.success(), "apps ls failed: {out:?}");
|
||||||
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
|
assert!(
|
||||||
|
stdout.contains(&slug_visible),
|
||||||
|
"member should see {slug_visible}, got: {stdout}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!stdout.contains(&slug_hidden),
|
||||||
|
"member should NOT see {slug_hidden}, got: {stdout}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn viewer_cannot_deploy_but_editor_can() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let admin_env = common::admin_env(fx);
|
||||||
|
let slug = common::unique_slug("roles-write");
|
||||||
|
common::pic_as(&admin_env)
|
||||||
|
.args(["apps", "create", &slug])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
let _g = AppGuard::new(&admin_env.url, &admin_env.token, &slug);
|
||||||
|
|
||||||
|
let m = member::member_user(fx, &common::unique_username("vw"));
|
||||||
|
member::grant_membership(fx, &slug, &m.id, "viewer");
|
||||||
|
let member_env = common::custom_env(&fx.url, &m.token);
|
||||||
|
common::seed_credentials(&member_env, &m.username);
|
||||||
|
|
||||||
|
let fixture = common::fixture_path("hello.rhai");
|
||||||
|
common::pic_as(&member_env)
|
||||||
|
.args([
|
||||||
|
"scripts",
|
||||||
|
"deploy",
|
||||||
|
fixture.to_str().unwrap(),
|
||||||
|
"--app",
|
||||||
|
&slug,
|
||||||
|
])
|
||||||
|
.assert()
|
||||||
|
.failure()
|
||||||
|
// `Forbidden` → 403. A regressed predicate of `"HTTP 4"` would
|
||||||
|
// have masked an auth break (401) as an authz issue.
|
||||||
|
.stderr(predicate::str::contains("HTTP 403"));
|
||||||
|
|
||||||
|
// Promote to Editor and retry — the same command should now succeed.
|
||||||
|
member::update_membership(fx, &slug, &m.id, "editor");
|
||||||
|
common::pic_as(&member_env)
|
||||||
|
.args([
|
||||||
|
"scripts",
|
||||||
|
"deploy",
|
||||||
|
fixture.to_str().unwrap(),
|
||||||
|
"--app",
|
||||||
|
&slug,
|
||||||
|
])
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(predicate::str::contains("Created hello v1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn member_can_invoke_any_script_with_id() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// `/api/v1/execute/{id}` is the unguarded data-plane ingress — even
|
||||||
|
// a member with no app membership can hit it as long as they hold
|
||||||
|
// a valid token (the orchestrator doesn't gate it).
|
||||||
|
let admin_env = common::admin_env(fx);
|
||||||
|
let (id, _guard) = common::deploy_fixture(&admin_env, "roles-inv", "hello.rhai");
|
||||||
|
|
||||||
|
let m = member::member_user(fx, &common::unique_username("inv"));
|
||||||
|
let member_env = common::custom_env(&fx.url, &m.token);
|
||||||
|
common::seed_credentials(&member_env, &m.username);
|
||||||
|
|
||||||
|
common::pic_as(&member_env)
|
||||||
|
.args(["scripts", "invoke", &id])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn non_member_cannot_read_logs() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let admin_env = common::admin_env(fx);
|
||||||
|
let (id, _guard) = common::deploy_fixture(&admin_env, "roles-log", "hello.rhai");
|
||||||
|
|
||||||
|
let m = member::member_user(fx, &common::unique_username("rl"));
|
||||||
|
let member_env = common::custom_env(&fx.url, &m.token);
|
||||||
|
common::seed_credentials(&member_env, &m.username);
|
||||||
|
|
||||||
|
common::pic_as(&member_env)
|
||||||
|
.args(["logs", &id])
|
||||||
|
.assert()
|
||||||
|
.failure()
|
||||||
|
// Non-member → 403 from the authz layer, not 404 — the script
|
||||||
|
// exists; the caller just can't see it.
|
||||||
|
.stderr(predicate::str::contains("HTTP 403"));
|
||||||
|
}
|
||||||
240
crates/picloud-cli/tests/scripts.rs
Normal file
240
crates/picloud-cli/tests/scripts.rs
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
//! `pic scripts deploy` / `pic scripts ls` edge cases beyond the
|
||||||
|
//! smoke test: unknown app, name override, version bumping, missing
|
||||||
|
//! file, and the no-`--app` walk across every accessible app.
|
||||||
|
|
||||||
|
use predicates::prelude::*;
|
||||||
|
|
||||||
|
use crate::common;
|
||||||
|
use crate::common::cleanup::AppGuard;
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn deploy_against_unknown_app_errors() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let fixture = common::fixture_path("hello.rhai");
|
||||||
|
let bogus_slug = common::unique_slug("nope");
|
||||||
|
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args([
|
||||||
|
"scripts",
|
||||||
|
"deploy",
|
||||||
|
fixture.to_str().unwrap(),
|
||||||
|
"--app",
|
||||||
|
&bogus_slug,
|
||||||
|
])
|
||||||
|
.assert()
|
||||||
|
.failure()
|
||||||
|
// Specifically 404 — `apps_get` short-circuits before the deploy
|
||||||
|
// request even starts. Loose `"HTTP 4"` would have matched a
|
||||||
|
// regressed 401 from broken auth.
|
||||||
|
.stderr(predicate::str::contains("HTTP 404"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn deploy_with_name_override() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let slug = common::unique_slug("scripts-named");
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["apps", "create", &slug])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||||
|
|
||||||
|
let fixture = common::fixture_path("hello.rhai");
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args([
|
||||||
|
"scripts",
|
||||||
|
"deploy",
|
||||||
|
fixture.to_str().unwrap(),
|
||||||
|
"--app",
|
||||||
|
&slug,
|
||||||
|
"--name",
|
||||||
|
"custom-name",
|
||||||
|
])
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(predicate::str::contains("Created custom-name v1"));
|
||||||
|
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args([
|
||||||
|
"scripts",
|
||||||
|
"deploy",
|
||||||
|
fixture.to_str().unwrap(),
|
||||||
|
"--app",
|
||||||
|
&slug,
|
||||||
|
"--name",
|
||||||
|
"custom-name",
|
||||||
|
])
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(predicate::str::contains("Updated custom-name v2"));
|
||||||
|
|
||||||
|
let out = common::pic_as(&env)
|
||||||
|
.args(["scripts", "ls", "--app", &slug])
|
||||||
|
.output()
|
||||||
|
.expect("scripts ls");
|
||||||
|
assert!(out.status.success());
|
||||||
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
|
assert!(
|
||||||
|
stdout
|
||||||
|
.lines()
|
||||||
|
.map(common::cells)
|
||||||
|
.any(|c| c.get(2).copied() == Some("custom-name") && c.get(3).copied() == Some("2")),
|
||||||
|
"expected custom-name v2 row, got: {stdout}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn deploy_bumps_version_each_redeploy() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let slug = common::unique_slug("scripts-bump");
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["apps", "create", &slug])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||||
|
|
||||||
|
let fixture = common::fixture_path("hello.rhai");
|
||||||
|
for expected in ["Created hello v1", "Updated hello v2", "Updated hello v3"] {
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args([
|
||||||
|
"scripts",
|
||||||
|
"deploy",
|
||||||
|
fixture.to_str().unwrap(),
|
||||||
|
"--app",
|
||||||
|
&slug,
|
||||||
|
])
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(predicate::str::contains(expected));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn deploy_missing_file_errors() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let slug = common::unique_slug("scripts-missing");
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["apps", "create", &slug])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||||
|
|
||||||
|
let missing = std::env::temp_dir().join(common::unique_slug("ghost") + ".rhai");
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args([
|
||||||
|
"scripts",
|
||||||
|
"deploy",
|
||||||
|
missing.to_str().unwrap(),
|
||||||
|
"--app",
|
||||||
|
&slug,
|
||||||
|
])
|
||||||
|
.assert()
|
||||||
|
.failure()
|
||||||
|
.stderr(predicate::str::contains("reading"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn ls_without_app_walks_every_accessible_app() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let slug_a = common::unique_slug("scripts-walk-a");
|
||||||
|
let slug_b = common::unique_slug("scripts-walk-b");
|
||||||
|
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["apps", "create", &slug_a])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
let _guard_a = AppGuard::new(&env.url, &env.token, &slug_a);
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["apps", "create", &slug_b])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
let _guard_b = AppGuard::new(&env.url, &env.token, &slug_b);
|
||||||
|
|
||||||
|
let fixture = common::fixture_path("hello.rhai");
|
||||||
|
for slug in [&slug_a, &slug_b] {
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args([
|
||||||
|
"scripts",
|
||||||
|
"deploy",
|
||||||
|
fixture.to_str().unwrap(),
|
||||||
|
"--app",
|
||||||
|
slug,
|
||||||
|
])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
}
|
||||||
|
|
||||||
|
// `pic scripts ls` (no `--app`) issues a single `GET /admin/scripts`
|
||||||
|
// against the server now — there's nothing per-app to race against
|
||||||
|
// a concurrent AppGuard drop. The previous implementation walked
|
||||||
|
// `apps_list` followed by per-app `scripts_list_by_app` calls and
|
||||||
|
// aborted on the first 404, which forced this test to retry 5× to
|
||||||
|
// paper over the bug. Both the walk and the retry are gone.
|
||||||
|
let out = common::pic_as(&env)
|
||||||
|
.args(["scripts", "ls"])
|
||||||
|
.output()
|
||||||
|
.expect("scripts ls");
|
||||||
|
assert!(out.status.success(), "scripts ls failed: {out:?}");
|
||||||
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
|
|
||||||
|
let slugs: std::collections::HashSet<&str> = stdout
|
||||||
|
.lines()
|
||||||
|
.map(common::cells)
|
||||||
|
.filter_map(|c| c.get(1).copied())
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
slugs.contains(slug_a.as_str()),
|
||||||
|
"missing app A in: {stdout}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
slugs.contains(slug_b.as_str()),
|
||||||
|
"missing app B in: {stdout}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn delete_removes_script_from_ls() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let (id, _guard) = common::deploy_fixture(&env, "scripts-del", "hello.rhai");
|
||||||
|
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["scripts", "delete", &id])
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(predicate::str::contains(format!("Deleted script {id}")));
|
||||||
|
|
||||||
|
let out = common::pic_as(&env)
|
||||||
|
.args(["scripts", "ls"])
|
||||||
|
.output()
|
||||||
|
.expect("scripts ls");
|
||||||
|
assert!(out.status.success());
|
||||||
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
|
assert!(
|
||||||
|
!stdout.contains(&id),
|
||||||
|
"deleted script id should not appear in ls: {stdout}"
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user