diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..6f34b48 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,52 @@ +name: Bug Report +description: Send a bug report +title: "[Bug]: " +labels: ["bug", "triage"] +assignees: + - taorepoara +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this bug report! + - type: textarea + id: what-happened + attributes: + label: What happened? + description: Also tell us, what did you expect to happen? + placeholder: Tell us what you see! + value: "A bug happened!" + validations: + required: true + - type: dropdown + id: browsers + attributes: + label: What browsers are you seeing the problem on? + multiple: true + options: + - Firefox + - Chrome (or chrome-based) + - Safari + - Microsoft Edge + - Other (specify above) + - type: input + id: version + attributes: + label: Version + description: What version of our software are you running? + validations: + required: false + - type: textarea + id: logs + attributes: + label: Relevant log output + description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. + render: Shell + # - type: checkboxes + # id: terms + # attributes: + # label: Code of Conduct + # description: By submitting this issue, you agree to follow our [Code of Conduct](https://example.com) + # options: + # - label: I agree to follow this project's Code of Conduct + # required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..5b0669e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,6 @@ + +blank_issues_enabled: true +contact_links: + - name: Feature request + url: https://github.com/orgs/lenra-io/discussions/categories/ideas?discussions_q=category%3AIdeas+is%3Aunlocked + about: Please send your feature request in the discussion tab ! diff --git a/.github/ISSUE_TEMPLATE/task.md b/.github/ISSUE_TEMPLATE/task.md new file mode 100644 index 0000000..9d60646 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/task.md @@ -0,0 +1,26 @@ +--- +name: Task +about: Create a new task. Technical task should be small enough to be done by only + one person in a reasonable time. +title: "[Task] " +labels: enhancement +assignees: '' + +--- + +## What should be done +Describe the task and scope... + +## Technical recommandation +Any technical recommandation or specific warning about this task (if needed) +- Security issue to be warned of +- Complexity issue + +## Ecological concerns +What can be done to improve the ecological impact of this task ? +- Can we minimize the data sent to the network ? +- Are data stored ? Can we minimize the amount of stored data ? +- Is it CPU-intensive ? Can we reduce the CPU charge ? +- Is it RAM-intensive ? Can we reduce the RAM load ? + +## Is this task linked with any other ? diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..e8d486a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "cargo" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" diff --git a/.github/workflows/build_ci.yml b/.github/workflows/build_ci.yml new file mode 100644 index 0000000..4eaf624 --- /dev/null +++ b/.github/workflows/build_ci.yml @@ -0,0 +1,258 @@ +name: Rust + +on: + push: + branches: + - main + - beta + paths-ignore: + - '*.md' + pull_request: + paths-ignore: + - '*.md' + +env: + CARGO_TERM_COLOR: always + +jobs: + get-next-version: + name: Get next version + runs-on: ubuntu-20.04 + continue-on-error: true # must be allow to fail + timeout-minutes: 2 + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: 16 + + + - id: get-next-version + name: Get next version + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: npx -p conventional-changelog-conventionalcommits@5 -p @semantic-release/git -p semantic-release-export-data -p semantic-release@19 semantic-release --dry-run + + outputs: + new-release-published: ${{ steps.get-next-version.outputs.new-release-published }} + new-release-version: ${{ steps.get-next-version.outputs.new-release-version }} + + style: + name: Check Style + runs-on: ubuntu-20.04 + timeout-minutes: 2 + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Install rust + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + components: rustfmt + profile: minimal + override: true + + - name: cargo fmt -- --check + uses: actions-rs/cargo@v1 + with: + command: fmt + args: --all -- --check + + test: + name: Test + needs: [style] + runs-on: ubuntu-20.04 + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@v1 + + - name: Install rust + uses: actions-rs/toolchain@v1 + with: + toolchain: nightly + profile: minimal + override: true + + - name: Build debug + uses: actions-rs/cargo@v1 + with: + command: build + args: --verbose + + - name: Test + uses: actions-rs/cargo@v1 + with: + command: test + args: --verbose + + build: + name: Build ${{ matrix.os }} ${{ matrix.arch }} + needs: [test, get-next-version] + runs-on: "${{ matrix.runner }}" + env: + VERSION: ${{ needs.get-next-version.outputs.new-release-version }} + timeout-minutes: 20 + strategy: + matrix: # Need to find what's the best target for `x86-x64-linux` and remove the others (gnu or musl) + include: + - target: aarch64-unknown-linux-musl + os: linux + arch: aarch64 + runner: ubuntu-20.04 + - target: x86_64-unknown-linux-musl + os: linux + arch: x86_64 + runner: ubuntu-20.04 + - target: x86_64-pc-windows-msvc + os: windows + arch: x86_64 + runner: windows-2022 + file_extension: '.exe' + # - target: aarch64-pc-windows-msvc + # os: windows + # arch: aarch64 + # runner: windows-2022 + # file_extension: '.exe' + - target: x86_64-apple-darwin + os: macos + arch: x86_64 + runner: macos-11 + - target: aarch64-apple-darwin + os: macos + arch: aarch64 + runner: macos-11 + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Install rust + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + profile: minimal + override: true + target: ${{ matrix.target }} + + - name: Install cargo-edit + uses: actions-rs/cargo@v1 + with: + command: install + args: cargo-edit + + - name: Set version + uses: actions-rs/cargo@v1 + with: + command: set-version + args: ${{ env.VERSION }} + + - name: Build target + uses: actions-rs/cargo@v1 + with: + use-cross: true + command: build + args: --release --target ${{ matrix.target }} + + - name: Zip + if: ${{ matrix.os == 'windows' }} + shell: pwsh + run: Compress-Archive "target/${{ matrix.target }}/release/lenra${{ matrix.file_extension }}" "lenra-${{ matrix.os }}-${{ matrix.arch }}.zip" + - name: Zip + if: ${{ matrix.os != 'windows' }} + shell: bash + run: tar -C "target/${{ matrix.target }}/release" -czf "lenra-${{ matrix.os }}-${{ matrix.arch }}.tar.gz" "lenra${{ matrix.file_extension }}" + + - name: Upload + uses: actions/upload-artifact@v3 + with: + name: lenra-${{ matrix.os }}-${{ matrix.arch }} + path: lenra-${{ matrix.os }}-${{ matrix.arch }}.* + + generate_doc_artifact: + name: Generate doc artifact + runs-on: ubuntu-latest + permissions: + contents: read + packages: read + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Setup Node.js + uses: actions/setup-node@v2 + with: + node-version: "16" + - name: Setup node deps + run: | + cd docs-page-builder/ + npm install + npm run build + - name: Compress tar.gz + run: | + cd docs-page-builder/build/ + tar czvf lenra-cli-docs.tar.gz * + mv lenra-cli-docs.tar.gz ../.. + - name: Compress zip + run: | + cd docs-page-builder/build/ + zip -r lenra-cli-docs.zip * + mv lenra-cli-docs.zip ../.. + - name: Documentation artifact + uses: actions/upload-artifact@v3 + with: + name: lenra-cli-docs + path: | + lenra-cli-docs.tar.gz + lenra-cli-docs.zip + + publish: + name: publish + needs: [build, generate_doc_artifact, get-next-version] + if: github.ref_name == 'main' || github.ref_name == 'beta' || github.ref_name == 'alpha' + runs-on: ubuntu-latest + env: + VERSION: ${{ needs.get-next-version.outputs.new-release-version }} + timeout-minutes: 8 + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: download-artifacts + uses: actions/download-artifact@v3 + with: + path: artifacts/ + + - name: Setup Node.js + uses: actions/setup-node@v2 + with: + node-version: "18" + + - id: release + name: Release + shell: bash + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: npx --force -p conventional-changelog-conventionalcommits@5 -p @semantic-release/git -p semantic-release-export-data -p https://github.com/Tlepel/semantic-release.git#fix-versions-share-head semantic-release + + - name: Install cargo-edit + if: ${{ steps.release.outputs.new-release-published }} + uses: actions-rs/cargo@v1 + with: + command: install + args: cargo-edit + + - name: Set version + if: ${{ steps.release.outputs.new-release-published }} + uses: actions-rs/cargo@v1 + with: + command: set-version + args: ${{ env.VERSION }} + + - name: Publish cargo + shell: bash + if: ${{ steps.release.outputs.new-release-published }} + run: cargo publish --allow-dirty --token "${{ secrets.CARGO_TOKEN }}" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bef4516 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.vscode +/target diff --git a/.releaserc.yml b/.releaserc.yml new file mode 100644 index 0000000..afad749 --- /dev/null +++ b/.releaserc.yml @@ -0,0 +1,19 @@ +--- +branches: + - "+([0-9])?(.{+([0-9]),x}).x" + - main + - name: beta + prerelease: true +plugins: + - - "@semantic-release/commit-analyzer" + - preset: conventionalcommits + - - "@semantic-release/release-notes-generator" + - preset: conventionalcommits + - - "@semantic-release/github" + - assets: + - path: artifacts/*/* + - path: lenra-cli-docs/lenra-cli-docs.zip + name: lenra-cli-docs-${nextRelease.gitTag}.zip + - path: lenra-cli-docs/lenra-cli-docs.tar.gz + name: lenra-cli-docs-${nextRelease.gitTag}.tar.gz + - - "semantic-release-export-data" diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..f4715e7 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1702 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "addr2line" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4fa78e18c64fce05e902adecd7a5eed15a5e0a3439f7b0e169f0252214865e3" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + +[[package]] +name = "aho-corasick" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43f6cb1bf222025340178f382c426f13757b2960e89779dfcb319c32542a5a41" +dependencies = [ + "memchr", +] + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f58811cfac344940f1a400b6e6231ce35171f614f26439e80f8c1465c5cc0c" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b84bf0a05bbb2a83e5eb6fa36bb6e87baa08193c35ff52bbf6b38d8af2890e46" + +[[package]] +name = "anstyle-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "938874ff5980b03a87c5524b3ae5b59cf99b1d6bc836848df7bc5ada9643c333" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca11d4be1bab0c8bc8734a9aa7bf4ee8316d462a08c6ac5052f888fef5b494b" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58f54d10c6dfa51283a066ceab3ec1ab78d13fae00aa49243a45e4571fb79dfd" +dependencies = [ + "anstyle", + "windows-sys", +] + +[[package]] +name = "async-trait" +version = "0.1.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6dde6e4ed435a4c1ee4e73592f5ba9da2151af10076cc04858746af9352d09" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.27", +] + +[[package]] +name = "autocfg" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + +[[package]] +name = "backtrace" +version = "0.3.68" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4319208da049c43661739c5fade2ba182f09d1dc2299b32298d3a31692b17e12" +dependencies = [ + "addr2line", + "cc", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", +] + +[[package]] +name = "base64" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "604178f6c5c21f02dc555784810edfb88d34ac2c73b2eae109655649ee73ce3d" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "630be753d4e58660abd17930c71b647fe46c27ea6b63cc59e1e3851406972e42" + +[[package]] +name = "bumpalo" +version = "3.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e2c3daef883ecc1b5d58c15adae93470a91d425f3532ba1695849656af3fc1" + +[[package]] +name = "bytes" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89b2fd2a0dcf38d7971e2194b6b6eebab45ae01067456a7fd93d5547a61b70be" + +[[package]] +name = "cc" +version = "1.0.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50d30906286121d95be3d479533b458f87493b30a4b5f79a607db8f5d11aa91f" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chrono" +version = "0.4.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec837a71355b28f6556dbd569b37b3f363091c0bd4b2e735674521b4c5fd9bc5" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "time", + "wasm-bindgen", + "winapi", +] + +[[package]] +name = "clap" +version = "4.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d7b8d5ec32af0fadc644bf1fd509a688c2103b185644bb1e29d164e0703136" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5179bb514e4d7c2051749d8fcefa2ed6d06a9f4e6d69faf3805f5d80b8cf8d56" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0862016ff20d69b84ef8247369fabf5c008a7417002411897d40ee1f4532b873" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.27", +] + +[[package]] +name = "clap_lex" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd7cc57abe963c6d3b9d8be5b06ba7c8957a930305ca90304f24ef040aa6f961" + +[[package]] +name = "clipboard-win" +version = "4.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7191c27c2357d9b7ef96baac1773290d4ca63b24205b82a3fd8a0637afcf0362" +dependencies = [ + "error-code", + "str-buf", + "winapi", +] + +[[package]] +name = "colorchoice" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" + +[[package]] +name = "colored" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2674ec482fbc38012cf31e6c42ba0177b431a0cb6f15fe40efa5aab1bda516f6" +dependencies = [ + "is-terminal", + "lazy_static", + "windows-sys", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa" + +[[package]] +name = "crc32fast" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b540bd8bc810d3885c6ea91e2018302f68baba2129ab3e88f32389ee9370880d" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "darling" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b750cb3417fd1b327431a470f388520309479ab0bf5e323505daf0290cd3850" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "109c1ca6e6b7f82cc233a97004ea8ed7ca123a9af07a8230878fcfda9b158bf0" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 1.0.109", +] + +[[package]] +name = "darling_macro" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4aab4dbc9f7611d8b55048a3a16d2d010c2c8334e46304b40ac1cc14bf3b48e" +dependencies = [ + "darling_core", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive_builder" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d67778784b508018359cbc8696edb3db78160bab2c2a28ba7f56ef6932997f8" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c11bdc11a0c47bc7d37d582b5285da6849c96681023680b906673c5707af7b0f" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive_builder_macro" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebcda35c7a396850a55ffeac740804b40ffec779b98fffbb1738f4033f0ee79e" +dependencies = [ + "derive_builder_core", + "syn 1.0.109", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys", +] + +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + +[[package]] +name = "docker-compose-types" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee50caf01e7a34feba906df016260b8850ccc7f5b3cf3a7e7841349af071e8b" +dependencies = [ + "derive_builder", + "indexmap 1.9.3", + "serde", + "serde_yaml", +] + +[[package]] +name = "dofigen" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77b39d241f5d272b78696bfc0fa06b94d92fcb0cb8b6cfd316f412fe8dd6cf72" +dependencies = [ + "serde", + "serde_json", + "serde_yaml", + "thiserror", +] + +[[package]] +name = "either" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07" + +[[package]] +name = "endian-type" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" + +[[package]] +name = "env_logger" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85cdab6a89accf66733ad5a1693a4dcced6aeff64602b634530dd73c1f3ee9f0" +dependencies = [ + "humantime", + "is-terminal", + "log", + "regex", + "termcolor", +] + +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + +[[package]] +name = "errno" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bcfec3a70f97c962c307b2d2c56e358cf1d00b558d74262b5f929ee8cc7e73a" +dependencies = [ + "errno-dragonfly", + "libc", + "windows-sys", +] + +[[package]] +name = "errno-dragonfly" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "error-code" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64f18991e7bf11e7ffee451b5318b5c1a73c52d0d0ada6e5a3017c8c1ced6a21" +dependencies = [ + "libc", + "str-buf", +] + +[[package]] +name = "fd-lock" +version = "3.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef033ed5e9bad94e55838ca0ca906db0e043f517adda0c8b79c7a8c66c93c1b5" +dependencies = [ + "cfg-if", + "rustix", + "windows-sys", +] + +[[package]] +name = "flate2" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b9429470923de8e8cbd4d2dc513535400b4b3fef0319fb5c4e1f520a7bef743" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a62bc1cf6f830c2ec14a513a9fb124d0a213a629668a4186f329db21fe045652" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23342abe12aba583913b2e62f22225ff9c950774065e4bfb61a19cd9770fec40" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "955518d47e09b25bbebc7a18df10b81f0c766eaf4c4f1cccef2fca5f2a4fb5f2" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bca583b7e26f571124fe5b7561d49cb2868d79116cfa0eefce955557c6fee8c" + +[[package]] +name = "futures-executor" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccecee823288125bd88b4d7f565c9e58e41858e47ab72e8ea2d64e93624386e0" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fff74096e71ed47f8e023204cfd0aa1289cd54ae5430a9523be060cdb849964" + +[[package]] +name = "futures-macro" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89ca545a94061b6365f2c7355b4b32bd20df3ff95f02da9329b34ccc3bd6ee72" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.27", +] + +[[package]] +name = "futures-sink" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f43be4fe21a13b9781a69afa4985b0f6ee0e1afab2c6f454a8cf30e2b2237b6e" + +[[package]] +name = "futures-task" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76d3d132be6c0e6aa1534069c705a74a5997a356c0dc2f86a47765e5617c5b65" + +[[package]] +name = "futures-timer" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e64b03909df88034c26dc1547e8970b91f98bdb65165d6a4e9110d94263dbb2c" + +[[package]] +name = "futures-util" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b01e40b772d54cf6c6d721c1d1abd0647a0106a12ecaa1c186273392a69533" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4136b2a15dd319360be1c07d9933517ccf0be8f16bf62a3bee4f0d618df427" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", +] + +[[package]] +name = "gimli" +version = "0.27.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c80984affa11d98d1b88b66ac8853f143217b399d3c74116778ff8fdb4ed2e" + +[[package]] +name = "glob" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c6201b9ff9fd90a5a3bac2e56a830d0caa509576f0e503818ee82c181b3437a" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "hermit-abi" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "443144c8cdadd93ebf52ddb4056d257f5b52c04d3c804e657d19eb73fc33668b" + +[[package]] +name = "humantime" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" + +[[package]] +name = "iana-time-zone" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fad5b825842d2b38bd206f3e81d6957625fd7f0a361e345c30e01a0ae2dd613" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5477fe2230a79769d8dc68e0eabf5437907c0457a5614a9e8dddb67f65eb65d" +dependencies = [ + "equivalent", + "hashbrown 0.14.0", +] + +[[package]] +name = "is-terminal" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb0889898416213fab133e1d33a0e5858a48177452750691bde3666d0fdbaf8b" +dependencies = [ + "hermit-abi", + "rustix", + "windows-sys", +] + +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af150ab688ff2122fcef229be89cb50dd66af9e01a4ff320cc137eecc9bacc38" + +[[package]] +name = "js-sys" +version = "0.3.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5f195fe497f702db0f318b07fdd68edb16955aed830df8363d837542f8f935a" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" + +[[package]] +name = "lenra_cli" +version = "0.0.0" +dependencies = [ + "async-trait", + "chrono", + "clap", + "colored", + "dirs", + "docker-compose-types", + "dofigen", + "env_logger", + "itertools", + "lazy_static", + "loading", + "log", + "mocktopus", + "regex", + "rstest", + "rustyline", + "serde", + "serde_json", + "serde_yaml", + "strum", + "thiserror", + "tokio", + "ureq", +] + +[[package]] +name = "libc" +version = "0.2.147" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3" + +[[package]] +name = "linux-raw-sys" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09fc20d2ca12cb9f044c93e3bd6d32d523e6e2ec3db4f7b2939cd99026ecd3f0" + +[[package]] +name = "loading" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "676420b03272176423578148eee61319e893422694f6d96a394841cb0901357f" + +[[package]] +name = "lock_api" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1cc9717a20b1bb222f333e6a92fd32f7d8a18ddc5a3191a11af45dcbf4dcd16" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b06a4cde4c0f271a446782e3eff8de789548ce57dbc8eca9292c27f4a42004b4" + +[[package]] +name = "memchr" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" + +[[package]] +name = "miniz_oxide" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7810e0be55b428ada41041c41f32c9f1a42817901b4ccf45fa3d4b6561e74c7" +dependencies = [ + "adler", +] + +[[package]] +name = "mio" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "927a765cd3fc26206e66b296465fa9d3e5ab003e651c1b3c060e7956d96b19d2" +dependencies = [ + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys", +] + +[[package]] +name = "mocktopus" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4f0d5a1621fea252541cf67533c4b9c32ee892d790768f4ad48f1063059537" +dependencies = [ + "mocktopus_macros", +] + +[[package]] +name = "mocktopus_macros" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3048ef3680533a27f9f8e7d6a0bce44dc61e4895ea0f42709337fa1c8616fefe" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "nibble_vec" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" +dependencies = [ + "smallvec", +] + +[[package]] +name = "nix" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f346ff70e7dbfd675fe90590b92d59ef2de15a8779ae305ebcbfd3f0caf59be4" +dependencies = [ + "autocfg", + "bitflags 1.3.2", + "cfg-if", + "libc", +] + +[[package]] +name = "num-traits" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f30b0abd723be7e2ffca1272140fac1a2f084c77ec3e123c192b66af1ee9e6c2" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "object" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bda667d9f2b5051b8833f59f3bf748b28ef54f850f4fcb389a252aa383866d1" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "parking_lot" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93f00c865fe7cabf650081affecd3871070f26767e7b2070a3ffae14c654b447" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.3.5", + "smallvec", + "windows-targets", +] + +[[package]] +name = "percent-encoding" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b2a4787296e9989611394c33f193f676704af1686e70b8f8033ab5ba9a35a94" + +[[package]] +name = "pin-project-lite" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c40d25201921e5ff0c862a505c6557ea88568a4e3ace775ab55e93f2f4f9d57" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "proc-macro2" +version = "1.0.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18fb31db3f9bddb2ea821cde30a9f70117e3f119938b5ee630b7403aa6e2ead9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50f3b39ccfb720540debaa0164757101c08ecb8d326b15358ce76a62c7e85965" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "radix_trie" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" +dependencies = [ + "endian-type", + "nibble_vec", +] + +[[package]] +name = "redox_syscall" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567664f262709473930a4bf9e51bf2ebf3348f2e748ccc50dea20646858f8f29" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_users" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b033d837a7cf162d7993aded9304e30a83213c648b6e389db233191f891e5c2b" +dependencies = [ + "getrandom", + "redox_syscall 0.2.16", + "thiserror", +] + +[[package]] +name = "regex" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2eae68fc220f7cf2532e4494aded17545fce192d59cd996e0fe7887f4ceb575" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39354c10dd07468c2e73926b23bb9c2caca74c5501e38a35da70406f1d923310" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5ea92a5b6195c6ef2a0295ea818b312502c6fc94dde986c5553242e18fd4ce2" + +[[package]] +name = "relative-path" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c707298afce11da2efef2f600116fa93ffa7a032b5d7b628aa17711ec81383ca" + +[[package]] +name = "ring" +version = "0.16.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" +dependencies = [ + "cc", + "libc", + "once_cell", + "spin", + "untrusted", + "web-sys", + "winapi", +] + +[[package]] +name = "rstest" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97eeab2f3c0a199bc4be135c36c924b6590b88c377d416494288c14f2db30199" +dependencies = [ + "futures", + "futures-timer", + "rstest_macros", + "rustc_version", +] + +[[package]] +name = "rstest_macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d428f8247852f894ee1be110b375111b586d4fa431f6c46e64ba5a0dcccbe605" +dependencies = [ + "cfg-if", + "glob", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version", + "syn 2.0.27", + "unicode-ident", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" + +[[package]] +name = "rustc_version" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa0f585226d2e68097d4f95d113b15b83a82e819ab25717ec0590d9584ef366" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a962918ea88d644592894bc6dc55acc6c0956488adcebbfb6e273506b7fd6e5" +dependencies = [ + "bitflags 2.3.3", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustls" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79ea77c539259495ce8ca47f53e66ae0330a8819f67e23ac96ca02f50e7b7d36" +dependencies = [ + "log", + "ring", + "rustls-webpki 0.101.1", + "sct", +] + +[[package]] +name = "rustls-webpki" +version = "0.100.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6207cd5ed3d8dca7816f8f3725513a34609c0c765bf652b8c3cb4cfd87db46b" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "rustls-webpki" +version = "0.101.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15f36a6828982f422756984e47912a7a51dcbc2a197aa791158f8ca61cd8204e" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ffc183a10b4478d04cbbbfc96d0873219d962dd5accaff2ffbd4ceb7df837f4" + +[[package]] +name = "rustyline" +version = "10.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1e83c32c3f3c33b08496e0d1df9ea8c64d39adb8eb36a1ebb1440c690697aef" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "clipboard-win", + "dirs-next", + "fd-lock", + "libc", + "log", + "memchr", + "nix", + "radix_trie", + "scopeguard", + "unicode-segmentation", + "unicode-width", + "utf8parse", + "winapi", +] + +[[package]] +name = "ryu" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad4cc8da4ef723ed60bced201181d83791ad433213d8c24efffda1eec85d741" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sct" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d53dcdb7c9f8158937a7981b48accfd39a43af418591a5d008c7b22b5e1b7ca4" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "semver" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0293b4b29daaf487284529cc2f5675b8e57c61f70167ba415a463651fd6a918" + +[[package]] +name = "serde" +version = "1.0.175" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d25439cd7397d044e2748a6fe2432b5e85db703d6d097bd014b3c0ad1ebff0b" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.175" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b23f7ade6f110613c0d63858ddb8b94c1041f550eab58a16b371bdf2c9c80ab4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.27", +] + +[[package]] +name = "serde_json" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d03b412469450d4404fe8499a268edd7f8b79fecb074b0d812ad64ca21f4031b" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a49e178e4452f45cb61d0cd8cebc1b0fafd3e41929e996cef79aa3aca91f574" +dependencies = [ + "indexmap 2.0.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1" +dependencies = [ + "libc", +] + +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + +[[package]] +name = "smallvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb4feee49fdd9f707ef802e22365a35de4b7b299de4763d44bfea899442ff9" + +[[package]] +name = "socket2" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64a4a911eed85daf18834cfaa86a79b7d266ff93ff5ba14005426219480ed662" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "spin" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" + +[[package]] +name = "str-buf" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e08d8363704e6c71fc928674353e6b7c23dcea9d82d7012c8faf2a3a025f8d0" + +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + +[[package]] +name = "strum" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6069ca09d878a33f883cc06aaa9718ede171841d3832450354410b718b097232" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.27", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b60f673f44a8255b9c8c657daf66a596d435f2da81a555b06dc644d080ba45e0" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "termcolor" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be55cf8942feac5c765c2c993422806843c9a9a45d4d5c407ad6dd2ea95eb9b6" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "611040a08a0439f8248d1990b111c95baa9c704c805fa1f62104b39655fd7f90" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "090198534930841fab3a5d1bb637cde49e339654e606195f8d9c76eeb081dc96" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.27", +] + +[[package]] +name = "time" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b797afad3f312d1c66a56d11d0316f916356d11bd158fbc6ca6389ff6bf805a" +dependencies = [ + "libc", + "wasi 0.10.0+wasi-snapshot-preview1", + "winapi", +] + +[[package]] +name = "tinyvec" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "532826ff75199d5833b9d2c5fe410f29235e25704ee5f0ef599fb51c21f4a4da" +dependencies = [ + "autocfg", + "backtrace", + "bytes", + "libc", + "mio", + "num_cpus", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "630bdcf245f78637c13ec01ffae6187cca34625e8c63150d424b59e55af2675e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.27", +] + +[[package]] +name = "unicode-bidi" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92888ba5573ff080736b3648696b70cafad7d250551175acbaa4e0385b3e1460" + +[[package]] +name = "unicode-ident" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "301abaae475aa91687eb82514b328ab47a211a533026cb25fc3e519b86adfc3c" + +[[package]] +name = "unicode-normalization" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36" + +[[package]] +name = "unicode-width" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28467d3e1d3c6586d8f25fa243f544f5800fec42d97032474e17222c2b75cfa" + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "ureq" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b11c96ac7ee530603dcdf68ed1557050f374ce55a5a07193ebf8cbc9f8927e9" +dependencies = [ + "base64", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-webpki 0.100.1", + "serde", + "serde_json", + "url", + "webpki-roots", +] + +[[package]] +name = "url" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50bff7831e19200a85b17131d085c25d7811bc4e186efdaf54bbd132994a88cb" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", +] + +[[package]] +name = "utf8parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" + +[[package]] +name = "wasi" +version = "0.10.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasm-bindgen" +version = "0.2.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7706a72ab36d8cb1f80ffbf0e071533974a60d0a308d01a5d0375bf60499a342" +dependencies = [ + "cfg-if", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ef2b6d3c510e9625e5fe6f509ab07d66a760f0885d858736483c32ed7809abd" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.27", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dee495e55982a3bd48105a7b947fd2a9b4a8ae3010041b9e0faab3f9cd028f1d" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54681b18a46765f095758388f2d0cf16eb8d4169b639ab575a8f5693af210c7b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.27", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca6ad05a4870b2bf5fe995117d3728437bd27d7cd5f06f13c17443ef369775a1" + +[[package]] +name = "web-sys" +version = "0.3.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b85cbef8c220a6abc02aefd892dfc0fc23afb1c6a426316ec33253a3877249b" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b03058f88386e5ff5310d9111d53f48b17d732b401aeb83a8d5190f2ac459338" +dependencies = [ + "rustls-webpki 0.100.1", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" +dependencies = [ + "winapi", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.48.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05d4b17490f70499f20b9e791dcf6a299785ce8af4d709018206dc5b4953e95f" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91ae572e1b79dba883e0d315474df7305d12f569b400fcf90581b06062f7e1bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2ef27e0d7bdfcfc7b868b317c1d32c641a6fe4629c171b8928c7b08d98d7cf3" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622a1962a7db830d6fd0a69683c80a18fda201879f0f447f065a3b7467daa241" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4542c6e364ce21bf45d69fdd2a8e455fa38d316158cfd43b3ac1c5b1b19f8e00" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2b8a661f7628cbd23440e50b05d705db3686f894fc9580820623656af974b1" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7896dbc1f41e08872e9d5e8f8baa8fdd2677f29468c4e156210174edc7f7b953" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..34480b2 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,47 @@ +[package] +name = "lenra_cli" +version = "0.0.0" +edition = "2021" +license = "MIT" +description = "The Lenra command line interface" +repository = "https://github.com/lenra-io/lenra_cli" +keywords = ["cli", "lenra"] +categories = ["command-line-utilities"] +include = [ + "**/*.rs", + "Cargo.toml", +] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[[bin]] +edition = "2021" +name = "lenra" +path = "src/main.rs" + +[dependencies] +clap = { version = "4.4", features = ["derive"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +serde_yaml = "0.9.19" +log = "0.4.17" +env_logger = "0.10.0" +regex = "1.8.2" +lazy_static = "1.4.0" +dofigen = "1.2.0" +docker-compose-types = "0.4.1" +rustyline = "10.1.0" +dirs = "5.0.0" +chrono = "0.4.24" +thiserror = "1.0.40" +colored = "2.0.0" +ureq = { version = "2.6.2", features = ["json"] } +tokio = { version = "1.27.0", features = ["full"] } +async-trait = "0.1.68" +strum = { version = "0.25.0", features = ["strum_macros", "derive"] } +itertools = "0.11.0" +loading = "0.3.0" + +[dev-dependencies] +mocktopus = "0.8.0" +rstest = "0.18.2" diff --git a/README.md b/README.md index 5fc8690..bb654b7 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,112 @@ -# lenra-cli -The Lenra's command line interface +
+ +
+ + +[![Contributors][contributors-shield]][contributors-url] +[![Forks][forks-shield]][forks-url] +[![Stargazers][stars-shield]][stars-url] +[![Issues][issues-shield]][issues-url] +[![MIT License][license-shield]][license-url] +
+ + +
+
+ + +

