From 7fd2c8f37fccc2b5fdb3ac53e1c55d168e79d09c Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Wed, 24 Jun 2026 06:37:59 +0000 Subject: [PATCH 1/2] feat(android): bootstrap rust android connection --- Cargo.lock | 131 +--- Cargo.toml | 29 +- LICENSE | 37 +- README.md | 412 +++--------- ...624_000000_bootstrap_android_connection.md | 6 + examples/basic_usage.rs | 17 +- src/bin/android-connection.rs | 54 ++ src/lib.rs | 261 ++++++- src/main.rs | 138 ++-- src/mcp.rs | 635 ++++++++++++++++++ src/sum.rs | 4 - tests/integration/cli.rs | 45 ++ tests/integration/mod.rs | 2 +- tests/integration/sum.rs | 36 - tests/unit/android_connection.rs | 44 ++ tests/unit/mod.rs | 2 +- tests/unit/sum.rs | 26 - 17 files changed, 1280 insertions(+), 599 deletions(-) create mode 100644 changelog.d/20260624_000000_bootstrap_android_connection.md create mode 100644 src/bin/android-connection.rs create mode 100644 src/mcp.rs delete mode 100644 src/sum.rs create mode 100644 tests/integration/cli.rs delete mode 100644 tests/integration/sum.rs create mode 100644 tests/unit/android_connection.rs delete mode 100644 tests/unit/sum.rs diff --git a/Cargo.lock b/Cargo.lock index 2fd813b..cdd7221 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13,9 +13,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.21" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -34,9 +34,9 @@ checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" -version = "0.2.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] @@ -63,9 +63,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.60" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", "clap_derive", @@ -73,9 +73,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.60" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstream", "anstyle", @@ -85,9 +85,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.55" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ "heck", "proc-macro2", @@ -108,49 +108,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] -name = "ctor" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec09e802f5081de6157da9a75701d6c713d8dc3ba52571fd4bd25f412644e8a6" -dependencies = [ - "ctor-proc-macro", - "dtor", -] - -[[package]] -name = "ctor-proc-macro" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2931af7e13dc045d8e9d26afccc6fa115d64e115c9c84b1166288b46f6782c2" - -[[package]] -name = "dotenvy" -version = "0.15.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" - -[[package]] -name = "dtor" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97cbdf2ad6846025e8e25df05171abfb30e3ababa12ee0a0e44b9bbe570633a8" -dependencies = [ - "dtor-proc-macro", -] - -[[package]] -name = "dtor-proc-macro" -version = "0.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7454e41ff9012c00d53cf7f475c5e3afa3b91b7c90568495495e8d9bf47a1055" - -[[package]] -name = "example-sum-package-name" -version = "0.19.2" +name = "docker-git-android-connection" +version = "0.0.0" dependencies = [ "clap", - "lino-arguments", "regex", + "serde", "serde_json", "walkdir", ] @@ -173,31 +136,11 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" -[[package]] -name = "lino-arguments" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be512a5c5eacea6ef5ec015fb0c7e1725c8e4cda1befd31606e203f281069968" -dependencies = [ - "clap", - "ctor", - "dotenvy", - "lino-env", - "serde", - "thiserror", -] - -[[package]] -name = "lino-env" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f453c53827aabe91a3d3856d61d14ae3867ab1a4344db22f9fa5396664c8d0e" - [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" [[package]] name = "once_cell_polyfill" @@ -207,27 +150,27 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "proc-macro2" -version = "1.0.103" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.42" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] [[package]] name = "regex" -version = "1.12.3" +version = "1.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" dependencies = [ "aho-corasick", "memchr", @@ -248,9 +191,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "same-file" @@ -312,40 +255,20 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" -version = "2.0.111" +version = "2.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "unicode-ident" -version = "1.0.22" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "utf8parse" diff --git a/Cargo.toml b/Cargo.toml index 70d0b92..a763d44 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,14 +1,14 @@ [package] -name = "example-sum-package-name" -version = "0.19.2" +name = "docker-git-android-connection" +version = "0.0.0" edition = "2021" -description = "A Rust package template for AI-driven development" +description = "Rust Android MCP and lifecycle module for docker-git" readme = "README.md" -license = "Unlicense" -keywords = ["template", "rust", "ai-driven"] -categories = ["development-tools"] -repository = "https://github.com/link-foundation/rust-ai-driven-development-pipeline-template" -documentation = "https://github.com/link-foundation/rust-ai-driven-development-pipeline-template" +license = "MIT" +keywords = ["docker-git", "android", "adb", "mcp", "rust"] +categories = ["command-line-utilities", "development-tools"] +repository = "https://github.com/ProverCoderAI/rust-android-connection" +documentation = "https://github.com/ProverCoderAI/rust-android-connection" rust-version = "1.70" # Narrow allowlist of files shipped in the published `.crate` archive. @@ -24,16 +24,21 @@ include = [ ] [lib] -name = "example_sum_package_name" +name = "docker_git_android_connection" path = "src/lib.rs" [[bin]] -name = "example-sum-package-name" +name = "docker-git-android-connection" path = "src/main.rs" +[[bin]] +name = "android-connection" +path = "src/bin/android-connection.rs" + [dependencies] -lino-arguments = "0.3" -clap = { version = "4.4", features = ["derive", "env"] } +clap = { version = "4.5.53", features = ["derive"] } +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.145" [dev-dependencies] regex = "1" diff --git a/LICENSE b/LICENSE index fdddb29..8738395 100644 --- a/LICENSE +++ b/LICENSE @@ -1,24 +1,21 @@ -This is free and unencumbered software released into the public domain. +MIT License -Anyone is free to copy, modify, publish, use, compile, sell, or -distribute this software, either in source code form or as a compiled -binary, for any purpose, commercial or non-commercial, and by any -means. +Copyright (c) 2026 ProverCoderAI Contributors -In jurisdictions that recognize copyright laws, the author or authors -of this software dedicate any and all copyright interest in the -software to the public domain. We make this dedication for the benefit -of the public at large and to the detriment of our heirs and -successors. We intend this dedication to be an overt act of -relinquishment in perpetuity of all present and future rights to this -software under copyright law. +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR -OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, -ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -OTHER DEALINGS IN THE SOFTWARE. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. -For more information, please refer to +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 4862d48..a7e0f20 100644 --- a/README.md +++ b/README.md @@ -1,365 +1,107 @@ -# rust-ai-driven-development-pipeline-template +# rust-android-connection -A comprehensive template for AI-driven Rust development with full CI/CD pipeline support. +Rust Android MCP and lifecycle module for docker-git. -[![CI/CD Pipeline](https://github.com/link-foundation/rust-ai-driven-development-pipeline-template/workflows/CI%2FCD%20Pipeline/badge.svg)](https://github.com/link-foundation/rust-ai-driven-development-pipeline-template/actions?workflow=CI%2FCD+Pipeline) -[![Crates.io](https://img.shields.io/crates/v/example-sum-package-name?label=crates.io&style=flat)](https://crates.io/crates/example-sum-package-name) -[![Docs.rs](https://docs.rs/example-sum-package-name/badge.svg)](https://docs.rs/example-sum-package-name) -[![Rust Version](https://img.shields.io/badge/rust-1.70%2B-blue.svg)](https://www.rust-lang.org/) -[![Codecov](https://codecov.io/gh/link-foundation/rust-ai-driven-development-pipeline-template/branch/main/graph/badge.svg)](https://codecov.io/gh/link-foundation/rust-ai-driven-development-pipeline-template) -[![License: Unlicense](https://img.shields.io/badge/license-Unlicense-blue.svg)](http://unlicense.org/) - -## Features - -- **Rust stable support**: Works with Rust stable version -- **Cross-platform testing**: CI runs on Ubuntu, macOS, and Windows -- **Comprehensive testing**: Unit tests, integration tests, and doc tests -- **Code quality**: rustfmt + Clippy with pedantic lints -- **Pre-commit hooks**: Automated code quality checks before commits -- **CI/CD pipeline**: GitHub Actions with multi-platform support -- **Changelog management**: Fragment-based changelog (like Changesets/Scriv) -- **Code coverage**: Automated coverage reports with cargo-llvm-cov and Codecov -- **Release automation**: Automatic GitHub releases, crates.io publishing, post-publish smoke tests, and optional Docker Hub image publishing -- **Template-safe defaults**: CI/CD skips publishing when package name is `example-sum-package-name` - -## Quick Start - -### Using This Template - -1. Click "Use this template" on GitHub to create a new repository -2. Clone your new repository -3. Update `Cargo.toml`: - - Change `name` from `example-sum-package-name` to your package name - - Update `description`, `repository`, and `documentation` URLs - - Update `[lib]` name and `[[bin]]` name -4. Keep `Cargo.lock` committed when the project has a binary target (`[[bin]]` or `src/main.rs`) -5. Update imports in `src/main.rs`, `tests/`, and `examples/` -6. Build and start developing! - -### Development Setup +## Install ```bash -# Clone the repository -git clone https://github.com/link-foundation/rust-ai-driven-development-pipeline-template.git -cd rust-ai-driven-development-pipeline-template - -# Build the project -cargo build - -# Run tests -cargo test - -# Run the CLI binary -cargo run -- --a 3 --b 7 - -# Run an example -cargo run --example basic_usage +cargo install --git https://github.com/ProverCoderAI/rust-android-connection --branch main --locked --bins ``` -### Running Tests +Installs two binaries: -```bash -# Run all tests -cargo test - -# Run tests with verbose output -cargo test --verbose - -# Run doc tests -cargo test --doc - -# Run a specific test -cargo test test_sum_positive_numbers - -# Run tests with output -cargo test -- --nocapture +```text +docker-git-android-connection # start/status/stop Android runtime container +android-connection # MCP stdio server for Codex, Claude, Gemini, and Grok ``` -CI caps each test-matrix job at 10 minutes. `cargo test` does not provide a portable global per-test timeout, so long-running network, IO, or async tests should use explicit test-level timeouts. Repositories that adopt `cargo nextest` can configure runner deadlines with options such as `--slow-timeout` and `--leak-timeout`. - -### Code Quality Checks +## Lifecycle CLI ```bash -# Format code -cargo fmt - -# Check formatting (CI style) -cargo fmt --check - -# Run Clippy lints -cargo clippy --all-targets --all-features - -# Check file size limits (requires rust-script: cargo install rust-script) -rust-script scripts/check-file-size.rs - -# Check the packaged crate stays under the crates.io 10 MiB upload limit -rust-script scripts/check-crate-size.rs - -# Run all checks -cargo fmt --check && cargo clippy --all-targets --all-features && rust-script scripts/check-file-size.rs -``` - -## Project Structure - +docker-git-android-connection status --project dg-my-project +docker-git-android-connection start --project dg-my-project --dry-run +docker-git-android-connection stop --project dg-my-project --dry-run ``` -. -├── .github/ -│ └── workflows/ -│ └── release.yml # CI/CD pipeline configuration -├── changelog.d/ # Changelog fragments -│ ├── README.md # Fragment instructions -│ └── *.md # Individual changelog entries -├── examples/ -│ └── basic_usage.rs # Usage examples -├── experiments/ # Experiment and debug scripts -│ ├── test-changelog-parsing.rs # Changelog parsing validation -│ └── test-crates-io-check.rs # Crates.io version check validation -├── scripts/ # Rust scripts (via rust-script) -│ ├── bump-version.rs # Version bumping utility -│ ├── check-changelog-fragment.rs # Changelog fragment validation -│ ├── check-crate-size.rs # Crate archive size guard (crates.io 10 MiB limit) -│ ├── check-file-size.rs # File size validation script -│ ├── check-release-needed.rs # Release necessity check -│ ├── check-version-modification.rs # Version modification detection -│ ├── collect-changelog.rs # Changelog collection script -│ ├── create-changelog-fragment.rs # Changelog fragment creation -│ ├── create-github-release.rs # GitHub release creation -│ ├── detect-code-changes.rs # Code change detection for CI -│ ├── get-bump-type.rs # Version bump type determination -│ ├── get-version.rs # Version extraction from Cargo.toml -│ ├── git-config.rs # Git configuration for CI -│ ├── publish-crate.rs # Crates.io publishing -│ ├── release-naming.rs # Release tag/title/badge naming helpers -│ ├── rust-paths.rs # Rust root path detection -│ ├── smoke-test-published-crate.rs # Install/import/run smoke test for published crates -│ ├── version-and-commit.rs # CI/CD version management -│ └── wait-for-crate.rs # Crates.io availability wait before image publishing -├── src/ -│ ├── lib.rs # Library entry point -│ ├── main.rs # CLI binary (uses lino-arguments) -│ └── sum.rs # Sum function module -├── tests/ -│ ├── unit_tests.rs # Unit test entry point -│ ├── unit/ -│ │ ├── mod.rs -│ │ ├── sum.rs # Unit tests for sum function -│ │ └── ci-cd/ -│ │ ├── mod.rs -│ │ └── changelog_parsing.rs # CI/CD changelog parsing tests -│ ├── integration_tests.rs # Integration test entry point -│ └── integration/ -│ ├── mod.rs -│ └── sum.rs # CLI integration tests -├── .gitignore # Git ignore patterns -├── .pre-commit-config.yaml # Pre-commit hooks configuration -├── Cargo.toml # Project configuration -├── CHANGELOG.md # Project changelog -├── CONTRIBUTING.md # Contribution guidelines -├── LICENSE # Unlicense (public domain) -└── README.md # This file -``` - -## Design Choices - -### Example Application - -The template includes a simple CLI sum application using [lino-arguments](https://github.com/link-foundation/lino-arguments) (a drop-in replacement for clap that also supports `.lenv` and `.env` files). This demonstrates: - -- Library module (`src/sum.rs`) with a pure function -- CLI binary (`src/main.rs`) using `lino-arguments` for argument parsing -- Unit tests (`tests/unit/sum.rs`) testing the function directly -- Integration tests (`tests/integration/sum.rs`) testing the full CLI binary -### Code Quality Tools +The lifecycle CLI computes deterministic Docker names from the project id and validates the configured ADB endpoint before constructing Docker arguments. -- **rustfmt**: Standard Rust code formatter -- **Clippy**: Rust linter with pedantic and nursery lints enabled -- **Pre-commit hooks**: Automated checks before each commit - -### Testing Strategy - -The template supports multiple levels of testing: - -- **Unit tests**: In `tests/unit/` directory, testing functions directly -- **Integration tests**: In `tests/integration/` directory, testing CLI binary -- **CI/CD tests**: In `tests/unit/ci-cd/` directory, testing CI/CD script logic -- **Doc tests**: In documentation examples using `///` comments -- **Examples**: In `examples/` directory (also serve as documentation) - -Users can easily delete CI/CD tests in `tests/unit/ci-cd/` if not needed. - -### Changelog Management - -This template uses a fragment-based changelog system similar to [Changesets](https://github.com/changesets/changesets) and [Scriv](https://scriv.readthedocs.io/). +## MCP Server ```bash -# Create a changelog fragment -touch changelog.d/$(date +%Y%m%d_%H%M%S)_my_change.md - -# Edit the fragment to document your changes +android-connection --project dg-my-project --network docker-git-shared --endpoint dg-my-project-android:5555 --workspace . ``` -### CI/CD Pipeline - -The GitHub Actions workflow provides: - -1. **Change detection**: Only runs relevant jobs based on changed files -2. **Changelog check**: Validates changelog fragments on PRs with code changes -3. **Version check**: Prevents manual version modification in PRs -4. **Linting**: rustfmt and Clippy checks -5. **Test matrix**: 3 OS (Ubuntu, macOS, Windows) with Rust stable -6. **Code coverage**: cargo-llvm-cov with Codecov upload -7. **Building**: Release build and package validation -8. **Auto release**: Automatic releases when changelog fragments are merged to main -9. **Manual release**: Workflow dispatch with version bump type selection -10. **Published crate smoke test**: Installs the just-published crate from crates.io, runs CLI entry points with captured output, and compiles a fresh dependent crate against the library -11. **Optional Docker Hub publishing**: Pushes `latest` and version tags after the matching crates.io version is visible and smoke-tested -12. **Documentation**: Automatic docs deployment to GitHub Pages after release - -#### Multi-Language Monorepos - -Release scripts auto-detect the Rust layout with no extra configuration. A root `Cargo.toml` is treated as a single-language repository and keeps the plain `v` tag plus ` ` GitHub release title. A `rust/Cargo.toml` layout is treated as a multi-language monorepo and uses `rust_v` tags plus `[Rust] ` titles so Rust releases do not collide with JavaScript or other language releases in the same GitHub Releases list. - -GitHub release notes include a crates.io badge that links to the exact published version page, for example `https://crates.io/crates//`. - -### Template-Safe Defaults - -The default package name `example-sum-package-name` triggers skip logic in CI/CD scripts: -- `publish-crate.rs` skips crates.io publishing -- `smoke-test-published-crate.rs` skips install-from-package verification -- `create-github-release.rs` skips GitHub release creation -- Docker Hub publishing stays disabled unless `DOCKERHUB_IMAGE` is configured and a root `Dockerfile` exists - -Rename the package in `Cargo.toml` to enable full CI/CD publishing. - -## Configuration - -### Updating Package Name - -After creating a repository from this template: - -1. Update `Cargo.toml`: - - Change `name` field from `example-sum-package-name` - - Update `repository` and `documentation` URLs - - Change `[lib]` name and `[[bin]]` name - - Keep `Cargo.lock` committed for executable crates - -2. Update imports: - - `src/main.rs` - - `tests/unit/sum.rs` - - `tests/integration/sum.rs` - - `examples/basic_usage.rs` - -3. Update badges in this `README.md` - -### Cargo.lock for Binary Crates - -This template leaves `Cargo.lock` committed because it includes a CLI binary. -Downstream executable crates should do the same. The CI workflow runs -`scripts/check-cargo-lock.rs` and fails when a binary package has no -`Cargo.lock` committed at `HEAD`. - -This prevents fresh dependency resolution from changing between CI runs. It also -keeps cargo cache keys deterministic: without a lockfile, GitHub Actions' -`hashFiles('**/Cargo.lock')` expression resolves to the same empty hash, so an -unpinned dependency graph can be cached and hide resolution drift. - -If the guard fails, generate the lockfile and commit it: +For handshake tests without ADB access: ```bash -cargo generate-lockfile -git add Cargo.lock +android-connection --project dg-my-project --no-adb-probe ``` -### Optional Docker Hub Publishing - -Projects that ship a Docker image can publish Docker Hub releases from the same Rust release workflow. Add a root `Dockerfile`, then configure: - -| Name | Type | Example | Purpose | -| ---- | ---- | ------- | ------- | -| `DOCKERHUB_IMAGE` | Repository variable | `my-dockerhub-user/my-image` | Docker Hub repository to publish | -| `DOCKERHUB_USERNAME` | Repository variable or secret | `my-dockerhub-user` | Docker Hub login username | -| `DOCKERHUB_TOKEN` | Repository secret | Docker Hub access token | Docker Hub login token | - -When configured, the release workflow publishes both `latest` and the Cargo package version tag, for example `my-dockerhub-user/my-image:0.10.0`. Docker publishing runs only after crates.io reports the matching version as available, and release checks rerun missing Docker Hub or GitHub release artifacts without bumping the version again. - -Add a visible Docker Hub badge next to the crates.io badge in repositories that enable image publishing: - -```markdown -[![Docker Hub](https://img.shields.io/docker/v/my-dockerhub-user/my-image?label=docker%20hub)](https://hub.docker.com/r/my-dockerhub-user/my-image) +## MCP Tools + +```text +android_status() +android_devices() +android_screenshot(path?) +android_tap(x, y) +android_swipe(startX, startY, endX, endY, durationMs?) +android_type_text(text) +android_press_key(keycode) +android_launch_app(package, activity?) +android_open_url(url) +android_logcat(lines?) +android_install_apk(path) ``` -## Deploying API documentation +`android_install_apk` is disabled unless the server is started with `--allow-install`. -The `deploy-docs` job in `.github/workflows/release.yml` publishes `cargo doc --no-deps --all-features` output to GitHub Pages on every push to `main` and on `workflow_dispatch` with `release_mode == 'instant'`. It adds a root `index.html` redirect to the generated crate documentation and a `.nojekyll` marker so rustdoc assets are served verbatim. It uses the official `actions/configure-pages` / `actions/upload-pages-artifact` / `actions/deploy-pages` flow, which requires the repository's Pages source to be set to **GitHub Actions**. +## Smoke Test -Before the first run on `main`, open **Settings → Pages** of the new repository and set **Source = GitHub Actions**. This is a one-time manual step and cannot be configured from a workflow. The `deploy-docs` job will then provision the Pages site on its first run. - -If this step is skipped, the first `deploy-docs` run fails on `actions/deploy-pages@v5` with `Error: Get Pages site failed.` / `Error: Failed to create deployment`. Flip the Pages source as described above and re-run the failed job; no workflow changes are required. - -## Scripts Reference - -All scripts in `scripts/` are Rust scripts that use [rust-script](https://github.com/fornwall/rust-script). -Install rust-script with: `cargo install rust-script` - -| Command | Description | -| ------------------------------------- | ------------------------ | -| `cargo test` | Run all tests | -| `cargo fmt` | Format code | -| `cargo clippy` | Run lints | -| `cargo run -- --a 3 --b 7` | Run CLI (sum 3 + 7) | -| `cargo run --example basic_usage` | Run example | -| `rust-script scripts/check-cargo-lock.rs` | Require committed Cargo.lock for binary crates | -| `rust-script scripts/check-file-size.rs` | Check file size limits | -| `rust-script scripts/check-crate-size.rs` | Check crate archive size (crates.io 10 MiB limit) | -| `rust-script scripts/smoke-test-published-crate.rs --release-version ` | Verify the published crates.io artifact from a clean install | -| `rust-script scripts/bump-version.rs` | Bump version | - -## Example Usage - -```rust -use example_sum_package_name::sum; - -fn main() { - let result = sum(2, 3); - println!("2 + 3 = {result}"); -} +```bash +python3 - <<'PY' | android-connection --project dg-my-project --no-adb-probe | python3 - <<'PY' +import json +import sys + +messages = [ + {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}, + {"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}}, + {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}, +] + +for message in messages: + body = json.dumps(message, separators=(",", ":")).encode() + sys.stdout.buffer.write(f"Content-Length: {len(body)}\r\n\r\n".encode()) + sys.stdout.buffer.write(body) +PY +import json +import sys + +stream = sys.stdin.buffer +while True: + header = {} + while True: + line = stream.readline() + if not line: + raise SystemExit(0) + stripped = line.strip() + if not stripped: + break + name, value = line.decode().split(":", 1) + header[name.lower()] = value.strip() + + length = int(header["content-length"]) + body = stream.read(length) + print(json.dumps(json.loads(body), indent=2)) +PY ``` -See `examples/basic_usage.rs` for more examples. - -## Contributing - -Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. +Expected: server `android-connection` and tools such as `android_status`, `android_tap`, and `android_screenshot`. -### Development Workflow +## Development -1. Fork the repository -2. Create a feature branch: `git checkout -b feature/my-feature` -3. Make your changes and add tests -4. Run quality checks: `cargo fmt && cargo clippy && cargo test` -5. Add a changelog fragment -6. Commit your changes (pre-commit hooks will run automatically) -7. Push and create a Pull Request - -## License - -[Unlicense](LICENSE) - Public Domain - -This is free and unencumbered software released into the public domain. See [LICENSE](LICENSE) for details. - -## Acknowledgments - -Inspired by: -- [js-ai-driven-development-pipeline-template](https://github.com/link-foundation/js-ai-driven-development-pipeline-template) -- [python-ai-driven-development-pipeline-template](https://github.com/link-foundation/python-ai-driven-development-pipeline-template) -- [lino-arguments](https://github.com/link-foundation/lino-arguments) -- [trees-rs](https://github.com/linksplatform/trees-rs) - -## Resources - -- [Rust Book](https://doc.rust-lang.org/book/) -- [Cargo Book](https://doc.rust-lang.org/cargo/) -- [Clippy Documentation](https://rust-lang.github.io/rust-clippy/) -- [rustfmt Documentation](https://rust-lang.github.io/rustfmt/) -- [Pre-commit Documentation](https://pre-commit.com/) +```bash +cargo fmt --check +cargo test --locked +cargo build --locked --bins +cargo clippy --locked --all-targets --all-features -- -D warnings +``` diff --git a/changelog.d/20260624_000000_bootstrap_android_connection.md b/changelog.d/20260624_000000_bootstrap_android_connection.md new file mode 100644 index 0000000..fe71c68 --- /dev/null +++ b/changelog.d/20260624_000000_bootstrap_android_connection.md @@ -0,0 +1,6 @@ +--- +bump: minor +--- + +### Added +- Bootstrap `docker-git-android-connection` as the Rust Android MCP and lifecycle crate. diff --git a/examples/basic_usage.rs b/examples/basic_usage.rs index be9a37f..4509e41 100644 --- a/examples/basic_usage.rs +++ b/examples/basic_usage.rs @@ -1,7 +1,16 @@ -use example_sum_package_name::sum; +use docker_git_android_connection::{ + android_spec, docker_run_args, DEFAULT_ADB_ENDPOINT, DEFAULT_ANDROID_IMAGE, +}; fn main() { - println!("2 + 3 = {}", sum(2, 3)); - println!("-5 + 10 = {}", sum(-5, 10)); - println!("1000 + 2000 = {}", sum(1000, 2000)); + let spec = android_spec( + "dg-my-project", + "docker-git-shared", + DEFAULT_ADB_ENDPOINT, + DEFAULT_ANDROID_IMAGE, + ) + .expect("default Android spec is valid"); + + println!("container: {}", spec.android_container_name); + println!("docker args: {}", docker_run_args(&spec).join(" ")); } diff --git a/src/bin/android-connection.rs b/src/bin/android-connection.rs new file mode 100644 index 0000000..ec26d60 --- /dev/null +++ b/src/bin/android-connection.rs @@ -0,0 +1,54 @@ +use clap::Parser; +use docker_git_android_connection::mcp::{run_stdio, McpState}; +use docker_git_android_connection::{ + android_spec, DEFAULT_ADB_ENDPOINT, DEFAULT_ANDROID_IMAGE, DEFAULT_PROJECT_ID, +}; +use std::io::{self, BufReader}; +use std::path::PathBuf; +use std::process::ExitCode; + +#[derive(Parser, Debug)] +#[command(version, about = "Android MCP stdio server for docker-git")] +struct Cli { + #[arg(long, default_value = DEFAULT_PROJECT_ID)] + project: String, + #[arg(long, default_value = "docker-git-shared")] + network: String, + #[arg(long, default_value = DEFAULT_ADB_ENDPOINT)] + endpoint: String, + #[arg(long, default_value = DEFAULT_ANDROID_IMAGE)] + image: String, + #[arg(long, default_value = ".")] + workspace: PathBuf, + #[arg(long)] + allow_install: bool, + #[arg(long)] + no_adb_probe: bool, +} + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("{error}"); + ExitCode::from(1) + } + } +} + +fn run() -> Result<(), Box> { + let cli = Cli::parse(); + let spec = android_spec(&cli.project, &cli.network, &cli.endpoint, &cli.image)?; + let state = McpState { + spec, + workspace: cli.workspace, + adb_probe: !cli.no_adb_probe, + allow_install: cli.allow_install, + }; + let stdin = io::stdin(); + let stdout = io::stdout(); + let mut reader = BufReader::new(stdin.lock()); + let mut writer = stdout.lock(); + run_stdio(&mut reader, &mut writer, &state)?; + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index 490125b..083f292 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,260 @@ -pub mod sum; +pub mod mcp; -pub use sum::sum; +use serde::Serialize; + +pub const SERVER_NAME: &str = "android-connection"; +pub const DEFAULT_ANDROID_IMAGE: &str = "budtmo/docker-android:emulator_14.0"; +pub const DEFAULT_ADB_ENDPOINT: &str = "android:5555"; +pub const DEFAULT_PROJECT_ID: &str = "docker-git"; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EndpointError { + pub value: String, +} + +impl std::fmt::Display for EndpointError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + formatter, + "invalid ADB endpoint {:?}; allowed characters are ASCII letters, digits, '.', '-', '_' and ':'", + self.value + ) + } +} + +impl std::error::Error for EndpointError {} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct AndroidSpec { + pub project_id: String, + pub project_container_name: String, + pub android_container_name: String, + pub android_volume_name: String, + pub docker_network: String, + pub adb_endpoint: String, + pub image: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct McpToolSpec { + pub name: &'static str, + pub description: &'static str, +} + +// CHANGE: normalize externally supplied project ids into Docker-safe names +// WHY: Android sidecar names are pure functions of the project id, so MCP clients and lifecycle CLI agree +// QUOTE(TZ): "Подключить mcp-android так же как работает MCP PLAYRIGHT" +// REF: issue-436 +// SOURCE: n/a +// FORMAT THEOREM: forall s: normalize(s) in [a-z0-9-]+ and normalize(s) != "" +// PURITY: CORE +// INVARIANT: output is non-empty, lowercase, and contains only Docker-name-safe characters +// COMPLEXITY: O(n)/O(n) +#[must_use] +pub fn normalize_project_id(raw: &str) -> String { + let mut normalized = String::new(); + let mut previous_dash = false; + + for byte in raw.bytes() { + let next = match byte { + b'a'..=b'z' | b'0'..=b'9' => Some(byte as char), + b'A'..=b'Z' => Some(byte.to_ascii_lowercase() as char), + _ => { + if normalized.is_empty() || previous_dash { + None + } else { + Some('-') + } + } + }; + + if let Some(character) = next { + previous_dash = character == '-'; + normalized.push(character); + } + } + + while normalized.ends_with('-') { + normalized.pop(); + } + + if normalized.is_empty() { + DEFAULT_PROJECT_ID.to_string() + } else { + normalized + } +} + +#[must_use] +pub fn android_container_name(project_id: &str) -> String { + format!("{}-android", normalize_project_id(project_id)) +} + +#[must_use] +pub fn android_volume_name(project_id: &str) -> String { + format!("{}-home-android", normalize_project_id(project_id)) +} + +#[must_use] +pub fn is_safe_adb_endpoint(value: &str) -> bool { + !value.is_empty() + && value.len() <= 255 + && value.contains(':') + && value.bytes().all(|byte| { + matches!( + byte, + b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'.' | b'-' | b'_' | b':' + ) + }) +} + +pub fn validate_adb_endpoint(value: &str) -> Result { + if is_safe_adb_endpoint(value) { + Ok(value.to_string()) + } else { + Err(EndpointError { + value: value.to_string(), + }) + } +} + +pub fn android_spec( + project_id: &str, + docker_network: &str, + adb_endpoint: &str, + image: &str, +) -> Result { + let normalized = normalize_project_id(project_id); + Ok(AndroidSpec { + project_id: normalized.clone(), + project_container_name: normalized.clone(), + android_container_name: android_container_name(&normalized), + android_volume_name: android_volume_name(&normalized), + docker_network: docker_network.to_string(), + adb_endpoint: validate_adb_endpoint(adb_endpoint)?, + image: image.to_string(), + }) +} + +#[must_use] +pub fn android_tools() -> Vec { + vec![ + McpToolSpec { + name: "android_status", + description: "Return the configured Android runtime and optional ADB status.", + }, + McpToolSpec { + name: "android_devices", + description: "List Android devices visible to adb.", + }, + McpToolSpec { + name: "android_screenshot", + description: "Capture a PNG screenshot into the workspace.", + }, + McpToolSpec { + name: "android_tap", + description: "Tap screen coordinates.", + }, + McpToolSpec { + name: "android_swipe", + description: "Swipe between screen coordinates.", + }, + McpToolSpec { + name: "android_type_text", + description: "Type text into the active Android input field.", + }, + McpToolSpec { + name: "android_press_key", + description: "Send an Android keycode.", + }, + McpToolSpec { + name: "android_launch_app", + description: "Launch an installed Android package.", + }, + McpToolSpec { + name: "android_open_url", + description: "Open a URL through Android intent handling.", + }, + McpToolSpec { + name: "android_logcat", + description: "Read recent logcat output.", + }, + McpToolSpec { + name: "android_install_apk", + description: "Install an APK from the workspace when explicitly enabled.", + }, + ] +} + +#[must_use] +pub fn docker_run_args(spec: &AndroidSpec) -> Vec { + vec![ + "run".to_string(), + "--detach".to_string(), + "--name".to_string(), + spec.android_container_name.clone(), + "--privileged".to_string(), + "--network".to_string(), + spec.docker_network.clone(), + "--env".to_string(), + "EMULATOR_HEADLESS=true".to_string(), + "--env".to_string(), + "WEB_VNC=true".to_string(), + "--volume".to_string(), + format!("{}:/root/.android", spec.android_volume_name), + spec.image.clone(), + ] +} + +#[must_use] +pub fn docker_stop_args(spec: &AndroidSpec) -> Vec { + vec![ + "rm".to_string(), + "--force".to_string(), + spec.android_container_name.clone(), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalizes_project_id_to_docker_safe_name() { + assert_eq!( + normalize_project_id("Org/Repo:Feature_X"), + "org-repo-feature-x" + ); + assert_eq!(normalize_project_id("///"), DEFAULT_PROJECT_ID); + } + + #[test] + fn rejects_shell_fragments_in_adb_endpoint() { + assert!(validate_adb_endpoint("dg-test-android:5555").is_ok()); + assert!(validate_adb_endpoint("dg-test-android:5555;touch /tmp/pwn").is_err()); + assert!(validate_adb_endpoint("$(whoami):5555").is_err()); + } + + #[test] + fn builds_deterministic_android_spec() { + let spec = android_spec( + "dg-test", + "docker-git-shared", + "dg-test-android:5555", + DEFAULT_ANDROID_IMAGE, + ) + .expect("valid spec"); + + assert_eq!(spec.project_container_name, "dg-test"); + assert_eq!(spec.android_container_name, "dg-test-android"); + assert_eq!(spec.android_volume_name, "dg-test-home-android"); + } + + #[test] + fn advertises_android_mcp_tools() { + let names: Vec<&str> = android_tools().into_iter().map(|tool| tool.name).collect(); + assert!(names.contains(&"android_status")); + assert!(names.contains(&"android_tap")); + assert!(names.contains(&"android_install_apk")); + } +} diff --git a/src/main.rs b/src/main.rs index 397ec09..493a558 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,74 +1,104 @@ -use lino_arguments::Parser; -use std::io::{self, Write}; - -use example_sum_package_name::sum; +use clap::{Args, Parser, Subcommand}; +use docker_git_android_connection::{ + android_spec, docker_run_args, docker_stop_args, DEFAULT_ADB_ENDPOINT, DEFAULT_ANDROID_IMAGE, + DEFAULT_PROJECT_ID, +}; +use serde_json::json; +use std::process::{Command, ExitCode}; #[derive(Parser, Debug)] -#[command(name = "example-sum-package-name", about = "Sum two numbers")] -struct Args { - #[arg(long, env = "A", default_value = "0", allow_hyphen_values = true)] - a: i64, - - #[arg(long, env = "B", default_value = "0", allow_hyphen_values = true)] - b: i64, -} - -fn write_output(writer: &mut impl Write, output: &str) -> io::Result<()> { - match writer - .write_all(output.as_bytes()) - .and_then(|()| writer.flush()) - { - Err(e) if e.kind() == io::ErrorKind::BrokenPipe => Ok(()), - result => result, - } +#[command(version, about = "docker-git Android runtime lifecycle CLI")] +struct Cli { + #[command(subcommand)] + command: LifecycleCommand, } -fn write_stdout(output: &str) -> io::Result<()> { - write_output(&mut io::stdout(), output) +#[derive(Subcommand, Debug)] +enum LifecycleCommand { + Start(LifecycleArgs), + Status(LifecycleArgs), + Stop(LifecycleArgs), } -fn main() -> io::Result<()> { - let args = Args::parse(); - write_stdout(&format!("{}\n", sum(args.a, args.b))) +#[derive(Args, Clone, Debug)] +struct LifecycleArgs { + #[arg(long, default_value = DEFAULT_PROJECT_ID)] + project: String, + #[arg(long, default_value = "docker-git-shared")] + network: String, + #[arg(long, default_value = DEFAULT_ADB_ENDPOINT)] + endpoint: String, + #[arg(long, default_value = DEFAULT_ANDROID_IMAGE)] + image: String, + #[arg(long)] + dry_run: bool, } -#[cfg(test)] -mod tests { - use super::*; - - struct BrokenPipeWriter; - - impl Write for BrokenPipeWriter { - fn write(&mut self, _buf: &[u8]) -> io::Result { - Err(io::Error::from(io::ErrorKind::BrokenPipe)) +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("{error}"); + ExitCode::from(1) } + } +} - fn flush(&mut self) -> io::Result<()> { - Ok(()) - } +fn run() -> Result<(), Box> { + let cli = Cli::parse(); + match cli.command { + LifecycleCommand::Start(args) => start(&args), + LifecycleCommand::Status(args) => status(&args), + LifecycleCommand::Stop(args) => stop(&args), } +} - struct OtherErrorWriter; +fn start(args: &LifecycleArgs) -> Result<(), Box> { + let spec = android_spec(&args.project, &args.network, &args.endpoint, &args.image)?; + let docker_args = docker_run_args(&spec); + if args.dry_run { + println!( + "{}", + serde_json::to_string_pretty(&json!({ "docker": docker_args }))? + ); + return Ok(()); + } - impl Write for OtherErrorWriter { - fn write(&mut self, _buf: &[u8]) -> io::Result { - Err(io::Error::from(io::ErrorKind::PermissionDenied)) - } + run_docker(&docker_args) +} - fn flush(&mut self) -> io::Result<()> { - Ok(()) - } - } +fn status(args: &LifecycleArgs) -> Result<(), Box> { + let spec = android_spec(&args.project, &args.network, &args.endpoint, &args.image)?; + println!("{}", serde_json::to_string_pretty(&spec)?); + Ok(()) +} - #[test] - fn write_output_treats_broken_pipe_as_clean_exit() { - assert!(write_output(&mut BrokenPipeWriter, "1\n").is_ok()); +fn stop(args: &LifecycleArgs) -> Result<(), Box> { + let spec = android_spec(&args.project, &args.network, &args.endpoint, &args.image)?; + let docker_args = docker_stop_args(&spec); + if args.dry_run { + println!( + "{}", + serde_json::to_string_pretty(&json!({ "docker": docker_args }))? + ); + return Ok(()); } - #[test] - fn write_output_preserves_other_io_errors() { - let err = write_output(&mut OtherErrorWriter, "1\n").unwrap_err(); + run_docker(&docker_args) +} - assert_eq!(err.kind(), io::ErrorKind::PermissionDenied); +fn run_docker(args: &[String]) -> Result<(), Box> { + let output = Command::new("docker").args(args).output()?; + if output.status.success() { + print!("{}", String::from_utf8_lossy(&output.stdout)); + return Ok(()); } + + Err(format!( + "docker failed with status {:?}\nstdout:\n{}\nstderr:\n{}", + output.status.code(), + String::from_utf8_lossy(&output.stdout).trim(), + String::from_utf8_lossy(&output.stderr).trim() + ) + .into()) } diff --git a/src/mcp.rs b/src/mcp.rs new file mode 100644 index 0000000..cf275c4 --- /dev/null +++ b/src/mcp.rs @@ -0,0 +1,635 @@ +use crate::{android_tools, AndroidSpec, SERVER_NAME}; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::fs; +use std::io::{self, BufRead, Write}; +use std::path::{Component, Path, PathBuf}; +use std::process::{Command, Output}; + +#[derive(Clone, Debug)] +pub struct McpState { + pub spec: AndroidSpec, + pub workspace: PathBuf, + pub adb_probe: bool, + pub allow_install: bool, +} + +#[derive(Debug)] +enum McpToolError { + MissingArgument(&'static str), + InvalidArgument(String), + AdbProbeDisabled, + CommandFailed(String), + Io(String), +} + +impl std::fmt::Display for McpToolError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::MissingArgument(name) => write!(formatter, "missing required argument: {name}"), + Self::InvalidArgument(message) => write!(formatter, "invalid argument: {message}"), + Self::AdbProbeDisabled => write!(formatter, "ADB probing is disabled for this server"), + Self::CommandFailed(message) | Self::Io(message) => write!(formatter, "{message}"), + } + } +} + +#[derive(Debug, Deserialize)] +struct JsonRpcRequest { + id: Option, + method: String, + params: Option, +} + +pub fn run_stdio(reader: &mut R, writer: &mut W, state: &McpState) -> io::Result<()> +where + R: BufRead, + W: Write, +{ + while let Some(raw) = read_next_message(reader)? { + let response = match serde_json::from_str::(&raw) { + Ok(request) => handle_request(&request, state), + Err(error) => Some(json_rpc_error( + &Value::Null, + -32700, + &format!("invalid JSON-RPC request: {error}"), + )), + }; + + if let Some(value) = response { + write_json_message(writer, &value)?; + } + } + + Ok(()) +} + +fn read_next_message(reader: &mut R) -> io::Result> { + loop { + let mut line = String::new(); + let bytes_read = reader.read_line(&mut line)?; + if bytes_read == 0 { + return Ok(None); + } + + let first_line = line.trim_end_matches(['\r', '\n']); + if first_line.is_empty() { + continue; + } + + if let Some(length) = parse_content_length(first_line)? { + read_headers(reader)?; + let mut payload = vec![0_u8; length]; + reader.read_exact(&mut payload)?; + return String::from_utf8(payload) + .map(Some) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)); + } + + return Ok(Some(first_line.to_string())); + } +} + +fn parse_content_length(header: &str) -> io::Result> { + let lowercase = header.to_ascii_lowercase(); + if !lowercase.starts_with("content-length:") { + return Ok(None); + } + + let raw_length = header + .split_once(':') + .map(|(_, value)| value.trim()) + .unwrap_or_default(); + raw_length + .parse::() + .map(Some) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) +} + +fn read_headers(reader: &mut R) -> io::Result<()> { + loop { + let mut line = String::new(); + let bytes_read = reader.read_line(&mut line)?; + if bytes_read == 0 { + return Ok(()); + } + if line.trim().is_empty() { + return Ok(()); + } + } +} + +fn write_json_message(writer: &mut W, value: &Value) -> io::Result<()> { + let body = serde_json::to_string(value) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + write!(writer, "Content-Length: {}\r\n\r\n{}", body.len(), body)?; + writer.flush() +} + +fn handle_request(request: &JsonRpcRequest, state: &McpState) -> Option { + if request.id.is_none() && request.method.starts_with("notifications/") { + return None; + } + + let id = request.id.clone().unwrap_or(Value::Null); + let response = match request.method.as_str() { + "initialize" => json!({ + "protocolVersion": "2024-11-05", + "capabilities": { "tools": {} }, + "serverInfo": { + "name": SERVER_NAME, + "version": env!("CARGO_PKG_VERSION") + } + }), + "tools/list" => json!({ "tools": render_tools() }), + "tools/call" => return Some(handle_tools_call(&id, request.params.as_ref(), state)), + method => { + return Some(json_rpc_error( + &id, + -32601, + &format!("method not found: {method}"), + )) + } + }; + + Some(json!({ + "jsonrpc": "2.0", + "id": id, + "result": response + })) +} + +fn json_rpc_error(id: &Value, code: i64, message: &str) -> Value { + json!({ + "jsonrpc": "2.0", + "id": id, + "error": { + "code": code, + "message": message + } + }) +} + +fn render_tools() -> Value { + Value::Array( + android_tools() + .into_iter() + .map(|tool| { + json!({ + "name": tool.name, + "description": tool.description, + "inputSchema": { + "type": "object", + "additionalProperties": true + } + }) + }) + .collect(), + ) +} + +fn handle_tools_call(id: &Value, params: Option<&Value>, state: &McpState) -> Value { + let result = call_tool_from_params(params, state); + let (text, is_error) = match result { + Ok(text) => (text, false), + Err(error) => (error.to_string(), true), + }; + + json!({ + "jsonrpc": "2.0", + "id": id, + "result": { + "content": [ + { + "type": "text", + "text": text + } + ], + "isError": is_error + } + }) +} + +fn call_tool_from_params(params: Option<&Value>, state: &McpState) -> Result { + let params = + params.ok_or_else(|| McpToolError::InvalidArgument("missing params".to_string()))?; + let name = params + .get("name") + .and_then(Value::as_str) + .ok_or_else(|| McpToolError::InvalidArgument("missing tool name".to_string()))?; + let arguments = params.get("arguments").unwrap_or(&Value::Null); + + match name { + "android_status" => android_status(state), + "android_devices" => android_devices(state), + "android_screenshot" => android_screenshot(state, arguments), + "android_tap" => android_tap(state, arguments), + "android_swipe" => android_swipe(state, arguments), + "android_type_text" => android_type_text(state, arguments), + "android_press_key" => android_press_key(state, arguments), + "android_launch_app" => android_launch_app(state, arguments), + "android_open_url" => android_open_url(state, arguments), + "android_logcat" => android_logcat(state, arguments), + "android_install_apk" => android_install_apk(state, arguments), + unknown => Err(McpToolError::InvalidArgument(format!( + "unknown Android MCP tool: {unknown}" + ))), + } +} + +fn android_status(state: &McpState) -> Result { + if !state.adb_probe { + return serde_json::to_string_pretty(&json!({ + "server": SERVER_NAME, + "adbProbe": false, + "spec": state.spec + })) + .map_err(|error| McpToolError::Io(error.to_string())); + } + + match run_adb(state, &["devices".to_string()]) { + Ok(output) => Ok(format!( + "Android runtime: {}\nADB endpoint: {}\n\n{}", + state.spec.android_container_name, state.spec.adb_endpoint, output + )), + Err(error) => Ok(format!( + "Android runtime: {}\nADB endpoint: {}\nADB status error: {}", + state.spec.android_container_name, state.spec.adb_endpoint, error + )), + } +} + +fn android_devices(state: &McpState) -> Result { + run_adb(state, &["devices".to_string()]) +} + +fn android_tap(state: &McpState, arguments: &Value) -> Result { + let x = integer_argument(arguments, "x")?; + let y = integer_argument(arguments, "y")?; + run_adb( + state, + &[ + "shell".to_string(), + "input".to_string(), + "tap".to_string(), + x.to_string(), + y.to_string(), + ], + ) +} + +fn android_swipe(state: &McpState, arguments: &Value) -> Result { + let start_x = integer_argument(arguments, "startX")?; + let start_y = integer_argument(arguments, "startY")?; + let end_x = integer_argument(arguments, "endX")?; + let end_y = integer_argument(arguments, "endY")?; + let duration_ms = optional_integer_argument(arguments, "durationMs")?.unwrap_or(300); + run_adb( + state, + &[ + "shell".to_string(), + "input".to_string(), + "swipe".to_string(), + start_x.to_string(), + start_y.to_string(), + end_x.to_string(), + end_y.to_string(), + duration_ms.to_string(), + ], + ) +} + +fn android_type_text(state: &McpState, arguments: &Value) -> Result { + let text = string_argument(arguments, "text")?.replace(' ', "%s"); + run_adb( + state, + &[ + "shell".to_string(), + "input".to_string(), + "text".to_string(), + text, + ], + ) +} + +fn android_press_key(state: &McpState, arguments: &Value) -> Result { + let keycode = string_argument(arguments, "keycode")?; + if !keycode + .bytes() + .all(|byte| matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_')) + { + return Err(McpToolError::InvalidArgument( + "keycode may contain only ASCII letters, digits, and '_'".to_string(), + )); + } + run_adb( + state, + &[ + "shell".to_string(), + "input".to_string(), + "keyevent".to_string(), + keycode, + ], + ) +} + +fn android_launch_app(state: &McpState, arguments: &Value) -> Result { + let package_name = string_argument(arguments, "package")?; + let activity = optional_string_argument(arguments, "activity")?; + match activity { + Some(activity) if !activity.is_empty() => run_adb( + state, + &[ + "shell".to_string(), + "am".to_string(), + "start".to_string(), + "-n".to_string(), + format!("{package_name}/{activity}"), + ], + ), + _ => run_adb( + state, + &[ + "shell".to_string(), + "monkey".to_string(), + "-p".to_string(), + package_name, + "-c".to_string(), + "android.intent.category.LAUNCHER".to_string(), + "1".to_string(), + ], + ), + } +} + +fn android_open_url(state: &McpState, arguments: &Value) -> Result { + let url = string_argument(arguments, "url")?; + run_adb( + state, + &[ + "shell".to_string(), + "am".to_string(), + "start".to_string(), + "-a".to_string(), + "android.intent.action.VIEW".to_string(), + "-d".to_string(), + url, + ], + ) +} + +fn android_logcat(state: &McpState, arguments: &Value) -> Result { + let lines = optional_integer_argument(arguments, "lines")? + .unwrap_or(200) + .clamp(1, 1000); + run_adb( + state, + &[ + "logcat".to_string(), + "-d".to_string(), + "-t".to_string(), + lines.to_string(), + ], + ) +} + +fn android_screenshot(state: &McpState, arguments: &Value) -> Result { + let output_path = optional_string_argument(arguments, "path")? + .unwrap_or_else(|| "android-screenshot.png".to_string()); + let target_path = workspace_path(&state.workspace, &output_path)?; + let output = run_adb_raw( + state, + &[ + "exec-out".to_string(), + "screencap".to_string(), + "-p".to_string(), + ], + )?; + if !output.status.success() { + return Err(McpToolError::CommandFailed(command_failure( + "adb screenshot", + &output, + ))); + } + if let Some(parent) = target_path.parent() { + fs::create_dir_all(parent).map_err(|error| McpToolError::Io(error.to_string()))?; + } + fs::write(&target_path, output.stdout).map_err(|error| McpToolError::Io(error.to_string()))?; + Ok(format!("screenshot written to {}", target_path.display())) +} + +fn android_install_apk(state: &McpState, arguments: &Value) -> Result { + if !state.allow_install { + return Err(McpToolError::InvalidArgument( + "APK installation requires --allow-install".to_string(), + )); + } + let apk_path = string_argument(arguments, "path")?; + let target_path = workspace_path(&state.workspace, &apk_path)?; + run_adb( + state, + &["install".to_string(), target_path.display().to_string()], + ) +} + +fn run_adb(state: &McpState, args: &[String]) -> Result { + let output = run_adb_raw(state, args)?; + output_to_text("adb", &output) +} + +fn run_adb_raw(state: &McpState, args: &[String]) -> Result { + if !state.adb_probe { + return Err(McpToolError::AdbProbeDisabled); + } + + let connect_output = Command::new("adb") + .arg("connect") + .arg(&state.spec.adb_endpoint) + .output() + .map_err(|error| { + McpToolError::CommandFailed(format!("failed to execute adb connect: {error}")) + })?; + if !connect_output.status.success() { + return Err(McpToolError::CommandFailed(command_failure( + "adb connect", + &connect_output, + ))); + } + + Command::new("adb") + .args(args) + .output() + .map_err(|error| McpToolError::CommandFailed(format!("failed to execute adb: {error}"))) +} + +fn output_to_text(label: &str, output: &Output) -> Result { + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Ok(match (stdout.is_empty(), stderr.is_empty()) { + (true, true) => format!("{label} completed successfully"), + (false, true) => stdout, + (true, false) => stderr, + (false, false) => format!("{stdout}\n{stderr}"), + }); + } + + Err(McpToolError::CommandFailed(command_failure(label, output))) +} + +fn command_failure(label: &str, output: &Output) -> String { + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + format!( + "{label} failed with status {:?}\nstdout:\n{}\nstderr:\n{}", + output.status.code(), + stdout.trim(), + stderr.trim() + ) +} + +fn integer_argument(arguments: &Value, name: &'static str) -> Result { + arguments + .get(name) + .and_then(Value::as_i64) + .ok_or(McpToolError::MissingArgument(name)) +} + +fn optional_integer_argument( + arguments: &Value, + name: &'static str, +) -> Result, McpToolError> { + arguments.get(name).map_or(Ok(None), |value| { + value + .as_i64() + .map(Some) + .ok_or_else(|| McpToolError::InvalidArgument(format!("{name} must be an integer"))) + }) +} + +fn string_argument(arguments: &Value, name: &'static str) -> Result { + arguments + .get(name) + .and_then(Value::as_str) + .map(ToString::to_string) + .ok_or(McpToolError::MissingArgument(name)) +} + +fn optional_string_argument( + arguments: &Value, + name: &'static str, +) -> Result, McpToolError> { + arguments.get(name).map_or(Ok(None), |value| { + value + .as_str() + .map(|text| Some(text.to_string())) + .ok_or_else(|| McpToolError::InvalidArgument(format!("{name} must be a string"))) + }) +} + +fn workspace_path(workspace: &Path, value: &str) -> Result { + let candidate = PathBuf::from(value); + if value.is_empty() + || candidate.is_absolute() + || candidate.components().any(|component| { + matches!( + component, + Component::Prefix(_) | Component::RootDir | Component::ParentDir + ) + }) + { + return Err(McpToolError::InvalidArgument( + "path must be relative, non-empty, and must not contain '..'".to_string(), + )); + } + + Ok(workspace.join(candidate)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{android_spec, DEFAULT_ANDROID_IMAGE}; + use std::io::Cursor; + + fn test_state() -> McpState { + McpState { + spec: android_spec( + "dg-test", + "docker-git-shared", + "dg-test-android:5555", + DEFAULT_ANDROID_IMAGE, + ) + .expect("valid android spec"), + workspace: PathBuf::from("/workspace"), + adb_probe: false, + allow_install: false, + } + } + + fn frame(value: &Value) -> String { + let payload = serde_json::to_string(&value).expect("serializable request"); + format!("Content-Length: {}\r\n\r\n{}", payload.len(), payload) + } + + #[test] + fn serves_initialize_and_tools_list_over_framed_stdio() { + let input = format!( + "{}{}", + frame(&json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {} + })), + frame(&json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/list", + "params": {} + })) + ); + let mut reader = Cursor::new(input.into_bytes()); + let mut output = Vec::new(); + + run_stdio(&mut reader, &mut output, &test_state()).expect("stdio server succeeds"); + + let output_text = String::from_utf8(output).expect("valid utf8 output"); + assert!(output_text.contains(SERVER_NAME)); + assert!(output_text.contains("android_status")); + assert!(output_text.contains("android_tap")); + } + + #[test] + fn reports_status_without_adb_when_probe_is_disabled() { + let input = frame(&json!({ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "android_status", + "arguments": {} + } + })); + let mut reader = Cursor::new(input.into_bytes()); + let mut output = Vec::new(); + + run_stdio(&mut reader, &mut output, &test_state()).expect("status succeeds"); + + let output_text = String::from_utf8(output).expect("valid utf8 output"); + assert!(output_text.contains("\"isError\":false")); + assert!(output_text.contains("adbProbe")); + assert!(output_text.contains("false")); + assert!(output_text.contains("dg-test-android")); + } + + #[test] + fn rejects_workspace_paths_outside_workspace() { + let workspace = PathBuf::from("/workspace"); + + assert!(workspace_path(&workspace, "screenshots/current.png").is_ok()); + assert!(workspace_path(&workspace, "/tmp/outside.png").is_err()); + assert!(workspace_path(&workspace, "../outside.png").is_err()); + assert!(workspace_path(&workspace, "screenshots/../outside.png").is_err()); + } +} diff --git a/src/sum.rs b/src/sum.rs deleted file mode 100644 index e0960d2..0000000 --- a/src/sum.rs +++ /dev/null @@ -1,4 +0,0 @@ -#[must_use] -pub const fn sum(a: i64, b: i64) -> i64 { - a + b -} diff --git a/tests/integration/cli.rs b/tests/integration/cli.rs new file mode 100644 index 0000000..2b98e92 --- /dev/null +++ b/tests/integration/cli.rs @@ -0,0 +1,45 @@ +use std::process::Command; + +#[test] +fn lifecycle_cli_renders_status_json() { + let output = Command::new(env!("CARGO_BIN_EXE_docker-git-android-connection")) + .args([ + "status", + "--project", + "dg-test", + "--endpoint", + "dg-test-android:5555", + ]) + .output() + .expect("failed to execute lifecycle binary"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("\"project_id\": \"dg-test\"")); + assert!(stdout.contains("\"android_container_name\": \"dg-test-android\"")); +} + +#[test] +fn lifecycle_cli_rejects_invalid_endpoint() { + let output = Command::new(env!("CARGO_BIN_EXE_docker-git-android-connection")) + .args(["status", "--endpoint", "$(whoami):5555"]) + .output() + .expect("failed to execute lifecycle binary"); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("invalid ADB endpoint")); +} + +#[test] +fn mcp_cli_exposes_help() { + let output = Command::new(env!("CARGO_BIN_EXE_android-connection")) + .arg("--help") + .output() + .expect("failed to execute MCP binary"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Android MCP stdio server")); + assert!(stdout.contains("--no-adb-probe")); +} diff --git a/tests/integration/mod.rs b/tests/integration/mod.rs index 47e2280..26710c1 100644 --- a/tests/integration/mod.rs +++ b/tests/integration/mod.rs @@ -1 +1 @@ -mod sum; +mod cli; diff --git a/tests/integration/sum.rs b/tests/integration/sum.rs deleted file mode 100644 index b0d15bc..0000000 --- a/tests/integration/sum.rs +++ /dev/null @@ -1,36 +0,0 @@ -use std::process::Command; - -#[test] -fn test_cli_default_args() { - let output = Command::new(env!("CARGO_BIN_EXE_example-sum-package-name")) - .output() - .expect("Failed to execute binary"); - - assert!(output.status.success()); - let stdout = String::from_utf8_lossy(&output.stdout); - assert_eq!(stdout.trim(), "0"); -} - -#[test] -fn test_cli_sum_two_numbers() { - let output = Command::new(env!("CARGO_BIN_EXE_example-sum-package-name")) - .args(["--a", "3", "--b", "7"]) - .output() - .expect("Failed to execute binary"); - - assert!(output.status.success()); - let stdout = String::from_utf8_lossy(&output.stdout); - assert_eq!(stdout.trim(), "10"); -} - -#[test] -fn test_cli_negative_numbers() { - let output = Command::new(env!("CARGO_BIN_EXE_example-sum-package-name")) - .args(["--a", "-5", "--b", "3"]) - .output() - .expect("Failed to execute binary"); - - assert!(output.status.success()); - let stdout = String::from_utf8_lossy(&output.stdout); - assert_eq!(stdout.trim(), "-2"); -} diff --git a/tests/unit/android_connection.rs b/tests/unit/android_connection.rs new file mode 100644 index 0000000..59a4d9a --- /dev/null +++ b/tests/unit/android_connection.rs @@ -0,0 +1,44 @@ +use docker_git_android_connection::{ + android_spec, android_tools, normalize_project_id, validate_adb_endpoint, + DEFAULT_ANDROID_IMAGE, DEFAULT_PROJECT_ID, +}; + +#[test] +fn normalizes_project_id_to_docker_safe_name() { + assert_eq!( + normalize_project_id("Org/Repo:Feature_X"), + "org-repo-feature-x" + ); + assert_eq!(normalize_project_id("///"), DEFAULT_PROJECT_ID); +} + +#[test] +fn rejects_shell_fragments_in_adb_endpoint() { + assert!(validate_adb_endpoint("dg-test-android:5555").is_ok()); + assert!(validate_adb_endpoint("dg-test-android:5555;touch /tmp/pwn").is_err()); + assert!(validate_adb_endpoint("$(whoami):5555").is_err()); +} + +#[test] +fn builds_deterministic_android_spec() { + let spec = android_spec( + "dg-test", + "docker-git-shared", + "dg-test-android:5555", + DEFAULT_ANDROID_IMAGE, + ) + .expect("valid spec"); + + assert_eq!(spec.project_container_name, "dg-test"); + assert_eq!(spec.android_container_name, "dg-test-android"); + assert_eq!(spec.android_volume_name, "dg-test-home-android"); +} + +#[test] +fn advertises_android_mcp_tools() { + let names: Vec<&str> = android_tools().into_iter().map(|tool| tool.name).collect(); + + assert!(names.contains(&"android_status")); + assert!(names.contains(&"android_tap")); + assert!(names.contains(&"android_install_apk")); +} diff --git a/tests/unit/mod.rs b/tests/unit/mod.rs index 7c158e6..67a6ab8 100644 --- a/tests/unit/mod.rs +++ b/tests/unit/mod.rs @@ -1,4 +1,4 @@ -mod sum; +mod android_connection; #[path = "ci-cd/mod.rs"] mod ci_cd; diff --git a/tests/unit/sum.rs b/tests/unit/sum.rs deleted file mode 100644 index 84937f6..0000000 --- a/tests/unit/sum.rs +++ /dev/null @@ -1,26 +0,0 @@ -use example_sum_package_name::sum; - -#[test] -fn test_sum_positive_numbers() { - assert_eq!(sum(2, 3), 5); -} - -#[test] -fn test_sum_negative_numbers() { - assert_eq!(sum(-1, -2), -3); -} - -#[test] -fn test_sum_zero() { - assert_eq!(sum(5, 0), 5); -} - -#[test] -fn test_sum_large_numbers() { - assert_eq!(sum(1_000_000, 2_000_000), 3_000_000); -} - -#[test] -fn test_sum_mixed_sign() { - assert_eq!(sum(-100, 50), -50); -} From 3660e4c87704b3dfc7445c872c6101625561c3ad Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Wed, 24 Jun 2026 09:53:13 +0000 Subject: [PATCH 2/2] fix(android): constrain runtime resources --- README.md | 19 +- examples/basic_usage.rs | 11 +- src/lib.rs | 224 +++++++++++++++++++++- src/main.rs | 317 ++++++++++++++++++++++++++++--- tests/integration/cli.rs | 190 ++++++++++++++++-- tests/unit/android_connection.rs | 111 ++++++++++- 6 files changed, 822 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index a7e0f20..172b801 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,24 @@ docker-git-android-connection start --project dg-my-project --dry-run docker-git-android-connection stop --project dg-my-project --dry-run ``` -The lifecycle CLI computes deterministic Docker names from the project id and validates the configured ADB endpoint before constructing Docker arguments. +The lifecycle CLI computes deterministic Docker names from the project id and validates the configured ADB endpoint before constructing Docker arguments. By default it publishes a Docker-Android noVNC bridge to `127.0.0.1:6080` and returns `noVncUrl` in lifecycle JSON: + +```json +{ + "androidContainerName": "dg-my-project-android", + "resourceLimits": { + "memory": "3g", + "memorySwap": "3g", + "cpus": "1.0" + }, + "noVncPublished": true, + "noVncUrl": "http://127.0.0.1:6080/?autoconnect=true&resize=remote" +} +``` + +Use `--novnc-port ` to request a different host port, `--novnc-bind-host ` to bind Docker publishing somewhere other than loopback, `--novnc-host ` to control the browser-facing URL host, or `--no-novnc-publish` to disable host publication. + +Android containers are resource-limited by default with `--memory 3g --memory-swap 3g --cpus 1.0`. Use `--memory `, `--memory-swap `, and `--cpus ` to override those limits for a specific run. ## MCP Server diff --git a/examples/basic_usage.rs b/examples/basic_usage.rs index 4509e41..cfc5623 100644 --- a/examples/basic_usage.rs +++ b/examples/basic_usage.rs @@ -1,5 +1,6 @@ use docker_git_android_connection::{ - android_spec, docker_run_args, DEFAULT_ADB_ENDPOINT, DEFAULT_ANDROID_IMAGE, + android_spec, default_android_resource_limits, docker_run_args, no_vnc_endpoint, + DEFAULT_ADB_ENDPOINT, DEFAULT_ANDROID_IMAGE, DEFAULT_NOVNC_HOST, DEFAULT_NOVNC_PORT, }; fn main() { @@ -12,5 +13,11 @@ fn main() { .expect("default Android spec is valid"); println!("container: {}", spec.android_container_name); - println!("docker args: {}", docker_run_args(&spec).join(" ")); + let no_vnc = no_vnc_endpoint(DEFAULT_NOVNC_HOST, DEFAULT_NOVNC_HOST, DEFAULT_NOVNC_PORT); + let resource_limits = default_android_resource_limits(); + println!("noVNC: {}", no_vnc.url); + println!( + "docker args: {}", + docker_run_args(&spec, Some(&no_vnc), &resource_limits).join(" ") + ); } diff --git a/src/lib.rs b/src/lib.rs index 083f292..5426a4e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,14 @@ pub const SERVER_NAME: &str = "android-connection"; pub const DEFAULT_ANDROID_IMAGE: &str = "budtmo/docker-android:emulator_14.0"; pub const DEFAULT_ADB_ENDPOINT: &str = "android:5555"; pub const DEFAULT_PROJECT_ID: &str = "docker-git"; +pub const DEFAULT_NOVNC_HOST: &str = "127.0.0.1"; +pub const DEFAULT_NOVNC_PORT: u16 = 6080; +pub const DEFAULT_NOVNC_CONTAINER_PORT: u16 = 6081; +pub const DEFAULT_NOVNC_WEB_PORT: u16 = 6080; +pub const DEFAULT_ANDROID_MEMORY_LIMIT: &str = "3g"; +pub const DEFAULT_ANDROID_MEMORY_SWAP_LIMIT: &str = "3g"; +pub const DEFAULT_ANDROID_CPUS: &str = "1.0"; +pub const NOVNC_DOCKER_BRIDGE_COMMAND: &str = "while true; do container_ip=$(hostname -i | awk '{print $1}'); /usr/bin/socat TCP-LISTEN:6081,bind=${container_ip},fork,reuseaddr TCP:127.0.0.1:6080; sleep 1; done & exec ${APP_PATH}/mixins/scripts/run.sh"; #[derive(Clone, Debug, Eq, PartialEq)] pub struct EndpointError { @@ -35,6 +43,24 @@ pub struct AndroidSpec { pub image: String, } +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NoVncEndpoint { + pub bind_host: String, + pub url_host: String, + pub host_port: u16, + pub container_port: u16, + pub url: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AndroidResourceLimits { + pub memory: String, + pub memory_swap: String, + pub cpus: String, +} + #[derive(Clone, Debug, Eq, PartialEq, Serialize)] pub struct McpToolSpec { pub name: &'static str, @@ -187,23 +213,124 @@ pub fn android_tools() -> Vec { } #[must_use] -pub fn docker_run_args(spec: &AndroidSpec) -> Vec { - vec![ +pub fn no_vnc_url_host_for_bind_host(bind_host: &str) -> String { + let trimmed = bind_host + .trim() + .trim_start_matches('[') + .trim_end_matches(']'); + let visible_host = match trimmed { + "" | "0.0.0.0" | "::" => DEFAULT_NOVNC_HOST, + host => host, + }; + + if visible_host.contains(':') && !visible_host.starts_with('[') { + format!("[{visible_host}]") + } else { + visible_host.to_string() + } +} + +#[must_use] +pub fn no_vnc_endpoint(bind_host: &str, url_host: &str, host_port: u16) -> NoVncEndpoint { + let url_host = no_vnc_url_host_for_bind_host(url_host); + NoVncEndpoint { + bind_host: bind_host.to_string(), + url_host: url_host.clone(), + host_port, + container_port: DEFAULT_NOVNC_CONTAINER_PORT, + url: format!("http://{url_host}:{host_port}/?autoconnect=true&resize=remote"), + } +} + +#[must_use] +pub fn parse_docker_no_vnc_port(output: &str, default_url_host: &str) -> Option { + output + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .find_map(|line| { + let (raw_host, raw_port) = line.rsplit_once(':')?; + let host_port = raw_port.parse::().ok()?; + if host_port == 0 { + return None; + } + + let bind_host = raw_host.trim_start_matches('[').trim_end_matches(']'); + let url_host = match bind_host { + "" | "0.0.0.0" | "::" => default_url_host, + host => host, + }; + Some(no_vnc_endpoint(bind_host, url_host, host_port)) + }) +} + +#[must_use] +pub fn android_resource_limits( + memory: &str, + memory_swap: &str, + cpus: &str, +) -> AndroidResourceLimits { + AndroidResourceLimits { + memory: memory.to_string(), + memory_swap: memory_swap.to_string(), + cpus: cpus.to_string(), + } +} + +#[must_use] +pub fn default_android_resource_limits() -> AndroidResourceLimits { + android_resource_limits( + DEFAULT_ANDROID_MEMORY_LIMIT, + DEFAULT_ANDROID_MEMORY_SWAP_LIMIT, + DEFAULT_ANDROID_CPUS, + ) +} + +#[must_use] +pub fn docker_run_args( + spec: &AndroidSpec, + no_vnc: Option<&NoVncEndpoint>, + resource_limits: &AndroidResourceLimits, +) -> Vec { + let mut args = vec![ "run".to_string(), "--detach".to_string(), "--name".to_string(), spec.android_container_name.clone(), + "--memory".to_string(), + resource_limits.memory.clone(), + "--memory-swap".to_string(), + resource_limits.memory_swap.clone(), + "--cpus".to_string(), + resource_limits.cpus.clone(), "--privileged".to_string(), "--network".to_string(), spec.docker_network.clone(), "--env".to_string(), - "EMULATOR_HEADLESS=true".to_string(), + "EMULATOR_HEADLESS=false".to_string(), "--env".to_string(), "WEB_VNC=true".to_string(), + "--env".to_string(), + format!("WEB_VNC_PORT={DEFAULT_NOVNC_WEB_PORT}"), "--volume".to_string(), format!("{}:/root/.android", spec.android_volume_name), - spec.image.clone(), - ] + ]; + + if let Some(no_vnc) = no_vnc { + args.extend([ + "--publish".to_string(), + format!( + "{}:{}:{}", + no_vnc.bind_host, no_vnc.host_port, no_vnc.container_port + ), + ]); + } + + args.push(spec.image.clone()); + if no_vnc.is_some() { + args.push(NOVNC_DOCKER_BRIDGE_COMMAND.to_string()); + } + args } #[must_use] @@ -257,4 +384,91 @@ mod tests { assert!(names.contains(&"android_tap")); assert!(names.contains(&"android_install_apk")); } + + #[test] + fn builds_no_vnc_endpoint_for_host_browser_access() { + let endpoint = no_vnc_endpoint("127.0.0.1", "127.0.0.1", 16_080); + + assert_eq!(endpoint.bind_host, "127.0.0.1"); + assert_eq!(endpoint.url_host, "127.0.0.1"); + assert_eq!(endpoint.host_port, 16_080); + assert_eq!(endpoint.container_port, DEFAULT_NOVNC_CONTAINER_PORT); + assert_eq!( + endpoint.url, + "http://127.0.0.1:16080/?autoconnect=true&resize=remote" + ); + } + + #[test] + fn renders_wildcard_no_vnc_binding_as_loopback_url() { + let endpoint = no_vnc_endpoint("0.0.0.0", "0.0.0.0", DEFAULT_NOVNC_PORT); + + assert_eq!(endpoint.bind_host, "0.0.0.0"); + assert_eq!(endpoint.url_host, DEFAULT_NOVNC_HOST); + assert_eq!( + endpoint.url, + "http://127.0.0.1:6080/?autoconnect=true&resize=remote" + ); + } + + #[test] + fn parses_docker_port_output_into_no_vnc_endpoint() { + let endpoint = parse_docker_no_vnc_port("0.0.0.0:16080\n[::]:16080\n", DEFAULT_NOVNC_HOST) + .expect("published noVNC port"); + + assert_eq!(endpoint.bind_host, "0.0.0.0"); + assert_eq!(endpoint.url_host, DEFAULT_NOVNC_HOST); + assert_eq!(endpoint.host_port, 16_080); + } + + #[test] + fn docker_run_args_publish_no_vnc_before_image() { + let spec = android_spec( + "dg-test", + "docker-git-shared", + "dg-test-android:5555", + DEFAULT_ANDROID_IMAGE, + ) + .expect("valid spec"); + let endpoint = no_vnc_endpoint("127.0.0.1", "127.0.0.1", DEFAULT_NOVNC_PORT); + + let resource_limits = default_android_resource_limits(); + let args = docker_run_args(&spec, Some(&endpoint), &resource_limits); + let publish_position = args + .iter() + .position(|value| value == "--publish") + .expect("publish flag"); + let image_position = args + .iter() + .position(|value| value == DEFAULT_ANDROID_IMAGE) + .expect("image argument"); + + assert!(publish_position < image_position); + assert_eq!( + args.get(image_position + 1), + Some(&NOVNC_DOCKER_BRIDGE_COMMAND.to_string()) + ); + assert_eq!( + args.get(publish_position + 1), + Some(&"127.0.0.1:6080:6081".to_string()) + ); + assert!(args + .windows(2) + .any(|window| window == ["--memory", DEFAULT_ANDROID_MEMORY_LIMIT])); + assert!(args + .windows(2) + .any(|window| window == ["--memory-swap", DEFAULT_ANDROID_MEMORY_SWAP_LIMIT])); + assert!(args + .windows(2) + .any(|window| window == ["--cpus", DEFAULT_ANDROID_CPUS])); + assert!(args + .windows(2) + .any(|window| window == ["--env", "EMULATOR_HEADLESS=false"])); + assert!(args + .windows(2) + .any(|window| window == ["--env", "WEB_VNC=true"])); + assert!(args + .windows(2) + .any(|window| window == ["--env", "WEB_VNC_PORT=6080"])); + } } diff --git a/src/main.rs b/src/main.rs index 493a558..75c8933 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,10 +1,20 @@ use clap::{Args, Parser, Subcommand}; use docker_git_android_connection::{ - android_spec, docker_run_args, docker_stop_args, DEFAULT_ADB_ENDPOINT, DEFAULT_ANDROID_IMAGE, - DEFAULT_PROJECT_ID, + android_resource_limits, android_spec, docker_run_args, docker_stop_args, no_vnc_endpoint, + parse_docker_no_vnc_port, AndroidResourceLimits, NoVncEndpoint, DEFAULT_ADB_ENDPOINT, + DEFAULT_ANDROID_CPUS, DEFAULT_ANDROID_IMAGE, DEFAULT_ANDROID_MEMORY_LIMIT, + DEFAULT_ANDROID_MEMORY_SWAP_LIMIT, DEFAULT_NOVNC_CONTAINER_PORT, DEFAULT_NOVNC_HOST, + DEFAULT_NOVNC_PORT, DEFAULT_PROJECT_ID, }; -use serde_json::json; -use std::process::{Command, ExitCode}; +use serde_json::{json, Value}; +use std::env; +use std::io::{self, Read, Write}; +use std::net::TcpListener; +use std::process::{Command, ExitCode, Output, Stdio}; +use std::thread; + +const DOCKER_BIN_ENV: &str = "DOCKER_GIT_ANDROID_DOCKER"; +const NO_VNC_PORT_FALLBACK_SPAN: u16 = 100; #[derive(Parser, Debug)] #[command(version, about = "docker-git Android runtime lifecycle CLI")] @@ -30,6 +40,24 @@ struct LifecycleArgs { endpoint: String, #[arg(long, default_value = DEFAULT_ANDROID_IMAGE)] image: String, + #[arg(long = "memory", default_value = DEFAULT_ANDROID_MEMORY_LIMIT, value_parser = parse_docker_size)] + memory: String, + #[arg(long = "memory-swap", default_value = DEFAULT_ANDROID_MEMORY_SWAP_LIMIT, value_parser = parse_docker_size)] + memory_swap: String, + #[arg(long = "cpus", default_value = DEFAULT_ANDROID_CPUS, value_parser = parse_cpus)] + cpus: String, + #[arg(long = "novnc-bind-host", default_value = DEFAULT_NOVNC_HOST)] + novnc_bind_host: String, + #[arg(long = "novnc-host", default_value = DEFAULT_NOVNC_HOST)] + novnc_host: String, + #[arg( + long = "novnc-port", + default_value_t = DEFAULT_NOVNC_PORT, + value_parser = parse_no_vnc_port + )] + novnc_port: u16, + #[arg(long)] + no_novnc_publish: bool, #[arg(long)] dry_run: bool, } @@ -55,50 +83,293 @@ fn run() -> Result<(), Box> { fn start(args: &LifecycleArgs) -> Result<(), Box> { let spec = android_spec(&args.project, &args.network, &args.endpoint, &args.image)?; - let docker_args = docker_run_args(&spec); + let no_vnc = configured_no_vnc(args, !args.dry_run)?; + let resource_limits = configured_resource_limits(args); + let docker_args = docker_run_args(&spec, no_vnc.as_ref(), &resource_limits); if args.dry_run { - println!( - "{}", - serde_json::to_string_pretty(&json!({ "docker": docker_args }))? - ); + print_json(&with_docker_args( + lifecycle_output(&spec, no_vnc.as_ref(), &resource_limits, None, None), + &docker_args, + ))?; return Ok(()); } - run_docker(&docker_args) + ensure_image_available(&spec.image)?; + let container_id = run_docker_capture_stdout(&docker_args)?; + print_json(&lifecycle_output( + &spec, + no_vnc.as_ref(), + &resource_limits, + Some(container_id.trim()), + None, + )) } fn status(args: &LifecycleArgs) -> Result<(), Box> { let spec = android_spec(&args.project, &args.network, &args.endpoint, &args.image)?; - println!("{}", serde_json::to_string_pretty(&spec)?); - Ok(()) + let resource_limits = configured_resource_limits(args); + let no_vnc = if args.no_novnc_publish { + None + } else { + docker_no_vnc_endpoint(&spec.android_container_name, &args.novnc_host) + .or(configured_no_vnc(args, false)?) + }; + print_json(&lifecycle_output( + &spec, + no_vnc.as_ref(), + &resource_limits, + None, + None, + )) } fn stop(args: &LifecycleArgs) -> Result<(), Box> { let spec = android_spec(&args.project, &args.network, &args.endpoint, &args.image)?; + let resource_limits = configured_resource_limits(args); let docker_args = docker_stop_args(&spec); if args.dry_run { - println!( - "{}", - serde_json::to_string_pretty(&json!({ "docker": docker_args }))? - ); + print_json(&with_docker_args( + lifecycle_output(&spec, None, &resource_limits, None, None), + &docker_args, + ))?; + return Ok(()); + } + + run_docker_capture_stdout(&docker_args)?; + print_json(&lifecycle_output( + &spec, + None, + &resource_limits, + None, + Some(true), + )) +} + +fn parse_docker_size(value: &str) -> Result { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err("Docker size must not be empty".to_string()); + } + + let digits = trimmed.chars().take_while(char::is_ascii_digit).count(); + if digits == 0 { + return Err(format!("Docker size {value:?} must start with a number")); + } + + let suffix = &trimmed[digits..]; + if suffix.is_empty() + || matches!( + suffix, + "b" | "B" | "k" | "K" | "m" | "M" | "g" | "G" | "kb" | "KB" | "mb" | "MB" | "gb" | "GB" + ) + { + Ok(trimmed.to_string()) + } else { + Err(format!( + "Docker size {value:?} must use bytes or b/k/m/g suffix" + )) + } +} + +fn parse_cpus(value: &str) -> Result { + let cpus = value + .parse::() + .map_err(|error| format!("invalid CPU limit {value:?}: {error}"))?; + if cpus.is_finite() && cpus > 0.0 { + Ok(value.to_string()) + } else { + Err("CPU limit must be a positive finite number".to_string()) + } +} + +fn parse_no_vnc_port(value: &str) -> Result { + let port = value + .parse::() + .map_err(|error| format!("invalid noVNC port {value:?}: {error}"))?; + if port == 0 { + Err("noVNC port must be in 1..=65535".to_string()) + } else { + Ok(port) + } +} + +fn configured_resource_limits(args: &LifecycleArgs) -> AndroidResourceLimits { + android_resource_limits(&args.memory, &args.memory_swap, &args.cpus) +} + +fn configured_no_vnc( + args: &LifecycleArgs, + reserve_free_port: bool, +) -> Result, Box> { + if args.no_novnc_publish { + return Ok(None); + } + + let host_port = if reserve_free_port { + first_available_port(&args.novnc_bind_host, args.novnc_port)? + } else { + args.novnc_port + }; + Ok(Some(no_vnc_endpoint( + &args.novnc_bind_host, + &args.novnc_host, + host_port, + ))) +} + +fn first_available_port(bind_host: &str, requested_port: u16) -> Result { + let last_port = requested_port.saturating_add(NO_VNC_PORT_FALLBACK_SPAN - 1); + for port in requested_port..=last_port { + if TcpListener::bind((bind_host, port)).is_ok() { + return Ok(port); + } + } + + Err(format!( + "no free noVNC port on {bind_host} in range {requested_port}..={last_port}" + )) +} + +fn docker_no_vnc_endpoint(container_name: &str, default_url_host: &str) -> Option { + let container_port = format!("{DEFAULT_NOVNC_CONTAINER_PORT}/tcp"); + let output = Command::new(docker_binary()) + .args(["port", container_name, &container_port]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + + parse_docker_no_vnc_port(&String::from_utf8_lossy(&output.stdout), default_url_host) +} + +fn lifecycle_output( + spec: &docker_git_android_connection::AndroidSpec, + no_vnc: Option<&NoVncEndpoint>, + resource_limits: &AndroidResourceLimits, + container_id: Option<&str>, + removed: Option, +) -> Value { + json!({ + "projectId": spec.project_id, + "projectContainerName": spec.project_container_name, + "androidContainerName": spec.android_container_name, + "androidVolumeName": spec.android_volume_name, + "dockerNetwork": spec.docker_network, + "adbEndpoint": spec.adb_endpoint, + "image": spec.image, + "resourceLimits": resource_limits, + "noVncPublished": no_vnc.is_some(), + "noVncUrl": no_vnc.map(|endpoint| endpoint.url.as_str()), + "noVncBindHost": no_vnc.map(|endpoint| endpoint.bind_host.as_str()), + "noVncHost": no_vnc.map(|endpoint| endpoint.url_host.as_str()), + "noVncPort": no_vnc.map(|endpoint| endpoint.host_port), + "noVncContainerPort": no_vnc.map(|endpoint| endpoint.container_port), + "containerId": container_id, + "removed": removed + }) +} + +fn with_docker_args(mut output: Value, docker_args: &[String]) -> Value { + if let Value::Object(object) = &mut output { + object.insert("docker".to_string(), json!(docker_args)); + } + output +} + +fn print_json(value: &Value) -> Result<(), Box> { + println!("{}", serde_json::to_string_pretty(&value)?); + Ok(()) +} + +fn ensure_image_available(image: &str) -> Result<(), Box> { + let inspect_args = vec![ + "image".to_string(), + "inspect".to_string(), + image.to_string(), + ]; + if run_docker_status(&inspect_args).is_ok() { return Ok(()); } - run_docker(&docker_args) + let pull_args = vec!["pull".to_string(), image.to_string()]; + run_docker_status_streaming_to_stderr(&pull_args) } -fn run_docker(args: &[String]) -> Result<(), Box> { - let output = Command::new("docker").args(args).output()?; +fn run_docker_status(args: &[String]) -> Result<(), Box> { + let output = Command::new(docker_binary()).args(args).output()?; if output.status.success() { - print!("{}", String::from_utf8_lossy(&output.stdout)); return Ok(()); } - Err(format!( - "docker failed with status {:?}\nstdout:\n{}\nstderr:\n{}", + Err(docker_error(args, &output).into()) +} + +fn run_docker_capture_stdout(args: &[String]) -> Result> { + let output = Command::new(docker_binary()).args(args).output()?; + if output.status.success() { + return Ok(String::from_utf8_lossy(&output.stdout).to_string()); + } + + Err(docker_error(args, &output).into()) +} + +fn run_docker_status_streaming_to_stderr( + args: &[String], +) -> Result<(), Box> { + let mut child = Command::new(docker_binary()) + .args(args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; + let stdout = child.stdout.take().map(copy_to_stderr); + let stderr = child.stderr.take().map(copy_to_stderr); + let status = child.wait()?; + + if let Some(handle) = stdout { + handle + .join() + .map_err(|_| "failed to join docker stdout")??; + } + if let Some(handle) = stderr { + handle + .join() + .map_err(|_| "failed to join docker stderr")??; + } + + if status.success() { + Ok(()) + } else { + Err(format!( + "docker {} failed with status {:?}", + args.join(" "), + status.code() + ) + .into()) + } +} + +fn copy_to_stderr(mut reader: R) -> thread::JoinHandle> +where + R: Read + Send + 'static, +{ + thread::spawn(move || { + let mut stderr = io::stderr().lock(); + io::copy(&mut reader, &mut stderr)?; + stderr.flush() + }) +} + +fn docker_error(args: &[String], output: &Output) -> String { + format!( + "docker {} failed with status {:?}\nstdout:\n{}\nstderr:\n{}", + args.join(" "), output.status.code(), String::from_utf8_lossy(&output.stdout).trim(), String::from_utf8_lossy(&output.stderr).trim() ) - .into()) +} + +fn docker_binary() -> String { + env::var(DOCKER_BIN_ENV).unwrap_or_else(|_| "docker".to_string()) } diff --git a/tests/integration/cli.rs b/tests/integration/cli.rs index 2b98e92..5727bb0 100644 --- a/tests/integration/cli.rs +++ b/tests/integration/cli.rs @@ -1,30 +1,169 @@ +use serde_json::Value; use std::process::Command; #[test] fn lifecycle_cli_renders_status_json() { - let output = Command::new(env!("CARGO_BIN_EXE_docker-git-android-connection")) - .args([ - "status", - "--project", - "dg-test", - "--endpoint", - "dg-test-android:5555", - ]) - .output() - .expect("failed to execute lifecycle binary"); + let output = lifecycle_output(&[ + "status", + "--project", + "dg-test", + "--endpoint", + "dg-test-android:5555", + "--novnc-port", + "16080", + ]); assert!(output.status.success()); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("\"project_id\": \"dg-test\"")); - assert!(stdout.contains("\"android_container_name\": \"dg-test-android\"")); + let json = parse_stdout_json(&output); + + assert_eq!(json["projectId"], "dg-test"); + assert_eq!(json["androidContainerName"], "dg-test-android"); + assert_eq!( + json["noVncUrl"], + "http://127.0.0.1:16080/?autoconnect=true&resize=remote" + ); + assert_eq!(json["noVncPublished"], true); + assert_eq!(json["resourceLimits"]["memory"], "3g"); + assert_eq!(json["resourceLimits"]["memorySwap"], "3g"); + assert_eq!(json["resourceLimits"]["cpus"], "1.0"); +} + +#[test] +fn lifecycle_cli_can_disable_no_vnc_publication() { + let output = lifecycle_output(&[ + "status", + "--project", + "dg-test", + "--endpoint", + "dg-test-android:5555", + "--no-novnc-publish", + ]); + + assert!(output.status.success()); + let json = parse_stdout_json(&output); + + assert_eq!(json["projectId"], "dg-test"); + assert_eq!(json["noVncPublished"], false); + assert!(json["noVncUrl"].is_null()); +} + +#[test] +fn start_dry_run_includes_publish_and_no_vnc_url_json() { + let output = lifecycle_output(&[ + "start", + "--project", + "dg-test", + "--endpoint", + "dg-test-android:5555", + "--novnc-port", + "16080", + "--dry-run", + ]); + + assert!(output.status.success()); + let json = parse_stdout_json(&output); + let docker_args = json["docker"] + .as_array() + .expect("docker args array") + .iter() + .filter_map(Value::as_str) + .collect::>(); + let publish_position = docker_args + .iter() + .position(|argument| *argument == "--publish") + .expect("publish flag"); + let image_position = docker_args + .iter() + .position(|argument| *argument == "budtmo/docker-android:emulator_14.0") + .expect("image argument"); + + assert!(publish_position < image_position); + assert!(docker_args + .get(image_position + 1) + .is_some_and(|argument| argument.contains("socat TCP-LISTEN:6081"))); + assert_eq!( + docker_args.get(publish_position + 1), + Some(&"127.0.0.1:16080:6081") + ); + assert!(docker_args + .windows(2) + .any(|window| window == ["--memory", "3g"])); + assert!(docker_args + .windows(2) + .any(|window| window == ["--memory-swap", "3g"])); + assert!(docker_args + .windows(2) + .any(|window| window == ["--cpus", "1.0"])); + assert_eq!( + json["noVncUrl"], + "http://127.0.0.1:16080/?autoconnect=true&resize=remote" + ); + assert_eq!(json["resourceLimits"]["memory"], "3g"); + assert_eq!(json["resourceLimits"]["memorySwap"], "3g"); + assert_eq!(json["resourceLimits"]["cpus"], "1.0"); +} + +#[test] +fn start_dry_run_accepts_custom_resource_limits() { + let output = lifecycle_output(&[ + "start", + "--project", + "dg-test", + "--endpoint", + "dg-test-android:5555", + "--memory", + "4g", + "--memory-swap", + "4g", + "--cpus", + "2.0", + "--dry-run", + ]); + + assert!(output.status.success()); + let json = parse_stdout_json(&output); + let docker_args = json["docker"] + .as_array() + .expect("docker args array") + .iter() + .filter_map(Value::as_str) + .collect::>(); + + assert!(docker_args + .windows(2) + .any(|window| window == ["--memory", "4g"])); + assert!(docker_args + .windows(2) + .any(|window| window == ["--memory-swap", "4g"])); + assert!(docker_args + .windows(2) + .any(|window| window == ["--cpus", "2.0"])); + assert_eq!(json["resourceLimits"]["memory"], "4g"); + assert_eq!(json["resourceLimits"]["memorySwap"], "4g"); + assert_eq!(json["resourceLimits"]["cpus"], "2.0"); +} + +#[test] +fn lifecycle_cli_rejects_invalid_no_vnc_port() { + let output = lifecycle_output(&["status", "--novnc-port", "0"]); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("noVNC port must be in 1..=65535")); +} + +#[test] +fn lifecycle_cli_rejects_invalid_cpu_limit() { + let output = lifecycle_output(&["status", "--cpus", "0"]); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("CPU limit must be a positive finite number")); } #[test] fn lifecycle_cli_rejects_invalid_endpoint() { - let output = Command::new(env!("CARGO_BIN_EXE_docker-git-android-connection")) - .args(["status", "--endpoint", "$(whoami):5555"]) - .output() - .expect("failed to execute lifecycle binary"); + let output = lifecycle_output(&["status", "--endpoint", "$(whoami):5555"]); assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); @@ -43,3 +182,20 @@ fn mcp_cli_exposes_help() { assert!(stdout.contains("Android MCP stdio server")); assert!(stdout.contains("--no-adb-probe")); } + +fn lifecycle_output(args: &[&str]) -> std::process::Output { + Command::new(env!("CARGO_BIN_EXE_docker-git-android-connection")) + .args(args) + .output() + .expect("failed to execute lifecycle binary") +} + +fn parse_stdout_json(output: &std::process::Output) -> Value { + serde_json::from_slice(&output.stdout).unwrap_or_else(|error| { + panic!( + "stdout is not valid JSON: {error}\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ) + }) +} diff --git a/tests/unit/android_connection.rs b/tests/unit/android_connection.rs index 59a4d9a..1ed9c54 100644 --- a/tests/unit/android_connection.rs +++ b/tests/unit/android_connection.rs @@ -1,6 +1,9 @@ use docker_git_android_connection::{ - android_spec, android_tools, normalize_project_id, validate_adb_endpoint, - DEFAULT_ANDROID_IMAGE, DEFAULT_PROJECT_ID, + android_resource_limits, android_spec, android_tools, default_android_resource_limits, + docker_run_args, no_vnc_endpoint, normalize_project_id, parse_docker_no_vnc_port, + validate_adb_endpoint, DEFAULT_ANDROID_CPUS, DEFAULT_ANDROID_IMAGE, + DEFAULT_ANDROID_MEMORY_LIMIT, DEFAULT_ANDROID_MEMORY_SWAP_LIMIT, DEFAULT_NOVNC_HOST, + DEFAULT_NOVNC_PORT, DEFAULT_PROJECT_ID, NOVNC_DOCKER_BRIDGE_COMMAND, }; #[test] @@ -42,3 +45,107 @@ fn advertises_android_mcp_tools() { assert!(names.contains(&"android_tap")); assert!(names.contains(&"android_install_apk")); } + +#[test] +fn docker_run_args_expose_no_vnc_on_host_port() { + let spec = android_spec( + "dg-test", + "docker-git-shared", + "dg-test-android:5555", + DEFAULT_ANDROID_IMAGE, + ) + .expect("valid spec"); + let no_vnc = no_vnc_endpoint(DEFAULT_NOVNC_HOST, DEFAULT_NOVNC_HOST, DEFAULT_NOVNC_PORT); + let resource_limits = default_android_resource_limits(); + + let args = docker_run_args(&spec, Some(&no_vnc), &resource_limits); + + assert!(args + .windows(2) + .any(|window| window == ["--publish", "127.0.0.1:6080:6081"])); + assert_eq!(args.last(), Some(&NOVNC_DOCKER_BRIDGE_COMMAND.to_string())); + assert!(args + .windows(2) + .any(|window| window == ["--memory", DEFAULT_ANDROID_MEMORY_LIMIT])); + assert!(args + .windows(2) + .any(|window| window == ["--memory-swap", DEFAULT_ANDROID_MEMORY_SWAP_LIMIT])); + assert!(args + .windows(2) + .any(|window| window == ["--cpus", DEFAULT_ANDROID_CPUS])); + assert!(args + .windows(2) + .any(|window| window == ["--env", "EMULATOR_HEADLESS=false"])); + assert!(args + .windows(2) + .any(|window| window == ["--env", "WEB_VNC=true"])); + assert!(args + .windows(2) + .any(|window| window == ["--env", "WEB_VNC_PORT=6080"])); +} + +#[test] +fn docker_run_args_can_disable_no_vnc_publication() { + let spec = android_spec( + "dg-test", + "docker-git-shared", + "dg-test-android:5555", + DEFAULT_ANDROID_IMAGE, + ) + .expect("valid spec"); + let resource_limits = default_android_resource_limits(); + + let args = docker_run_args(&spec, None, &resource_limits); + + assert!(!args.iter().any(|argument| argument == "--publish")); + assert_eq!(args.last(), Some(&DEFAULT_ANDROID_IMAGE.to_string())); + assert!(args + .windows(2) + .any(|window| window == ["--env", "WEB_VNC=true"])); +} + +#[test] +fn docker_run_args_allow_custom_resource_limits() { + let spec = android_spec( + "dg-test", + "docker-git-shared", + "dg-test-android:5555", + DEFAULT_ANDROID_IMAGE, + ) + .expect("valid spec"); + let resource_limits = android_resource_limits("4g", "4g", "2.0"); + + let args = docker_run_args(&spec, None, &resource_limits); + + assert!(args.windows(2).any(|window| window == ["--memory", "4g"])); + assert!(args + .windows(2) + .any(|window| window == ["--memory-swap", "4g"])); + assert!(args.windows(2).any(|window| window == ["--cpus", "2.0"])); +} + +#[test] +fn parses_docker_port_for_actual_no_vnc_url() { + let endpoint = + parse_docker_no_vnc_port("127.0.0.1:16080\n", DEFAULT_NOVNC_HOST).expect("port binding"); + + assert_eq!(endpoint.bind_host, "127.0.0.1"); + assert_eq!(endpoint.url_host, "127.0.0.1"); + assert_eq!( + endpoint.url, + "http://127.0.0.1:16080/?autoconnect=true&resize=remote" + ); +} + +#[test] +fn wildcard_docker_port_does_not_leak_into_no_vnc_url() { + let endpoint = + parse_docker_no_vnc_port("0.0.0.0:16080\n", DEFAULT_NOVNC_HOST).expect("port binding"); + + assert_eq!(endpoint.bind_host, "0.0.0.0"); + assert_eq!(endpoint.url_host, DEFAULT_NOVNC_HOST); + assert_eq!( + endpoint.url, + "http://127.0.0.1:16080/?autoconnect=true&resize=remote" + ); +}