diff --git a/Cargo.lock b/Cargo.lock index 1b890090..ccf775be 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -106,13 +106,12 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.36" +version = "0.4.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98ec5f6c2f8bc326c994cb9e241cc257ddaba9afa8555a43cffbb5dd86efaa37" +checksum = "d10e4f991a553474232bc0a31799f6d24b034a84c0971d80d2e2f78b2e576e40" dependencies = [ "compression-codecs", "compression-core", - "futures-core", "pin-project-lite", "tokio", ] @@ -151,9 +150,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aws-lc-rs" -version = "1.15.2" +version = "1.15.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a88aab2464f1f25453baa7a07c84c5b7684e274054ba06817f382357f77a288" +checksum = "e84ce723ab67259cfeb9877c6a639ee9eb7a27b28123abd71db7f0d5d0cc9d86" dependencies = [ "aws-lc-sys", "zeroize", @@ -161,9 +160,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.35.0" +version = "0.36.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b45afffdee1e7c9126814751f88dddc747f41d91da16c9551a0f1e8a11e788a1" +checksum = "43a442ece363113bd4bd4c8b18977a7798dd4d3c3383f34fb61936960e8f4ad8" dependencies = [ "cc", "cmake", @@ -243,9 +242,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64ct" -version = "1.8.2" +version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d809780667f4410e7c41b07f52439b94d2bdf8528eeedc287fa38d3b7f95d82" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "bitflags" @@ -301,9 +300,9 @@ checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" [[package]] name = "cc" -version = "1.2.51" +version = "1.2.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a0aeaff4ff1a90589618835a598e545176939b97874f7abc7851caa0618f203" +checksum = "755d2fce177175ffca841e9a06afdb2c4ab0f593d53b4dee48147dfaade85932" dependencies = [ "find-msvc-tools", "jobserver", @@ -331,9 +330,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" -version = "0.4.42" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" dependencies = [ "iana-time-zone", "js-sys", @@ -379,9 +378,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.6" +version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" [[package]] name = "cmake" @@ -410,9 +409,9 @@ dependencies = [ [[package]] name = "compression-codecs" -version = "0.4.35" +version = "0.4.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0f7ac3e5b97fdce45e8922fb05cae2c37f7bbd63d30dd94821dacfd8f3f2bf2" +checksum = "00828ba6fd27b45a448e57dbfe84f1029d4c9f26b368157e9a448a5f49a2ec2a" dependencies = [ "compression-core", "flate2", @@ -818,15 +817,15 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff" +checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" [[package]] name = "flate2" -version = "1.1.5" +version = "1.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" +checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369" dependencies = [ "crc32fast", "miniz_oxide", @@ -997,9 +996,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "js-sys", @@ -1399,9 +1398,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.12.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", "hashbrown 0.16.1", @@ -1491,9 +1490,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.83" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" dependencies = [ "once_cell", "wasm-bindgen", @@ -1561,9 +1560,9 @@ checksum = "2604dd126bb14f13fb5d1bd6a66155079cb9fa655b37f875b3a742c705dbed17" [[package]] name = "libc" -version = "0.2.179" +version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5a2d376baa530d1238d133232d15e239abad80d05838b4b59354e5268af431f" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" [[package]] name = "libm" @@ -1979,7 +1978,7 @@ dependencies = [ [[package]] name = "oci-client" version = "0.15.0" -source = "git+https://github.com/toksdotdev/rust-oci-client.git?branch=toks%2Fstring-impl-for-enums#e165654aecf842b3dd57d8b31268378e8d1884c0" +source = "git+https://github.com/toksdotdev/rust-oci-client.git?branch=toks%2Fstring-impl-for-enums#0c0e352487c407a662de1b645bbc7ab2b87c1191" dependencies = [ "bytes", "chrono", @@ -2385,7 +2384,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha 0.9.0", - "rand_core 0.9.3", + "rand_core 0.9.5", ] [[package]] @@ -2405,7 +2404,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core 0.9.3", + "rand_core 0.9.5", ] [[package]] @@ -2414,14 +2413,14 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", ] [[package]] name = "rand_core" -version = "0.9.3" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ "getrandom 0.3.4", ] @@ -2450,7 +2449,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", "libredox", "thiserror 2.0.17", ] @@ -2605,7 +2604,7 @@ dependencies = [ "anyhow", "async-trait", "futures", - "getrandom 0.2.16", + "getrandom 0.2.17", "http", "hyper", "reqwest 0.12.28", @@ -2634,7 +2633,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom 0.2.16", + "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0", @@ -2708,9 +2707,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.13.2" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ "web-time", "zeroize", @@ -2745,9 +2744,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.8" +version = "0.103.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" dependencies = [ "aws-lc-rs", "ring", @@ -3330,9 +3329,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.113" +version = "2.0.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "678faa00651c9eb72dd2020cbdf275d92eccb2400d568e419efdd64838145cb4" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" dependencies = [ "proc-macro2", "quote", @@ -3455,30 +3454,30 @@ dependencies = [ [[package]] name = "time" -version = "0.3.44" +version = "0.3.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd" dependencies = [ "deranged", "itoa", "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" +checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca" [[package]] name = "time-macros" -version = "0.2.24" +version = "0.2.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +checksum = "71e552d1249bf61ac2a52db88179fd0673def1e1ad8243a00d9ec9ed71fee3dd" dependencies = [ "num-conv", "time-core", @@ -3583,9 +3582,9 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.10+spec-1.1.0" +version = "0.9.11+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0825052159284a1a8b4d6c0c86cbc801f2da5afd2b225fa548c72f2e74002f48" +checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46" dependencies = [ "indexmap", "serde_core", @@ -3622,9 +3621,9 @@ checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" [[package]] name = "tower" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", @@ -3926,9 +3925,9 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.1+wasi-0.2.4" +version = "1.0.2+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" dependencies = [ "wit-bindgen", ] @@ -3941,9 +3940,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" dependencies = [ "cfg-if", "once_cell", @@ -3954,11 +3953,12 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.56" +version = "0.4.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" dependencies = [ "cfg-if", + "futures-util", "js-sys", "once_cell", "wasm-bindgen", @@ -3967,9 +3967,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3977,9 +3977,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" dependencies = [ "bumpalo", "proc-macro2", @@ -3990,9 +3990,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" dependencies = [ "unicode-ident", ] @@ -4026,9 +4026,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.83" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" dependencies = [ "js-sys", "wasm-bindgen", @@ -4473,9 +4473,9 @@ checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" [[package]] name = "wit-bindgen" -version = "0.46.0" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" [[package]] name = "writeable" @@ -4518,18 +4518,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.32" +version = "0.8.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fabae64378cb18147bb18bca364e63bdbe72a0ffe4adf0addfec8aa166b2c56" +checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.32" +version = "0.8.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9c2d862265a8bb4471d87e033e730f536e2a285cc7cb05dbce09a2a97075f90" +checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" dependencies = [ "proc-macro2", "quote", @@ -4598,6 +4598,6 @@ dependencies = [ [[package]] name = "zmij" -version = "1.0.12" +version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fc5a66a20078bf1251bde995aa2fdcc4b800c70b5d92dd2c62abc5c60f679f8" +checksum = "94f63c051f4fe3c1509da62131a678643c5b6fbdc9273b2b79d4378ebda003d2" diff --git a/Makefile b/Makefile index 616bab1a..9f700e5b 100644 --- a/Makefile +++ b/Makefile @@ -161,7 +161,7 @@ uninstall: fi build_libkrun: - ./scripts/build_libkrun.sh --no-clean --build-dir "$(BUILD_DIR)" + bash ./scripts/build_libkrun.sh --no-clean --build-dir "$(BUILD_DIR)" # Catch-all target to allow example names and arguments %: diff --git a/docs/references/cli.md b/docs/references/cli.md index bd42a52d..c06616ae 100644 --- a/docs/references/cli.md +++ b/docs/references/cli.md @@ -646,6 +646,15 @@ msb pull [--image] [--image-group] [options] **Examples:** ```bash +# Pull a private image using env credentials (token) +export MSB_REGISTRY_TOKEN=token123 +msb pull registry.example.com/org/app:1.0 + +# Pull a private image using env credentials (username/password) +export MSB_REGISTRY_USERNAME=user +export MSB_REGISTRY_PASSWORD=pass +msb pull registry.example.com/org/app:1.0 + # Pull an image msb pull --image python:3.11 @@ -686,6 +695,66 @@ msb push myapp:latest === +==- `msb login` +Set registry credentials (persisted in microsandbox home). + +```bash +msb login [registry] [--username ] [--password-stdin] [--token ] +``` + +| Option | Description | +| ----------------- | ---------------------------------- | +| `--username` | Registry username | +| `--password-stdin`| Read password from stdin | +| `--token` | Registry access token (bearer) | + +**Examples:** + +```bash +# Provide a token +msb login ghcr.io --token token123 + +# Provide username and password via stdin +echo "pass" | msb login docker.io --username user --password-stdin + +# Use env fallback if CLI is invalid +export MSB_REGISTRY_TOKEN=token123 +msb login ghcr.io --username user --password-stdin +``` + +!!!note +`msb login` stores credentials locally but does not validate them against the registry. +!!! + +!!!warning Security +Credentials are stored in `~/.microsandbox/registry_auth.json`. Restrict file permissions and avoid sharing it. +!!! + +=== + +==- `msb logout` +Remove stored registry credentials. + +```bash +msb logout [registry] [--all] +``` + +| Option | Description | +| --------- | ---------------------------------------- | +| `--all` | Remove all stored registry credentials | + +**Examples:** + +```bash +# Remove credentials for a registry +msb logout ghcr.io + +# Remove all stored credentials +msb logout --all +``` + +=== + --- ### Maintenance diff --git a/microsandbox-cli/bin/msb/handlers.rs b/microsandbox-cli/bin/msb/handlers.rs index 08201c9d..5b89add9 100644 --- a/microsandbox-cli/bin/msb/handlers.rs +++ b/microsandbox-cli/bin/msb/handlers.rs @@ -11,8 +11,12 @@ use microsandbox_core::{ oci::Reference, }; use microsandbox_server::MicrosandboxServerResult; -use microsandbox_utils::{NAMESPACES_SUBDIR, env}; +use microsandbox_utils::{ + NAMESPACES_SUBDIR, StoredRegistryCredentials, clear_registry_credentials, env, + remove_registry_credentials, store_registry_credentials, +}; use std::{collections::HashMap, path::PathBuf}; +use tokio::io::{self, AsyncReadExt}; use typed_path::Utf8UnixPathBuf; //-------------------------------------------------------------------------------------------------- @@ -54,7 +58,7 @@ pub async fn add_subcommand( names: Vec, image: String, memory: Option, - cpus: Option, + cpus: Option, volumes: Vec, ports: Vec, envs: Vec, @@ -219,7 +223,7 @@ pub async fn script_run_subcommand( #[allow(clippy::too_many_arguments)] pub async fn exe_subcommand( name: String, - cpus: Option, + cpus: Option, memory: Option, volumes: Vec, ports: Vec, @@ -472,7 +476,7 @@ pub async fn self_subcommand(action: SelfAction) -> MicrosandboxCliResult<()> { pub async fn install_subcommand( name: String, alias: Option, - cpus: Option, + cpus: Option, memory: Option, volumes: Vec, ports: Vec, @@ -671,11 +675,60 @@ pub async fn server_status_subcommand( Ok(()) } -pub async fn login_subcommand() -> MicrosandboxCliResult<()> { - println!( - "{} login functionality is not yet implemented", - "error:".error() - ); +pub async fn login_subcommand( + registry: Option, + username: Option, + password_stdin: bool, + token: Option, +) -> MicrosandboxCliResult<()> { + let registry = resolve_registry_host(registry); + let creds = resolve_login_credentials(username, password_stdin, token).await?; + + match creds { + LoginCredentials::Basic { username, password } => { + store_registry_credentials( + ®istry, + StoredRegistryCredentials::Basic { username, password }, + ) + .map_err(|err| MicrosandboxCliError::ConfigError(err.to_string()))?; + println!( + "info: credentials saved for registry {} (not validated)", + registry + ); + } + LoginCredentials::Token { token } => { + store_registry_credentials(®istry, StoredRegistryCredentials::Token { token }) + .map_err(|err| MicrosandboxCliError::ConfigError(err.to_string()))?; + println!( + "info: token saved for registry {} (not validated)", + registry + ); + } + } + + Ok(()) +} + +pub async fn logout_subcommand(registry: Option, all: bool) -> MicrosandboxCliResult<()> { + if all { + clear_registry_credentials() + .map_err(|err| MicrosandboxCliError::ConfigError(err.to_string()))?; + println!("info: cleared all stored registry credentials"); + return Ok(()); + } + + let registry = resolve_registry_host(registry); + let removed = remove_registry_credentials(®istry) + .map_err(|err| MicrosandboxCliError::ConfigError(err.to_string()))?; + if removed { + println!("info: removed stored credentials for registry {}", registry); + } else { + println!( + "info: no stored credentials found for registry {}", + registry + ); + } + Ok(()) } @@ -741,6 +794,100 @@ fn parse_name_and_script(name_and_script: &str) -> (&str, Option<&str>) { (name, script) } +//-------------------------------------------------------------------------------------------------- +// Functions: Login Helpers +//-------------------------------------------------------------------------------------------------- + +enum LoginCredentials { + Basic { username: String, password: String }, + Token { token: String }, +} + +fn resolve_registry_host(registry: Option) -> String { + registry + .or_else(env::get_registry_host) + .unwrap_or_else(env::get_oci_registry) +} + +async fn resolve_login_credentials( + username: Option, + password_stdin: bool, + token: Option, +) -> MicrosandboxCliResult { + let cli_password = if password_stdin { + Some(read_password_from_stdin().await?) + } else { + None + }; + + let cli_provided = token.is_some() || username.is_some() || password_stdin; + if cli_provided { + let cli_result = if token.is_some() && (username.is_some() || cli_password.is_some()) { + Err(MicrosandboxCliError::InvalidArgument( + "token cannot be combined with username/password".to_string(), + )) + } else if let Some(token) = token { + Ok(LoginCredentials::Token { token }) + } else { + match (username, cli_password) { + (Some(username), Some(password)) => { + Ok(LoginCredentials::Basic { username, password }) + } + (None, None) => Err(MicrosandboxCliError::InvalidArgument( + "no credentials provided; use flags or environment variables".to_string(), + )), + _ => Err(MicrosandboxCliError::InvalidArgument( + "both username and password are required".to_string(), + )), + } + }; + + if let Ok(creds) = cli_result { + return Ok(creds); + } + + // CLI was provided but invalid; attempt env fallback. + tracing::debug!("login: CLI credentials invalid, falling back to environment variables"); + } + + let env_token = env::get_registry_token(); + let env_username = env::get_registry_username(); + let env_password = env::get_registry_password(); + + if env_token.is_some() && (env_username.is_some() || env_password.is_some()) { + return Err(MicrosandboxCliError::InvalidArgument( + "token cannot be combined with username/password".to_string(), + )); + } + + if let Some(token) = env_token { + return Ok(LoginCredentials::Token { token }); + } + + match (env_username, env_password) { + (Some(username), Some(password)) => Ok(LoginCredentials::Basic { username, password }), + (None, None) => Err(MicrosandboxCliError::InvalidArgument( + "no credentials provided; use flags or environment variables".to_string(), + )), + _ => Err(MicrosandboxCliError::InvalidArgument( + "both username and password are required".to_string(), + )), + } +} + +async fn read_password_from_stdin() -> MicrosandboxCliResult { + let mut input = String::new(); + let mut stdin = io::stdin(); + stdin.read_to_string(&mut input).await?; + let password = input.trim_end_matches(&['\n', '\r'][..]).to_string(); + if password.is_empty() { + return Err(MicrosandboxCliError::InvalidArgument( + "password provided via stdin is empty".to_string(), + )); + } + Ok(password) +} + /// Parse a file path into project path and config file name. /// /// If the file path is a directory, it is treated as the project path. diff --git a/microsandbox-cli/bin/msb/main.rs b/microsandbox-cli/bin/msb/main.rs index dedc8cb1..f4dc41bd 100644 --- a/microsandbox-cli/bin/msb/main.rs +++ b/microsandbox-cli/bin/msb/main.rs @@ -259,8 +259,16 @@ async fn main() -> MicrosandboxCliResult<()> { handlers::server_ssh_subcommand(namespace, sandbox, name).await?; } }, - Some(MicrosandboxSubcommand::Login) => { - handlers::login_subcommand().await?; + Some(MicrosandboxSubcommand::Login { + registry, + username, + password_stdin, + token, + }) => { + handlers::login_subcommand(registry, username, password_stdin, token).await?; + } + Some(MicrosandboxSubcommand::Logout { registry, all }) => { + handlers::logout_subcommand(registry, all).await?; } Some(MicrosandboxSubcommand::Push { image, name }) => { handlers::push_subcommand(image, name).await?; diff --git a/microsandbox-cli/bin/msbrun.rs b/microsandbox-cli/bin/msbrun.rs index 4f99b693..bf70166e 100644 --- a/microsandbox-cli/bin/msbrun.rs +++ b/microsandbox-cli/bin/msbrun.rs @@ -17,7 +17,7 @@ //! --log-level=3 \ //! --native-rootfs=/path/to/rootfs \ //! --overlayfs-rootfs=/path/to/rootfs \ -//! --num-vcpus=2 \ +//! --num-vcpus=0.5 \ //! --memory-mib=1024 \ //! --workdir-path=/app \ //! --exec-path=/usr/bin/python3 \ @@ -41,7 +41,7 @@ //! --log-level=3 \ //! --native-rootfs=/path/to/rootfs \ //! --overlayfs-rootfs=/path/to/rootfs \ -//! --num-vcpus=2 \ +//! --num-vcpus=0.5 \ //! --memory-mib=1024 \ //! --workdir-path=/app \ //! --exec-path=/usr/bin/python3 \ @@ -256,6 +256,7 @@ async fn main() -> Result<()> { log_dir.clone(), rootfs.clone(), forward_output, + num_vcpus, ) .await?; diff --git a/microsandbox-cli/bin/msbserver.rs b/microsandbox-cli/bin/msbserver.rs index 62afd843..170b72fc 100644 --- a/microsandbox-cli/bin/msbserver.rs +++ b/microsandbox-cli/bin/msbserver.rs @@ -41,11 +41,20 @@ pub async fn main() -> MicrosandboxCliResult<()> { args.dev_mode, )?); - // Get namespace directory from config + // Get namespace directory and port range from config let namespace_dir = config.get_namespace_dir().clone(); + let port_range = ( + config.get_port_range_min().as_ref().copied(), + config.get_port_range_max().as_ref().copied(), + ); - // Initialize the port manager - let port_manager = PortManager::new(namespace_dir).await.map_err(|e| { + // Initialize the port manager with the configured port range + let port_manager = if let (Some(min), Some(max)) = port_range { + PortManager::new_with_range(namespace_dir, Some((min, max))).await + } else { + PortManager::new(namespace_dir).await + } + .map_err(|e| { eprintln!("Error initializing port manager: {}", e); e })?; diff --git a/microsandbox-cli/lib/args/msb.rs b/microsandbox-cli/lib/args/msb.rs index 33d27f52..c6a003e3 100644 --- a/microsandbox-cli/lib/args/msb.rs +++ b/microsandbox-cli/lib/args/msb.rs @@ -78,7 +78,7 @@ pub enum MicrosandboxSubcommand { /// Number of CPUs #[arg(long, alias = "cpu")] - cpus: Option, + cpus: Option, /// Volume mappings, format: : #[arg(short, long = "volume", name = "VOLUME")] @@ -290,7 +290,7 @@ pub enum MicrosandboxSubcommand { /// Number of CPUs #[arg(long, alias = "cpu")] - cpus: Option, + cpus: Option, /// Memory in MB #[arg(long)] @@ -342,7 +342,7 @@ pub enum MicrosandboxSubcommand { /// Number of CPUs #[arg(long, alias = "cpu")] - cpus: Option, + cpus: Option, /// Memory in MB #[arg(long)] @@ -520,7 +520,33 @@ pub enum MicrosandboxSubcommand { /// Login to a registry #[command(name = "login")] - Login, + Login { + /// Registry host (defaults to OCI_REGISTRY_DOMAIN or docker.io) + registry: Option, + + /// Registry username + #[arg(short, long)] + username: Option, + + /// Read password from stdin + #[arg(long)] + password_stdin: bool, + + /// Registry token + #[arg(long)] + token: Option, + }, + + /// Logout from a registry + #[command(name = "logout")] + Logout { + /// Registry host (defaults to OCI_REGISTRY_DOMAIN or docker.io) + registry: Option, + + /// Remove all stored registry credentials + #[arg(long)] + all: bool, + }, /// Push image to a registry #[command(name = "push")] diff --git a/microsandbox-cli/lib/args/msbrun.rs b/microsandbox-cli/lib/args/msbrun.rs index cb75e747..c22f14cb 100644 --- a/microsandbox-cli/lib/args/msbrun.rs +++ b/microsandbox-cli/lib/args/msbrun.rs @@ -35,9 +35,9 @@ pub enum McrunSubcommand { #[arg(long)] overlayfs_layer: Vec, - /// Number of virtual CPUs + /// Number of virtual CPUs (supports fractional values) #[arg(long)] - num_vcpus: Option, + num_vcpus: Option, /// Memory size in MiB #[arg(long)] @@ -119,9 +119,9 @@ pub enum McrunSubcommand { #[arg(long)] overlayfs_layer: Vec, - /// Number of virtual CPUs + /// Number of virtual CPUs (supports fractional values) #[arg(long)] - num_vcpus: Option, + num_vcpus: Option, /// Memory size in MiB #[arg(long)] diff --git a/microsandbox-core/lib/config/microsandbox/builder.rs b/microsandbox-core/lib/config/microsandbox/builder.rs index 7080b6f4..fa24eedf 100644 --- a/microsandbox-core/lib/config/microsandbox/builder.rs +++ b/microsandbox-core/lib/config/microsandbox/builder.rs @@ -58,7 +58,7 @@ pub struct SandboxBuilder { meta: Option, image: I, memory: Option, - cpus: Option, + cpus: Option, volumes: Vec, ports: Vec, envs: Vec, @@ -163,7 +163,7 @@ impl SandboxBuilder { } /// Sets the maximum number of CPUs allowed for the sandbox - pub fn cpus(mut self, cpus: u8) -> SandboxBuilder { + pub fn cpus(mut self, cpus: f32) -> SandboxBuilder { self.cpus = Some(cpus); self } diff --git a/microsandbox-core/lib/config/microsandbox/config.rs b/microsandbox-core/lib/config/microsandbox/config.rs index e7224edd..1250fc72 100644 --- a/microsandbox-core/lib/config/microsandbox/config.rs +++ b/microsandbox-core/lib/config/microsandbox/config.rs @@ -130,10 +130,11 @@ pub struct Build { #[builder(default, setter(strip_option))] pub(crate) memory: Option, - /// The number of vCPUs to use. + /// The number of vCPUs to use (supports fractional values like 0.5, 0.25). + /// Valid range: 0.1 to 128.0 #[serde(skip_serializing_if = "Option::is_none", default)] #[builder(default, setter(strip_option))] - pub(crate) cpus: Option, + pub(crate) cpus: Option, /// The volumes to mount. #[serde(skip_serializing_if = "Vec::is_empty", default)] @@ -242,9 +243,10 @@ pub struct Sandbox { #[serde(skip_serializing_if = "Option::is_none", default)] pub(crate) memory: Option, - /// The number of vCPUs to use. + /// The number of vCPUs to use (supports fractional values like 0.5, 0.25). + /// Valid range: 0.1 to 128.0 #[serde(skip_serializing_if = "Option::is_none", default)] - pub(crate) cpus: Option, + pub(crate) cpus: Option, /// The volumes to mount. #[serde(skip_serializing_if = "Vec::is_empty", default)] @@ -633,7 +635,7 @@ mod tests { let sandbox = sandboxes.get("test_sandbox").unwrap(); assert_eq!(sandbox.version.as_ref().unwrap().to_string(), "1.0.0"); assert_eq!(sandbox.memory.unwrap(), 1024); - assert_eq!(sandbox.cpus.unwrap(), 2); + assert_eq!(sandbox.cpus.unwrap(), 2.0); assert_eq!(sandbox.volumes[0].to_string(), "./src:/app/src"); assert_eq!(sandbox.ports[0].to_string(), "8080:80"); assert_eq!(sandbox.envs[0].to_string(), "DEBUG=true"); @@ -718,7 +720,7 @@ mod tests { let builds = &config.builds; let base_build = builds.get("base_build").unwrap(); assert_eq!(base_build.memory.unwrap(), 2048); - assert_eq!(base_build.cpus.unwrap(), 2); + assert_eq!(base_build.cpus.unwrap(), 2.0); assert_eq!( base_build.workdir.as_ref().unwrap(), &Utf8UnixPathBuf::from("/build") @@ -742,7 +744,7 @@ mod tests { let api = sandboxes.get("api").unwrap(); assert_eq!(api.version.as_ref().unwrap().to_string(), "1.0.0"); assert_eq!(api.memory.unwrap(), 1024); - assert_eq!(api.cpus.unwrap(), 1); + assert_eq!(api.cpus.unwrap(), 1.0); assert_eq!(api.depends_on, vec!["database", "cache"]); assert_eq!(api.scope, NetworkScope::Public); } @@ -795,4 +797,50 @@ mod tests { "#; assert!(serde_yaml::from_str::(yaml).is_err()); } + + #[test] + fn microsandbox_config_accepts_fractional_cpus_in_sandbox_and_build() { + let yaml = r#" + builds: + base: + image: "alpine:latest" + cpus: 0.5 + sandboxes: + svc: + image: "alpine:latest" + shell: "/bin/sh" + cpus: 0.25 + "#; + + let config: Microsandbox = serde_yaml::from_str(yaml).unwrap(); + let base = config.get_build("base").unwrap(); + assert_eq!(base.cpus, Some(0.5)); + let svc = config.get_sandbox("svc").unwrap(); + assert_eq!(svc.cpus, Some(0.25)); + } + + #[test] + fn microsandbox_config_missing_cpus_is_none() { + let yaml = r#" + sandboxes: + a: + image: "alpine:latest" + shell: "/bin/sh" + "#; + let config: Microsandbox = serde_yaml::from_str(yaml).unwrap(); + assert!(config.get_sandbox("a").unwrap().cpus.is_none()); + } + + #[test] + fn microsandbox_config_integer_cpus_still_supported() { + let yaml = r#" + sandboxes: + b: + image: "alpine:latest" + shell: "/bin/sh" + cpus: 2 + "#; + let config: Microsandbox = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(config.get_sandbox("b").unwrap().cpus, Some(2.0)); + } } diff --git a/microsandbox-core/lib/management/config.rs b/microsandbox-core/lib/management/config.rs index 79793add..288ff2be 100644 --- a/microsandbox-core/lib/management/config.rs +++ b/microsandbox-core/lib/management/config.rs @@ -35,7 +35,7 @@ pub struct SandboxConfig { pub memory: Option, /// The number of CPUs to use. - pub cpus: Option, + pub cpus: Option, /// The volumes to mount. pub volumes: Vec, @@ -168,7 +168,7 @@ pub async fn add( } if let Some(cpus_value) = config.cpus { - sandbox_mapping.insert_u32("cpus", cpus_value); + sandbox_mapping.insert_f32("cpus", cpus_value); } // Add shell (default if not provided) diff --git a/microsandbox-core/lib/management/home.rs b/microsandbox-core/lib/management/home.rs index 808c89f2..d1a68df0 100644 --- a/microsandbox-core/lib/management/home.rs +++ b/microsandbox-core/lib/management/home.rs @@ -147,7 +147,7 @@ pub async fn clean(force: bool) -> MicrosandboxResult<()> { /// &image, /// Some("shell"), // Run shell script /// Some("ubuntu-shell"), // Custom alias -/// Some(2), // 2 CPUs +/// Some(2.0), // 2 CPUs /// Some(1024), // 1GB RAM /// vec![ // Mount host's /tmp to sandbox's /data /// "/tmp:/data".to_string() @@ -172,7 +172,7 @@ pub async fn install( image: &Reference, script: Option<&str>, alias: Option<&str>, - cpus: Option, + cpus: Option, memory: Option, volumes: Vec, ports: Vec, diff --git a/microsandbox-core/lib/management/image.rs b/microsandbox-core/lib/management/image.rs index 195a8454..fb362e9b 100644 --- a/microsandbox-core/lib/management/image.rs +++ b/microsandbox-core/lib/management/image.rs @@ -14,7 +14,12 @@ use futures::{StreamExt, future}; use indicatif::{ProgressBar, ProgressStyle}; #[cfg(feature = "cli")] use microsandbox_utils::term::{self, MULTI_PROGRESS}; -use microsandbox_utils::{EXTRACTED_LAYER_SUFFIX, LAYERS_SUBDIR, OCI_DB_FILENAME, env}; +use microsandbox_utils::{ + DockerAuthCredentials, EXTRACTED_LAYER_SUFFIX, LAYERS_SUBDIR, OCI_DB_FILENAME, + StoredRegistryCredentials, env, load_docker_registry_credentials, + load_stored_registry_credentials, +}; +use oci_client::secrets::RegistryAuth; use oci_spec::image::Platform; #[cfg(feature = "cli")] use pin_project_lite::pin_project; @@ -93,7 +98,15 @@ pub async fn pull(image: Reference, layer_output_dir: Option) -> Micros let microsandbox_home_path = env::get_microsandbox_home_path(); let db_path = microsandbox_home_path.join(OCI_DB_FILENAME); let db = db::get_or_create_pool(&db_path, &db::OCI_DB_MIGRATOR).await?; - let registry = Registry::new(temp_download_dir, db.clone(), Platform::default(), layer).await?; + let auth = resolve_registry_auth(&image)?; + let registry = Registry::new( + temp_download_dir, + db.clone(), + Platform::default(), + layer, + auth, + ) + .await?; // Use custom layer_output_dir if specified, otherwise use default microsandbox layers directory let layers_dir = layer_output_dir.unwrap_or_else(|| microsandbox_home_path.join(LAYERS_SUBDIR)); @@ -175,6 +188,228 @@ where Ok(()) } +//-------------------------------------------------------------------------------------------------- +// Functions: Auth resolution +//-------------------------------------------------------------------------------------------------- + +/// Resolve registry authentication for a given image reference. +/// +/// This uses the following precedence: +/// 1) Environment variables +/// 2) Stored credentials (msb login) +/// 3) Docker config (auths/cred helpers) +/// 4) Anonymous +pub fn resolve_registry_auth(reference: &Reference) -> MicrosandboxResult { + // TODO: Ensure msb server inherits auth sources (env, stored creds, Docker config) in its runtime. + let registry = reference.registry(); + if let Some(auth) = resolve_env_auth()? { + return Ok(auth); + } + + match load_stored_registry_credentials(registry) { + Ok(Some(creds)) => return Ok(convert_stored_credentials(creds)), + Ok(None) => (), + Err(err) => { + tracing::warn!("failed to load stored registry auth: {}", err); + } + } + + match load_docker_registry_credentials(registry) { + Ok(Some(creds)) => Ok(convert_docker_credentials(creds)), + Ok(None) => Ok(RegistryAuth::Anonymous), + Err(err) => { + tracing::warn!("failed to load docker config auth: {}", err); + Ok(RegistryAuth::Anonymous) + } + } +} + +fn resolve_env_auth() -> MicrosandboxResult> { + let token = env::get_registry_token(); + let username = env::get_registry_username(); + let password = env::get_registry_password(); + + if token.is_none() && username.is_none() && password.is_none() { + return Ok(None); + } + + if token.is_some() && (username.is_some() || password.is_some()) { + return Err(MicrosandboxError::ConfigValidation( + "registry token cannot be combined with username/password".to_string(), + )); + } + + if let Some(token) = token { + return Ok(Some(RegistryAuth::Bearer(token))); + } + + match (username, password) { + (Some(username), Some(password)) => Ok(Some(RegistryAuth::Basic(username, password))), + (None, None) => Ok(None), + _ => Err(MicrosandboxError::ConfigValidation( + "both registry username and password are required".to_string(), + )), + } +} + +fn convert_docker_credentials(creds: DockerAuthCredentials) -> RegistryAuth { + match creds { + DockerAuthCredentials::Basic { username, password } => { + RegistryAuth::Basic(username, password) + } + DockerAuthCredentials::Token { token } => RegistryAuth::Bearer(token), + } +} + +fn convert_stored_credentials(creds: StoredRegistryCredentials) -> RegistryAuth { + match creds { + StoredRegistryCredentials::Basic { username, password } => { + RegistryAuth::Basic(username, password) + } + StoredRegistryCredentials::Token { token } => RegistryAuth::Bearer(token), + } +} + +//-------------------------------------------------------------------------------------------------- +// Tests +//-------------------------------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use std::{env as std_env, fs, sync::Mutex}; + use tempfile::TempDir; + + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + struct EnvGuard { + key: &'static str, + prev: Option, + } + + impl EnvGuard { + fn set(key: &'static str, value: impl Into) -> Self { + let prev = std_env::var_os(key); + let value: std::ffi::OsString = value.into(); + unsafe { std_env::set_var(key, &value) }; + Self { key, prev } + } + + fn remove(key: &'static str) -> Self { + let prev = std_env::var_os(key); + unsafe { std_env::remove_var(key) }; + Self { key, prev } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + if let Some(value) = self.prev.take() { + unsafe { std_env::set_var(self.key, value) }; + } else { + unsafe { std_env::remove_var(self.key) }; + } + } + } + + fn write_docker_config(temp_dir: &TempDir, contents: &str) -> std::path::PathBuf { + let path = temp_dir.path().join("config.json"); + fs::write(&path, contents).expect("write docker config"); + path + } + + #[test] + fn env_token_resolves() { + let _lock = ENV_LOCK.lock().unwrap(); + let _host = EnvGuard::remove(env::MSB_REGISTRY_HOST_ENV_VAR); + let _user = EnvGuard::remove(env::MSB_REGISTRY_USERNAME_ENV_VAR); + let _pass = EnvGuard::remove(env::MSB_REGISTRY_PASSWORD_ENV_VAR); + let _token = EnvGuard::set(env::MSB_REGISTRY_TOKEN_ENV_VAR, "token-abc"); + + let auth = resolve_env_auth().expect("resolve env").expect("auth"); + assert!(matches!(auth, RegistryAuth::Bearer(t) if t == "token-abc")); + } + + #[test] + fn env_basic_requires_both_fields() { + let _lock = ENV_LOCK.lock().unwrap(); + let _host = EnvGuard::remove(env::MSB_REGISTRY_HOST_ENV_VAR); + let _token = EnvGuard::remove(env::MSB_REGISTRY_TOKEN_ENV_VAR); + let _user = EnvGuard::set(env::MSB_REGISTRY_USERNAME_ENV_VAR, "user"); + let _pass = EnvGuard::remove(env::MSB_REGISTRY_PASSWORD_ENV_VAR); + + let err = resolve_env_auth().expect_err("missing password should error"); + assert!(matches!(err, MicrosandboxError::ConfigValidation(_))); + } + + #[test] + fn resolve_registry_auth_prefers_env() { + let _lock = ENV_LOCK.lock().unwrap(); + let _host = EnvGuard::remove(env::MSB_REGISTRY_HOST_ENV_VAR); + let _user = EnvGuard::remove(env::MSB_REGISTRY_USERNAME_ENV_VAR); + let _pass = EnvGuard::remove(env::MSB_REGISTRY_PASSWORD_ENV_VAR); + let _token = EnvGuard::set(env::MSB_REGISTRY_TOKEN_ENV_VAR, "token-xyz"); + let msb_home = TempDir::new().expect("temp msb home"); + let _msb_home = EnvGuard::set(env::MICROSANDBOX_HOME_ENV_VAR, msb_home.path()); + + let temp = TempDir::new().expect("temp dir"); + let config = r#"{ + "auths": { + "registry.example.com": { "username": "user", "password": "pass" } + } +}"#; + let path = write_docker_config(&temp, config); + let _docker_config = EnvGuard::set("DOCKER_CONFIG", path.to_string_lossy().to_string()); + + let reference: Reference = "registry.example.com/repo:latest".parse().unwrap(); + let auth = resolve_registry_auth(&reference).expect("resolve auth"); + assert!(matches!(auth, RegistryAuth::Bearer(t) if t == "token-xyz")); + } + + #[test] + fn resolve_registry_auth_from_docker_config() { + let _lock = ENV_LOCK.lock().unwrap(); + let _host = EnvGuard::remove(env::MSB_REGISTRY_HOST_ENV_VAR); + let _token = EnvGuard::remove(env::MSB_REGISTRY_TOKEN_ENV_VAR); + let _user = EnvGuard::remove(env::MSB_REGISTRY_USERNAME_ENV_VAR); + let _pass = EnvGuard::remove(env::MSB_REGISTRY_PASSWORD_ENV_VAR); + let msb_home = TempDir::new().expect("temp msb home"); + let _msb_home = EnvGuard::set(env::MICROSANDBOX_HOME_ENV_VAR, msb_home.path()); + + let temp = TempDir::new().expect("temp dir"); + let config = r#"{ + "auths": { + "registry.example.com": { "username": "user", "password": "pass" } + } +}"#; + let path = write_docker_config(&temp, config); + let _docker_config = EnvGuard::set("DOCKER_CONFIG", path.to_string_lossy().to_string()); + + let reference: Reference = "registry.example.com/repo:latest".parse().unwrap(); + let auth = resolve_registry_auth(&reference).expect("resolve auth"); + assert!(matches!(auth, RegistryAuth::Basic(u, p) if u == "user" && p == "pass")); + } + + #[test] + fn resolve_registry_auth_defaults_anonymous_when_no_config() { + let _lock = ENV_LOCK.lock().unwrap(); + let _host = EnvGuard::remove(env::MSB_REGISTRY_HOST_ENV_VAR); + let _token = EnvGuard::remove(env::MSB_REGISTRY_TOKEN_ENV_VAR); + let _user = EnvGuard::remove(env::MSB_REGISTRY_USERNAME_ENV_VAR); + let _pass = EnvGuard::remove(env::MSB_REGISTRY_PASSWORD_ENV_VAR); + let msb_home = TempDir::new().expect("temp msb home"); + let _msb_home = EnvGuard::set(env::MICROSANDBOX_HOME_ENV_VAR, msb_home.path()); + + let temp = TempDir::new().expect("temp dir"); + let _docker_config = + EnvGuard::set("DOCKER_CONFIG", temp.path().to_string_lossy().to_string()); + + let reference: Reference = "registry.example.com/repo:latest".parse().unwrap(); + let auth = resolve_registry_auth(&reference).expect("resolve auth"); + assert!(matches!(auth, RegistryAuth::Anonymous)); + } +} + //-------------------------------------------------------------------------------------------------- // Functions: Helpers //-------------------------------------------------------------------------------------------------- diff --git a/microsandbox-core/lib/management/sandbox.rs b/microsandbox-core/lib/management/sandbox.rs index 36b713ca..68fc4cde 100644 --- a/microsandbox-core/lib/management/sandbox.rs +++ b/microsandbox-core/lib/management/sandbox.rs @@ -421,7 +421,7 @@ pub async fn prepare_run( /// sandbox::run_temp( /// &image, /// Some("start"), // Script name -/// Some(2), // 2 CPUs +/// Some(2.0), // 2 CPUs /// Some(1024), // 1GB RAM /// vec![ // Mount host's /tmp to sandbox's /data /// "/tmp:/data".to_string() @@ -445,7 +445,7 @@ pub async fn prepare_run( pub async fn run_temp( image: &Reference, script: Option<&str>, - cpus: Option, + cpus: Option, memory: Option, volumes: Vec, ports: Vec, diff --git a/microsandbox-core/lib/oci/mocks.rs b/microsandbox-core/lib/oci/mocks.rs index e9bd4f6c..25afaa33 100644 --- a/microsandbox-core/lib/oci/mocks.rs +++ b/microsandbox-core/lib/oci/mocks.rs @@ -20,8 +20,14 @@ pub(crate) async fn mock_registry_and_db() -> (Registry, Pool< let platform = Platform::default(); let layer_ops = GlobalLayerCache::default(); - let registry = Registry::new(download_dir, db.clone(), platform, layer_ops) - .await - .unwrap(); + let registry = Registry::new( + download_dir, + db.clone(), + platform, + layer_ops, + oci_client::secrets::RegistryAuth::Anonymous, + ) + .await + .unwrap(); (registry, db, temp_dir) } diff --git a/microsandbox-core/lib/oci/registry.rs b/microsandbox-core/lib/oci/registry.rs index 09523179..4d539eb4 100644 --- a/microsandbox-core/lib/oci/registry.rs +++ b/microsandbox-core/lib/oci/registry.rs @@ -92,6 +92,7 @@ where db: Pool, platform: Platform, layer_ops: T, + auth: RegistryAuth, ) -> MicrosandboxResult { let config = OciClientConfig { platform_resolver: Some(Box::new(move |manifests| { @@ -102,7 +103,7 @@ where Ok(Self { client: OciClient::new(config), - auth: RegistryAuth::Anonymous, + auth, layer_download_dir: layer_download_dir.into(), db, global_cache: layer_ops, diff --git a/microsandbox-core/lib/runtime/monitor.rs b/microsandbox-core/lib/runtime/monitor.rs index 180adeb3..abdb3ff9 100644 --- a/microsandbox-core/lib/runtime/monitor.rs +++ b/microsandbox-core/lib/runtime/monitor.rs @@ -60,6 +60,13 @@ pub struct MicroVmMonitor { /// Whether to forward output to stdout/stderr forward_output: bool, + + /// The requested number of CPUs, if provided + num_vcpus: Option, + + /// The cgroup name used for throttling (Linux only) + #[cfg(target_os = "linux")] + cgroup_name: Option, } //-------------------------------------------------------------------------------------------------- @@ -78,6 +85,7 @@ impl MicroVmMonitor { log_dir: impl Into, rootfs: Rootfs, forward_output: bool, + num_vcpus: Option, ) -> MicrosandboxResult { Ok(Self { supervisor_pid, @@ -90,6 +98,9 @@ impl MicroVmMonitor { rootfs, original_term: None, forward_output, + num_vcpus, + #[cfg(target_os = "linux")] + cgroup_name: None, }) } @@ -113,6 +124,94 @@ impl MicroVmMonitor { // Place the log file inside that directory with the sandbox name config_dir.join(format!("{}.{}", self.sandbox_name, LOG_SUFFIX)) } + + #[cfg(target_os = "linux")] + fn build_cgroup_name(&self) -> String { + format!( + "{}_{}", + sanitize_cgroup_segment(&self.config_file), + sanitize_cgroup_segment(&self.sandbox_name) + ) + } + + fn maybe_apply_cpu_quota(&mut self, microvm_pid: u32) { + let Some(num_vcpus) = self.num_vcpus else { + return; + }; + + if num_vcpus >= 1.0 { + return; + } + + #[cfg(target_os = "linux")] + { + if !crate::vm::has_cgroup_v2() { + // TODO: consider emitting a structured event for monitoring instead of a log. + tracing::warn!( + "cgroups v2 not available; skipping CPU throttle for {}", + self.sandbox_name + ); + return; + } + + let cgroup_name = self.build_cgroup_name(); + match crate::vm::apply_cpu_quota(microvm_pid, num_vcpus, &cgroup_name) { + Ok(quota) => tracing::info!( + "applied cgroup CPU quota: cpus={:.2}, quota_us={}, period_us={}", + num_vcpus, + quota.quota_us, + quota.period_us + ), + Err(e) => tracing::warn!( + error = %e, + "failed to apply cgroup CPU quota for {}", + self.sandbox_name + ), + } + self.cgroup_name = Some(cgroup_name); + } + + #[cfg(not(target_os = "linux"))] + { + let _ = microvm_pid; + // TODO: consider surfacing this as a user-facing warning in non-Linux environments. + tracing::warn!( + "CPU throttling via cgroups is only available on Linux; skipping for {}", + self.sandbox_name + ); + } + } + + fn maybe_cleanup_cgroup(&mut self) { + #[cfg(target_os = "linux")] + { + let Some(cgroup_name) = self.cgroup_name.take() else { + return; + }; + + if !crate::vm::has_cgroup_v2() { + return; + } + + if let Err(e) = crate::vm::cleanup_cgroup(&cgroup_name) { + tracing::warn!(error = %e, "failed to cleanup cgroup {}", cgroup_name); + } + } + } +} + +#[cfg(target_os = "linux")] +fn sanitize_cgroup_segment(input: &str) -> String { + input + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + c + } else { + '_' + } + }) + .collect() } //-------------------------------------------------------------------------------------------------- @@ -163,6 +262,8 @@ impl ProcessMonitor for MicroVmMonitor { .await .map_err(MicrosandboxUtilsError::custom)?; + self.maybe_apply_cpu_quota(microvm_pid); + match child_io { ChildIo::Piped { stdin, @@ -318,6 +419,8 @@ impl ProcessMonitor for MicroVmMonitor { // Restore terminal settings if they were modified self.restore_terminal_settings(); + self.maybe_cleanup_cgroup(); + // Update sandbox status to stopped db::update_sandbox_status( &self.sandbox_db, @@ -338,5 +441,6 @@ impl ProcessMonitor for MicroVmMonitor { impl Drop for MicroVmMonitor { fn drop(&mut self) { self.restore_terminal_settings(); + self.maybe_cleanup_cgroup(); } } diff --git a/microsandbox-core/lib/vm/builder.rs b/microsandbox-core/lib/vm/builder.rs index f9e8a677..45e1b33d 100644 --- a/microsandbox-core/lib/vm/builder.rs +++ b/microsandbox-core/lib/vm/builder.rs @@ -38,7 +38,7 @@ use super::{ pub struct MicroVmConfigBuilder { log_level: LogLevel, rootfs: R, - num_vcpus: u8, + num_vcpus: f32, memory_mib: u32, mapped_dirs: Vec, port_map: Vec, @@ -89,7 +89,7 @@ pub struct MicroVmConfigBuilder { /// let vm = MicroVmBuilder::default() /// .log_level(LogLevel::Debug) /// .rootfs(Rootfs::Native(PathBuf::from("/tmp"))) -/// .num_vcpus(2) +/// .num_vcpus(2.0) /// .memory_mib(1024) /// .mapped_dirs(["/home:/guest/mount".parse()?]) /// .port_map(["8080:80".parse()?]) @@ -192,6 +192,7 @@ impl MicroVmConfigBuilder { /// Sets the number of virtual CPUs (vCPUs) for the MicroVm. /// /// This determines how many CPU cores are available to the guest system. + /// Supports fractional values (e.g., 0.5, 0.25) for CPU throttling. /// /// ## Examples /// @@ -199,14 +200,17 @@ impl MicroVmConfigBuilder { /// use microsandbox_core::vm::MicroVmConfigBuilder; /// /// let config = MicroVmConfigBuilder::default() - /// .num_vcpus(2); // Allocate 2 virtual CPU cores + /// .num_vcpus(2.0); // Allocate 2 virtual CPU cores + /// let config = MicroVmConfigBuilder::default() + /// .num_vcpus(0.5); // Allocate 0.5 CPU (50% of one core) /// ``` /// /// ## Notes - /// - The default is 1 vCPU if not specified - /// - The number of vCPUs should not exceed the host's physical CPU cores + /// - The default is 1.0 vCPU if not specified + /// - Valid range: 0.1 to 128.0 + /// - Fractional values (< 1.0) will be throttled using cgroups /// - More vCPUs aren't always better - consider the workload's needs - pub fn num_vcpus(mut self, num_vcpus: u8) -> Self { + pub fn num_vcpus(mut self, num_vcpus: f32) -> Self { self.num_vcpus = num_vcpus; self } @@ -275,11 +279,11 @@ impl MicroVmConfigBuilder { /// ## Examples /// /// ```rust - /// use microsandbox_core::vm::MicroVmConfigBuilder; + /// use microsandbox_core::vm::MicroVmBuilder; /// use microsandbox_core::config::PortPair; /// /// # fn main() -> anyhow::Result<()> { - /// let config = MicroVmConfigBuilder::default() + /// let vm = MicroVmBuilder::default() /// .port_map([ /// // Map host port 8080 to guest port 80 /// "8080:80".parse()?, @@ -654,7 +658,8 @@ impl MicroVmBuilder { /// Sets the number of virtual CPUs (vCPUs) for the MicroVm. /// - /// This determines how many CPU cores are available to the guest system. + /// This determines how many CPU cores are available to the guest system and + /// supports fractional CPU values where supported. /// /// ## Examples /// @@ -667,7 +672,7 @@ impl MicroVmBuilder { /// let vm = MicroVmBuilder::default() /// .rootfs(Rootfs::Native(temp_dir.path().to_path_buf())) /// .memory_mib(1024) - /// .num_vcpus(2) // Allocate 2 virtual CPU cores + /// .num_vcpus(2.0) // Allocate 2 virtual CPU cores /// .exec_path("/bin/echo") /// .build()?; /// # Ok(()) @@ -677,8 +682,11 @@ impl MicroVmBuilder { /// ## Notes /// - The default is 1 vCPU if not specified /// - More vCPUs aren't always better - consider the workload's needs - pub fn num_vcpus(mut self, num_vcpus: u8) -> Self { - self.inner = self.inner.num_vcpus(num_vcpus); + pub fn num_vcpus(mut self, num_vcpus: T) -> Self + where + T: Into, + { + self.inner = self.inner.num_vcpus(num_vcpus.into()); self } @@ -1134,7 +1142,7 @@ mod tests { let builder = MicroVmBuilder::default() .log_level(LogLevel::Debug) .rootfs(rootfs.clone()) - .num_vcpus(2) + .num_vcpus(2u8) .memory_mib(1024) .mapped_dirs(["/guest/mount:/host/mount".parse()?]) .port_map(["8080:80".parse()?]) @@ -1147,7 +1155,7 @@ mod tests { assert_eq!(builder.inner.log_level, LogLevel::Debug); assert_eq!(builder.inner.rootfs, rootfs); - assert_eq!(builder.inner.num_vcpus, 2); + assert_eq!(builder.inner.num_vcpus, 2.0); assert_eq!(builder.inner.memory_mib, 1024); assert_eq!( builder.inner.mapped_dirs, @@ -1199,3 +1207,34 @@ mod tests { Ok(()) } } + +#[cfg(test)] +mod fractional_cpu_tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn microvm_config_builder_preserves_fractional_num_vcpus() { + let tmp = TempDir::new().unwrap(); + let cfg = MicroVmConfigBuilder::default() + .rootfs(Rootfs::Native(tmp.path().to_path_buf())) + .exec_path("/bin/echo") + .num_vcpus(0.5) + .memory_mib(256) + .build(); + + assert_eq!(cfg.num_vcpus, 0.5); + assert_eq!(cfg.memory_mib, 256); + } + + #[test] + fn microvm_config_builder_allows_smallest_practical_fraction() { + let tmp = TempDir::new().unwrap(); + let cfg = MicroVmConfigBuilder::default() + .rootfs(Rootfs::Native(tmp.path().to_path_buf())) + .exec_path("/bin/echo") + .num_vcpus(0.25) + .build(); + assert_eq!(cfg.num_vcpus, 0.25); + } +} diff --git a/microsandbox-core/lib/vm/cgroup.rs b/microsandbox-core/lib/vm/cgroup.rs new file mode 100644 index 00000000..63f4786d --- /dev/null +++ b/microsandbox-core/lib/vm/cgroup.rs @@ -0,0 +1,126 @@ +//! cgroups v2 helpers for runtime resource controls. + +use std::path::Path; + +#[cfg(target_os = "linux")] +use std::path::PathBuf; + +use crate::MicrosandboxResult; + +/// Default cgroup v2 CPU period in microseconds. +pub const DEFAULT_CPU_PERIOD_US: u64 = 100_000; + +/// Computed CPU quota values for cgroups v2. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CpuQuota { + /// The quota in microseconds. + pub quota_us: u64, + /// The period in microseconds. + pub period_us: u64, +} + +/// Computes the cgroup v2 CPU quota for a fractional CPU request. +pub fn compute_cpu_quota(cpus: f32, period_us: u64) -> CpuQuota { + let quota = (period_us as f32 * cpus).round().max(1.0) as u64; + CpuQuota { + quota_us: quota, + period_us, + } +} + +/// Returns true if cgroups v2 appears to be available on the host. +pub fn has_cgroup_v2() -> bool { + Path::new("/sys/fs/cgroup/cgroup.controllers").exists() +} + +/// Applies a CPU quota to the process PID using cgroups v2. +/// +/// This creates a dedicated cgroup under `/sys/fs/cgroup/microsandbox/`, +/// writes the CPU limit to `cpu.max`, and moves the PID into `cgroup.procs`. +/// +#[cfg(target_os = "linux")] +pub fn apply_cpu_quota(pid: u32, cpus: f32, cgroup_name: &str) -> MicrosandboxResult { + let quota = compute_cpu_quota(cpus, DEFAULT_CPU_PERIOD_US); + let cgroup_path = cgroup_dir_path(cgroup_name); + + std::fs::create_dir_all(&cgroup_path)?; + std::fs::write(cgroup_path.join("cpu.max"), format!("{} {}", quota.quota_us, quota.period_us))?; + std::fs::write(cgroup_path.join("cgroup.procs"), pid.to_string())?; + + Ok(quota) +} + +/// Applies a CPU quota on non-Linux systems (no-op). +/// +/// TODO: define a clearer contract for non-Linux CPU throttling if it ever becomes available. +#[cfg(not(target_os = "linux"))] +pub fn apply_cpu_quota(_pid: u32, _cpus: f32, _cgroup_name: &str) -> MicrosandboxResult { + Ok(compute_cpu_quota(_cpus, DEFAULT_CPU_PERIOD_US)) +} + +#[cfg(target_os = "linux")] +fn cgroup_dir_path(cgroup_name: &str) -> PathBuf { + Path::new("/sys/fs/cgroup") + .join("microsandbox") + .join(cgroup_name) +} + +/// Removes the cgroup directory for a sandbox. +/// +/// TODO: consider tolerating EBUSY by retrying after moving the process out. +#[cfg(target_os = "linux")] +pub fn cleanup_cgroup(cgroup_name: &str) -> MicrosandboxResult<()> { + let cgroup_path = cgroup_dir_path(cgroup_name); + if cgroup_path.exists() { + std::fs::remove_dir_all(cgroup_path)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cpu_quota_rounds_to_nearest() { + let quota = compute_cpu_quota(0.1, DEFAULT_CPU_PERIOD_US); + assert_eq!( + quota, + CpuQuota { + quota_us: 10_000, + period_us: 100_000 + } + ); + + let quota = compute_cpu_quota(0.5, DEFAULT_CPU_PERIOD_US); + assert_eq!( + quota, + CpuQuota { + quota_us: 50_000, + period_us: 100_000 + } + ); + } + + #[test] + #[cfg(not(target_os = "linux"))] + fn apply_cpu_quota_is_noop_on_non_linux() { + let pid = std::process::id(); + let cgroup_name = format!("msb_noop_{}", pid); + let path = Path::new("/sys/fs/cgroup").join("microsandbox").join(&cgroup_name); + if path.exists() { + eprintln!("skipping: cgroup path exists on this host"); + return; + } + + let quota = apply_cpu_quota(pid, 0.5, &cgroup_name).expect("no-op should succeed"); + assert_eq!( + quota, + CpuQuota { + quota_us: 50_000, + period_us: 100_000 + } + ); + assert!(!path.exists()); + } +} diff --git a/microsandbox-core/lib/vm/microvm.rs b/microsandbox-core/lib/vm/microvm.rs index 1debc6a7..cc8be8d0 100644 --- a/microsandbox-core/lib/vm/microvm.rs +++ b/microsandbox-core/lib/vm/microvm.rs @@ -121,8 +121,9 @@ pub struct MicroVmConfig { /// The rootfs for the MicroVm. pub rootfs: Rootfs, - /// The number of vCPUs to use for the MicroVm. - pub num_vcpus: u8, + /// The number of vCPUs to use for the MicroVm (supports fractional values like 0.5, 0.25). + /// Valid range: 0.1 to 128.0. Values less than 1.0 will be throttled using cgroups. + pub num_vcpus: f32, /// The amount of memory in MiB to use for the MicroVm. pub memory_mib: u32, @@ -312,12 +313,29 @@ impl MicroVm { assert!(status >= 0, "failed to set log level: {}", status); } + // Convert f32 CPU count to u8 for libkrun + // For fractional CPUs, we allocate at least 1 vCPU and use cgroups to throttle + let vcpus_for_krun = if config.num_vcpus >= 1.0 { + config.num_vcpus.ceil() as u8 + } else { + 1u8 // Minimum 1 vCPU for libkrun + }; + // Set basic VM configuration unsafe { - let status = ffi::krun_set_vm_config(ctx_id, config.num_vcpus, config.memory_mib); + let status = ffi::krun_set_vm_config(ctx_id, vcpus_for_krun, config.memory_mib); assert!(status >= 0, "failed to set VM config: {}", status); } + // Store the actual CPU request for later cgroup throttling + // This will be applied if the system supports cgroups v2 + if config.num_vcpus < 1.0 { + tracing::info!( + "CPU fractional value {:.2} detected, will apply cgroup throttling", + config.num_vcpus + ); + } + // Set rootfs. match &config.rootfs { Rootfs::Native(path) => { @@ -600,7 +618,7 @@ impl MicroVmConfig { } } - if self.num_vcpus == 0 { + if self.num_vcpus == 0.0 { return Err(MicrosandboxError::InvalidMicroVMConfig( InvalidMicroVMConfigError::NumVCPUsIsZero, )); diff --git a/microsandbox-core/lib/vm/mod.rs b/microsandbox-core/lib/vm/mod.rs index d099cbe4..d6fce073 100644 --- a/microsandbox-core/lib/vm/mod.rs +++ b/microsandbox-core/lib/vm/mod.rs @@ -1,6 +1,7 @@ //! Runtime management and configuration. mod builder; +mod cgroup; mod ffi; mod microvm; mod rlimit; @@ -10,6 +11,7 @@ mod rlimit; //-------------------------------------------------------------------------------------------------- pub use builder::*; +pub use cgroup::*; #[allow(unused)] pub use ffi::*; pub use microvm::*; diff --git a/microsandbox-core/tests/cgroup_integration.rs b/microsandbox-core/tests/cgroup_integration.rs new file mode 100644 index 00000000..0220f56d --- /dev/null +++ b/microsandbox-core/tests/cgroup_integration.rs @@ -0,0 +1,61 @@ +#![cfg(target_os = "linux")] + +use std::path::Path; + +use microsandbox_core::vm::{apply_cpu_quota, cleanup_cgroup, has_cgroup_v2}; +use microsandbox_core::MicrosandboxResult; + +#[test] +fn apply_cpu_quota_writes_cpu_max() -> MicrosandboxResult<()> { + if !has_cgroup_v2() { + eprintln!("skipping: cgroups v2 not available on this host"); + return Ok(()); + } + + let pid = std::process::id(); + let cgroup_name = format!("msb_test_{}", pid); + let result = apply_cpu_quota(pid, 0.5, &cgroup_name); + if let Err(e) = result { + eprintln!("skipping: failed to apply cpu quota ({})", e); + return Ok(()); + } + + let cpu_max_path = Path::new("/sys/fs/cgroup") + .join("microsandbox") + .join(&cgroup_name) + .join("cpu.max"); + let cpu_max = std::fs::read_to_string(cpu_max_path)?; + + assert!(cpu_max.starts_with("50000 100000")); + + // TODO: cleanup cgroup after validation if permissions allow removal. + Ok(()) +} + +#[test] +fn cleanup_cgroup_removes_directory() -> MicrosandboxResult<()> { + if !has_cgroup_v2() { + eprintln!("skipping: cgroups v2 not available on this host"); + return Ok(()); + } + + let pid = std::process::id(); + let cgroup_name = format!("msb_cleanup_{}", pid); + let cgroup_path = Path::new("/sys/fs/cgroup") + .join("microsandbox") + .join(&cgroup_name); + + let result = apply_cpu_quota(pid, 0.5, &cgroup_name); + if let Err(e) = result { + eprintln!("skipping: failed to apply cpu quota ({})", e); + return Ok(()); + } + + if let Err(e) = cleanup_cgroup(&cgroup_name) { + eprintln!("skipping: failed to cleanup cgroup ({})", e); + return Ok(()); + } + + assert!(!cgroup_path.exists()); + Ok(()) +} diff --git a/microsandbox-core/tests/registry_auth_integration.rs b/microsandbox-core/tests/registry_auth_integration.rs new file mode 100644 index 00000000..cb12ae36 --- /dev/null +++ b/microsandbox-core/tests/registry_auth_integration.rs @@ -0,0 +1,85 @@ +use std::sync::Mutex; + +use microsandbox_core::{management::image::resolve_registry_auth, oci::Reference}; +use microsandbox_utils::{ + StoredRegistryCredentials, clear_registry_credentials, env, store_registry_credentials, +}; +use tempfile::TempDir; + +static ENV_LOCK: Mutex<()> = Mutex::new(()); + +struct EnvGuard { + key: &'static str, + prev: Option, +} + +impl EnvGuard { + fn set(key: &'static str, value: impl Into) -> Self { + let prev = std::env::var_os(key); + let value: std::ffi::OsString = value.into(); + unsafe { std::env::set_var(key, &value) }; + Self { key, prev } + } + + fn remove(key: &'static str) -> Self { + let prev = std::env::var_os(key); + unsafe { std::env::remove_var(key) }; + Self { key, prev } + } +} + +impl Drop for EnvGuard { + fn drop(&mut self) { + if let Some(value) = self.prev.take() { + unsafe { std::env::set_var(self.key, value) }; + } else { + unsafe { std::env::remove_var(self.key) }; + } + } +} + +#[test] +fn resolves_stored_credentials_when_env_missing() { + let _lock = ENV_LOCK.lock().unwrap(); + let _token = EnvGuard::remove(env::MSB_REGISTRY_TOKEN_ENV_VAR); + let _user = EnvGuard::remove(env::MSB_REGISTRY_USERNAME_ENV_VAR); + let _pass = EnvGuard::remove(env::MSB_REGISTRY_PASSWORD_ENV_VAR); + + let msb_home = TempDir::new().expect("temp msb home"); + let _msb_home = EnvGuard::set(env::MICROSANDBOX_HOME_ENV_VAR, msb_home.path()); + clear_registry_credentials().expect("clear"); + store_registry_credentials( + "ghcr.io", + StoredRegistryCredentials::Token { + token: "stored-token".to_string(), + }, + ) + .expect("store"); + + let reference: Reference = "ghcr.io/aurial-rocks/python313:0.1.2".parse().unwrap(); + let auth = resolve_registry_auth(&reference).expect("resolve auth"); + assert!(matches!(auth, oci_client::secrets::RegistryAuth::Bearer(t) if t == "stored-token")); +} + +#[test] +fn env_overrides_stored_credentials() { + let _lock = ENV_LOCK.lock().unwrap(); + let _token = EnvGuard::set(env::MSB_REGISTRY_TOKEN_ENV_VAR, "env-token"); + let _user = EnvGuard::remove(env::MSB_REGISTRY_USERNAME_ENV_VAR); + let _pass = EnvGuard::remove(env::MSB_REGISTRY_PASSWORD_ENV_VAR); + + let msb_home = TempDir::new().expect("temp msb home"); + let _msb_home = EnvGuard::set(env::MICROSANDBOX_HOME_ENV_VAR, msb_home.path()); + clear_registry_credentials().expect("clear"); + store_registry_credentials( + "ghcr.io", + StoredRegistryCredentials::Token { + token: "stored-token".to_string(), + }, + ) + .expect("store"); + + let reference: Reference = "ghcr.io/aurial-rocks/python313:0.1.2".parse().unwrap(); + let auth = resolve_registry_auth(&reference).expect("resolve auth"); + assert!(matches!(auth, oci_client::secrets::RegistryAuth::Bearer(t) if t == "env-token")); +} diff --git a/microsandbox-server/lib/config.rs b/microsandbox-server/lib/config.rs index c3a83e40..600c0cc3 100644 --- a/microsandbox-server/lib/config.rs +++ b/microsandbox-server/lib/config.rs @@ -56,6 +56,12 @@ pub struct Config { /// Address to listen on addr: SocketAddr, + + /// Minimum port for sandbox port range (if set) + port_range_min: Option, + + /// Maximum port for sandbox port range (if set) + port_range_max: Option, } //-------------------------------------------------------------------------------------------------- @@ -91,6 +97,13 @@ impl Config { let namespace_dir = namespace_dir .unwrap_or_else(|| env::get_microsandbox_home_path().join(NAMESPACES_SUBDIR)); + // Load sandbox port range from environment variables + let port_range = env::get_sandbox_port_range(); + let (port_range_min, port_range_max) = match port_range { + Some((min, max)) => (Some(min), Some(max)), + None => (None, None), + }; + Ok(Self { key, namespace_dir, @@ -98,6 +111,8 @@ impl Config { host: host_ip, port, addr, + port_range_min, + port_range_max, }) } } diff --git a/microsandbox-server/lib/handler.rs b/microsandbox-server/lib/handler.rs index 125d0a37..d953bd66 100644 --- a/microsandbox-server/lib/handler.rs +++ b/microsandbox-server/lib/handler.rs @@ -37,7 +37,7 @@ use crate::{ payload::{ JSONRPC_VERSION, JsonRpcError, JsonRpcRequest, JsonRpcResponse, JsonRpcResponseOrNotification, RegularMessageResponse, SandboxMetricsGetParams, - SandboxStartParams, SandboxStopParams, + SandboxStartParams, SandboxStopParams, SandboxConfig }, state::AppState, }; @@ -349,7 +349,7 @@ pub async fn forward_rpc_to_portal( /// Implementation for starting a sandbox pub async fn sandbox_start_impl( state: AppState, - params: SandboxStartParams, + mut params: SandboxStartParams, ) -> ServerResult { // Validate sandbox name and namespace validate_sandbox_name(¶ms.sandbox)?; @@ -399,6 +399,10 @@ pub async fn sandbox_start_impl( )); } + if let Some(config) = params.config.as_mut() { + adjust_config_for_platform(config); + } + // Load or create the config let mut config_yaml: serde_yaml::Value; @@ -714,6 +718,69 @@ pub async fn sandbox_start_impl( } } +fn adjust_config_for_platform(config: &mut SandboxConfig) { + if let Some(cpus) = config.cpus + && cpus < 1.0 + && !cfg!(target_os = "linux") + { + tracing::warn!("fractional CPUs are only supported on Linux; using cpus=1.0"); + config.cpus = Some(1.0); + } +} + +//-------------------------------------------------------------------------------------------------- +// Tests +//-------------------------------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::adjust_config_for_platform; + use crate::payload::SandboxConfig; + use std::collections::HashMap; + + #[cfg(not(target_os = "linux"))] + #[test] + fn adjusts_fractional_cpus_to_one_on_non_linux() { + let mut config = SandboxConfig { + image: Some("alpine:latest".to_string()), + memory: None, + cpus: Some(0.1), + volumes: vec![], + ports: vec![], + envs: vec![], + depends_on: vec![], + workdir: None, + shell: None, + scripts: HashMap::new(), + exec: None, + }; + + adjust_config_for_platform(&mut config); + assert_eq!(config.cpus, Some(1.0)); + } + + #[cfg(target_os = "linux")] + #[test] + fn keeps_fractional_cpus_on_linux() { + let mut config = SandboxConfig { + image: Some("alpine:latest".to_string()), + memory: None, + cpus: Some(0.1), + volumes: vec![], + ports: vec![], + envs: vec![], + depends_on: vec![], + workdir: None, + shell: None, + scripts: HashMap::new(), + exec: None, + }; + + adjust_config_for_platform(&mut config); + assert_eq!(config.cpus, Some(0.1)); + } +} + /// Polls the sandbox until it's verified to be running async fn poll_sandbox_until_running( sandbox_name: &str, diff --git a/microsandbox-server/lib/payload.rs b/microsandbox-server/lib/payload.rs index fafe7eb1..e77e3b93 100644 --- a/microsandbox-server/lib/payload.rs +++ b/microsandbox-server/lib/payload.rs @@ -151,7 +151,7 @@ pub struct SandboxMetricsGetParams { /// Configuration for a sandbox /// Similar to microsandbox-core's Sandbox but with optional fields for update operations -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] pub struct SandboxConfig { /// The image to use (optional for updates) pub image: Option, @@ -159,8 +159,8 @@ pub struct SandboxConfig { /// The amount of memory in MiB to use pub memory: Option, - /// The number of vCPUs to use - pub cpus: Option, + /// The number of vCPUs to use (supports fractional values like 0.5, 0.25) + pub cpus: Option, /// The volumes to mount #[serde(default)] @@ -387,3 +387,73 @@ impl axum::response::IntoResponse for JsonRpcResponseOrNotification { } } } + +//-------------------------------------------------------------------------------------------------- +// Tests +//-------------------------------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deserializes_sandbox_config_with_fractional_cpus() { + let json = r#"{ + "image": "python:3.11-slim", + "memory": 512, + "cpus": 0.5, + "volumes": [], + "ports": [], + "envs": [], + "depends_on": [] + }"#; + + let cfg: SandboxConfig = serde_json::from_str(json).unwrap(); + assert_eq!(cfg.cpus, Some(0.5)); + assert_eq!(cfg.memory, Some(512)); + assert_eq!(cfg.image.as_deref(), Some("python:3.11-slim")); + } + + #[test] + fn deserializes_sandbox_config_without_cpus_sets_none() { + let json = r#"{ + "image": "alpine:latest", + "memory": 256 + }"#; + + let cfg: SandboxConfig = serde_json::from_str(json).unwrap(); + assert!(cfg.cpus.is_none()); + } + + #[test] + fn deserializes_sandbox_config_with_null_cpus_sets_none() { + let json = r#"{ + "image": "alpine:latest", + "memory": 256, + "cpus": null + }"#; + + let cfg: SandboxConfig = serde_json::from_str(json).unwrap(); + assert!(cfg.cpus.is_none()); + } + + #[test] + fn serializes_sandbox_config_with_fractional_cpus() { + let cfg = SandboxConfig { + image: Some("node:20".to_string()), + memory: Some(1024), + cpus: Some(0.25), + volumes: vec![], + ports: vec![], + envs: vec![], + depends_on: vec![], + workdir: None, + shell: None, + scripts: Default::default(), + exec: None, + }; + + let value = serde_json::to_value(&cfg).unwrap(); + assert_eq!(value.get("cpus").and_then(|v| v.as_f64()), Some(0.25)); + } +} diff --git a/microsandbox-server/lib/port.rs b/microsandbox-server/lib/port.rs index 631dd0b4..ee680937 100644 --- a/microsandbox-server/lib/port.rs +++ b/microsandbox-server/lib/port.rs @@ -64,6 +64,9 @@ pub struct PortManager { /// Path to the port mappings file file_path: PathBuf, + + /// Optional port range (min, max) for sandbox port allocation + port_range: Option<(u16, u16)>, } //-------------------------------------------------------------------------------------------------- @@ -158,12 +161,27 @@ impl BiPortMapping { impl PortManager { /// Create a new port manager pub async fn new(namespace_dir: impl AsRef) -> MicrosandboxServerResult { + Self::new_with_range(namespace_dir, None).await + } + + /// Create a new port manager with an optional port range + pub async fn new_with_range( + namespace_dir: impl AsRef, + port_range: Option<(u16, u16)>, + ) -> MicrosandboxServerResult { let file_path = namespace_dir.as_ref().join(PORTAL_PORTS_FILE); let mappings = Self::load_mappings(&file_path).await?; + if let Some((min, max)) = port_range { + info!("Port manager initialized with port range: {}-{}", min, max); + } else { + debug!("Port manager initialized with dynamic port allocation"); + } + Ok(Self { mappings, file_path, + port_range, }) } @@ -240,8 +258,8 @@ impl PortManager { // Get a lock to ensure only one thread gets a port at a time let _lock = PORT_ASSIGNMENT_LOCK.lock().await; - // Get a truly available port from the OS - let port = self.get_available_port_from_os()?; + // Get an available port (from range or from OS) + let port = self.get_available_port()?; // Save the mapping self.mappings.insert(key.to_string(), port); @@ -272,7 +290,34 @@ impl PortManager { TcpListener::bind(addr).is_ok() } - /// Get an available port from the OS + /// Get an available port from the OS or from the configured port range + fn get_available_port(&self) -> MicrosandboxServerResult { + // If a port range is configured, try to find an available port within it + if let Some((min, max)) = self.port_range { + debug!( + "Attempting to find an available port in range {}-{}", + min, max + ); + + for port in min..=max { + if self.verify_port_availability(port) { + debug!("Found available port {} in configured range", port); + return Ok(port); + } + } + + // If no port is available in the range, log a warning and try dynamic allocation + warn!( + "No available ports found in configured range {}-{}, falling back to OS allocation", + min, max + ); + } + + // Fall back to dynamic port allocation from the OS + self.get_available_port_from_os() + } + + /// Get a truly available port from the OS by binding to port 0 fn get_available_port_from_os(&self) -> MicrosandboxServerResult { // Bind to port 0 to let the OS assign an available port let addr = SocketAddr::new(LOCALHOST_IP, 0); diff --git a/microsandbox-utils/Cargo.toml b/microsandbox-utils/Cargo.toml index e5ada7cd..d9110df6 100644 --- a/microsandbox-utils/Cargo.toml +++ b/microsandbox-utils/Cargo.toml @@ -14,6 +14,7 @@ path = "lib/lib.rs" [dependencies] anyhow.workspace = true async-trait.workspace = true +base64.workspace = true console.workspace = true dirs.workspace = true futures.workspace = true @@ -21,6 +22,8 @@ indicatif.workspace = true libc.workspace = true nix = { workspace = true, features = ["fs", "process", "signal", "term"] } pretty-error-debug.workspace = true +serde.workspace = true +serde_json.workspace = true thiserror.workspace = true tokio.workspace = true tracing.workspace = true diff --git a/microsandbox-utils/lib/defaults.rs b/microsandbox-utils/lib/defaults.rs index b8ac74f5..bd89e8dd 100644 --- a/microsandbox-utils/lib/defaults.rs +++ b/microsandbox-utils/lib/defaults.rs @@ -24,8 +24,8 @@ use crate::MICROSANDBOX_HOME_DIR; /// The default maximum log file size (10MB) pub const DEFAULT_LOG_MAX_SIZE: u64 = 10 * 1024 * 1024; -/// The default number of vCPUs to use for the MicroVm. -pub const DEFAULT_NUM_VCPUS: u8 = 1; +/// The default number of vCPUs to use for the MicroVm (supports fractional values). +pub const DEFAULT_NUM_VCPUS: f32 = 1.0; /// The default amount of memory in MiB to use for the MicroVm. pub const DEFAULT_MEMORY_MIB: u32 = 1024; @@ -77,3 +77,68 @@ pub const DEFAULT_SERVER_PORT: u16 = 5555; /// The default microsandbox-portal port. pub const DEFAULT_PORTAL_GUEST_PORT: u16 = 4444; + +//-------------------------------------------------------------------------------------------------- +// Tests +//-------------------------------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_num_vcpus_is_fractional_one() { + assert_eq!(DEFAULT_NUM_VCPUS, 1.0f32); + } + + #[test] + fn default_memory_mib_is_1024() { + assert_eq!(DEFAULT_MEMORY_MIB, 1024u32); + } + + #[test] + fn default_microsandbox_home_points_to_user_home_dir() { + let home = dirs::home_dir().unwrap().join(MICROSANDBOX_HOME_DIR); + assert_eq!(*DEFAULT_MICROSANDBOX_HOME, home); + } + + #[test] + fn default_config_has_sandboxes_section() { + assert!(DEFAULT_CONFIG.contains("sandboxes:")); + } + + #[test] + fn default_shell_is_bin_sh() { + assert_eq!(DEFAULT_SHELL, "/bin/sh"); + } + + #[test] + fn default_server_namespace_is_default() { + assert_eq!(DEFAULT_SERVER_NAMESPACE, "default"); + } + + #[test] + fn default_server_host_and_port_match_expected() { + assert_eq!(DEFAULT_SERVER_HOST, "127.0.0.1"); + assert_eq!(DEFAULT_SERVER_PORT, 5555u16); + } + + #[test] + fn default_portal_guest_port_is_4444() { + assert_eq!(DEFAULT_PORTAL_GUEST_PORT, 4444u16); + } + + #[test] + fn default_msbrun_and_msbserver_paths_end_with_expected_binaries() { + let msbrun = DEFAULT_MSBRUN_EXE_PATH + .file_name() + .unwrap() + .to_string_lossy(); + let msbserver = DEFAULT_MSBSERVER_EXE_PATH + .file_name() + .unwrap() + .to_string_lossy(); + assert_eq!(msbrun, "msbrun"); + assert_eq!(msbserver, "msbserver"); + } +} diff --git a/microsandbox-utils/lib/docker_config.rs b/microsandbox-utils/lib/docker_config.rs new file mode 100644 index 00000000..7a22a4f5 --- /dev/null +++ b/microsandbox-utils/lib/docker_config.rs @@ -0,0 +1,402 @@ +//! Docker config reader utilities for registry authentication. +//! +//! # Examples +//! ```no_run +//! use microsandbox_utils::load_docker_registry_credentials; +//! +//! let creds = load_docker_registry_credentials("ghcr.io") +//! .map_err(|err| microsandbox_utils::MicrosandboxUtilsError::custom(err))?; +//! if let Some(creds) = creds { +//! println!("loaded docker credentials: {:?}", creds); +//! } +//! # Ok::<(), microsandbox_utils::MicrosandboxUtilsError>(()) +//! ``` + +use std::{ + collections::HashMap, + io::Write, + path::{Path, PathBuf}, + process::{Command, Stdio}, +}; + +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; +use serde::Deserialize; + +//-------------------------------------------------------------------------------------------------- +// Constants +//-------------------------------------------------------------------------------------------------- + +const DOCKER_CONFIG_ENV_VAR: &str = "DOCKER_CONFIG"; +const DOCKER_CONFIG_FILENAME: &str = "config.json"; +const DOCKER_IO_LEGACY_KEY: &str = "https://index.docker.io/v1/"; + +//-------------------------------------------------------------------------------------------------- +// Types +//-------------------------------------------------------------------------------------------------- + +/// Credentials loaded from Docker config. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DockerAuthCredentials { + /// Basic auth using username + password. + Basic { + /// Registry username. + username: String, + /// Registry password. + password: String, + }, + /// Token-based auth (identity token). + Token { + /// Registry token. + token: String, + }, +} + +/// Errors that can occur while reading Docker config. +#[derive(Debug, thiserror::Error)] +pub enum DockerConfigError { + /// IO error while reading config file. + #[error("io error: {0}")] + Io(#[from] std::io::Error), + /// JSON parse error. + #[error("json error: {0}")] + Json(#[from] serde_json::Error), + /// Base64 decode error. + #[error("base64 error: {0}")] + Base64(#[from] base64::DecodeError), + /// Invalid auth entry. + #[error("invalid auth entry: {0}")] + InvalidAuth(String), +} + +#[derive(Debug, Deserialize)] +struct DockerConfig { + auths: Option>, + #[allow(dead_code)] + #[serde(rename = "credsStore")] + creds_store: Option, + #[allow(dead_code)] + #[serde(rename = "credHelpers")] + cred_helpers: Option>, +} + +#[derive(Debug, Deserialize)] +struct DockerAuthEntry { + auth: Option, + identitytoken: Option, + username: Option, + password: Option, +} + +//-------------------------------------------------------------------------------------------------- +// Functions +//-------------------------------------------------------------------------------------------------- + +/// Loads credentials for a registry host from Docker config if present. +pub fn load_docker_registry_credentials( + host: &str, +) -> Result, DockerConfigError> { + let config_path = match docker_config_path() { + Some(path) => path, + None => return Ok(None), + }; + if !config_path.exists() { + return Ok(None); + } + + let config = read_config(&config_path)?; + if let Some(creds) = load_from_helpers(host, &config)? { + return Ok(Some(creds)); + } + + if let Some(auths) = config.auths { + for key in candidate_registry_keys(host) { + if let Some(entry) = auths.get(key) { + return parse_auth_entry(entry).map(Some); + } + } + } + + Ok(None) +} + +fn docker_config_path() -> Option { + if let Ok(path) = std::env::var(DOCKER_CONFIG_ENV_VAR) { + let path = PathBuf::from(path); + return Some(if path.is_dir() { + path.join(DOCKER_CONFIG_FILENAME) + } else { + path + }); + } + + let home = dirs::home_dir()?; + Some(home.join(".docker").join(DOCKER_CONFIG_FILENAME)) +} + +fn read_config(path: &Path) -> Result { + let contents = std::fs::read_to_string(path)?; + Ok(serde_json::from_str::(&contents)?) +} + +fn candidate_registry_keys(host: &str) -> Vec<&str> { + if host == "docker.io" { + vec![host, DOCKER_IO_LEGACY_KEY] + } else { + vec![host] + } +} + +fn parse_auth_entry(entry: &DockerAuthEntry) -> Result { + if let Some(token) = entry.identitytoken.as_ref() { + if token.is_empty() { + return Err(DockerConfigError::InvalidAuth( + "identitytoken is empty".to_string(), + )); + } + return Ok(DockerAuthCredentials::Token { + token: token.to_string(), + }); + } + + if let (Some(username), Some(password)) = (entry.username.as_ref(), entry.password.as_ref()) { + if username.is_empty() || password.is_empty() { + return Err(DockerConfigError::InvalidAuth( + "username/password is empty".to_string(), + )); + } + return Ok(DockerAuthCredentials::Basic { + username: username.to_string(), + password: password.to_string(), + }); + } + + if let Some(encoded) = entry.auth.as_ref() { + if encoded.is_empty() { + return Err(DockerConfigError::InvalidAuth("auth is empty".to_string())); + } + let decoded = BASE64_STANDARD.decode(encoded)?; + let decoded = String::from_utf8_lossy(&decoded); + let (username, password) = decoded + .split_once(':') + .ok_or_else(|| DockerConfigError::InvalidAuth("auth missing ':'".to_string()))?; + if username.is_empty() || password.is_empty() { + return Err(DockerConfigError::InvalidAuth( + "auth username/password is empty".to_string(), + )); + } + return Ok(DockerAuthCredentials::Basic { + username: username.to_string(), + password: password.to_string(), + }); + } + + Err(DockerConfigError::InvalidAuth( + "no supported auth fields".to_string(), + )) +} + +fn load_from_helpers( + host: &str, + config: &DockerConfig, +) -> Result, DockerConfigError> { + let helper = match select_credential_helper(host, config) { + Some(helper) => helper, + None => return Ok(None), + }; + + for key in candidate_registry_keys(host) { + if let Some(creds) = run_credential_helper(&helper, key)? { + return Ok(Some(creds)); + } + } + + Ok(None) +} + +fn select_credential_helper(host: &str, config: &DockerConfig) -> Option { + if let Some(helpers) = config.cred_helpers.as_ref() + && let Some(helper) = helpers.get(host) + { + return Some(helper.to_string()); + } + + config.creds_store.as_ref().map(|v| v.to_string()) +} + +fn run_credential_helper( + helper: &str, + server_url: &str, +) -> Result, DockerConfigError> { + let helper_bin = format!("docker-credential-{}", helper); + let mut child = match Command::new(&helper_bin) + .arg("get") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + { + Ok(child) => child, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(DockerConfigError::Io(err)), + }; + + if let Some(mut stdin) = child.stdin.take() { + stdin.write_all(server_url.as_bytes())?; + stdin.write_all(b"\n")?; + } + + let output = child.wait_with_output()?; + if !output.status.success() { + return Ok(None); + } + + let creds = parse_credential_helper_output(&output.stdout)?; + Ok(Some(creds)) +} + +fn parse_credential_helper_output(raw: &[u8]) -> Result { + #[derive(Deserialize)] + struct HelperOutput { + #[allow(dead_code)] + #[serde(rename = "ServerURL")] + server_url: Option, + #[serde(rename = "Username")] + username: String, + #[serde(rename = "Secret")] + secret: String, + } + + let output: HelperOutput = serde_json::from_slice(raw)?; + if output.secret.is_empty() { + return Err(DockerConfigError::InvalidAuth( + "credential helper secret is empty".to_string(), + )); + } + + if output.username.is_empty() { + return Ok(DockerAuthCredentials::Token { + token: output.secret, + }); + } + + Ok(DockerAuthCredentials::Basic { + username: output.username, + password: output.secret, + }) +} + +//-------------------------------------------------------------------------------------------------- +// Tests +//-------------------------------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + struct EnvGuard { + key: &'static str, + prev: Option, + } + + impl EnvGuard { + fn set(key: &'static str, value: String) -> Self { + let prev = std::env::var_os(key); + unsafe { std::env::set_var(key, value) }; + Self { key, prev } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + if let Some(value) = self.prev.take() { + unsafe { std::env::set_var(self.key, value) }; + } else { + unsafe { std::env::remove_var(self.key) }; + } + } + } + + fn write_config(temp_dir: &TempDir, contents: &str) -> PathBuf { + let path = temp_dir.path().join("config.json"); + fs::write(&path, contents).expect("write config"); + path + } + + #[test] + fn load_auth_from_basic_auth_field() { + let dir = TempDir::new().expect("temp dir"); + let encoded = BASE64_STANDARD.encode("user:pass"); + let config = format!( + r#"{{ + "auths": {{ + "registry.example.com": {{ "auth": "{}" }} + }} +}}"#, + encoded + ); + let path = write_config(&dir, &config); + let _guard = EnvGuard::set(DOCKER_CONFIG_ENV_VAR, path.to_string_lossy().to_string()); + + let creds = load_docker_registry_credentials("registry.example.com") + .expect("load creds") + .expect("creds"); + + assert_eq!( + creds, + DockerAuthCredentials::Basic { + username: "user".to_string(), + password: "pass".to_string() + } + ); + } + + #[test] + fn load_auth_from_identity_token() { + let dir = TempDir::new().expect("temp dir"); + let config = r#"{ + "auths": { + "registry.example.com": { "identitytoken": "token-123" } + } +}"#; + let path = write_config(&dir, config); + let _guard = EnvGuard::set(DOCKER_CONFIG_ENV_VAR, path.to_string_lossy().to_string()); + + let creds = load_docker_registry_credentials("registry.example.com") + .expect("load creds") + .expect("creds"); + + assert_eq!( + creds, + DockerAuthCredentials::Token { + token: "token-123".to_string() + } + ); + } + + #[test] + fn parse_helper_output_basic() { + let raw = br#"{"ServerURL":"ghcr.io","Username":"user","Secret":"pat"}"#; + let creds = parse_credential_helper_output(raw).expect("parse helper output"); + assert_eq!( + creds, + DockerAuthCredentials::Basic { + username: "user".to_string(), + password: "pat".to_string() + } + ); + } + + #[test] + fn parse_helper_output_token() { + let raw = br#"{"ServerURL":"ghcr.io","Username":"","Secret":"token"}"#; + let creds = parse_credential_helper_output(raw).expect("parse helper output"); + assert_eq!( + creds, + DockerAuthCredentials::Token { + token: "token".to_string() + } + ); + } +} diff --git a/microsandbox-utils/lib/env.rs b/microsandbox-utils/lib/env.rs index 1796f53d..4b3547c6 100644 --- a/microsandbox-utils/lib/env.rs +++ b/microsandbox-utils/lib/env.rs @@ -14,12 +14,30 @@ pub const MICROSANDBOX_HOME_ENV_VAR: &str = "MICROSANDBOX_HOME"; /// Environment variable for the OCI registry domain pub const OCI_REGISTRY_ENV_VAR: &str = "OCI_REGISTRY_DOMAIN"; +/// Environment variable for registry host (CLI fallback) +pub const MSB_REGISTRY_HOST_ENV_VAR: &str = "MSB_REGISTRY_HOST"; + +/// Environment variable for registry username +pub const MSB_REGISTRY_USERNAME_ENV_VAR: &str = "MSB_REGISTRY_USERNAME"; + +/// Environment variable for registry password +pub const MSB_REGISTRY_PASSWORD_ENV_VAR: &str = "MSB_REGISTRY_PASSWORD"; + +/// Environment variable for registry token +pub const MSB_REGISTRY_TOKEN_ENV_VAR: &str = "MSB_REGISTRY_TOKEN"; + /// Environment variable for the msbrun binary path pub const MSBRUN_EXE_ENV_VAR: &str = "MSBRUN_EXE"; /// Environment variable for the msbserver binary path pub const MSBSERVER_EXE_ENV_VAR: &str = "MSBSERVER_EXE"; +/// Environment variable for the minimum port in the sandbox port range +pub const MICROSANDBOX_PORT_MIN_ENV_VAR: &str = "MICROSANDBOX_PORT_MIN"; + +/// Environment variable for the maximum port in the sandbox port range +pub const MICROSANDBOX_PORT_MAX_ENV_VAR: &str = "MICROSANDBOX_PORT_MAX"; + //-------------------------------------------------------------------------------------------------- // Functions //-------------------------------------------------------------------------------------------------- @@ -45,3 +63,40 @@ pub fn get_oci_registry() -> String { DEFAULT_OCI_REGISTRY.to_string() } } + +/// Returns the registry host from environment, if set. +pub fn get_registry_host() -> Option { + std::env::var(MSB_REGISTRY_HOST_ENV_VAR).ok() +} + +/// Returns the registry username from environment, if set. +pub fn get_registry_username() -> Option { + std::env::var(MSB_REGISTRY_USERNAME_ENV_VAR).ok() +} + +/// Returns the registry password from environment, if set. +pub fn get_registry_password() -> Option { + std::env::var(MSB_REGISTRY_PASSWORD_ENV_VAR).ok() +} + +/// Returns the registry token from environment, if set. +pub fn get_registry_token() -> Option { + std::env::var(MSB_REGISTRY_TOKEN_ENV_VAR).ok() +} + +/// Returns the port range for sandbox port allocation. +/// If both MICROSANDBOX_PORT_MIN and MICROSANDBOX_PORT_MAX are set, +/// returns Some((min, max)). Otherwise, returns None for dynamic allocation. +pub fn get_sandbox_port_range() -> Option<(u16, u16)> { + let min = std::env::var(MICROSANDBOX_PORT_MIN_ENV_VAR) + .ok() + .and_then(|v| v.parse::().ok()); + let max = std::env::var(MICROSANDBOX_PORT_MAX_ENV_VAR) + .ok() + .and_then(|v| v.parse::().ok()); + + match (min, max) { + (Some(min_val), Some(max_val)) if min_val <= max_val => Some((min_val, max_val)), + _ => None, + } +} diff --git a/microsandbox-utils/lib/error.rs b/microsandbox-utils/lib/error.rs index de7c0656..f26e5929 100644 --- a/microsandbox-utils/lib/error.rs +++ b/microsandbox-utils/lib/error.rs @@ -37,6 +37,10 @@ pub enum MicrosandboxUtilsError { #[error("nix error: {0}")] NixError(#[from] nix::Error), + /// An error that occurred during a Serde JSON operation + #[error("serde json error: {0}")] + SerdeJson(#[from] serde_json::Error), + /// Custom error. #[error("Custom error: {0}")] Custom(#[from] AnyError), diff --git a/microsandbox-utils/lib/lib.rs b/microsandbox-utils/lib/lib.rs index afeba142..043ca1e6 100644 --- a/microsandbox-utils/lib/lib.rs +++ b/microsandbox-utils/lib/lib.rs @@ -4,10 +4,12 @@ #![allow(clippy::module_inception)] pub mod defaults; +pub mod docker_config; pub mod env; pub mod error; pub mod log; pub mod path; +pub mod registry_auth; pub mod runtime; pub mod seekable; pub mod term; @@ -17,10 +19,12 @@ pub mod term; //-------------------------------------------------------------------------------------------------- pub use defaults::*; +pub use docker_config::*; pub use env::*; pub use error::*; pub use log::*; pub use path::*; +pub use registry_auth::*; pub use runtime::*; pub use seekable::*; pub use term::*; diff --git a/microsandbox-utils/lib/registry_auth.rs b/microsandbox-utils/lib/registry_auth.rs new file mode 100644 index 00000000..06b04586 --- /dev/null +++ b/microsandbox-utils/lib/registry_auth.rs @@ -0,0 +1,149 @@ +//! Registry auth persistence helpers. +//! +//! # Examples +//! ```no_run +//! use microsandbox_utils::{store_registry_credentials, load_stored_registry_credentials, StoredRegistryCredentials}; +//! +//! store_registry_credentials( +//! "ghcr.io", +//! StoredRegistryCredentials::Token { +//! token: "token-123".to_string(), +//! }, +//! )?; +//! +//! let creds = load_stored_registry_credentials("ghcr.io")? +//! .expect("missing credentials"); +//! +//! match creds { +//! StoredRegistryCredentials::Token { token } => { +//! assert_eq!(token, "token-123"); +//! } +//! _ => unreachable!("expected token credentials"), +//! } +//! # Ok::<(), microsandbox_utils::MicrosandboxUtilsError>(()) +//! ``` + +use std::{ + collections::HashMap, + fs, + path::{Path, PathBuf}, +}; + +use serde::{Deserialize, Serialize}; + +use crate::{MicrosandboxUtilsResult, env}; + +//-------------------------------------------------------------------------------------------------- +// Types +//-------------------------------------------------------------------------------------------------- + +/// Stored credentials for a registry host. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum StoredRegistryCredentials { + /// Basic auth using username + password. + #[serde(rename = "basic")] + Basic { + /// Registry username. + username: String, + /// Registry password. + password: String, + }, + /// Bearer token. + #[serde(rename = "token")] + Token { + /// Registry token. + token: String, + }, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct RegistryAuthFile { + auths: HashMap, +} + +//-------------------------------------------------------------------------------------------------- +// Functions +//-------------------------------------------------------------------------------------------------- + +/// Load stored registry credentials for a host, if present. +pub fn load_stored_registry_credentials( + host: &str, +) -> MicrosandboxUtilsResult> { + let path = registry_auth_path(); + if !path.exists() { + return Ok(None); + } + + let data = fs::read_to_string(&path)?; + let auth_file: RegistryAuthFile = serde_json::from_str(&data)?; + Ok(auth_file.auths.get(host).cloned()) +} + +/// Store registry credentials for a host (overwrites existing entry). +pub fn store_registry_credentials( + host: &str, + credentials: StoredRegistryCredentials, +) -> MicrosandboxUtilsResult<()> { + let path = registry_auth_path(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + + let mut auth_file = if path.exists() { + let data = fs::read_to_string(&path)?; + serde_json::from_str::(&data)? + } else { + RegistryAuthFile::default() + }; + + auth_file.auths.insert(host.to_string(), credentials); + let json = serde_json::to_string_pretty(&auth_file)?; + fs::write(&path, json)?; + + set_permissions_restrictive(&path)?; + Ok(()) +} + +/// Remove stored registry credentials for a host. +pub fn remove_registry_credentials(host: &str) -> MicrosandboxUtilsResult { + let path = registry_auth_path(); + if !path.exists() { + return Ok(false); + } + + let data = fs::read_to_string(&path)?; + let mut auth_file: RegistryAuthFile = serde_json::from_str(&data)?; + let removed = auth_file.auths.remove(host).is_some(); + + let json = serde_json::to_string_pretty(&auth_file)?; + fs::write(&path, json)?; + set_permissions_restrictive(&path)?; + + Ok(removed) +} + +/// Remove all stored registry credentials. +pub fn clear_registry_credentials() -> MicrosandboxUtilsResult<()> { + let path = registry_auth_path(); + if path.exists() { + fs::remove_file(path)?; + } + Ok(()) +} + +fn registry_auth_path() -> PathBuf { + env::get_microsandbox_home_path().join("registry_auth.json") +} + +fn set_permissions_restrictive(path: &Path) -> MicrosandboxUtilsResult<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let metadata = fs::metadata(path)?; + let mut perms = metadata.permissions(); + perms.set_mode(0o600); + fs::set_permissions(path, perms)?; + } + Ok(()) +} diff --git a/scripts/build_libkrun.sh b/scripts/build_libkrun.sh index 1b08cf3d..bbc6a72f 100755 --- a/scripts/build_libkrun.sh +++ b/scripts/build_libkrun.sh @@ -1,4 +1,4 @@ -#!/bin/sh +#!/usr/bin/env bash # build_libkrun.sh # --------------- diff --git a/scripts/install_microsandbox.sh b/scripts/install_microsandbox.sh index 658a8fdd..e9a2d930 100755 --- a/scripts/install_microsandbox.sh +++ b/scripts/install_microsandbox.sh @@ -1,4 +1,4 @@ -#!/bin/sh +#!/usr/bin/env bash # install_microsandbox.sh # ------------------ diff --git a/scripts/package_microsandbox.sh b/scripts/package_microsandbox.sh index eb0536bb..8523e252 100755 --- a/scripts/package_microsandbox.sh +++ b/scripts/package_microsandbox.sh @@ -1,4 +1,4 @@ -#!/bin/sh +#!/usr/bin/env bash # package_microsandbox.sh # ------------------ diff --git a/scripts/setup_env.sh b/scripts/setup_env.sh index 8572f99c..8ac4f7a4 100755 --- a/scripts/setup_env.sh +++ b/scripts/setup_env.sh @@ -1,4 +1,4 @@ -#!/bin/sh +#!/usr/bin/env bash # setup_env.sh # ----------- diff --git a/sdk/python/microsandbox/base_sandbox.py b/sdk/python/microsandbox/base_sandbox.py index 74da7aae..47f29fa1 100644 --- a/sdk/python/microsandbox/base_sandbox.py +++ b/sdk/python/microsandbox/base_sandbox.py @@ -9,12 +9,14 @@ from contextlib import asynccontextmanager from typing import Optional +import logging import aiohttp from dotenv import load_dotenv from .command import Command from .metrics import Metrics +logger = logging.getLogger(__name__) class BaseSandbox(ABC): """ @@ -75,6 +77,9 @@ async def create( namespace: str = "default", name: Optional[str] = None, api_key: Optional[str] = None, + cpus: Optional[float] = None, + memory: Optional[int] = None, + timeout: Optional[int] = None, ): """ Create and initialize a new sandbox as an async context manager. @@ -84,6 +89,9 @@ async def create( namespace: Namespace for the sandbox name: Optional name for the sandbox. If not provided, a random name will be generated. api_key: API key for Microsandbox server authentication. If not provided, it will be read from MSB_API_KEY environment variable. + cpus: Number of CPUs to allocate to the sandbox + memory: Amount of memory (in MB) to allocate to the sandbox + timeout: Maximum time in seconds to wait for the sandbox to start Returns: An instance of the sandbox ready for use @@ -106,7 +114,14 @@ async def create( # Create HTTP session sandbox._session = aiohttp.ClientSession() # Start the sandbox - await sandbox.start() + start_kwargs = {} + if cpus is not None: + start_kwargs["cpus"] = cpus + if memory is not None: + start_kwargs["memory"] = memory + if timeout is not None: + start_kwargs["timeout"] = timeout + await sandbox.start(**start_kwargs) yield sandbox finally: # Stop the sandbox @@ -129,7 +144,7 @@ async def start( Args: image: Docker image to use for the sandbox (defaults to language-specific image) memory: Memory limit in MB - cpus: CPU limit (will be rounded to nearest integer) + cpus: CPU limit (supports fractional values like 0.5) timeout: Maximum time in seconds to wait for the sandbox to start (default: 180 seconds) Raises: @@ -139,6 +154,16 @@ async def start( if self._is_started: return + if cpus < 1.0: + import platform + + if platform.system() != "Linux": + logger.warning( + "Fractional CPUs are only supported on Linux. " + "Using default cpus=1.0." + ) + cpus = 1.0 + sandbox_image = image or await self.get_default_image() request_data = { "jsonrpc": "2.0", @@ -149,7 +174,7 @@ async def start( "config": { "image": sandbox_image, "memory": memory, - "cpus": int(round(cpus)), + "cpus": cpus, }, }, "id": str(uuid.uuid4()), diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml index 3a90c61c..f76eef92 100644 --- a/sdk/python/pyproject.toml +++ b/sdk/python/pyproject.toml @@ -4,7 +4,7 @@ requires = ["setuptools>=42", "wheel"] [project] name = "microsandbox" -version = "0.1.8" +version = "0.1.17" description = "Microsandbox Python SDK" readme = "README.md" requires-python = ">=3.8" @@ -26,3 +26,9 @@ dependencies = ["aiohttp>=3.10.0,<3.11.0", "python-dotenv"] [project.urls] Homepage = "https://github.com/microsandbox/microsandbox/" Repository = "https://github.com/microsandbox/microsandbox/" + +[tool.setuptools] +packages = ["microsandbox"] + +[tool.setuptools.package-dir] +"" = "."