Lenra CLI

+ +

+ Lenra is an open-source and ethical alternative to Firebase. Optimize your app creation, and just like that, make the world a better place;) + Create / host and deploy in one place. +

+ +[Report Bug](https://github.com/lenra-io/lenra_cli/issues) +· +[Request Feature](https://github.com/lenra-io/lenra_cli/issues) + +List of avaible language templates +
+ + + + + +The Lenra's command line interface helps you building your Lenra app locally. + + +## What is Lenra + +Lenra is an open source framework to create your app using any language, and deploy it without any Ops scale, built on ethical values. + +[Discover our framework](https://www.lenra.io/discover.html) + + +
+Capture d’écran 2023-03-21 à 11 24 03 +
+ + +## Getting Started + +To start using Lenra to build your app, [install the CLI](install.md) and learn [how to use it](docs/index.md). + +

(back to top)

+ + + +## Contributing + +Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**. + +If you have a suggestion that would make this better, please open an issue with the tag "enhancement" or "bug". +Don't forget to give the project a star! Thanks again! + +### Run tests + +In order to have more advanced unit tests, we use [Mocktopus](https://github.com/CodeSandwich/Mocktopus) that is based on the nightly Rust toolchain. +To run them you have to install the toolchain and run them with it: + +```bash +rustup install nightly +cargo +nightly test +``` + +

(back to top)

+ + + + +## License + +Distributed under the **MIT** License. See [LICENSE](./LICENSE) for more information. + +

(back to top)

+ + + + +## Contact + +Lenra - [@lenra_dev](https://twitter.com/lenra_dev) - contact@lenra.io + +Project Link: [https://github.com/lenra-io/lenra_cli](https://github.com/lenra-io/lenra_cli) + +

(back to top)

+ + + + +[contributors-shield]: https://img.shields.io/github/contributors/lenra-io/lenra_cli.svg?style=for-the-badge +[contributors-url]: https://github.com/lenra-io/lenra_cli/graphs/contributors +[forks-shield]: https://img.shields.io/github/forks/lenra-io/lenra_cli.svg?style=for-the-badge +[forks-url]: https://github.com/lenra-io/lenra_cli/network/members +[stars-shield]: https://img.shields.io/github/stars/lenra-io/lenra_cli.svg?style=for-the-badge +[stars-url]: https://github.com/lenra-io/lenra_cli/stargazers +[issues-shield]: https://img.shields.io/github/issues/lenra-io/lenra_cli.svg?style=for-the-badge +[issues-url]: https://github.com/lenra-io/lenra_cli/issues +[license-shield]: https://img.shields.io/github/license/lenra-io/lenra_cli.svg?style=for-the-badge +[license-url]: https://github.com/lenra-io/lenra_cli/blob/master/LICENSE.txt diff --git a/docs-page-builder/.gitignore b/docs-page-builder/.gitignore new file mode 100644 index 0000000..b51ea71 --- /dev/null +++ b/docs-page-builder/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +build/ \ No newline at end of file diff --git a/docs-page-builder/build.js b/docs-page-builder/build.js new file mode 100644 index 0000000..0024564 --- /dev/null +++ b/docs-page-builder/build.js @@ -0,0 +1,63 @@ +import * as fs from 'fs/promises'; +import Showdown from 'showdown'; +import fm from 'front-matter'; +import path from 'path'; + +const converter = new Showdown.Converter({ tables: true }); +converter.addExtension({ + type: 'output', + filter: function (text) { + const replaced = text + // Replace markdown links to html + .replace(/href="([^"]+)"/g, (_, href) => `href="${href.replace(/\.md(#|$)/, '.html$1')}"`); + return replaced; + } +}); + +const githubBasPath = "https://github.com/lenra-io/lenra_cli/blob/beta/"; + +const docsDir = path.join("..", "docs"); +const outDir = "build"; + +async function buildFiles(src, dest) { + const files = await fs.readdir(src, { withFileTypes: true }); + const promises = files.map(info => { + const filePath = path.join(src, info.name); + if (info.isFile()) return buildFile(filePath, info.name, dest); + if (!info.isDirectory()) throw new Error("Error building", filePath); + return buildFiles(filePath, path.join(dest, info.name)); + }); + return Promise.all(promises); +} + +/** + * Build the given file to the target directory + * @param {string} src + * @param {string} filename + * @param {string} destDir + */ +async function buildFile(src, filename, destDir) { + await fs.mkdir(destDir, { recursive: true }); + if (filename.endsWith(".md")) { + console.log(`buiding ${src} to ${destDir}`); + const fmResult = fm(await fs.readFile(src, 'utf8')); + const baseName = filename.replace(/.md$/, ""); + const name = fmResult.attributes.name ?? (filename === "index.md" ? undefined : baseName); + const destFile = path.join(destDir, `${baseName}.html`); + const destJsonFile = destFile + '.json'; + return Promise.all([ + fs.writeFile(destFile, converter.makeHtml(fmResult.body), 'utf8'), + fs.writeFile(destJsonFile, JSON.stringify({ + ...fmResult.attributes, + name, + sourceFile: githubBasPath + src.replace(/^..\//, '') + }), 'utf8') + ]); + } + else { + return fs.copyFile(src, path.join(destDir, filename)); + } +} + +console.log("Start building doc pages"); +buildFiles(docsDir, outDir).then(_ => console.log("Doc pages built")); diff --git a/docs-page-builder/package-lock.json b/docs-page-builder/package-lock.json new file mode 100644 index 0000000..71e3cc6 --- /dev/null +++ b/docs-page-builder/package-lock.json @@ -0,0 +1,92 @@ +{ + "name": "docs-page-builder", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "docs-page-builder", + "version": "1.0.0", + "license": "MIT", + "devDependencies": { + "front-matter": "^4.0.2", + "showdown": "^2.1.0" + } + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/front-matter": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/front-matter/-/front-matter-4.0.2.tgz", + "integrity": "sha512-I8ZuJ/qG92NWX8i5x1Y8qyj3vizhXS31OxjKDu3LKP+7/qBgfIKValiZIEwoVoJKUHlhWtYrktkxV1XsX+pPlg==", + "dev": true, + "dependencies": { + "js-yaml": "^3.13.1" + } + }, + "node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/showdown": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/showdown/-/showdown-2.1.0.tgz", + "integrity": "sha512-/6NVYu4U819R2pUIk79n67SYgJHWCce0a5xTP979WbNp0FL9MN1I1QK662IDU1b6JzKTvmhgI7T7JYIxBi3kMQ==", + "dev": true, + "dependencies": { + "commander": "^9.0.0" + }, + "bin": { + "showdown": "bin/showdown.js" + }, + "funding": { + "type": "individual", + "url": "https://www.paypal.me/tiviesantos" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true + } + } +} diff --git a/docs-page-builder/package.json b/docs-page-builder/package.json new file mode 100644 index 0000000..1a3875f --- /dev/null +++ b/docs-page-builder/package.json @@ -0,0 +1,17 @@ +{ + "name": "docs-page-builder", + "version": "1.0.0", + "description": "Generates HTML pages from markdown files", + "type": "module", + "main": "build.js", + "scripts": { + "build": "node build.js", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "Lenra", + "license": "MIT", + "devDependencies": { + "front-matter": "^4.0.2", + "showdown": "^2.1.0" + } +} \ No newline at end of file diff --git a/docs/commands/build.md b/docs/commands/build.md new file mode 100644 index 0000000..4b5a7d4 --- /dev/null +++ b/docs/commands/build.md @@ -0,0 +1,22 @@ +--- +description: This subcommand builds the Lenra app of the current directory. +--- + +This subcommand builds the Lenra app of the current directory. +The app configuration is defined by a [configuration file](#configuration-file). + +```bash +$ lenra build --help +lenra-build +Build your app in release mode + +USAGE: + lenra build [OPTIONS] + +OPTIONS: + --config The app configuration file [default: lenra.yml] + --expose Exposes services ports [possible values: app, devtool, postgres, mongo] + -h, --help Print help information + --production Remove debug access to the app + -v, --verbose Run the commands as verbose +``` diff --git a/docs/commands/check/index.md b/docs/commands/check/index.md new file mode 100644 index 0000000..69db697 --- /dev/null +++ b/docs/commands/check/index.md @@ -0,0 +1,27 @@ +--- +description: This subcommands check the running app. +--- + +This subcommands check the running app. + +```bash +$ lenra check --help +lenra-check +Checks the running app + +USAGE: + lenra check + +OPTIONS: + -h, --help Print help information + +SUBCOMMANDS: + help Print this message or the help of the given subcommand(s) + template Checks the current project as a template +``` + +## Subcommands + +This tool contains many subcommands to help you doing what you need. + +- [template](./template.md): checks the current project as a template \ No newline at end of file diff --git a/docs/commands/check/template.md b/docs/commands/check/template.md new file mode 100644 index 0000000..98168e7 --- /dev/null +++ b/docs/commands/check/template.md @@ -0,0 +1,22 @@ +--- +description: This subcommands checks the running app as a Lenra template app. +--- + +This subcommands checks the running app as a Lenra template app. + +```bash +$ lenra check template --help +lenra-check-template +Checks the current project as a template + +USAGE: + lenra check template [OPTIONS] [RULES]... + +ARGS: + ... The rules + +OPTIONS: + -h, --help Print help information + --ignore A list of rules to ignore + --strict The strict mode also fails with warning rules +``` \ No newline at end of file diff --git a/docs/commands/dev.md b/docs/commands/dev.md new file mode 100644 index 0000000..3e60c01 --- /dev/null +++ b/docs/commands/dev.md @@ -0,0 +1,35 @@ +--- +description: This subcommand starts the Lenra app of the current directory in dev mode. +--- + +This subcommand starts the Lenra app of the current directory in dev mode. + +The dev mode builds and starts the app and then displays its logs. + +```bash +$ lenra dev --help +lenra-dev +Start the app in an interactive mode + +USAGE: + lenra dev [OPTIONS] + +OPTIONS: + --attach Attach the dev mode without rebuilding the app and restarting it + --config The app configuration file [default: lenra.yml] + --expose Exposes services ports [possible values: app, devtool, postgres, mongo] + -h, --help Print help information + -v, --verbose Run the commands as verbose +``` + +When your app is in dev mode, you can run interactive commands through keyboard shortcuts. + +Here is the help interactive command result displayed pressing `H` key: + +```bash +SHORTCUTS: (Command Key(s) Description) + Help H Print this message + Reload R Reload the app by rebuilding and restarting it + Quit Q, Ctrl+C Quit the interactive mode + Stop S Stop your app previously started with the start command +``` diff --git a/docs/commands/index.md b/docs/commands/index.md new file mode 100644 index 0000000..39c9242 --- /dev/null +++ b/docs/commands/index.md @@ -0,0 +1,59 @@ +--- +description: This CLI contains many subcommands to help you doing what you need. +--- + +This CLI contains many subcommands to help you doing what you need. + +- [new](./new.md): creates a new Lenra app project from a template +- [dev](./dev/index.md): starts your app in dev mode +- [update](./update.md): updates the tools Docker images +- [upgrade](./upgrade.md): upgrades the app with the last template updates +- [build](./build.md): builds the Lenra app of the current directory +- [start](./start.md): starts your app previously built with the build command +- [reload](./reload.md): starts your app previously built with the build command +- [logs](./logs.md): displays output from the containers +- [stop](./stop.md): stops your app previously started with the start command +- [check](./check/index.md): checks the running app + +Use the help options or help subcommand to understand how to use them: + +```bash +$ lenra --help +lenra_cli 0.0.0 +The Lenra command line interface + +USAGE: + lenra [OPTIONS] [SUBCOMMAND] + +OPTIONS: + --config The app configuration file [default: lenra.yml] + --expose Exposes services ports [possible values: app, devtool, postgres, mongo] + -h, --help Print help information + -v, --verbose Run the commands as verbose + -V, --version Print version information + +SUBCOMMANDS: + build Build your app in release mode + check Checks the running app + dev Start the app in an interactive mode + help Print this message or the help of the given subcommand(s) + logs View output from the containers + new Create a new Lenra app project from a template + reload Reload the app by rebuilding and restarting it + start Start your app previously built with the build command + stop Stop your app previously started with the start command + update Update the tools Docker images + upgrade Upgrade the app with the last template updates +``` + +Some global options are available for all subcommands: + +```bash +OPTIONS: + --config The app configuration file [default: lenra.yml] + --expose Exposes services ports [possible values: app, devtool, postgres, mongo] + -v, --verbose Run the commands as verbose +``` + +They won't have effect on all subcommands but can be used for most of them. +Also, you will be able to set them in the [terminal context](./terminal/index.md) since they are defined for the whole terminal lifetime (except the `--expose` option that can be redefined by the [`expose` command](./terminal/expose.md)). diff --git a/docs/commands/logs.md b/docs/commands/logs.md new file mode 100644 index 0000000..a18981d --- /dev/null +++ b/docs/commands/logs.md @@ -0,0 +1,34 @@ +--- +description: This subcommand displays output from the containers. +--- + +This subcommand displays output from the containers. + +```bash +$ lenra logs --help +lenra-logs +View output from the containers + +USAGE: + lenra logs [OPTIONS] [SERVICES]... + +ARGS: + ... The logged service list [default: app] [possible values: app, devtool, + postgres, mongo] + +OPTIONS: + --config The app configuration file [default: lenra.yml] + --expose Exposes services ports [possible values: app, devtool, postgres, mongo] + -f, --follow Follow log output + -h, --help Print help information + --no-color Produce monochrome output + --no-log-prefix Don't print prefix in logs + --since Show logs since timestamp (e.g. 2013-01-02T13:23:37Z) or relative (e.g. + 42m for 42 minutes) + -t, --timestamps Show timestamps + --tail Number of lines to show from the end of the logs for each container + [default: all] + --until Show logs before a timestamp (e.g. 2013-01-02T13:23:37Z) or relative + (e.g. 42m for 42 minutes) + -v, --verbose Run the commands as verbose +``` diff --git a/docs/commands/new.md b/docs/commands/new.md new file mode 100644 index 0000000..ed1f11e --- /dev/null +++ b/docs/commands/new.md @@ -0,0 +1,30 @@ +--- +description: This subcommand creates a new Lenra app project from a template. +--- + +This subcommand creates a new Lenra app project from a template. +The target directory must not exist. + +```bash +$ lenra new --help +lenra-new +Create a new Lenra app project from a template + +USAGE: + lenra new [OPTIONS] [TOPICS]... + +ARGS: + ... The project template topics from which your project will be created. For + example, defining `rust` look for the next API endpoint: + https://api.github.com/search/repositories?q=topic:lenra+topic:template+topic:rust&sort=stargazers + You can find all the templates at this url: + https://github.com/search?q=topic%3Alenra+topic%3Atemplate&sort=stargazers&type=repositories + You also can set the template project full url to use custom ones + +OPTIONS: + --config The app configuration file [default: lenra.yml] + --expose Exposes services ports [possible values: app, devtool, postgres, mongo] + -h, --help Print help information + -p, --path The new project path [default: .] + -v, --verbose Run the commands as verbose +``` diff --git a/docs/commands/reload.md b/docs/commands/reload.md new file mode 100644 index 0000000..1831e74 --- /dev/null +++ b/docs/commands/reload.md @@ -0,0 +1,20 @@ +--- +description: This subcommand reloads the app by rebuilding and restarting it. +--- + +This subcommand reloads the app by rebuilding and restarting it. + +```bash +$ lenra reload --help +lenra-reload +Reload the app by rebuilding and restarting it + +USAGE: + lenra reload [OPTIONS] + +OPTIONS: + --config The app configuration file [default: lenra.yml] + --expose Exposes services ports [possible values: app, devtool, postgres, mongo] + -h, --help Print help information + -v, --verbose Run the commands as verbose +``` diff --git a/docs/commands/start.md b/docs/commands/start.md new file mode 100644 index 0000000..01902a0 --- /dev/null +++ b/docs/commands/start.md @@ -0,0 +1,20 @@ +--- +description: This subcommand starts the Lenra app of the current directory previously built. +--- + +This subcommand starts the Lenra app of the current directory previously built. + +```bash +$ lenra start --help +lenra-start +Start your app previously built with the build command + +USAGE: + lenra start [OPTIONS] + +OPTIONS: + --config The app configuration file [default: lenra.yml] + --expose Exposes services ports [possible values: app, devtool, postgres, mongo] + -h, --help Print help information + -v, --verbose Run the commands as verbose +``` diff --git a/docs/commands/stop.md b/docs/commands/stop.md new file mode 100644 index 0000000..000be2e --- /dev/null +++ b/docs/commands/stop.md @@ -0,0 +1,20 @@ +--- +description: This subcommand stops the Lenra app of the current directory and removes the Docker Compose elements. +--- + +This subcommand stops the Lenra app of the current directory and removes the Docker Compose elements. + +```bash +$ lenra stop --help +lenra-stop +Stop your app previously started with the start command + +USAGE: + lenra stop [OPTIONS] + +OPTIONS: + --config The app configuration file [default: lenra.yml] + --expose Exposes services ports [possible values: app, devtool, postgres, mongo] + -h, --help Print help information + -v, --verbose Run the commands as verbose +``` diff --git a/docs/commands/terminal/exit.md b/docs/commands/terminal/exit.md new file mode 100644 index 0000000..d880b54 --- /dev/null +++ b/docs/commands/terminal/exit.md @@ -0,0 +1,17 @@ +--- +description: This command let you exit from the terminal. +--- + +This command let you exit from the terminal. You also can use the `Ctrl + C` and `Ctrl + D` shortcuts. + +```bash +[lenra]$ exit --help +lenra-exit +Exits the terminal + +USAGE: + lenra exit + +OPTIONS: + -h, --help Print help information +``` diff --git a/docs/commands/terminal/expose.md b/docs/commands/terminal/expose.md new file mode 100644 index 0000000..5a8598e --- /dev/null +++ b/docs/commands/terminal/expose.md @@ -0,0 +1,21 @@ +--- +description: This command exposes the containers ports. +--- + +This command exposes the containers ports. + +```bash +[lenra]$ expose --help +lenra-expose +Exposes the app ports + +USAGE: + lenra expose [SERVICES]... + +ARGS: + ... The service list to expose [default: app postgres mongo] [possible values: + app, devtool, postgres, mongo] + +OPTIONS: + -h, --help Print help information +``` diff --git a/docs/commands/terminal/index.md b/docs/commands/terminal/index.md new file mode 100644 index 0000000..98266fe --- /dev/null +++ b/docs/commands/terminal/index.md @@ -0,0 +1,65 @@ +--- +description: The terminal is a command prompt that let you run only Lenra specific commands. It has his own history and keep the same command context during it lifetime. +--- + +The terminal is a command prompt that let you run only Lenra specific commands. It has his own history and keep the same command context during it lifetime. + +To start the terminal, run the `lenra` command without any subcommand: + +```bash +$ lenra +``` + +You can use the global options to configure the terminal context: + +```bash +OPTIONS: + --config The app configuration file [default: lenra.yml] + --expose Exposes services ports [possible values: app, devtool, postgres, mongo] + -v, --verbose Run the commands as verbose +``` + +## Commands + +The terminal let run all the `lenra` subcommands (excpet the `terminal` itself and the `new` one) without the need of write `lenra` before them each time and even more: + + +- [dev](../dev.md): starts your app in dev mode +- [update](../update.md): updates the tools Docker images +- [upgrade](../upgrade.md): upgrades the app with the last template updates +- [build](../build.md): builds the Lenra app of the current directory +- [start](../start.md): starts your app previously built with the build command +- [reload](../reload.md): starts your app previously built with the build command +- [logs](../logs.md): displays output from the containers +- [stop](../stop.md): stops your app previously started with the start command +- [check](../check/index.md): checks the running app +- [expose](./expose.md): exposes the services ports and keep it in the terminal context +- [exit](./exit.md): exits the terminal + +Here is the help result in the terminal: + +```bash +[lenra]$ help +lenra_cli +The Lenra interactive command line interface + +USAGE: + lenra + +OPTIONS: + -h, --help Print help information + +SUBCOMMANDS: + build Build your app in release mode + check Checks the running app + dev Start the app in an interactive mode + exit Exits the terminal + expose Exposes the app ports + help Print this message or the help of the given subcommand(s) + logs View output from the containers + reload Reload the app by rebuilding and restarting it + start Start your app previously built with the build command + stop Stop your app previously started with the start command + update Update the tools Docker images + upgrade Upgrade the app with the last template updates +``` diff --git a/docs/commands/update.md b/docs/commands/update.md new file mode 100644 index 0000000..3c9dcad --- /dev/null +++ b/docs/commands/update.md @@ -0,0 +1,24 @@ +--- +description: This subcommand updates the tools Docker images. +--- + +This subcommand updates the tools Docker images. + +```bash +$ lenra update --help +lenra-update +Update the tools Docker images + +USAGE: + lenra update [OPTIONS] [SERVICES]... + +ARGS: + ... The service list to pull [default: devtool postgres mongo] [possible + values: app, devtool, postgres, mongo] + +OPTIONS: + --config The app configuration file [default: lenra.yml] + --expose Exposes services ports [possible values: app, devtool, postgres, mongo] + -h, --help Print help information + -v, --verbose Run the commands as verbose +``` diff --git a/docs/commands/upgrade.md b/docs/commands/upgrade.md new file mode 100644 index 0000000..7257c21 --- /dev/null +++ b/docs/commands/upgrade.md @@ -0,0 +1,20 @@ +--- +description: This subcommand upgrades the app with the last template updates. +--- + +This subcommand upgrades the app with the last template updates. + +```bash +$ lenra upgrade --help +lenra-upgrade +Upgrade the app with the last template updates + +USAGE: + lenra upgrade [OPTIONS] + +OPTIONS: + --config The app configuration file [default: lenra.yml] + --expose Exposes services ports [possible values: app, devtool, postgres, mongo] + -h, --help Print help information + -v, --verbose Run the commands as verbose +``` diff --git a/docs/config-file.md b/docs/config-file.md new file mode 100644 index 0000000..9c9ef2e --- /dev/null +++ b/docs/config-file.md @@ -0,0 +1,44 @@ +--- +name: lenra.yml config file +description: The Lenra's configuration file describes your Lenra app configurations, like API versions or how to build it. +--- + +The Lenra's configuration file describes your Lenra app configurations, like API versions or how to build it. + +Here is an example using a Dofigen file: + +```yaml +path: "." +generator: + dofigen: dofigen.yml +``` + +## Configuration + +The configuration is the main element of the file: + +| Field | Type | Description | +| ----------- | ----------------------- | ------------------------------ | +| `path` | String | The project path (default ".") | +| `generator` | [Generator](#generator) | The generator configuration | + +## Generator + +The generator define your application is built. There are many configurators: + +- [Configuration](#configuration) +- [Generator](#generator) + - [Dofigen](#dofigen) + - [Docker](#docker) + +### Dofigen + +The Dofigen generator use a [Dofigen](https://github.com/lenra-io/dofigen) configuration to generate the Docker image. + +The Dofigen configuration can be the path to a Dofigen file or it content directly. + +### Docker + +The Docker generator use a Dockerfile to generate the Docker image. + +The Dockerfile can be the path to a file or it content directly. \ No newline at end of file diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..1bfee7a --- /dev/null +++ b/docs/index.md @@ -0,0 +1,35 @@ +--- +name: CLI +description: The Lenra's command line interface helps you building your Lenra app locally and it's gona be your best friend ! +--- + +The Lenra's command line interface helps you building your Lenra app locally and it's gona be your best friend ! + +We've created many [commands](commands/index.md) to help you managing a local smaller Lenra instance to create and test your app. + +## Create a new app + +To create an app, you should use the [`lenra new` subcommand](commands/new.md) that will create a new project based on [a Lenra app template](https://github.com/orgs/lenra-io/repositories?q=&type=template&language=&sort=stargazers). + +Start it running the [`lenra dev` subcommand](commands/dev/index.md). +Your app will be built, started and exposed on http://localhost:4000/ + +Here are the steps to start building a JavaScript Lenra app: + +```bash +# new app from javascript template in a new 'my-app' directory +lenra new javascript -p my-app +# move to the new app dir +cd my-app +# initialize git versionning +git init +# start your app +lenra dev +``` + +Look the [`lenra dev` subcommand](commands/dev/index.md) to understand the new dev mode. + +## Configure your app container + +Lenra app system is based containers. +Look at our [`lenra.yml` config file](config-file.md) to adapt your app configuration and better understand how you can tools in your app containers. \ No newline at end of file diff --git a/install.md b/install.md new file mode 100644 index 0000000..272c303 --- /dev/null +++ b/install.md @@ -0,0 +1,46 @@ +## Prerequisites + +To build and run the Lenra elements that handle your app, the Lenra CLI needs [Docker](https://docs.docker.com/engine/install/) and [Docker Compose](https://docs.docker.com/compose/install/). + +To create a new project and upgrade it later, the CLI also needs [git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) (that we hope you already use ^^). + +You can also install the [Docker buildx command](https://docs.docker.com/build/buildx/install/) to use the [Buildkit optimization given by Dofigen](https://github.com/lenra-io/dofigen). + +Install the Lenra CLI using one of the next possibilities. + +## Install + +### Download the binary + +You can download the binary from [the release page](https://github.com/lenra-io/lenra_cli/releases) and add it to your path environment variable. + +### Cargo install + +First install Cargo, the Rust package manager: https://doc.rust-lang.org/cargo/getting-started/installation.html + +Then use the next command to install the Lenra's cli: + +```bash +cargo install lenra_cli +``` + +Since the CLI is not released yet, you have to target a [pre-release version](https://github.com/lenra-io/lenra_cli/releases) like that: + +```bash +cargo install lenra_cli@~1.0.0-beta.0 +``` + +### Build it from sources + +First install Cargo, the Rust package manager: https://doc.rust-lang.org/cargo/getting-started/installation.html + +Then clone this repository and install it with Cargo: + +```bash +git clone https://github.com/lenra-io/lenra_cli.git +cargo install --path . +``` + +## And now ? + +Learn how to use it with [our docs website](https://docs.lenra.io). \ No newline at end of file diff --git a/src/cli/build.rs b/src/cli/build.rs new file mode 100644 index 0000000..f57ddfe --- /dev/null +++ b/src/cli/build.rs @@ -0,0 +1,44 @@ +use async_trait::async_trait; + +use clap; + +use crate::cli::CliCommand; +use crate::errors::Result; +use crate::lenra; + +use super::{loader, CommandContext}; + +#[derive(clap::Args, Default, Debug, Clone)] +pub struct Build { + /// Remove debug access to the app. + #[clap(long, alias = "prod", action)] + pub production: bool, +} + +#[async_trait] +impl CliCommand for Build { + async fn run(&self, context: &mut CommandContext) -> Result<()> { + generate_app_env_loader(context, self.production).await?; + build_loader(context).await + } +} + +pub async fn generate_app_env_loader(context: &mut CommandContext, production: bool) -> Result<()> { + loader( + "Generate app env...", + "App env generated", + "Failed generating app env", + || async { lenra::generate_app_env(context, production).await }, + ) + .await +} + +pub async fn build_loader(context: &mut CommandContext) -> Result<()> { + loader( + "Build app...", + "App built", + "Failed building app", + || async { lenra::build_app(context).await }, + ) + .await +} diff --git a/src/cli/check/mod.rs b/src/cli/check/mod.rs new file mode 100644 index 0000000..c17c26d --- /dev/null +++ b/src/cli/check/mod.rs @@ -0,0 +1,292 @@ +use std::fmt::Debug; + +use async_trait::async_trait; +use clap::{Args, Subcommand}; +use colored::{Color, ColoredString, Colorize}; +use log::{debug, info}; +use serde_json::Value; + +use crate::{ + docker_compose::{get_service_published_ports, Service}, + errors::{Error, Result}, + matching::{Matching, MatchingErrorType}, +}; + +use self::template::TemplateChecker; + +use super::{CliCommand, CommandContext}; + +mod template; + +pub const RULE_SEPARATOR: &str = ":"; +pub const VIEW: &str = "view"; + +#[derive(Args, Clone, Debug)] +pub struct Check { + #[clap(subcommand)] + command: CheckCommandType, +} + +/// The check subcommands +#[derive(Subcommand, Clone, Debug)] +pub enum CheckCommandType { + // /// Checks the current project as an app + // App(CheckParameters), + /// Checks the current project as a template + Template(CheckParameters), +} + +#[async_trait] +impl CliCommand for Check { + async fn run(&self, context: &mut CommandContext) -> Result<()> { + // check that the app service is exposed + if get_service_published_ports(context, Service::App) + .await? + .is_empty() + { + return Err(Error::ServiceNotExposed(Service::App)); + } + match self.command.clone() { + CheckCommandType::Template(params) => { + let template_checker = TemplateChecker; + template_checker.check(params) + } + } + } +} + +#[derive(Args, Default, Clone, Debug)] +pub struct CheckParameters { + /// The strict mode also fails with warning rules. + #[clap(long, action)] + pub strict: bool, + + /// A list of rules to ignore + #[clap(long)] + pub ignore: Option>, + + /// The rules + #[clap()] + pub rules: Vec, +} + +pub trait AppChecker: Debug { + fn check_list(&self) -> Vec; + + fn check(&self, params: CheckParameters) -> Result<()> { + info!("Check with {:?}", self); + // TODO: start app + + let check_list = self.check_list(); + + debug!("Check list: {:?}", check_list); + + let mut fail: bool = false; + check_list + .iter() + .filter(|checker| params.rules.is_empty() || params.rules.contains(&checker.name)) + .for_each(|checker| { + let errors = checker.check(params.ignore.clone().unwrap_or(vec![])); + let name = checker.name.clone(); + let mut messages: Vec = vec![]; + + let mut levels: Vec = errors + .iter() + .map(|error| { + let lvl = match error.level { + RuleErrorLevel::Warning => CheckerLevel::Warning, + RuleErrorLevel::Error => CheckerLevel::Error, + }; + messages.push( + format!(" {}\n {}", error.rule, error.message) + .color(lvl.color()), + ); + lvl + }) + .collect(); + levels.sort(); + levels.reverse(); + + let level: &CheckerLevel = levels.get(0).unwrap_or(&CheckerLevel::Ok); + println!( + "{}", + format!("{:20}: {:?}", name, level).color(level.color()) + ); + messages.iter().for_each(|msg| println!("{}", msg)); + if level == &CheckerLevel::Error + || (level == &CheckerLevel::Warning && params.strict) + { + fail = true; + } + }); + if fail { + return Err(Error::Check); + } + Ok(()) + } +} + +fn ignore_rule(parts: Vec, ignores: Vec) -> bool { + let mut prefix = String::new(); + for part in parts { + prefix.push_str(part.as_str()); + if ignores.contains(&prefix) { + return true; + } + prefix.push_str(RULE_SEPARATOR); + if ignores.contains(&format!("{}*", prefix)) { + return true; + } + } + return false; +} + +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum CheckerLevel { + Ok, + Warning, + Error, +} + +impl CheckerLevel { + fn color(&self) -> Color { + match self { + CheckerLevel::Ok => Color::Green, + CheckerLevel::Warning => Color::Yellow, + CheckerLevel::Error => Color::Red, + } + } +} + +#[derive(Debug)] +pub struct ValueChecker { + name: String, + expected: Value, + loader: fn() -> Result, +} + +impl ValueChecker { + pub fn rules(&self) -> Vec> { + vec![Rule { + name: "match".into(), + description: "Checks that the data matches the expected one".into(), + examples: vec![], + check: |value, expected| { + value + .check_match(&expected) + .iter() + .map(|err| match err.error_type.clone() { + MatchingErrorType::NotSameType { actual, expected } => RuleError { + rule: format!("{}{}{}", "sameType", RULE_SEPARATOR, err.path), + message: format!( + "Not matching type for {}: got {} but expected {}", + err.path, + actual.type_name(), + expected.type_name() + ), + level: RuleErrorLevel::Error, + }, + MatchingErrorType::NotSameValue { actual, expected } => RuleError { + rule: format!("{}{}{}", "sameValue", RULE_SEPARATOR, err.path), + message: format!( + "Not matching value for {}: got {:?} but expected {:?}", + err.path, actual, expected + ), + level: RuleErrorLevel::Error, + }, + MatchingErrorType::AdditionalProperty => RuleError { + rule: format!("{}{}{}", "additionalProperty", RULE_SEPARATOR, err.path), + message: format!("Additional property {}", err.path), + level: RuleErrorLevel::Warning, + }, + MatchingErrorType::MissingProperty => RuleError { + rule: format!("{}{}{}", "missingProperty", RULE_SEPARATOR, err.path), + message: format!("Missing property {}", err.path), + level: RuleErrorLevel::Error, + }, + }) + .collect() + }, + }] + } + + pub fn check(&self, ignores: Vec) -> Vec { + if ignore_rule(vec![self.name.clone()], ignores.clone()) { + info!("Checker '{}' ignored", self.name); + return vec![]; + } + let res = (self.loader)(); + match res { + Ok(value) => self + .rules() + .iter() + .flat_map(|rule| { + if ignore_rule(vec![self.name.clone(), rule.name.clone()], ignores.clone()) { + info!("Rule '{}' ignored for checker '{}'", rule.name, self.name); + return vec![]; + } + + debug!("Check {}{}{}", self.name, RULE_SEPARATOR, rule.name); + rule.check(value.clone(), self.expected.clone()) + .iter() + .map(|error| RuleError { + rule: format!("{}{}{}", self.name, RULE_SEPARATOR, error.rule), + message: error.message.clone(), + level: error.level.clone(), + }) + .filter(|error| { + !ignore_rule( + error + .rule + .split(RULE_SEPARATOR) + .map(|str| str.into()) + .collect(), + ignores.clone(), + ) + }) + .collect() + }) + .collect(), + Err(err) => vec![RuleError { + rule: format!("{}{}{}", self.name, RULE_SEPARATOR, "unexpectedError"), + message: format!("Error loading {} checker data: {:?}", self.name, err), + level: RuleErrorLevel::Error, + }], + } + } +} + +#[derive(Debug, Clone)] +pub struct RuleError { + rule: String, + message: String, + level: RuleErrorLevel, +} + +#[derive(Debug, Clone)] +pub enum RuleErrorLevel { + Warning, + Error, +} + +#[derive(Debug, Clone)] +pub struct Rule { + pub name: String, + pub description: String, + pub examples: Vec, + pub check: fn(T, T) -> Vec, +} + +impl Rule { + fn check(&self, param: T, expected: T) -> Vec { + (self.check)(param, expected) + } +} + +pub fn call_app(request: Value) -> Result { + ureq::post("http://localhost:8080") + .send_json(request) + .map_err(Error::from)? + .into_json() + .map_err(Error::from) +} diff --git a/src/cli/check/template.rs b/src/cli/check/template.rs new file mode 100644 index 0000000..91409c2 --- /dev/null +++ b/src/cli/check/template.rs @@ -0,0 +1,234 @@ +pub use clap::Args; + +use serde_json::json; + +use crate::errors::Error; + +use super::{call_app, AppChecker, ValueChecker, RULE_SEPARATOR, VIEW}; + +#[derive(Debug)] +pub struct TemplateChecker; + +impl AppChecker for TemplateChecker { + fn check_list(&self) -> Vec { + vec![ + ValueChecker { + name: "manifest".into(), + loader: || call_app(json!({})).map_err(Error::from), + expected: json!({ + "manifest": { + "rootView": "main" + } + }), + }, + ValueChecker { + name: format!("{}{}{}", VIEW, RULE_SEPARATOR, "main"), + loader: || { + call_app(json!({ + "view": "main" + })) + .map_err(Error::from) + }, + expected: json!({ + "type": "flex", + "direction": "vertical", + "scroll": true, + "spacing": 4, + "crossAxisAlignment": "center", + "children": [ + { + "type": "view", + "name": "menu", + }, + { + "type": "view", + "name": "home" + } + ] + }), + }, + ValueChecker { + name: format!("{}{}{}", VIEW, RULE_SEPARATOR, "menu"), + loader: || { + call_app(json!({ + "view": "menu" + })) + .map_err(Error::from) + }, + expected: json!({ + "type": "container", + "decoration": { + "color": 0xFFFFFFFFu32, + "boxShadow": { + "blurRadius": 8, + "color": 0x1A000000, + "offset": { + "dx": 0, + "dy": 1 + } + }, + }, + "padding": { + "top": 16, + "bottom": 16, + "left": 32, + "right": 32, + }, + "child": { + "type": "flex", + "fillParent": true, + "mainAxisAlignment": "spaceBetween", + "crossAxisAlignment": "center", + "padding": { "right": 32 }, + "children": [ + { + "type": "container", + "constraints": { + "minWidth": 32, + "minHeight": 32, + "maxWidth": 32, + "maxHeight": 32, + }, + "child": { + "type": "image", + "src": "logo.png" + }, + }, + { + "type": "flexible", + "child": { + "type": "container", + "child": { + "type": "text", + "value": "Hello World", + "textAlign": "center", + "style": { + "fontWeight": "bold", + "fontSize": 24, + }, + } + } + } + ] + }, + }), + }, + ValueChecker { + name: format!("{}{}{}", VIEW, RULE_SEPARATOR, "home"), + loader: || { + call_app(json!({ + "view": "home" + })) + .map_err(Error::from) + }, + expected: json!({ + "type": "flex", + "direction": "vertical", + "spacing": 16, + "mainAxisAlignment": "spaceEvenly", + "crossAxisAlignment": "center", + "children": [ + { + "type": "view", + "name": "counter", + "coll": "counter", + "query": { + "user": "@me" + }, + "props": { "text": "My personnal counter" } + }, + { + "type": "view", + "name": "counter", + "coll": "counter", + "query": { + "user": "global" + }, + "props": { "text": "The common counter" } + } + ] + }), + }, + ValueChecker { + name: format!("{}{}{}", VIEW, RULE_SEPARATOR, "counter"), + loader: || { + call_app(json!({ + "view": "counter", + "data": [{ + "_id": "ObjectId(my_counter_id)", + "count": 2, + "user": "my_user_id", + }], + "props": { "text": "My counter text" } + })) + .map_err(Error::from) + }, + expected: json!({ + "type": "flex", + "spacing": 16, + "mainAxisAlignment": "spaceEvenly", + "crossAxisAlignment": "center", + "children": [ + { + "type": "text", + "value": "My counter text: 2", + }, + { + "type": "button", + "text": "+", + "onPressed": { + "action": "increment", + "props": { + "id": "ObjectId(my_counter_id)" + } + } + } + ] + }), + }, + ] + } +} + +#[cfg(test)] +mod test { + use super::TemplateChecker; + use crate::cli::check::AppChecker; + + #[test] + fn check_list_size() { + let template_checker = TemplateChecker; + let check_list = template_checker.check_list(); + assert_eq!( + check_list.len(), + 5, + "The template checklist size is not correct" + ); + } + + #[test] + fn check_unique_names() { + let template_checker = TemplateChecker; + let check_list = template_checker.check_list(); + let mut checker_names: Vec = vec![]; + check_list.iter().for_each(|checker| { + assert!( + !checker_names.contains(&checker.name), + "There is at least two checkers with the same name: {}", + checker.name + ); + checker_names.push(checker.name.clone()); + let mut rule_names: Vec = vec![]; + + checker.rules().iter().for_each(|rule| { + assert!( + !rule_names.contains(&rule.name), + "There is at least two rules with the same name in the '{}' checker: {}", + checker.name, + rule.name + ); + rule_names.push(rule.name.clone()); + }); + }); + } +} diff --git a/src/cli/dev/interactive.rs b/src/cli/dev/interactive.rs new file mode 100644 index 0000000..5a96a75 --- /dev/null +++ b/src/cli/dev/interactive.rs @@ -0,0 +1,133 @@ +use crate::{ + cli::{ + reload::Reload, + stop::Stop, + terminal::{TerminalCli, TerminalCommand}, + }, + errors::Result, + keyboard_event::{keyevent_to_string, KeyEventListener, KeyboardListener}, +}; +use clap::CommandFactory; +pub use clap::{Args, Parser, Subcommand}; +use colored::{Color, Colorize}; +use lazy_static::__Deref; +use log::debug; +use rustyline::{Cmd, KeyCode, KeyEvent, Modifiers, Movement}; +use std::sync::{Arc, Mutex}; +use strum::{Display, EnumIter, IntoEnumIterator}; + +const ENTER_EVENT: KeyEvent = KeyEvent(KeyCode::Enter, Modifiers::NONE); + +pub trait KeyboardShorcut { + fn about(&self) -> String; + fn events(&self) -> Vec; + fn to_value(&self) -> Option; +} + +#[derive(EnumIter, Display, Debug, PartialEq, Clone)] +pub enum InteractiveCommand { + Help, + Reload, + Quit, + Stop, +} + +impl InteractiveCommand { + pub fn name(&self) -> String { + format!("{}", self) + } +} + +impl KeyboardShorcut for InteractiveCommand { + fn about(&self) -> String { + match self { + InteractiveCommand::Help => "Print this message".into(), + InteractiveCommand::Quit => "Quit the interactive mode".into(), + _ => { + let main_command = TerminalCli::command(); + let command = main_command.find_subcommand(self.name().to_lowercase().as_str()); + command.unwrap().get_about().unwrap().into() + } + } + } + + fn events(&self) -> Vec { + match self { + InteractiveCommand::Quit => vec![ + KeyEvent(KeyCode::Char('q'), Modifiers::NONE), + KeyEvent(KeyCode::Char('C'), Modifiers::CTRL), + ], + _ => { + let name = format!("{}", self); + vec![KeyEvent::new( + name.to_lowercase().chars().next().unwrap(), + Modifiers::NONE, + )] + } + } + } + + fn to_value(&self) -> Option { + match self { + InteractiveCommand::Help => { + display_help(); + None + } + InteractiveCommand::Reload => Some(TerminalCommand::Reload(Reload { + ..Default::default() + })), + InteractiveCommand::Quit => Some(TerminalCommand::Exit), + InteractiveCommand::Stop => Some(TerminalCommand::Stop(Stop)), + } + } +} + +pub async fn listen_interactive_command() -> Result> { + debug!("Listen interactive command"); + let command: Arc>> = Arc::new(Mutex::new(None)); + let mut listener = KeyboardListener::new()?; + InteractiveCommand::iter().for_each(|cmd| { + cmd.events().iter().for_each(|&event| { + let cmd = cmd.clone(); + let local_command = command.clone(); + let f = move || { + let mut c = local_command.lock().unwrap(); + *c = cmd.to_value(); + debug!("{}", cmd.name()); + Some(Cmd::AcceptLine) + }; + listener.add_listener(event, f); + }); + }); + listener.add_listener(ENTER_EVENT, || { + println!(); + Some(Cmd::Replace(Movement::WholeBuffer, Some("".into()))) + }); + listener.listen().await?; + let mutex = command.lock().unwrap(); + let command = mutex.deref(); + debug!("Interactive command: {:?}", command); + Ok(command.clone()) +} + +fn display_help() { + let mut vector = Vec::new(); + vector.extend(InteractiveCommand::iter().map(|cmd| { + let mut shortcuts = Vec::new(); + shortcuts.extend(cmd.events().iter().map(|&e| keyevent_to_string(e))); + format!( + " {:8} {:15} {}", + cmd.name().color(Color::Green), + shortcuts.join(", ").color(Color::Blue), + cmd.about() + ) + })); + println!( + "\n{} ({} {} {})\n{}\n", + "SHORTCUTS:".color(Color::Yellow), + "Command".color(Color::Green), + "Key(s)".color(Color::Blue), + "Description", + vector.join("\n") + ) +} diff --git a/src/cli/dev/mod.rs b/src/cli/dev/mod.rs new file mode 100644 index 0000000..c89caac --- /dev/null +++ b/src/cli/dev/mod.rs @@ -0,0 +1,87 @@ +use async_trait::async_trait; +use chrono::{DateTime, SecondsFormat, Utc}; +pub use clap::Args; +use tokio::select; + +use crate::docker_compose::Service; +use crate::errors::Result; +use crate::{ + cli::{ + build, + dev::interactive::listen_interactive_command, + logs::Logs, + start, + terminal::{run_command, TerminalCommand}, + CliCommand, CommandContext, + }, + lenra, +}; + +use interactive::{InteractiveCommand, KeyboardShorcut}; + +mod interactive; + +#[derive(Args, Debug, Clone)] +pub struct Dev { + /// Attach the dev mode without rebuilding the app and restarting it. + #[clap(long, action)] + pub attach: bool, +} + +#[async_trait] +impl CliCommand for Dev { + async fn run(&self, context: &mut CommandContext) -> Result<()> { + log::info!("Run dev mode"); + + if !self.attach { + build::generate_app_env_loader(context, false).await?; + build::build_loader(context).await?; + start::start_loader(context).await?; + start::clear_cache_loader(context).await?; + } + + let previous_log = Logs { + services: vec![Service::App], + follow: true, + ..Default::default() + }; + let mut last_logs: Option> = None; + + lenra::display_app_access_url(); + InteractiveCommand::Help.to_value(); + let mut interactive_cmd = None; + loop { + if let Some(command) = interactive_cmd { + let keep_running = run_command(&command, context).await; + if !keep_running { + break; + } + } + let end_date; + (end_date, interactive_cmd) = run_logs(&previous_log, last_logs, context).await?; + last_logs = Some(end_date); + } + + log::debug!("End of dev mode"); + Ok(()) + } +} + +async fn run_logs( + logs: &Logs, + last_end: Option>, + context: &mut CommandContext, +) -> Result<(DateTime, Option)> { + let mut clone = logs.clone(); + if let Some(last_logs) = last_end { + // Only displays new logs + clone.since = Some(last_logs.to_rfc3339_opts(SecondsFormat::Secs, true)); + } + + let command = select! { + res = listen_interactive_command() => {res?} + res = clone.run(context) => {res?; None} + // res = tokio::signal::ctrl_c() => {res?; None} + }; + Ok((Utc::now(), command)) +} diff --git a/src/cli/logs.rs b/src/cli/logs.rs new file mode 100644 index 0000000..36a9776 --- /dev/null +++ b/src/cli/logs.rs @@ -0,0 +1,96 @@ +use std::process::Stdio; + +use async_trait::async_trait; +pub use clap::Args; +use log::warn; + +use crate::cli::CliCommand; +use crate::docker_compose::{create_compose_command, Service}; +use crate::errors::Result; + +use super::CommandContext; + +#[derive(Args, Default, Clone, Debug)] +pub struct Logs { + /// Follow log output + #[clap(short, long, action)] + pub follow: bool, + + /// Produce monochrome output + #[clap(long, action)] + pub no_color: bool, + + /// Don't print prefix in logs + #[clap(long, action)] + pub no_log_prefix: bool, + + /// Show logs since timestamp (e.g. 2013-01-02T13:23:37Z) or relative (e.g. 42m for 42 minutes) + #[clap(long)] + pub since: Option, + + /// Number of lines to show from the end of the logs for each container + #[clap(long, default_value = "all")] + pub tail: String, + + /// Show timestamps + #[clap(short, long, action)] + pub timestamps: bool, + + /// Show logs before a timestamp (e.g. 2013-01-02T13:23:37Z) or relative (e.g. 42m for 42 minutes) + #[clap(long)] + pub until: Option, + + /// The logged service list + #[clap(value_enum, default_value = "app")] + pub services: Vec, +} + +#[async_trait] +impl CliCommand for Logs { + async fn run(&self, context: &mut CommandContext) -> Result<()> { + log::info!("Show logs"); + + let mut command = create_compose_command(context); + + command + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .arg("logs") + .arg("--tail") + .arg(self.tail.clone()); + + if self.follow { + command.arg("--follow"); + } + if self.no_color { + command.arg("--no-color"); + } + if self.no_log_prefix { + command.arg("--no-log-prefix"); + } + if let Some(since) = self.since.clone() { + command.arg("--since").arg(since); + } + if self.timestamps { + command.arg("--timestamps"); + } + if let Some(until) = self.until.clone() { + command.arg("--until").arg(until); + } + self.services.iter().for_each(|service| { + command.arg(service.to_str()); + }); + + log::debug!("cmd: {:?}", command); + let output = command.spawn()?.wait_with_output().await?; + if !output.status.success() { + warn!( + "An error occured while displaying the docker-compose logs:\n{}\n{}", + String::from_utf8(output.stdout).unwrap(), + String::from_utf8(output.stderr).unwrap() + ) + } + + Ok(()) + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs new file mode 100644 index 0000000..0be2e53 --- /dev/null +++ b/src/cli/mod.rs @@ -0,0 +1,323 @@ +use std::{future::Future, path::PathBuf}; + +use async_trait::async_trait; +pub use clap::{Args, Parser, Subcommand}; +use loading::Loading; +use log::debug; + +use crate::{ + config::{load_config_file, Application, DEFAULT_CONFIG_FILE}, + docker_compose::Service, + errors::Result, +}; + +use self::{ + build::Build, check::Check, dev::Dev, logs::Logs, new::New, reload::Reload, start::Start, + stop::Stop, update::Update, upgrade::Upgrade, +}; + +mod build; +mod check; +mod dev; +mod logs; +mod new; +mod reload; +mod start; +mod stop; +pub mod terminal; +mod update; +mod upgrade; + +/// The Lenra command line interface +#[derive(Parser, Debug, Clone)] +#[clap(author, version, about, long_about = None, rename_all = "kebab-case")] +pub struct Cli { + #[clap(subcommand)] + pub command: Option, + + /// The app configuration file. + #[clap(global=true, parse(from_os_str), long, default_value = DEFAULT_CONFIG_FILE)] + pub config: std::path::PathBuf, + + /// Exposes services ports. + #[clap(global=true, long, value_enum, default_values = &[], default_missing_values = &["app", "postgres", "mongo"])] + pub expose: Vec, + + /// Run the commands as verbose. + #[clap(global = true, short, long, action)] + pub verbose: bool, +} + +#[async_trait] +pub trait CliCommand { + async fn run(&self, context: &mut CommandContext) -> Result<()>; + fn need_config(&self) -> bool { + true + } +} + +/// The subcommands +#[derive(Subcommand, Debug, Clone)] +pub enum Command { + /// Create a new Lenra app project from a template + New(New), + /// Build your app in release mode + Build(Build), + /// Start your app previously built with the build command + Start(Start), + /// View output from the containers + Logs(Logs), + /// Stop your app previously started with the start command + Stop(Stop), + /// Start the app in an interactive mode + Dev(Dev), + /// Upgrade the app with the last template updates + Upgrade(Upgrade), + /// Update the tools Docker images + Update(Update), + /// Checks the running app + Check(Check), + /// Reload the app by rebuilding and restarting it + Reload(Reload), +} + +#[async_trait] +impl CliCommand for Command { + async fn run(&self, context: &mut CommandContext) -> Result<()> { + log::debug!("Run command {:?}", self); + if self.need_config() { + context.load_config()?; + } + match self { + Command::New(new) => new.run(context), + Command::Build(build) => build.run(context), + Command::Start(start) => start.run(context), + Command::Logs(logs) => logs.run(context), + Command::Stop(stop) => stop.run(context), + Command::Dev(dev) => dev.run(context), + Command::Upgrade(upgrade) => upgrade.run(context), + Command::Update(update) => update.run(context), + Command::Check(check) => check.run(context), + Command::Reload(reload) => reload.run(context), + } + .await + } + + fn need_config(&self) -> bool { + match self { + Command::New(_) => false, + _ => true, + } + } +} + +#[derive(Clone, Debug, Default)] +pub struct CommandContext { + /// The app configuration file. + pub config_path: std::path::PathBuf, + + /// The app configuration. + pub config: Option, + + /// Exposes all services ports. + pub expose: Vec, + + /// Run command as verbose. + pub verbose: bool, +} + +impl CommandContext { + pub fn load_config(&mut self) -> Result { + debug!("Load config from {:?}", self.config_path); + let app: Application = load_config_file(&self.config_path)?; + self.config = Some(app.clone()); + Ok(app) + } + + /// Resolve a path relative to the current directory and base on the path property of the config. + pub fn resolve_path(&self, path: &PathBuf) -> PathBuf { + let mut resolved_path = self.get_app_workdir(); + resolved_path.push(path); + debug!("Resolved path {:?} to {:?}", path, resolved_path); + resolved_path + } + + pub fn get_app_workdir(&self) -> PathBuf { + let mut workdir = self.config_path.clone(); + workdir.pop(); + if let Some(app_dir_path) = self.get_app_path_config() { + workdir.push(app_dir_path); + }; + workdir + } + + fn get_app_path_config(&self) -> Option { + self.config + .as_ref() + .map(|app| app.path.clone().unwrap_or(PathBuf::from("."))) + } +} + +pub async fn loader(text: &str, success: &str, fail: &str, task: F) -> Result +where + F: FnOnce() -> Fut, + Fut: Future>, +{ + let loading = Loading::default(); + loading.text(text); + let res = task().await; + if res.is_ok() { + loading.success(success); + } else { + loading.fail(fail); + } + loading.end(); + res +} + +#[cfg(test)] +mod test { + use std::path::PathBuf; + + use clap::{CommandFactory, FromArgMatches}; + + use super::*; + + pub fn parse_command_line(line: String) -> Result { + let args = &mut line.split_whitespace().collect::>(); + let command = ::command(); + let mut matches = command + .clone() + .try_get_matches_from(args.clone()) + .map_err(format_error)?; + ::from_arg_matches_mut(&mut matches).map_err(format_error) + } + + fn format_error(err: clap::Error) -> clap::Error { + let mut command = ::command(); + err.format(&mut command) + } + + #[test] + fn test_load_config() { + let mut context = CommandContext::default(); + context.config_path = PathBuf::from("test/config/app_path.yml"); + let app = context.load_config().unwrap(); + assert_eq!(app.path, Some(PathBuf::from("test_app"))); + } + + macro_rules! resolve_path_tests { + ($($name:ident: $value:expr,)*) => { + mod resolve_path { + use std::path::PathBuf; + use super::super::*; + $( + #[test] + fn $name() { + let (config_path, config, expected) = $value; + let mut context = CommandContext::default(); + context.config_path = PathBuf::from(config_path); + context.config = config; + let path = PathBuf::from(".lenra/compose.yml"); + let resolved_path = context.resolve_path(&path); + assert_eq!( + resolved_path, + PathBuf::from(expected) + ); + } + )* + } + } + } + + resolve_path_tests! { + simple: ( + "", + Some(Application {..Default::default()}), + "./.lenra/compose.yml" + ), + app_path: ( + "", + Some(Application {path: Some(PathBuf::from("test_app")), ..Default::default()}), + "test_app/.lenra/compose.yml" + ), + app_path_and_config_file: ( + "test/config/lenra.yml", + Some(Application {path: Some(PathBuf::from("test_app")), ..Default::default()}), + "test/config/test_app/.lenra/compose.yml" + ), + app_path_in_parent_dir: ( + "", + Some(Application {path: Some(PathBuf::from("../test_app")), ..Default::default()}), + "../test_app/.lenra/compose.yml" + ), + app_path_in_parent_dir_and_config_file: ( + "test/config/lenra.yml", + Some(Application {path: Some(PathBuf::from("../test_app")), ..Default::default()}), + "test/config/../test_app/.lenra/compose.yml" + ), + } + + macro_rules! get_app_workdir_tests { + ($($name:ident: $value:expr,)*) => { + mod get_app_workdir { + use std::path::PathBuf; + use super::super::*; + $( + #[test] + fn $name() { + let (config_path, config, expected) = $value; + let mut context = CommandContext::default(); + context.config_path = PathBuf::from(config_path); + context.config = config; + let workdir = context.get_app_workdir(); + assert_eq!( + workdir, + PathBuf::from(expected) + ); + } + )* + } + } + } + + get_app_workdir_tests! { + simple: ( + "", + Some(Application {..Default::default()}), + "." + ), + app_path: ( + "", + Some(Application {path: Some(PathBuf::from("test_app")), ..Default::default()}), + "test_app" + ), + app_path_and_config_file: ( + "test/config/lenra.yml", + Some(Application {path: Some(PathBuf::from("test_app")), ..Default::default()}), + "test/config/test_app" + ), + app_path_in_parent_dir: ( + "", + Some(Application {path: Some(PathBuf::from("../test_app")), ..Default::default()}), + "../test_app" + ), + app_path_in_parent_dir_and_config_file: ( + "test/config/lenra.yml", + Some(Application {path: Some(PathBuf::from("../test_app")), ..Default::default()}), + "test/config/../test_app" + ), + } + + #[test] + fn test_get_app_path_config() { + let mut context = CommandContext::default(); + let app = Application { + path: Some(PathBuf::from("test_app")), + ..Default::default() + }; + context.config = Some(app); + let app_path = context.get_app_path_config().unwrap(); + assert_eq!(app_path, PathBuf::from("test_app")); + } +} diff --git a/src/cli/new.rs b/src/cli/new.rs new file mode 100644 index 0000000..d30eb30 --- /dev/null +++ b/src/cli/new.rs @@ -0,0 +1,286 @@ +//! # new +//! +//! The new subcommand creates a new Lenra app project from a template + +use async_trait::async_trait; +pub use clap::Args; + +use crate::cli::CliCommand; +use crate::errors::{Error, Result}; +use crate::{cli, git, lenra, template}; + +use super::CommandContext; + +#[derive(Args, Debug, Clone)] +pub struct New { + /// The project template topics from which your project will be created. + /// For example, defining `rust` look for the next API endpoint: https://api.github.com/search/repositories?q=topic:lenra+topic:template+topic:rust&sort=stargazers + /// You can find all the templates at this url: https://github.com/search?q=topic%3Alenra+topic%3Atemplate&sort=stargazers&type=repositories + /// You also can set the template project full url to use custom ones. + pub topics: Vec, + + /// The new project path + #[clap(short, long, parse(from_os_str), default_value = ".")] + path: std::path::PathBuf, +} + +#[async_trait] +impl CliCommand for New { + async fn run(&self, _context: &mut CommandContext) -> Result<()> { + log::debug!("topics {:?}", self.topics); + + let template = + if self.topics.len() == 1 && git::GIT_REPO_REGEX.is_match(self.topics[0].as_str()) { + self.topics[0].clone() + } else { + let repos = template::list_templates(&self.topics).await?; + if repos.is_empty() { + return Err(Error::NoTemplateFound); + } else if repos.len() == 1 { + repos[0].url.clone() + } else { + template::choose_repository(repos).await?.url + } + }; + + println!("Using template: {}", template); + cli::loader( + "Creating new project...", + "Project created", + "Failed creating new project", + || async { lenra::create_new_project(template.as_str(), &self.path).await }, + ) + .await + } +} + +#[cfg(test)] +mod tests { + use std::{rc::Rc, sync::Mutex}; + + use mocktopus::mocking::{MockResult, Mockable}; + + use super::*; + use crate::{ + cli::{self, Command}, + git::Repository, + template, + }; + + const NODE_TEMPLATE_HTTP_URL: &str = "https://github.com/lenra-io/template-javascript.git"; + const NODE_TEMPLATE_SSH_URL: &str = "git@github.com:lenra-io/template-javascript.git"; + const BUN_TEMPLATE_HTTP_URL: &str = "https://github.com/taorepoara/lenra-template-bun-js.git"; + + fn mock_all() { + template::list_templates.mock_safe(|_| unreachable!()); + template::choose_repository.mock_safe(|_| unreachable!()); + lenra::create_new_project.mock_safe(|_, _| unreachable!()); + } + + #[tokio::test] + async fn no_matching_template() -> Result<(), Box> { + let cli = cli::test::parse_command_line(String::from("lenra new js"))?; + let command = cli.command; + let new = match command { + Some(Command::New(new)) => new, + _ => panic!("wrong command"), + }; + let expected_topics = vec!["js".to_string()]; + + assert_eq!(new.path, std::path::PathBuf::from(".")); + assert_eq!(new.topics, expected_topics); + mock_all(); + let list_templates_call_counter = Rc::new(Mutex::new(0)); + let counter = Rc::clone(&list_templates_call_counter); + template::list_templates.mock_safe(move |topics| { + let mut num = counter.lock().unwrap(); + *num += 1; + println!("called {} times", *num); + assert_eq!(topics, &expected_topics); + MockResult::Return(Box::pin(async move { Ok(vec![]) })) + }); + let result = new + .run(&mut CommandContext { + ..Default::default() + }) + .await; + let call_count = *list_templates_call_counter.lock().unwrap(); + println!("called {} times", call_count); + assert_eq!(call_count, 1); + assert!(result.is_err()); + let error = result.unwrap_err(); + match error { + Error::NoTemplateFound => (), + er => panic!("wrong error type {er}"), + } + Ok(()) + } + + #[tokio::test] + async fn one_matching_template() -> Result<(), Box> { + let cli = cli::test::parse_command_line(String::from("lenra new js"))?; + let command = cli.command; + let new = match command { + Some(Command::New(new)) => new, + _ => panic!("wrong command"), + }; + let expected_topics = vec!["js".to_string()]; + + assert_eq!(new.path, std::path::PathBuf::from(".")); + assert_eq!(new.topics, expected_topics); + mock_all(); + let list_templates_call_counter = Rc::new(Mutex::new(0)); + let counter = Rc::clone(&list_templates_call_counter); + template::list_templates.mock_safe(move |topics| { + let mut num = counter.lock().unwrap(); + *num += 1; + assert_eq!(topics, &expected_topics); + MockResult::Return(Box::pin(async move { + Ok(vec![Repository { + name: "template-javascript".to_string(), + description: "Javascript template".to_string(), + url: NODE_TEMPLATE_HTTP_URL.into(), + stars: 0, + }]) + })) + }); + let create_new_project_call_counter = Rc::new(Mutex::new(0)); + let counter = Rc::clone(&create_new_project_call_counter); + lenra::create_new_project.mock_safe(move |_, _| { + let mut num = counter.lock().unwrap(); + *num += 1; + MockResult::Return(Box::pin(async move { Ok(()) })) + }); + let result = new + .run(&mut CommandContext { + ..Default::default() + }) + .await; + assert_eq!(*list_templates_call_counter.lock().unwrap(), 1); + assert_eq!(*create_new_project_call_counter.lock().unwrap(), 1); + assert!(result.is_ok()); + Ok(()) + } + + #[tokio::test] + async fn two_matching_templates() -> Result<(), Box> { + let cli = cli::test::parse_command_line(String::from("lenra new js"))?; + let command = cli.command; + let new = match command { + Some(Command::New(new)) => new, + _ => panic!("wrong command"), + }; + let expected_topics = vec!["js".to_string()]; + + assert_eq!(new.path, std::path::PathBuf::from(".")); + assert_eq!(new.topics, expected_topics); + mock_all(); + let list_templates_call_counter = Rc::new(Mutex::new(0)); + let counter = Rc::clone(&list_templates_call_counter); + template::list_templates.mock_safe(move |topics| { + let mut num = counter.lock().unwrap(); + *num += 1; + assert_eq!(topics, &expected_topics); + MockResult::Return(Box::pin(async move { + Ok(vec![ + Repository { + name: "template-javascript".to_string(), + description: "Javascript template".to_string(), + url: NODE_TEMPLATE_HTTP_URL.into(), + stars: 1, + }, + Repository { + name: "template-bun".to_string(), + description: "Javascript template with Bun.sh".to_string(), + url: BUN_TEMPLATE_HTTP_URL.into(), + stars: 0, + }, + ]) + })) + }); + let choose_repository_call_counter = Rc::new(Mutex::new(0)); + let counter = Rc::clone(&choose_repository_call_counter); + template::choose_repository.mock_safe(move |repos| { + let mut num = counter.lock().unwrap(); + *num += 1; + assert_eq!(repos.len(), 2); + MockResult::Return(Box::pin(async move { Ok(repos[0].clone()) })) + }); + let create_new_project_call_counter = Rc::new(Mutex::new(0)); + let counter = Rc::clone(&create_new_project_call_counter); + lenra::create_new_project.mock_safe(move |_, _| { + let mut num = counter.lock().unwrap(); + *num += 1; + MockResult::Return(Box::pin(async move { Ok(()) })) + }); + let result = new + .run(&mut CommandContext { + ..Default::default() + }) + .await; + assert_eq!(*list_templates_call_counter.lock().unwrap(), 1); + assert_eq!(*create_new_project_call_counter.lock().unwrap(), 1); + assert!(result.is_ok()); + Ok(()) + } + + #[tokio::test] + async fn from_http_url() -> Result<(), Box> { + let cli = cli::test::parse_command_line(format!("lenra new {}", NODE_TEMPLATE_HTTP_URL))?; + let command = cli.command; + let new = match command { + Some(Command::New(new)) => new, + _ => panic!("wrong command"), + }; + let expected_topics = vec![NODE_TEMPLATE_HTTP_URL.to_string()]; + + assert_eq!(new.path, std::path::PathBuf::from(".")); + assert_eq!(new.topics, expected_topics); + mock_all(); + let create_new_project_call_counter = Rc::new(Mutex::new(0)); + let counter = Rc::clone(&create_new_project_call_counter); + lenra::create_new_project.mock_safe(move |_, _| { + let mut num = counter.lock().unwrap(); + *num += 1; + MockResult::Return(Box::pin(async move { Ok(()) })) + }); + let result = new + .run(&mut CommandContext { + ..Default::default() + }) + .await; + assert_eq!(*create_new_project_call_counter.lock().unwrap(), 1); + assert!(result.is_ok()); + Ok(()) + } + + #[tokio::test] + async fn from_ssh_url() -> Result<(), Box> { + let cli = cli::test::parse_command_line(format!("lenra new {}", NODE_TEMPLATE_SSH_URL))?; + let command = cli.command; + let new = match command { + Some(Command::New(new)) => new, + _ => panic!("wrong command"), + }; + let expected_topics = vec![NODE_TEMPLATE_SSH_URL.to_string()]; + + assert_eq!(new.path, std::path::PathBuf::from(".")); + assert_eq!(new.topics, expected_topics); + mock_all(); + let create_new_project_call_counter = Rc::new(Mutex::new(0)); + let counter = Rc::clone(&create_new_project_call_counter); + lenra::create_new_project.mock_safe(move |_, _| { + let mut num = counter.lock().unwrap(); + *num += 1; + MockResult::Return(Box::pin(async move { Ok(()) })) + }); + let result = new + .run(&mut CommandContext { + ..Default::default() + }) + .await; + assert_eq!(*create_new_project_call_counter.lock().unwrap(), 1); + assert!(result.is_ok()); + Ok(()) + } +} diff --git a/src/cli/reload.rs b/src/cli/reload.rs new file mode 100644 index 0000000..bf53d7e --- /dev/null +++ b/src/cli/reload.rs @@ -0,0 +1,24 @@ +use async_trait::async_trait; +pub use clap::Args; + +use crate::cli::CliCommand; +use crate::errors::Result; + +use super::{ + build::{build_loader, generate_app_env_loader}, + start::{clear_cache_loader, start_loader}, + CommandContext, +}; + +#[derive(Args, Default, Debug, Clone)] +pub struct Reload; + +#[async_trait] +impl CliCommand for Reload { + async fn run(&self, context: &mut CommandContext) -> Result<()> { + generate_app_env_loader(context, false).await?; + build_loader(context).await?; + start_loader(context).await?; + clear_cache_loader(context).await + } +} diff --git a/src/cli/start.rs b/src/cli/start.rs new file mode 100644 index 0000000..df9cbf8 --- /dev/null +++ b/src/cli/start.rs @@ -0,0 +1,41 @@ +use async_trait::async_trait; +pub use clap::Args; + +use crate::cli::CliCommand; +use crate::errors::Result; +use crate::lenra; + +use super::{loader, CommandContext}; + +#[derive(Args, Default, Debug, Clone)] +pub struct Start; + +#[async_trait] +impl CliCommand for Start { + async fn run(&self, context: &mut CommandContext) -> Result<()> { + start_loader(context).await?; + clear_cache_loader(context).await?; + lenra::display_app_access_url(); + Ok(()) + } +} + +pub async fn start_loader(context: &mut CommandContext) -> Result<()> { + loader( + "Start app environment...", + "App environment started", + "Failed starting app", + || async { lenra::start_env(context).await }, + ) + .await +} + +pub async fn clear_cache_loader(context: &mut CommandContext) -> Result<()> { + loader( + "Clearing cache...", + "Cache cleared", + "Failed clearing cache", + || async { lenra::clear_cache(context).await }, + ) + .await +} diff --git a/src/cli/stop.rs b/src/cli/stop.rs new file mode 100644 index 0000000..e690b9d --- /dev/null +++ b/src/cli/stop.rs @@ -0,0 +1,28 @@ +use async_trait::async_trait; +pub use clap::Args; + +use crate::cli::CliCommand; +use crate::errors::Result; +use crate::lenra; + +use super::{loader, CommandContext}; + +#[derive(Args, Debug, Clone)] +pub struct Stop; + +#[async_trait] +impl CliCommand for Stop { + async fn run(&self, context: &mut CommandContext) -> Result<()> { + stop_loader(context).await + } +} + +pub async fn stop_loader(context: &mut CommandContext) -> Result<()> { + loader( + "Stop app environment...", + "App environment stopped", + "Failed stopping app environment", + || async { lenra::stop_env(context).await }, + ) + .await +} diff --git a/src/cli/terminal.rs b/src/cli/terminal.rs new file mode 100644 index 0000000..8d34d20 --- /dev/null +++ b/src/cli/terminal.rs @@ -0,0 +1,203 @@ +use std::fs; + +use crate::lenra; +pub use clap::{Args, Parser, Subcommand}; +use clap::{CommandFactory, FromArgMatches}; +use colored::{Color, Colorize}; +use dirs::config_dir; +use log::{debug, warn}; +use rustyline::{error::ReadlineError, Editor}; + +use crate::{ + docker_compose::Service, + errors::{Error, Result}, +}; + +use crate::cli::{check::Check, logs::Logs, CliCommand}; + +use super::{ + build::Build, dev::Dev, reload::Reload, start::Start, stop::Stop, update::Update, + upgrade::Upgrade, CommandContext, +}; + +const LENRA_COMMAND: &str = "lenra"; +const READLINE_PROMPT: &str = "[lenra]$ "; +// const ESCAPE_EVENT: KeyEvent = KeyEvent(KeyCode::Esc, Modifiers::NONE); + +pub async fn start_terminal(context: &mut CommandContext) -> Result<()> { + let history_path = config_dir() + .ok_or(Error::Custom("Can't get the user config directory".into()))? + .join("lenra") + .join("dev.history"); + let mut rl = Editor::<()>::new()?; + + debug!("Load history from {:?}", history_path); + if rl.load_history(&history_path).is_err() { + debug!("No previous history."); + } + + loop { + let readline = rl.readline(READLINE_PROMPT); + let command = match readline { + Ok(line) => { + if line.trim().is_empty() { + continue; + } + + rl.add_history_entry(line.as_str()); + + let parse_result = parse_command_line(line.clone()).map_err(Error::from); + match parse_result { + Ok(dev_cli) => dev_cli.command, + Err(Error::ParseCommand(clap_error)) => { + clap_error.print().ok(); + continue; + } + Err(err) => { + debug!("Parse command error: {}", err); + warn!("not a valid command {}", line); + continue; + } + } + } + Err(ReadlineError::Interrupted) => { + debug!("CTRL-C"); + break; + } + Err(ReadlineError::Eof) => { + debug!("CTRL-D"); + break; + } + Err(err) => { + println!("Error: {:?}", err); + break; + } + }; + + debug!("Run command {:#?}", command); + let keep_running = run_command(&command, context).await; + if !keep_running { + break; + } + } + debug!("Save history to {:?}", history_path); + fs::create_dir_all(history_path.parent().unwrap())?; + rl.save_history(&history_path).map_err(Error::from) +} + +pub async fn run_command(command: &TerminalCommand, context: &mut CommandContext) -> bool { + debug!("Run command {:#?}", command); + command.run(context).await.unwrap_or_else(|error| { + eprintln!("{}", error.to_string().color(Color::Red)); + }); + let keep_running = match command { + TerminalCommand::Exit | TerminalCommand::Stop(_) => false, + _ => true, + }; + keep_running +} + +fn parse_command_line(line: String) -> Result { + let args = &mut line.split_whitespace().collect::>(); + + let first_arg = if args.len() > 0 { Some(args[0]) } else { None }; + if let Some(arg) = first_arg { + if LENRA_COMMAND != arg { + args.push(LENRA_COMMAND); + args.rotate_right(1); + } + } + debug!("Try to parse dev terminal command: {:?}", args); + + let command = ::command(); + let mut matches = command + .clone() + .try_get_matches_from(args.clone()) + .map_err(format_error)?; + ::from_arg_matches_mut(&mut matches).map_err(format_error) +} + +fn format_error(err: clap::Error) -> clap::Error { + let mut command = ::command(); + err.format(&mut command) +} + +/// The Lenra interactive command line interface +#[derive(Parser, Debug, Clone)] +#[clap(rename_all = "kebab-case")] +pub struct TerminalCli { + #[clap(subcommand)] + pub command: TerminalCommand, +} + +/// The interactive commands +#[derive(Subcommand, Clone, Debug)] +pub enum TerminalCommand { + /// Build your app in release mode + Build(Build), + /// Start your app previously built with the build command + Start(Start), + /// View output from the containers + Logs(Logs), + /// Stop your app previously started with the start command + Stop(Stop), + /// Start the app in an interactive mode + Dev(Dev), + /// Upgrade the app with the last template updates + Upgrade(Upgrade), + /// Update the tools Docker images + Update(Update), + /// Checks the running app + Check(Check), + /// Reload the app by rebuilding and restarting it + Reload(Reload), + /// Exits the terminal + Exit, + /// Exposes the app ports + Expose(Expose), +} + +#[derive(Args, Clone, Debug)] +pub struct Expose { + /// The service list to expose + #[clap(value_enum, default_values = &["app", "postgres", "mongo"])] + pub services: Vec, +} + +impl TerminalCommand { + pub async fn run(&self, context: &mut CommandContext) -> Result<()> { + log::debug!("Run terminal command {:?}", self); + if self.need_config_reload() { + context.load_config()?; + } + match self { + TerminalCommand::Exit => {} + TerminalCommand::Expose(expose) => { + lenra::generate_app_env(context, false).await?; + lenra::start_env(context).await?; + + context.expose = expose.services.clone(); + } + TerminalCommand::Build(build) => build.run(context).await?, + TerminalCommand::Start(start) => start.run(context).await?, + TerminalCommand::Logs(logs) => logs.run(context).await?, + TerminalCommand::Stop(stop) => stop.run(context).await?, + TerminalCommand::Dev(dev) => dev.run(context).await?, + TerminalCommand::Upgrade(upgrade) => upgrade.run(context).await?, + TerminalCommand::Update(update) => update.run(context).await?, + TerminalCommand::Check(check) => check.run(context).await?, + TerminalCommand::Reload(reload) => reload.run(context).await?, + }; + Ok(()) + } + + fn need_config_reload(&self) -> bool { + match self { + TerminalCommand::Build(_) + | TerminalCommand::Start(_) + | TerminalCommand::Reload(_) + | TerminalCommand::Update(_) => true, + _ => false, + } + } +} diff --git a/src/cli/update.rs b/src/cli/update.rs new file mode 100644 index 0000000..e48750c --- /dev/null +++ b/src/cli/update.rs @@ -0,0 +1,33 @@ +use async_trait::async_trait; +pub use clap::Args; + +use crate::cli::CliCommand; +use crate::docker_compose::Service; +use crate::errors::Result; +use crate::lenra; + +use super::{loader, CommandContext}; + +#[derive(Args, Debug, Clone)] +pub struct Update { + /// The service list to pull + #[clap(value_enum, default_values = &["devtool", "postgres", "mongo"])] + pub services: Vec, +} + +#[async_trait] +impl CliCommand for Update { + async fn run(&self, context: &mut CommandContext) -> Result<()> { + update_loader(context, &self.services).await + } +} + +pub async fn update_loader(context: &mut CommandContext, services: &Vec) -> Result<()> { + loader( + "Update environment images...", + "Environment images updated", + "Failed updating environment images", + || async { lenra::update_env_images(context, services).await }, + ) + .await +} diff --git a/src/cli/upgrade.rs b/src/cli/upgrade.rs new file mode 100644 index 0000000..9715199 --- /dev/null +++ b/src/cli/upgrade.rs @@ -0,0 +1,18 @@ +use async_trait::async_trait; +use clap; + +use crate::cli::CliCommand; +use crate::errors::Result; +use crate::lenra; + +use super::CommandContext; + +#[derive(clap::Args, Debug, Clone)] +pub struct Upgrade; + +#[async_trait] +impl CliCommand for Upgrade { + async fn run(&self, _context: &mut CommandContext) -> Result<()> { + lenra::upgrade_app().await + } +} diff --git a/src/command.rs b/src/command.rs new file mode 100644 index 0000000..e5bf134 --- /dev/null +++ b/src/command.rs @@ -0,0 +1,47 @@ +use std::process::{Output, Stdio}; + +use tokio::process::Command; + +use crate::errors::{CommandError, Error, Result}; + +static mut INHERIT_STDIO: bool = false; + +pub fn is_inherit_stdio() -> bool { + unsafe { INHERIT_STDIO } +} + +pub fn set_inherit_stdio(val: bool) { + unsafe { + INHERIT_STDIO = val; + } +} + +pub fn create_command(cmd: &str) -> Command { + let mut cmd = Command::new(cmd); + cmd.kill_on_drop(true); + if is_inherit_stdio() { + cmd.stdout(Stdio::inherit()).stderr(Stdio::inherit()); + } else { + cmd.stdout(Stdio::null()).stderr(Stdio::null()); + } + cmd +} + +pub async fn run_command(command: Command) -> Result { + let mut command = Command::from(command); + let output = command.output().await?; + + if !output.status.success() { + return Err(Error::Command(CommandError { command, output })); + } + + Ok(output) +} + +pub async fn get_command_output(command: Command) -> Result { + let output = run_command(command).await?; + + String::from_utf8(output.stdout) + .map(|name| name.trim().to_string()) + .map_err(Error::from) +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..667f84e --- /dev/null +++ b/src/config.rs @@ -0,0 +1,421 @@ +use std::{collections::HashMap, fmt::Debug, fs, path::PathBuf}; + +use dofigen_lib::{ + self, from_file_path, generate_dockerfile, generate_dockerignore, Artifact, Builder, + Healthcheck, +}; +use serde::{Deserialize, Serialize}; +use serde_yaml::Value; + +use crate::{ + cli::CommandContext, + docker_compose::generate_docker_compose, + errors::{Error, Result}, +}; + +pub const DEFAULT_CONFIG_FILE: &str = "lenra.yml"; +pub const LENRA_CACHE_DIRECTORY: &str = ".lenra"; + +pub const DOCKERFILE_DEFAULT_PATH: [&str; 2] = [LENRA_CACHE_DIRECTORY, "Dockerfile"]; +pub const DOCKERIGNORE_DEFAULT_PATH: [&str; 2] = [LENRA_CACHE_DIRECTORY, "Dockerfile.dockerignore"]; +pub const DOCKERCOMPOSE_DEFAULT_PATH: [&str; 2] = [LENRA_CACHE_DIRECTORY, "compose.yml"]; + +pub const OF_WATCHDOG_BUILDER: &str = "of-watchdog"; +pub const OF_WATCHDOG_IMAGE: &str = "ghcr.io/openfaas/of-watchdog"; +pub const OF_WATCHDOG_VERSION: &str = "0.9.10"; + +pub fn load_config_file(path: &std::path::PathBuf) -> Result { + let file = fs::File::open(path).map_err(|err| Error::OpenFile(err, path.clone()))?; + match path.extension() { + Some(os_str) => match os_str.to_str() { + Some("yml" | "yaml" | "json") => { + Ok(serde_yaml::from_reader(file).map_err(Error::from)?) + } + Some(ext) => Err(Error::Custom(format!( + "Not managed config file extension {}", + ext + ))), + None => Err(Error::Custom( + "The config file has no extension".to_string(), + )), + }, + None => Err(Error::Custom( + "The config file has no extension".to_string(), + )), + } +} + +/** The main component of the config file */ +#[derive(Serialize, Deserialize, Debug, PartialEq, Default, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Application { + pub path: Option, + pub generator: Generator, + pub dev: Option, +} + +/** The dev specific configuration */ +#[derive(Serialize, Deserialize, Debug, PartialEq, Clone, Default)] +pub struct Dev { + pub app: Option, + pub devtool: Option, + pub postgres: Option, + pub mongo: Option, + pub dofigen: Option, +} + +/** A Docker image */ +#[derive(Serialize, Deserialize, Debug, PartialEq, Clone, Default)] +pub struct Image { + pub image: Option, + pub tag: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] +pub struct DebugDofigen { + pub cmd: Option>, + pub ports: Option>, +} + +/** The application generator configuration */ +#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] +#[serde(untagged)] +pub enum Generator { + Dofigen(Dofigen), + DofigenFile(DofigenFile), + DofigenError { dofigen: Value }, + Dockerfile(Dockerfile), + Docker(Docker), + Unknow, +} + +/** The Dofigen configuration */ +#[derive(Serialize, Deserialize, Debug, PartialEq, Clone, Default)] +pub struct Dofigen { + pub dofigen: dofigen_lib::Image, +} + +/** The Dofigen configuration file */ +#[derive(Serialize, Deserialize, Debug, PartialEq, Clone, Default)] +pub struct DofigenFile { + pub dofigen: std::path::PathBuf, +} + +/** The Docker configuration */ +#[derive(Serialize, Deserialize, Debug, PartialEq, Clone, Default)] +pub struct Docker { + pub docker: String, + pub ignore: Option, +} + +/** The Docker configuration file */ +#[derive(Serialize, Deserialize, Debug, PartialEq, Clone, Default)] +pub struct Dockerfile { + pub docker: std::path::PathBuf, +} + +impl Application { + /// Generates all the files needed to build and run the application + pub async fn generate_files(&self, context: &mut CommandContext, debug: bool) -> Result<()> { + self.generate_docker_files(context, debug)?; + self.generate_docker_compose_file(context, debug).await?; + Ok(()) + } + + pub fn generate_docker_files(&self, context: &mut CommandContext, debug: bool) -> Result<()> { + log::info!("Docker files generation"); + // create the `.lenra` cache directory + fs::create_dir_all(context.resolve_path(&PathBuf::from(LENRA_CACHE_DIRECTORY))).unwrap(); + + match &self.generator { + // If args '--prod' is passed then not debug + Generator::Dofigen(dofigen) => { + self.build_dofigen(context, dofigen.dofigen.clone(), debug) + } + Generator::DofigenFile(dofigen_file) => self.build_dofigen( + context, + from_file_path(&context.resolve_path(&dofigen_file.dofigen)) + .map_err(Error::from)?, + debug, + ), + Generator::DofigenError { dofigen: _ } => Err(Error::Custom( + "Your Dofigen configuration is not correct".into(), + )), + Generator::Dockerfile(_dockerfile) => Ok(()), + Generator::Docker(docker) => { + self.save_docker_content(context, docker.docker.clone(), docker.ignore.clone()) + } + Generator::Unknow => Err(Error::Custom("Not managed generator".into())), + } + } + + pub async fn generate_docker_compose_file( + &self, + context: &mut CommandContext, + debug: bool, + ) -> Result<()> { + log::info!("Docker Compose file generation"); + // create the `.lenra` cache directory + fs::create_dir_all(context.resolve_path(&PathBuf::from(LENRA_CACHE_DIRECTORY))) + .map_err(Error::from)?; + + let dockerfile: PathBuf = if let Generator::Dockerfile(file_conf) = &self.generator { + context.resolve_path(&file_conf.docker.clone()) + } else { + context.resolve_path(&DOCKERFILE_DEFAULT_PATH.iter().collect()) + }; + + generate_docker_compose(context, dockerfile, &self.dev, debug) + .await + .map_err(Error::from)?; + Ok(()) + } + + /// Builds a Docker image from a Dofigen structure + fn build_dofigen( + &self, + context: &mut CommandContext, + image: dofigen_lib::Image, + debug: bool, + ) -> Result<()> { + // Generate the Dofigen config with OpenFaaS overlay to handle the of-watchdog + let overlay = self.dofigen_of_overlay(image)?; + + // when debug add cmd and ports to the Dofigen descriptor + let overlay = if debug { + self.dofigen_debug_overlay(overlay)? + } else { + overlay + }; + + // generate the Dockerfile and .dockerignore files with Dofigen + let dockerfile = generate_dockerfile(&overlay); + let dockerignore = generate_dockerignore(&overlay); + self.save_docker_content(context, dockerfile, Some(dockerignore)) + } + + fn dofigen_debug_overlay(&self, image: dofigen_lib::Image) -> Result { + log::info!("Adding debug overlay to the Dofigen descriptor"); + let mut debug_overlay = image; + if let Some(dev) = &self.dev { + if let Some(dofigen) = &dev.dofigen { + if let Some(cmd) = &dofigen.cmd { + let mut envs = debug_overlay.envs.unwrap(); + envs.insert("fprocess".to_string(), cmd.join(" ")); + debug_overlay.envs = Some(envs); + } + if let Some(ports) = &dofigen.ports { + debug_overlay.ports = Some( + debug_overlay + .ports + .unwrap() + .into_iter() + .chain(ports.into_iter().map(|&value| value)) + .collect(), + ) + } + } + } + Ok(debug_overlay) + } + + /// Add an overlay to the given Dofigen structure to manage OpenFaaS + fn dofigen_of_overlay(&self, image: dofigen_lib::Image) -> Result { + log::info!("Adding OpenFaaS overlay to the Dofigen descriptor"); + let mut builders = if let Some(vec) = image.builders { + vec + } else { + Vec::new() + }; + // add of-watchdog builder + builders.push(Builder { + name: Some(String::from(OF_WATCHDOG_BUILDER)), + image: format!("{}:{}", OF_WATCHDOG_IMAGE, OF_WATCHDOG_VERSION), + ..Default::default() + }); + + let mut artifacts = if let Some(arts) = image.artifacts { + arts + } else { + Vec::new() + }; + // get of-watchdog artifact + artifacts.push(Artifact { + builder: OF_WATCHDOG_BUILDER.to_string(), + source: "/fwatchdog".to_string(), + destination: "/fwatchdog".to_string(), + }); + + let mut envs = if let Some(envs) = image.envs { + envs + } else { + HashMap::new() + }; + + let mut healthcheck = None; + // http mode (not if empty) + if let Some(ports) = image.ports { + if ports.len() > 1 { + return Err(Error::Custom( + "More than one port has been defined in the Dofigen descriptor".into(), + )); + } + if ports.len() == 1 { + envs.insert("mode".to_string(), "http".to_string()); + envs.insert( + "upstream_url".to_string(), + format!("http://127.0.0.1:{}", ports[0]), + ); + envs.insert("suppress_lock".to_string(), "true".to_string()); + if !envs.contains_key("exec_timeout") { + envs.insert("exec_timeout".to_string(), "3600".to_string()); + } + if !envs.contains_key("read_timeout") { + envs.insert("read_timeout".to_string(), "3600".to_string()); + } + if !envs.contains_key("write_timeout") { + envs.insert("write_timeout".to_string(), "3600".to_string()); + } + // handle healthcheck + healthcheck = Some(Healthcheck { + cmd: "curl --fail http://localhost:8080/_/health".into(), + start: Some("3s".into()), + interval: Some("3s".into()), + timeout: Some("1s".into()), + retries: Some(10), + }); + } + }; + + // prevent custom entrypoint + if image.entrypoint.is_some() { + return Err(Error::Custom( + "The Dofigen descriptor can't have entrypoint defined. Use cmd instead".into(), + )); + } + + // envs.insert("exec_timeout".to_string(), "0".to_string()); + + if let Some(cmd) = image.cmd { + envs.insert("fprocess".to_string(), cmd.join(" ")); + } else { + return Err(Error::Custom( + "The Dofigen cmd property is not defined".into(), + )); + } + + Ok(dofigen_lib::Image { + image: image.image, + builders: Some(builders), + artifacts: Some(artifacts), + ports: Some(vec![8080]), + envs: Some(envs), + entrypoint: None, + cmd: Some(vec!["/fwatchdog".to_string()]), + user: image.user, + workdir: image.workdir, + adds: image.adds, + root: image.root, + script: image.script, + caches: image.caches, + healthcheck: healthcheck, + ignores: image.ignores, + }) + } + + /// Saves the Dockerfile and dockerignore (if present) files from their contents + fn save_docker_content( + &self, + context: &mut CommandContext, + dockerfile_content: String, + dockerignore_content: Option, + ) -> Result<()> { + let dockerfile_path: PathBuf = + context.resolve_path(&DOCKERFILE_DEFAULT_PATH.iter().collect()); + let dockerignore_path: PathBuf = + context.resolve_path(&DOCKERIGNORE_DEFAULT_PATH.iter().collect()); + + fs::write(dockerfile_path, dockerfile_content)?; + if let Some(content) = dockerignore_content { + fs::write(dockerignore_path, content)?; + } + Ok(()) + } +} + +impl Default for Generator { + fn default() -> Self { + Generator::Unknow + } +} + +#[cfg(test)] +mod dofigen_of_overlay_tests { + use super::*; + + #[test] + fn simple_image() { + let image = dofigen_lib::Image { + image: "my-dockerimage".into(), + cmd: Some(vec!["/app/my-app".into()]), + ..Default::default() + }; + let overlayed_image = dofigen_lib::Image { + builders: Some(vec![Builder { + name: Some("of-watchdog".into()), + image: format!("ghcr.io/openfaas/of-watchdog:{}", OF_WATCHDOG_VERSION), + ..Default::default() + }]), + image: String::from("my-dockerimage"), + envs: Some( + [ + // ("exec_timeout".to_string(), "0".to_string()), + ("fprocess".to_string(), "/app/my-app".to_string()), + ] + .into(), + ), + artifacts: Some(vec![Artifact { + builder: "of-watchdog".into(), + source: "/fwatchdog".into(), + destination: "/fwatchdog".into(), + }]), + ports: Some(vec![8080]), + cmd: Some(vec!["/fwatchdog".into()]), + ..Default::default() + }; + let config = Application { + generator: Generator::Dofigen(Dofigen { + dofigen: image.clone(), + }), + ..Default::default() + }; + + assert_eq!(config.dofigen_of_overlay(image).unwrap(), overlayed_image); + } + + #[test] + #[should_panic] + fn no_cmd() { + let image = dofigen_lib::Image { + image: "my-dockerimage".into(), + ..Default::default() + }; + let config = Application { + generator: Generator::Dofigen(Dofigen { + dofigen: image.clone(), + }), + ..Default::default() + }; + config.dofigen_of_overlay(image).unwrap(); + } +} + +impl Image { + pub fn to_image(&self, default_image: &str, default_tag: &str) -> String { + format!( + "{}:{}", + self.image.clone().unwrap_or(default_image.to_string()), + self.tag.clone().unwrap_or(default_tag.to_string()) + ) + } +} diff --git a/src/devtool.rs b/src/devtool.rs new file mode 100644 index 0000000..8efb0bc --- /dev/null +++ b/src/devtool.rs @@ -0,0 +1,23 @@ +use log::debug; + +use crate::{ + cli::CommandContext, + docker_compose::{execute_compose_service_command, Service}, + errors::Result, +}; + +pub async fn stop_app_env(context: &mut CommandContext) -> Result<()> { + debug!("Stop app environment"); + execute_compose_service_command( + context, + Service::Devtool, + &[ + "bin/dev_tools", + "rpc", + "ApplicationRunner.Environment.DynamicSupervisor.stop_env(1)", + ], + ) + .await?; + debug!("App environment stopped"); + Ok(()) +} diff --git a/src/docker.rs b/src/docker.rs new file mode 100644 index 0000000..28db242 --- /dev/null +++ b/src/docker.rs @@ -0,0 +1,58 @@ +use std::{ + collections::hash_map::DefaultHasher, + hash::{Hash, Hasher}, +}; + +use regex::Regex; + +pub fn normalize_tag(tag: String) -> String { + let re = Regex::new(r"[^A-Za-z0-9._-]").unwrap(); + let tag = re.replace_all(tag.as_str(), "-").to_string(); + if tag.len() > 63 { + let mut hacher = DefaultHasher::new(); + tag.hash(&mut hacher); + let hash = format!("{:X}", hacher.finish()); + format!( + "{}{}", + tag.chars().take(63 - hash.len()).collect::(), + hash + ) + } else { + tag + } +} + +#[cfg(test)] +mod test_normalize_tag { + use std::{ + collections::hash_map::DefaultHasher, + hash::{Hash, Hasher}, + }; + + use regex::Regex; + + use super::normalize_tag; + + #[test] + fn prefixed_tag_name() { + let tag_name = "prefixed/branch-name".to_string(); + assert_eq!(normalize_tag(tag_name), "prefixed-branch-name".to_string()); + } + + #[test] + fn long_tag_name() { + let tag_name = + "prefixed/branch-name/with_many-many.many_many&many-many/underscore".to_string(); + let re = Regex::new(r"[^A-Za-z0-9._-]").unwrap(); + let tag = re.replace_all(tag_name.as_str(), "-").to_string(); + let mut hacher = DefaultHasher::new(); + tag.hash(&mut hacher); + let hash = format!("{:X}", hacher.finish()); + let tag = format!( + "{}{}", + tag.chars().take(63 - hash.len()).collect::(), + hash + ); + assert_eq!(normalize_tag(tag_name), tag.to_string()); + } +} diff --git a/src/docker_compose.rs b/src/docker_compose.rs new file mode 100644 index 0000000..3911bdb --- /dev/null +++ b/src/docker_compose.rs @@ -0,0 +1,669 @@ +use docker_compose_types::{ + AdvancedBuildStep, BuildStep, Command, Compose, DependsCondition, DependsOnOptions, Deploy, + EnvTypes, Environment, Healthcheck, HealthcheckTest, Limits, Resources, Services, +}; +use itertools::Itertools; +use lazy_static::lazy_static; +use log::{debug, warn}; +use serde::{Deserialize, Serialize}; +use std::process::Stdio; +use std::{convert::TryInto, env, fs, path::PathBuf}; +use strum::Display; +use tokio::process; + +use crate::cli::CommandContext; +use crate::command::{get_command_output, is_inherit_stdio}; +use crate::config::Image; +use crate::docker::normalize_tag; +use crate::errors::Error; +use crate::{ + config::{Dev, DOCKERCOMPOSE_DEFAULT_PATH}, + errors::{CommandError, Result}, + git::get_current_branch, +}; + +pub const APP_SERVICE_NAME: &str = "app"; +pub const DEVTOOL_SERVICE_NAME: &str = "devtool"; +pub const POSTGRES_SERVICE_NAME: &str = "postgres"; +pub const MONGO_SERVICE_NAME: &str = "mongo"; +const APP_BASE_IMAGE: &str = "lenra/app/"; +const APP_DEFAULT_IMAGE: &str = "my"; +const APP_DEFAULT_IMAGE_TAG: &str = "latest"; +const DEVTOOL_IMAGE: &str = "lenra/devtools"; +const DEVTOOL_DEFAULT_TAG: &str = "beta"; +const POSTGRES_IMAGE: &str = "postgres"; +const POSTGRES_IMAGE_TAG: &str = "13"; +const MONGO_IMAGE: &str = "mongo"; +const MONGO_IMAGE_TAG: &str = "5"; +pub const OF_WATCHDOG_PORT: u16 = 8080; +pub const DEVTOOL_WEB_PORT: u16 = 4000; +pub const DEVTOOL_API_PORT: u16 = 4001; +pub const DEVTOOL_OAUTH_PORT: u16 = 4444; +pub const MONGO_PORT: u16 = 27017; +pub const POSTGRES_PORT: u16 = 5432; +pub const NON_ROOT_USER: &str = "12000"; +const MEMORY_RESERVATION: &str = "128M"; +const MEMORY_LIMIT: &str = "256M"; + +lazy_static! { + static ref COMPOSE_COMMAND: std::process::Command = get_compose_command(); +} + +#[derive(Serialize, Deserialize, Debug, PartialEq)] +#[serde(rename_all = "PascalCase")] +pub struct ServiceInformations { + service: Service, + #[serde(rename = "ID")] + id: String, + name: String, + image: String, + command: String, + project: String, + state: ServiceState, + status: String, + created: String, + health: String, + exit_code: String, + publishers: Vec, +} + +#[derive(Serialize, Deserialize, Debug, PartialEq)] +#[serde(rename_all = "PascalCase")] +pub struct Publisher { + #[serde(rename = "URL")] + url: String, + target_port: u16, + published_port: u16, + protocol: String, +} + +#[derive(clap::ValueEnum, Serialize, Deserialize, Clone, Debug, PartialEq, Display)] +pub enum Service { + App, + Devtool, + Postgres, + Mongo, +} + +impl Service { + pub fn to_str(&self) -> &str { + match self { + Service::App => APP_SERVICE_NAME, + Service::Devtool => DEVTOOL_SERVICE_NAME, + Service::Postgres => POSTGRES_SERVICE_NAME, + Service::Mongo => MONGO_SERVICE_NAME, + } + } +} + +#[derive(Clone, Debug)] +pub struct ServiceImages { + pub app: String, + pub devtool: String, + pub postgres: String, + pub mongo: String, +} + +#[derive(clap::ValueEnum, Serialize, Deserialize, Clone, Debug, PartialEq)] +pub enum ServiceState { + Running, + Paused, + Restarting, + Removing, + Dead, + Created, + Exited, +} + +/// Generates the docker-compose.yml file +pub async fn generate_docker_compose( + context: &mut CommandContext, + dockerfile: PathBuf, + dev_conf: &Option, + debug: bool, +) -> Result<()> { + let compose_content = + generate_docker_compose_content(dockerfile, dev_conf, &context.expose, debug).await?; + let compose_path: PathBuf = context.resolve_path(&DOCKERCOMPOSE_DEFAULT_PATH.iter().collect()); + fs::write(compose_path, compose_content).map_err(Error::from)?; + Ok(()) +} + +async fn generate_docker_compose_content( + dockerfile: PathBuf, + dev_conf: &Option, + exposed_services: &Vec, + debug: bool, +) -> Result { + let mut devtool_env_vec: Vec<(String, Option)> = vec![ + ( + "POSTGRES_USER".into(), + Some(EnvTypes::String("postgres".into())), + ), + ( + "POSTGRES_PASSWORD".into(), + Some(EnvTypes::String("postgres".into())), + ), + ( + "POSTGRES_DB".into(), + Some(EnvTypes::String("lenra_devtool".into())), + ), + ]; + let postgres_envs: [(String, Option); 3] = + devtool_env_vec.clone().try_into().unwrap(); + + devtool_env_vec.push(( + "POSTGRES_HOST".into(), + Some(EnvTypes::String(POSTGRES_SERVICE_NAME.into())), + )); + devtool_env_vec.push(( + "OF_WATCHDOG_URL".into(), + Some(EnvTypes::String(format!( + "http://{}:{}", + APP_SERVICE_NAME, OF_WATCHDOG_PORT + ))), + )); + devtool_env_vec.push(( + "LENRA_API_URL".into(), + Some(EnvTypes::String(format!( + "http://{}:{}", + DEVTOOL_SERVICE_NAME, DEVTOOL_API_PORT + ))), + )); + devtool_env_vec.push(( + "MONGO_HOSTNAME".into(), + Some(EnvTypes::String(MONGO_SERVICE_NAME.into())), + )); + let devtool_envs: [(String, Option); 7] = devtool_env_vec.try_into().unwrap(); + + let mongo_envs: [(String, Option); 2] = [ + ( + "MONGO_INITDB_DATABASE".to_string(), + Some(EnvTypes::String("test".into())), + ), + ( + "CONFIG".to_string(), + Some(EnvTypes::String(format!( + r#"{{"_id" : "rs0", "members" : [{{"_id" : 0,"host" : "{}:{}"}}]}}"#, + MONGO_SERVICE_NAME, MONGO_PORT + ))), + ), + ]; + + let service_images = get_services_images(dev_conf).await; + let mut app_ports = vec![]; + if exposed_services.contains(&Service::App) { + app_ports.push(port_to_port_binding(OF_WATCHDOG_PORT)); + } + if debug { + if let Some(conf) = dev_conf { + if let Some(dofigen) = &conf.dofigen { + if let Some(ports) = &dofigen.ports { + for port in ports { + app_ports.push(port_to_port_binding(*port)); + } + } + } + } + } + + let compose = Compose { + services: Some(Services( + [ + ( + APP_SERVICE_NAME.into(), + Some(docker_compose_types::Service { + image: Some(service_images.app), + ports: if !app_ports.is_empty() { Some(app_ports)} else {None}, + build_: Some(BuildStep::Advanced(AdvancedBuildStep { + context: "..".into(), + dockerfile: Some(dockerfile.to_str().unwrap().into()), + ..Default::default() + })), + user: Some(NON_ROOT_USER.into()), + deploy: Some(Deploy { + resources: Some(Resources { + limits: Some(Limits { + memory: Some(MEMORY_LIMIT.into()), + ..Default::default() + }), + reservations: Some(Limits { + memory: Some(MEMORY_RESERVATION.into()), + ..Default::default() + }) + }), + ..Default::default() + }), + // TODO: Add resources management when managed by the docker-compose-types lib + ..Default::default() + }), + ), + ( + DEVTOOL_SERVICE_NAME.into(), + Some(docker_compose_types::Service { + image: Some(service_images.devtool), + ports: Some(vec![DEVTOOL_WEB_PORT, DEVTOOL_API_PORT, DEVTOOL_OAUTH_PORT].into_iter().map(port_to_port_binding).collect()), + environment: Some(Environment::KvPair(devtool_envs.into())), + healthcheck: Some(Healthcheck { + test: Some(HealthcheckTest::Multiple(vec![ + "CMD".into(), + "wget".into(), + "--spider".into(), + "-q".into(), + "http://localhost:4000/health".into(), + ])), + start_period: Some("10s".into()), + interval: Some("1s".into()), + timeout: None, + retries: 5, + disable: false, + }), + depends_on: Some(DependsOnOptions::Conditional( + [( + POSTGRES_SERVICE_NAME.into(), + DependsCondition { + condition: "service_healthy".into(), + }, + ),( + MONGO_SERVICE_NAME.into(), + DependsCondition { + condition: "service_healthy".into(), + }, + )] + .into(), + )), + ..Default::default() + }), + ), + ( + POSTGRES_SERVICE_NAME.into(), + Some( + docker_compose_types::Service { + image: Some(service_images.postgres), + ports: if exposed_services.contains(&Service::Postgres) {Some(vec![port_to_port_binding(POSTGRES_PORT)])} else {None}, + environment: Some(Environment::KvPair(postgres_envs.into())), + healthcheck: Some(Healthcheck { + test: Some(HealthcheckTest::Multiple(vec![ + "CMD".into(), + "pg_isready".into(), + "-U".into(), + "postgres".into(), + ])), + start_period: Some("5s".into()), + interval: Some("1s".into()), + timeout: None, + retries: 5, + disable: false, + }), + ..Default::default() + } + ), + ), + ( + MONGO_SERVICE_NAME.into(), + Some( docker_compose_types::Service { + image: Some(service_images.mongo), + ports: if exposed_services.contains(&Service::Mongo) {Some(vec![port_to_port_binding(MONGO_PORT)])} else {None}, + environment: Some(Environment::KvPair(mongo_envs.into())), + healthcheck: Some(Healthcheck { + test: Some(HealthcheckTest::Single(r#"test $$(echo "rs.initiate($$CONFIG).ok || rs.status().ok" | mongo --quiet) -eq 1"#.to_string())), + start_period: Some("5s".into()), + interval: Some("1s".into()), + timeout: None, + retries: 5, + disable: false, + }), + command: Some(Command::Simple("mongod --replSet rs0".into())), + ..Default::default() + } + ), + ), + ] + .into(), + )), + ..Default::default() + }; + serde_yaml::to_string(&compose).map_err(Error::from) +} + +fn port_to_port_binding(port: u16) -> String { + format!("{}:{}", port, port) +} + +pub fn create_compose_command(context: &mut CommandContext) -> process::Command { + let dockercompose_path: PathBuf = + context.resolve_path(&DOCKERCOMPOSE_DEFAULT_PATH.iter().collect()); + let mut cmd = process::Command::from(COMPOSE_COMMAND.clone()); + cmd.arg("-f").arg(dockercompose_path).kill_on_drop(true); + if is_inherit_stdio() { + cmd.stdout(Stdio::inherit()).stderr(Stdio::inherit()); + } else { + cmd.stdout(Stdio::null()).stderr(Stdio::null()); + } + cmd +} + +pub async fn compose_up(context: &mut CommandContext) -> Result<()> { + let mut command = create_compose_command(context); + + command.arg("up").arg("-d").arg("--wait"); + + log::debug!("cmd: {:?}", command); + let output = command.spawn()?.wait_with_output().await?; + + if !output.status.success() { + warn!( + "An error occured while running the docker-compose app:\n{}", + CommandError { command, output } + ) + } + Ok(()) +} + +pub async fn compose_down(context: &mut CommandContext) -> Result<()> { + let mut command = create_compose_command(context); + + command.arg("down").arg("--volumes"); + + log::debug!("cmd: {:?}", command); + let output = command.spawn()?.wait_with_output().await?; + if !output.status.success() { + warn!("An error occured while stoping the docker-compose app"); + return Err(Error::Command(CommandError { command, output })); + } + Ok(()) +} + +pub async fn compose_build(context: &mut CommandContext) -> Result<()> { + let mut command = create_compose_command(context); + command.arg("build"); + + // Use Buildkit to improve performance + command.env("DOCKER_BUILDKIT", "1"); + + log::debug!("cmd: {:?}", command); + let output = command.spawn()?.wait_with_output().await?; + + if !output.status.success() { + warn!( + "An error occured while building the Docker image:\n{}", + CommandError { command, output } + ) + } + Ok(()) +} + +pub async fn compose_pull(context: &mut CommandContext, services: &Vec) -> Result<()> { + log::debug!("Pulling services: {:?}", services); + let mut command = create_compose_command(context); + command.arg("pull"); + services.iter().for_each(|service| { + command.arg(service.to_str()); + }); + + log::debug!("cmd: {:?}", command); + let output = command.spawn()?.wait_with_output().await?; + + if !output.status.success() { + warn!( + "An error occured while building the Docker image:\n{}", + CommandError { command, output } + ) + } + Ok(()) +} + +/// List all the current Docker Compose running services +pub async fn list_running_services(context: &mut CommandContext) -> Result> { + let mut command = create_compose_command(context); + command + .arg("ps") + .arg("--services") + .arg("--filter") + .arg("status=running"); + + let services: Vec = get_command_output(command).await.map(|output| { + output + .lines() + .map(|service| match service.trim() { + APP_SERVICE_NAME => Some(Service::App), + DEVTOOL_SERVICE_NAME => Some(Service::Devtool), + POSTGRES_SERVICE_NAME => Some(Service::Postgres), + MONGO_SERVICE_NAME => Some(Service::Mongo), + _ => None, + }) + .filter_map(|service| service) + .collect() + })?; + Ok(services) +} + +/// Get the given Docker Compose service information +pub async fn get_service_informations( + context: &mut CommandContext, + service: Service, +) -> Result { + let mut command = create_compose_command(context); + let service_name = service.to_str(); + command + .arg("ps") + .arg(service_name) + .arg("--format") + .arg("json"); + + let output = get_command_output(command).await?; + let infos: ServiceInformations = serde_yaml::from_str(output.as_str())?; + Ok(infos) +} + +/// Get the given Docker Compose service published port +pub async fn get_service_published_ports( + context: &mut CommandContext, + service: Service, +) -> Result> { + let infos = get_service_informations(context, service).await?; + let ports = infos + .publishers + .iter() + .map(|publisher| publisher.published_port) + .unique() + .collect(); + Ok(ports) +} + +pub async fn execute_compose_service_command( + context: &mut CommandContext, + service: Service, + cmd: &[&str], +) -> Result { + let mut command = create_compose_command(context); + + command.arg("exec").arg(service.to_str()); + + cmd.iter().for_each(|&part| { + command.arg(part); + () + }); + + let output = command.output().await.map_err(Error::from)?; + + if !output.status.success() { + return Err(Error::from(CommandError { command, output })); + } + + String::from_utf8(output.stdout) + .map(|name| name.trim().to_string()) + .map_err(Error::from) +} + +fn current_dir_name() -> Option { + if let Ok(path) = env::current_dir() { + path.file_name() + .map(|name| String::from(name.to_str().unwrap())) + } else { + None + } +} + +pub async fn get_services_images(dev_conf: &Option) -> ServiceImages { + let default_app_image = format!( + "{}{}", + APP_BASE_IMAGE, + current_dir_name().unwrap_or(APP_DEFAULT_IMAGE.to_string()) + ); + let default_app_tag = match get_current_branch(None).await { + Ok(branch_name) => normalize_tag(branch_name), + _ => APP_DEFAULT_IMAGE_TAG.to_string(), + }; + + let dev = dev_conf.clone().unwrap_or(Dev { + ..Default::default() + }); + ServiceImages { + app: dev + .app + .unwrap_or(Image { + ..Default::default() + }) + .to_image(&default_app_image, &default_app_tag), + devtool: dev + .devtool + .unwrap_or(Image { + ..Default::default() + }) + .to_image(DEVTOOL_IMAGE, DEVTOOL_DEFAULT_TAG), + postgres: dev + .postgres + .unwrap_or(Image { + ..Default::default() + }) + .to_image(POSTGRES_IMAGE, POSTGRES_IMAGE_TAG), + mongo: dev + .mongo + .unwrap_or(Image { + ..Default::default() + }) + .to_image(MONGO_IMAGE, MONGO_IMAGE_TAG), + } +} + +fn get_compose_command() -> std::process::Command { + match std::process::Command::new("docker-compose") + .arg("version") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + { + Ok(_) => { + debug!("Using 'docker-compose'"); + std::process::Command::new("docker-compose") + } + Err(e) => { + if std::io::ErrorKind::NotFound != e.kind() { + warn!( + "An unexpected error occured while runing 'docker-compose version' {}", + e + ); + } + debug!("Using 'docker compose'"); + let mut cmd = std::process::Command::new("docker"); + cmd.arg("compose"); + cmd + } + } + .into() +} + +trait CloneCommand { + fn clone(&self) -> Self; +} + +impl CloneCommand for std::process::Command { + fn clone(&self) -> Self { + let mut new = Self::new(self.get_program()); + new.args(self.get_args()); + new + } +} + +#[cfg(test)] +mod test_get_services_images { + use std::{ + collections::hash_map::DefaultHasher, + hash::{Hash, Hasher}, + }; + + use crate::git; + + use super::*; + use mocktopus::mocking::*; + use regex::Regex; + + #[tokio::test] + async fn basic() { + let app_tag = "test"; + let images: ServiceImages = get_services_images(&Some(Dev { + app: Some(Image { + image: None, + tag: Some(app_tag.into()), + }), + ..Default::default() + })) + .await; + assert_eq!(images.app, format!("lenra/app/lenra_cli:{}", app_tag)); + assert_eq!( + images.devtool, + format!("{}:{}", DEVTOOL_IMAGE, DEVTOOL_DEFAULT_TAG) + ); + assert_eq!( + images.postgres, + format!("{}:{}", POSTGRES_IMAGE, POSTGRES_IMAGE_TAG) + ); + assert_eq!(images.mongo, format!("{}:{}", MONGO_IMAGE, MONGO_IMAGE_TAG)); + } + + #[tokio::test] + async fn branch_name() { + git::get_current_branch + .mock_safe(|_| MockResult::Return(Box::pin(async move { Ok("test".to_string()) }))); + let images: ServiceImages = get_services_images(&None).await; + assert_eq!(images.app, "lenra/app/lenra_cli:test".to_string()); + } + + #[tokio::test] + async fn path_branch_name() { + git::get_current_branch.mock_safe(|_| { + MockResult::Return(Box::pin(async move { + Ok("prefixed/branch-name_withUnderscore".to_string()) + })) + }); + let images: ServiceImages = get_services_images(&None).await; + assert_eq!( + images.app, + "lenra/app/lenra_cli:prefixed-branch-name_withUnderscore".to_string() + ); + } + + #[tokio::test] + async fn long_branch_name() { + let branch_name = + "prefixed/branch-name/with_many-many.many_many-many-many/underscore".to_string(); + let re = Regex::new(r"[^A-Za-z0-9._-]").unwrap(); + let tag = re.replace_all(branch_name.as_str(), "-").to_string(); + let mut hacher = DefaultHasher::new(); + tag.hash(&mut hacher); + let hash = format!("{:X}", hacher.finish()); + let tag = format!( + "{}{}", + tag.chars().take(63 - hash.len()).collect::(), + hash + ); + + git::get_current_branch.mock_safe(move |_| { + let branch = branch_name.clone(); + MockResult::Return(Box::pin(async move { Ok(branch) })) + }); + let images: ServiceImages = get_services_images(&None).await; + assert_eq!(images.app, format!("lenra/app/lenra_cli:{}", tag)); + } +} diff --git a/src/errors.rs b/src/errors.rs new file mode 100644 index 0000000..ade1445 --- /dev/null +++ b/src/errors.rs @@ -0,0 +1,71 @@ +use std::{process::Output, string::FromUtf8Error}; + +use rustyline::error::ReadlineError; +use thiserror::Error; +use tokio::{process::Command, task::JoinError}; + +use crate::docker_compose::Service; + +pub type Result = std::result::Result; + +#[derive(Error, Debug)] +pub enum Error { + #[error("Could not open file {1}: {0}")] + OpenFile(std::io::Error, std::path::PathBuf), + #[error("StdIO error {0}")] + Stdio(#[from] std::io::Error), + #[error("Error while deserializing the document: {0}")] + Deserialize(#[from] serde_yaml::Error), + #[error("{0}")] + Dofigen(#[from] dofigen_lib::Error), + #[error("Could not read command: {0}")] + ReadLine(#[from] ReadlineError), + #[error("Could not parse command: {0}")] + ParseCommand(#[from] clap::Error), + #[error("Error while requesting: {0}")] + Request(#[from] ureq::Error), + #[error("Error while joining an async task: {0}")] + Join(#[from] JoinError), + #[error("The command execution failed: {0}")] + Command(#[from] CommandError), + #[error("{0}")] + FromUtf8(#[from] FromUtf8Error), + #[error("The {0} service is not exposed")] + ServiceNotExposed(Service), + #[error("Some services are not started")] + NotStartedServices, + #[error("The app must be built before running it")] + NeverBuiltApp, + #[error("The new project directory is not empty")] + ProjectPathNotEmpty, + #[error("Check error")] + Check, + #[error("The next GitHub topic is not correct: {0}")] + InvalidGitHubTopic(String), + #[error("No template found")] + NoTemplateFound, + #[error("{0}")] + Custom(String), +} + +#[derive(Debug)] +pub struct CommandError { + pub command: Command, + pub output: Output, +} + +impl std::error::Error for CommandError {} + +impl std::fmt::Display for CommandError { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + let output = self.output.clone(); + write!( + f, + "Command exec exited with code {}:\n\tcmd: {:?}\n\tstdout: {}\n\tstderr: {}", + output.status.code().unwrap(), + self.command, + String::from_utf8(output.stdout).unwrap(), + String::from_utf8(output.stderr).unwrap() + ) + } +} diff --git a/src/git.rs b/src/git.rs new file mode 100644 index 0000000..5453a5b --- /dev/null +++ b/src/git.rs @@ -0,0 +1,69 @@ +use std::path::PathBuf; + +use crate::{ + command::{create_command, get_command_output}, + errors::{Error, Result}, +}; +use lazy_static::lazy_static; +use regex::Regex; +use tokio::process; + +#[cfg(test)] +use mocktopus::macros::mockable; + +lazy_static! { + pub static ref GIT_REPO_REGEX: Regex = + Regex::new(r"^((git|ssh|http(s)?)|(git@[\w\.]+))(:(//)?)([\w\.@\:/\-~]+)(\.git)(/)?$") + .unwrap(); +} + +pub fn create_git_command() -> process::Command { + create_command("git") +} + +#[cfg_attr(test, mockable)] +pub async fn get_current_branch(git_dir: Option) -> Result { + let mut cmd = create_git_command(); + if let Some(dir) = git_dir { + cmd.arg("--git-dir").arg(dir.as_os_str()); + } + cmd.arg("rev-parse").arg("--abbrev-ref").arg("HEAD"); + get_command_output(cmd).await +} + +#[cfg_attr(test, mockable)] +pub async fn get_current_commit(git_dir: Option) -> Result { + let mut cmd = create_git_command(); + if let Some(dir) = git_dir { + cmd.arg("--git-dir").arg(dir.as_os_str()); + } + cmd.arg("rev-parse").arg("HEAD"); + get_command_output(cmd).await +} + +pub async fn pull(git_dir: Option) -> Result<()> { + log::debug!("git pull {:?}", git_dir); + let mut cmd = create_git_command(); + + if let Some(dir) = git_dir { + cmd.arg("--git-dir").arg(dir.as_os_str()); + } + + cmd.arg("pull"); + + cmd.spawn()?.wait_with_output().await.map_err(Error::from)?; + + Ok(()) +} + +#[derive(Clone, Debug)] +pub struct Repository { + pub name: String, + pub description: String, + pub url: String, + pub stars: u32, +} + +pub trait PlatformRepository { + fn to_repository(&self) -> Repository; +} diff --git a/src/github.rs b/src/github.rs new file mode 100644 index 0000000..ad3c89a --- /dev/null +++ b/src/github.rs @@ -0,0 +1,56 @@ +use lazy_static::lazy_static; +use regex::Regex; +use serde::{Deserialize, Serialize}; + +use crate::{ + errors::{Error, Result}, + git::{PlatformRepository, Repository}, +}; + +lazy_static! { + pub static ref GITHUB_TOPIC_REGEX: Regex = Regex::new(r"^[a-z0-9]+(-[a-z0-9]+)*$").unwrap(); +} + +#[derive(Serialize, Deserialize, Debug)] +struct GitHubSearchRepoResponse { + pub total_count: u32, + pub items: Vec, +} + +#[derive(Serialize, Deserialize, Debug)] +struct GitHubRepository { + pub full_name: String, + pub description: String, + pub clone_url: String, + pub stargazers_count: u32, +} + +impl PlatformRepository for GitHubRepository { + fn to_repository(&self) -> Repository { + Repository { + name: self.full_name.clone(), + description: self.description.clone(), + url: self.clone_url.clone(), + stars: self.stargazers_count, + } + } +} + +pub async fn search_repositories(query: &str) -> Result> { + let request = format!( + "https://api.github.com/search/repositories?q={}&sort=stars&order=desc", + query + ); + log::debug!("search repositories on GitHub: {}", request); + let reponse: GitHubSearchRepoResponse = ureq::get(request.as_str()) + .call() + .map_err(Error::from)? + .into_json() + .map_err(Error::from)?; + + reponse + .items + .into_iter() + .map(|repo| Ok(repo.to_repository())) + .collect() +} diff --git a/src/keyboard_event.rs b/src/keyboard_event.rs new file mode 100644 index 0000000..70000ea --- /dev/null +++ b/src/keyboard_event.rs @@ -0,0 +1,127 @@ +use log::{debug, warn}; +use rustyline::{ + Cmd, ConditionalEventHandler, Editor, Event, EventContext, EventHandler, Helper, KeyCode, + KeyEvent, Modifiers, RepeatCount, +}; + +use crate::errors::{Error, Result}; + +#[derive(Debug)] +struct KeyEventHandler +where + F: Fn() -> Option + Send + Sync + 'static, +{ + event: KeyEvent, + listener: F, +} + +pub trait KeyEventListener +where + F: Fn() -> Option + Send + Sync + 'static, +{ + fn add_listener(&mut self, event: KeyEvent, listener: F) -> &mut Self; +} + +impl KeyEventListener for Editor +where + F: Fn() -> Option + Send + Sync + 'static, + H: Helper, +{ + fn add_listener(&mut self, event: KeyEvent, listener: F) -> &mut Self { + let normalized_event = KeyEvent::normalize(event); + self.bind_sequence( + normalized_event.clone(), + EventHandler::Conditional(Box::new(KeyEventHandler { + event: normalized_event, + listener: listener, + })), + ); + self + } +} + +impl ConditionalEventHandler for KeyEventHandler +where + F: Fn() -> Option + Send + Sync, +{ + fn handle(&self, evt: &Event, _: RepeatCount, _: bool, _: &EventContext) -> Option { + debug!("KeyEventHandler: {:?}", evt); + if let Some(k) = evt.get(0) { + let key = KeyEvent::normalize(*k); + debug!("KeyEventHandler: {:?}", key); + if key == self.event { + return (self.listener)(); + } + } else { + warn!("KeyEventHandler without key"); + } + Some(Cmd::Insert(0, "".into())) + } +} + +pub struct KeyboardListener { + editor: Editor<()>, +} + +impl KeyEventListener for KeyboardListener +where + F: Fn() -> Option + Send + Sync + 'static, +{ + fn add_listener(&mut self, event: KeyEvent, listener: F) -> &mut Self { + self.editor.add_listener(event, listener); + self + } +} + +impl KeyboardListener { + pub async fn listen(mut self) -> Result<()> { + tokio::task::spawn_blocking(move || self.editor.readline("").map_err(Error::from)) + .await + .map_err(Error::from)? + .ok(); + Ok(()) + } + + pub fn new() -> Result { + Ok(KeyboardListener { + editor: Editor::new()?, + }) + } +} + +pub fn keyevent_to_string(event: KeyEvent) -> String { + let mut parts = Vec::new(); + if event.1 & Modifiers::CTRL == Modifiers::CTRL { + parts.push("Ctrl".to_string()); + } + if event.1 & Modifiers::ALT == Modifiers::ALT { + parts.push("Alt".to_string()); + } + if event.1 & Modifiers::SHIFT == Modifiers::SHIFT { + parts.push("Shift".to_string()); + } + parts.push(keycode_to_string(event.0)); + parts.join("+") +} + +fn keycode_to_string(keycode: KeyCode) -> String { + match keycode { + KeyCode::Backspace => "⌫".to_string(), + KeyCode::Char(c) => c.to_string().to_uppercase(), + KeyCode::Delete => "Del".to_string(), + KeyCode::Down => "Down".to_string(), + KeyCode::End => "End".to_string(), + KeyCode::Enter => "Enter".to_string(), + KeyCode::Esc => "Esc".to_string(), + KeyCode::F(num) => format!("F{}", num), + KeyCode::Home => "Home".to_string(), + KeyCode::Insert => "Insert".to_string(), + KeyCode::Left => "Left".to_string(), + KeyCode::PageDown => "Page Down".to_string(), + KeyCode::PageUp => "Page Up".to_string(), + KeyCode::Right => "Right".to_string(), + KeyCode::Tab => "Tab".to_string(), + KeyCode::Up => "Up".to_string(), + _ => todo!(), + } +} diff --git a/src/lenra.rs b/src/lenra.rs new file mode 100644 index 0000000..fb7ee88 --- /dev/null +++ b/src/lenra.rs @@ -0,0 +1,226 @@ +use std::{ + fs, + path::{Path, PathBuf}, + process::Stdio, +}; + +use rustyline::Editor; + +use crate::{ + cli::CommandContext, + command::get_command_output, + config::{DOCKERCOMPOSE_DEFAULT_PATH, LENRA_CACHE_DIRECTORY}, + devtool::stop_app_env, + docker_compose::{ + self, compose_build, compose_down, compose_up, list_running_services, Service, + }, + errors::{Error, Result}, + git, + template::{self, TemplateData}, +}; + +#[cfg(test)] +use mocktopus::macros::mockable; + +#[cfg_attr(test, mockable)] +pub async fn create_new_project(template: &str, path: &PathBuf) -> Result<()> { + log::info!("Creating a new project"); + // check that the path does not exists or is empty + if path.exists() && path.read_dir().map_err(Error::from)?.next().is_some() { + return Err(Error::ProjectPathNotEmpty); + } + + template::clone_template(template, path).await?; + + // create `.template` file to save template repo url and commit + let git_dir = path.join(".git"); + let commit = git::get_current_commit(Some(git_dir.clone())).await?; + TemplateData { + template: template.to_string(), + commit: Some(commit.clone()), + } + .save_to(&path.join(template::TEMPLATE_DATA_FILE)) + .await + .map_err(Error::from)?; + + create_cache_directories(path, &git_dir)?; + + log::info!("Project created"); + Ok(()) +} + +#[cfg_attr(test, mockable)] +fn create_cache_directories(path: &PathBuf, git_dir: &PathBuf) -> Result<()> { + log::debug!("create cache directories"); + // create the `.lenra` cache directory + let cache_dir = path.join(LENRA_CACHE_DIRECTORY); + fs::create_dir_all(cache_dir.clone()).unwrap(); + // move the template `.git` directory + fs::rename(git_dir, cache_dir.join(template::TEMPLATE_GIT_DIR))?; + + log::info!("Project created"); + Ok(()) +} + +pub async fn generate_app_env(context: &mut CommandContext, production: bool) -> Result<()> { + log::info!("Generating the app environment"); + let conf = context + .config + .clone() + .ok_or(Error::Custom("The config is missing".into()))?; + // TODO: check the components API version + + conf.generate_files(context, !production).await?; + Ok(()) +} + +pub async fn build_app(context: &mut CommandContext) -> Result<()> { + log::info!("Build the Docker image"); + compose_build(context).await?; + log::info!("Image built"); + Ok(()) +} + +pub async fn start_env(context: &mut CommandContext) -> Result<()> { + let dockercompose_path: PathBuf = + context.resolve_path(&DOCKERCOMPOSE_DEFAULT_PATH.iter().collect()); + if !dockercompose_path.exists() { + return Err(Error::NeverBuiltApp); + } + + log::info!("Start the containers"); + compose_up(context).await?; + let running_services: Vec = list_running_services(context).await?; + if running_services.len() < 4 { + return Err(Error::NotStartedServices); + } + Ok(()) +} + +pub async fn stop_env(context: &mut CommandContext) -> Result<()> { + log::info!("Stop the containers"); + compose_down(context).await?; + Ok(()) +} + +pub async fn clear_cache(context: &mut CommandContext) -> Result<()> { + log::info!("Clearing cache"); + stop_app_env(context).await?; + Ok(()) +} + +pub fn display_app_access_url() { + println!( + "\nApplication available at http://localhost:{}\n", + docker_compose::DEVTOOL_WEB_PORT + ); +} + +pub async fn update_env_images( + context: &mut CommandContext, + services: &Vec, +) -> Result<()> { + log::info!("Update the environment images"); + docker_compose::compose_pull(context, services).await?; + Ok(()) +} + +pub async fn upgrade_app() -> Result<()> { + log::info!("Upgrading the application"); + // get template data + let template_data = template::get_template_data().await?; + let git_dir = Path::new(LENRA_CACHE_DIRECTORY).join(template::TEMPLATE_GIT_DIR); + + if git_dir.is_dir() { + // update the template repo + git::pull(Some(git_dir.clone())).await?; + } else { + let template_tmp = Path::new(LENRA_CACHE_DIRECTORY).join(template::TEMPLATE_TEMP_DIR); + // clone template project + template::clone_template(template_data.template.as_str(), &template_tmp).await?; + fs::rename(template_tmp.join(".git"), git_dir.clone())?; + fs::remove_dir_all(template_tmp)?; + } + + let current_commit = git::get_current_commit(Some(git_dir.clone())).await?; + if let Some(commit) = template_data.commit { + if commit == current_commit { + println!("This application is already up to date"); + return Ok(()); + } + + // get diff between previous commit and current commit + let patch_file = Path::new(LENRA_CACHE_DIRECTORY) + .join(format!("patch.{}-{}.diff", commit, current_commit)); + log::debug!( + "create patch between {} and {}: {:?}", + commit, + current_commit, + patch_file + ); + let mut cmd = git::create_git_command(); + cmd.arg("--git-dir") + .arg(git_dir.as_os_str()) + .arg("diff") + .arg(commit) + .arg(current_commit.clone()); + let mut patch = get_command_output(cmd).await?; + patch.push('\n'); + fs::write(patch_file.clone(), patch)?; + + // apply a patch + log::debug!("apply patch on project"); + let mut cmd = git::create_git_command(); + cmd.arg("apply") + .arg(patch_file.clone()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()); + let patch_file_str = patch_file.to_string_lossy(); + while !cmd.spawn()?.wait_with_output().await?.status.success() { + println!("An error occured applying the patch {patch_file_str}"); + let mut rl = Editor::<()>::new()?; + rl.readline("Fix it and press enter to retry")?; + } + fs::remove_file(patch_file)?; + } else { + // ask for user confirmation + if !confirm_checkout()? { + println!("Upgrade canceled"); + return Ok(()); + } + + // checkout the template in the current dir + log::debug!("checkout the template"); + let mut cmd = git::create_git_command(); + cmd.arg("--git-dir") + .arg(git_dir.as_os_str()) + .arg("checkout") + .arg("HEAD") + .arg("--") + .arg("."); + cmd.spawn()?.wait_with_output().await.map_err(Error::from)?; + } + // save template data + TemplateData { + template: template_data.template, + commit: Some(current_commit), + } + .save() + .await +} + +fn confirm_checkout() -> Result { + let mut rl = Editor::<()>::new()?; + println!("There is no template last commit in this project, the template files will checked out to your app.\nMake sure your project is saved (for example with git)."); + loop { + let res = rl + .readline("Checkout the template ? [y/N] ")? + .trim() + .to_lowercase(); + if res == "y" || res == "yes" { + return Ok(true); + } else if res.is_empty() || res == "n" || res == "no" { + return Ok(false); + } + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..e1d10b4 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,47 @@ +//! # lenra_cli +//! +//! The Lenra's command line interface + +use clap::Parser; +use cli::{terminal::start_terminal, Cli, CliCommand, CommandContext}; +use env_logger; + +mod cli; +mod command; +mod config; +mod devtool; +mod docker; +mod docker_compose; +mod errors; +mod git; +mod github; +mod keyboard_event; +mod lenra; +mod matching; +mod template; + +#[tokio::main] +async fn main() -> () { + env_logger::init(); + let args = Cli::parse(); + let context = &mut CommandContext { + config_path: args.config, + config: None, + expose: args.expose, + verbose: args.verbose, + }; + if args.verbose { + command::set_inherit_stdio(true); + } + let res = match args.command { + Some(command) => command.run(context).await, + None => start_terminal(context).await, + }; + match res { + Ok(_) => (), + Err(e) => { + println!("{}", e.to_string()); + std::process::exit(1); + } + } +} diff --git a/src/matching.rs b/src/matching.rs new file mode 100644 index 0000000..1d76f57 --- /dev/null +++ b/src/matching.rs @@ -0,0 +1,173 @@ +use serde_json::Value; + +#[derive(Clone)] +pub enum MatchingErrorType { + NotSameType { actual: Value, expected: Value }, + NotSameValue { actual: Value, expected: Value }, + AdditionalProperty, + MissingProperty, +} + +#[derive(Clone)] +pub struct MatchingError { + pub path: String, + pub error_type: MatchingErrorType, +} + +pub trait Matching { + fn match_type(&self, val: &Value) -> bool; + fn check_match(&self, expected: &Value) -> Vec; + fn type_name(&self) -> &str; +} + +impl Matching for Value { + fn match_type(&self, val: &Value) -> bool { + match self { + Value::Null => val.is_null(), + Value::Bool(_) => val.is_boolean(), + Value::Number(_) => val.is_number(), + Value::String(_) => val.is_string(), + Value::Array(_) => val.is_array(), + Value::Object(_) => val.is_object(), + } + } + + fn check_match(&self, expected: &Value) -> Vec { + if expected == self { + return vec![]; + } + if !self.match_type(expected) { + return vec![MatchingError { + path: "".into(), + error_type: MatchingErrorType::NotSameType { + actual: self.clone(), + expected: expected.clone(), + }, + }]; + } + + match self { + Value::Array(array) => { + let expected_array = expected.as_array().unwrap(); + let mut ret: Vec = vec![]; + let common_length = if array.len() > expected_array.len() { + expected_array.len() + } else { + array.len() + }; + + for i in 0..common_length { + let v = array.get(i).unwrap(); + let expected_v = expected_array.get(i).unwrap(); + v.check_match(expected_v) + .iter() + .map(|error| MatchingError { + path: if error.path.is_empty() { + format!("{}", i) + } else { + format!("{}.{}", i, error.path) + }, + error_type: error.error_type.clone(), + }) + .for_each(|error| ret.push(error)); + } + for i in common_length..array.len() { + ret.push(MatchingError { + path: format!("{}", i), + error_type: MatchingErrorType::AdditionalProperty, + }); + } + for i in common_length..expected_array.len() { + ret.push(MatchingError { + path: format!("{}", i), + error_type: MatchingErrorType::MissingProperty, + }); + } + + ret + } + Value::Object(object) => { + let expected_object = expected.as_object().unwrap(); + let keys = object.keys(); + let expected_keys = expected_object.keys(); + let mut ret: Vec = vec![]; + + expected_keys.for_each(|key| { + if object.contains_key(key) { + let value = object.get(key).unwrap(); + let expected_value = expected_object.get(key).unwrap(); + value + .check_match(expected_value) + .iter() + .map(|error| MatchingError { + path: if error.path.is_empty() { + key.into() + } else { + format!("{}.{}", key, error.path) + }, + error_type: error.error_type.clone(), + }) + .for_each(|error| ret.push(error)); + } else { + ret.push(MatchingError { + path: key.into(), + error_type: MatchingErrorType::MissingProperty, + }); + } + }); + + keys.for_each(|key| { + if !expected_object.contains_key(key) { + ret.push(MatchingError { + path: key.into(), + error_type: MatchingErrorType::AdditionalProperty, + }); + } + }); + + ret + } + Value::Number(number) => { + let result = if number.is_f64() || expected.is_f64() { + number.as_f64().unwrap().eq(&expected.as_f64().unwrap()) + } else if number.is_i64() || expected.is_i64() { + number.as_i64().unwrap().eq(&expected.as_i64().unwrap()) + } else { + number.as_u64().unwrap().eq(&expected.as_u64().unwrap()) + }; + + if !result { + vec![MatchingError { + path: "".into(), + error_type: MatchingErrorType::NotSameValue { + actual: self.clone(), + expected: expected.clone(), + }, + }] + } else { + vec![] + } + } + Value::Null => panic!("Should not be reached"), + // Since equality have been tested before + _ => vec![MatchingError { + path: "".into(), + error_type: MatchingErrorType::NotSameValue { + actual: self.clone(), + expected: expected.clone(), + }, + }], + } + } + + fn type_name(&self) -> &str { + match self { + Value::Null => "null", + Value::Bool(_) => "bool", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } + } +} diff --git a/src/template.rs b/src/template.rs new file mode 100644 index 0000000..032cd21 --- /dev/null +++ b/src/template.rs @@ -0,0 +1,163 @@ +use std::{ + collections::HashMap, + fs, + path::{Path, PathBuf}, +}; + +use crate::{ + command::{get_command_output, run_command}, + config::LENRA_CACHE_DIRECTORY, + errors::{Error, Result}, + git::{create_git_command, Repository}, + github::{search_repositories, GITHUB_TOPIC_REGEX}, +}; +use lazy_static::lazy_static; +use log; +use regex::Regex; +use rustyline::Editor; + +#[cfg(test)] +use mocktopus::macros::mockable; + +pub const TEMPLATE_DATA_FILE: &str = ".template"; +pub const TEMPLATE_GIT_DIR: &str = "template.git"; +pub const TEMPLATE_TEMP_DIR: &str = "template.tmp"; +pub const RL_CHOOSE_TEMPLATE_MSG: &str = "Which template do you want to use ? "; + +lazy_static! { + static ref TEMPLATE_ALIASES: HashMap<&'static str, &'static str> = vec![ + ("js", "javascript"), + ("ts", "typescript"), + ("rs", "rust"), + ("py", "python"), + ] + .into_iter() + .collect(); +} + +lazy_static! { + pub static ref TEMPLATE_SHORT_REGEX: Regex = + Regex::new(r"^(template-)?([0-9a-zA-Z]+([_-][0-9a-zA-Z]+)*)$").unwrap(); +} + +pub struct TemplateData { + pub template: String, + pub commit: Option, +} + +#[cfg_attr(test, mockable)] +impl TemplateData { + pub async fn save(&self) -> Result<()> { + let path = Path::new(TEMPLATE_DATA_FILE); + self.save_to(&path).await + } + + pub async fn save_to(&self, path: &Path) -> Result<()> { + let commit = self.commit.clone().unwrap(); + log::debug!("save template data {}:{}", self.template, commit); + fs::write(path, format!("{}\n{}", self.template, commit)).map_err(Error::from) + } +} + +pub fn normalize_template(template: String) -> String { + if TEMPLATE_SHORT_REGEX.is_match(template.as_str()) { + // Replace aliases + let &name = TEMPLATE_ALIASES + .get(template.as_str()) + .unwrap_or(&template.as_str()); + + format!( + "https://github.com/lenra-io/template-{}", + TEMPLATE_SHORT_REGEX.replace(name, "$2") + ) + } else { + template.clone() + } +} + +#[cfg_attr(test, mockable)] +pub async fn list_templates(topics: &Vec) -> Result> { + let mut query: String = String::from("topic:lenra+topic:template"); + for topic in topics { + // check topic format + if !GITHUB_TOPIC_REGEX.is_match(topic.as_str()) { + return Err(Error::InvalidGitHubTopic(topic.clone())); + } + query.push_str(format!("+topic:{}", topic).as_str()); + } + search_repositories(query.as_str()).await +} + +#[cfg_attr(test, mockable)] +pub async fn choose_repository(repos: Vec) -> Result { + let mut rl = Editor::<()>::new()?; + let mut index = 0; + let mut max_index = 0; + for repo in &repos { + println!( + "{:5} {} ({} stars) - {}", + format!("[{}]:", index + 1), + repo.name, + repo.stars, + repo.description + ); + index += 1; + max_index = index; + } + let mut choice = rl.readline(RL_CHOOSE_TEMPLATE_MSG)?; + while choice.parse::().is_err() + || choice.parse::().unwrap() < 1 + || choice.parse::().unwrap() > max_index + { + choice = rl.readline(RL_CHOOSE_TEMPLATE_MSG)?; + } + Ok(repos[choice.parse::().unwrap() - 1].clone()) +} + +#[cfg_attr(test, mockable)] +pub async fn clone_template(template: &str, target_dir: &PathBuf) -> Result<()> { + log::debug!( + "clone the template {} into {}", + template, + target_dir.display() + ); + let mut cmd = create_git_command(); + cmd.arg("clone").arg(template).arg(target_dir.as_os_str()); + run_command(cmd).await?; + + Ok(()) +} + +#[cfg_attr(test, mockable)] +pub async fn get_template_data() -> Result { + let template_data_file = Path::new(TEMPLATE_DATA_FILE); + let git_dir = Path::new(LENRA_CACHE_DIRECTORY).join(TEMPLATE_GIT_DIR); + if template_data_file.exists() { + let template_data = fs::read_to_string(template_data_file).map_err(Error::from)?; + let template_data: Vec<&str> = template_data.split("\n").collect(); + Ok(TemplateData { + template: template_data[0].into(), + commit: Some(template_data[1].into()), + }) + } else if git_dir.exists() { + let mut cmd = create_git_command(); + cmd.arg("--git-dir") + .arg(git_dir.as_os_str()) + .arg("config") + .arg("--get") + .arg("remote.origin.url"); + let template = get_command_output(cmd).await?; + Ok(TemplateData { + template, + commit: None, + }) + } else { + let mut rl = Editor::<()>::new()?; + println!("The '.template' file does not exist."); + let template = normalize_template(rl.readline("What is the template to use ? ")?); + Ok(TemplateData { + template, + commit: None, + }) + } +} diff --git a/test/config/app_path.yml b/test/config/app_path.yml new file mode 100644 index 0000000..a489dd1 --- /dev/null +++ b/test/config/app_path.yml @@ -0,0 +1,4 @@ +path: test_app +generator: + dofigen: + from: scratch \ No newline at end of file