├── .github ├── buildomat │ ├── config.toml │ └── jobs │ │ └── build.sh ├── dependabot.yml └── workflows │ ├── cargo-build-stable.yml │ ├── cargo-clippy.yml │ ├── cargo-test.yml │ ├── check-illumos.sh │ ├── cross-deps.sh │ ├── make-cross.yml │ └── make-release.yml ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── Makefile ├── README.md ├── build.rs ├── cli-macro-impl ├── Cargo.toml ├── src │ └── lib.rs └── tests │ ├── gen │ ├── disks.rs.gen │ ├── images.rs.gen │ ├── images_global.rs.gen │ ├── instances.rs.gen │ ├── organizations.rs.gen │ ├── projects.rs.gen │ ├── routes.rs.gen │ ├── sleds.rs.gen │ ├── subnets.rs.gen │ └── vpcs.rs.gen │ └── tests.rs ├── cli-macro ├── Cargo.toml └── src │ └── lib.rs ├── docs └── oxide.json ├── rust-toolchain.toml ├── rustfmt.toml ├── spec-serial.json ├── spec.json ├── src ├── cmd.rs ├── cmd_alias.rs ├── cmd_api.rs ├── cmd_auth.rs ├── cmd_completion.rs ├── cmd_config.rs ├── cmd_disk.rs ├── cmd_generate.rs ├── cmd_image.rs ├── cmd_image_global.rs ├── cmd_instance.rs ├── cmd_instance_serial.rs ├── cmd_open.rs ├── cmd_org.rs ├── cmd_project.rs ├── cmd_rack.rs ├── cmd_role.rs ├── cmd_route.rs ├── cmd_router.rs ├── cmd_sled.rs ├── cmd_snapshot.rs ├── cmd_ssh_key.rs ├── cmd_subnet.rs ├── cmd_update.rs ├── cmd_version.rs ├── cmd_vpc.rs ├── colors.rs ├── config.rs ├── config_alias.rs ├── config_file.rs ├── config_from_env.rs ├── config_from_file.rs ├── config_map.rs ├── context.rs ├── docs_man.rs ├── docs_markdown.rs ├── iostreams.rs ├── main.rs ├── prompt_ext.rs ├── tests.rs ├── types.rs └── update.rs └── tests └── omicron.toml /.github/buildomat/config.toml: -------------------------------------------------------------------------------- 1 | # 2 | # This file, with this flag, must be present in the default branch in order for 3 | # the buildomat integration to create check suites. 4 | # 5 | enable = true 6 | -------------------------------------------------------------------------------- /.github/buildomat/jobs/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | #: 3 | #: name = "build (illumos)" 4 | #: variety = "basic" 5 | #: target = "helios" 6 | #: rust_toolchain = "stable" 7 | #: output_rules = [ 8 | #: "/work/out/*", 9 | #: ] 10 | #: 11 | #: [[publish]] 12 | #: series = "build-illumos" 13 | #: name = "oxide.gz" 14 | #: from_output = "/work/out/oxide.gz" 15 | #: 16 | #: [[publish]] 17 | #: series = "build-illumos" 18 | #: name = "oxide.sha256.txt" 19 | #: from_output = "/work/out/oxide.sha256.txt" 20 | #: 21 | #: [[publish]] 22 | #: series = "build-illumos" 23 | #: name = "oxide.gz.sha256.txt" 24 | #: from_output = "/work/out/oxide.gz.sha256.txt" 25 | #: 26 | 27 | set -o errexit 28 | set -o pipefail 29 | set -o xtrace 30 | 31 | cargo --version 32 | rustc --version 33 | 34 | banner build 35 | ptime -m cargo build --verbose --release --bin oxide 36 | 37 | banner outputs 38 | mkdir -p /work/out 39 | 40 | digest -a sha256 target/release/oxide > /work/out/oxide.sha256.txt 41 | cat /work/out/oxide.sha256.txt 42 | 43 | gzip -9 < target/release/oxide > /work/out/oxide.gz 44 | 45 | digest -a sha256 /work/out/oxide.gz > /work/out/oxide.gz.sha256.txt 46 | cat /work/out/oxide.gz.sha256.txt 47 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "cargo" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "daily" 12 | - package-ecosystem: "github-actions" # See documentation for possible values 13 | directory: "/" # Location of package manifests 14 | schedule: 15 | interval: "daily" 16 | -------------------------------------------------------------------------------- /.github/workflows/cargo-build-stable.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | paths: 6 | - "**.rs" 7 | - Cargo.toml 8 | - Cargo.lock 9 | - .github/workflows/cargo-build.yml 10 | - "rust-toolchain" 11 | - "rust-toolchain.toml" 12 | pull_request: 13 | paths: 14 | - "**.rs" 15 | - Cargo.toml 16 | - Cargo.lock 17 | - .github/workflows/cargo-build.yml 18 | - "rust-toolchain" 19 | - "rust-toolchain.toml" 20 | name: cargo build (nightly) 21 | jobs: 22 | cargobuild: 23 | name: cargo build 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: actions/checkout@master 27 | - name: Install latest rust 28 | uses: actions-rs/toolchain@v1 29 | with: 30 | toolchain: nightly 31 | override: true 32 | components: rustfmt, clippy 33 | - name: Cache cargo registry 34 | uses: actions/cache@v3 35 | with: 36 | path: ~/.cargo/registry 37 | key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} 38 | - name: Cache cargo index 39 | uses: actions/cache@v3 40 | with: 41 | path: ~/.cargo/git 42 | key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }} 43 | - name: Cache cargo build 44 | uses: actions/cache@v3 45 | with: 46 | path: target 47 | key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} 48 | - name: Run cargo build 49 | run: | 50 | cargo build 51 | shell: bash 52 | -------------------------------------------------------------------------------- /.github/workflows/cargo-clippy.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | paths: 6 | - "**.rs" 7 | - Cargo.toml 8 | - Cargo.lock 9 | - .github/workflows/cargo-clippy.yml 10 | - "rust-toolchain" 11 | - "rust-toolchain.toml" 12 | pull_request: 13 | paths: 14 | - "**.rs" 15 | - Cargo.toml 16 | - Cargo.lock 17 | - .github/workflows/cargo-build.yml 18 | - "rust-toolchain" 19 | - "rust-toolchain.toml" 20 | name: cargo clippy 21 | jobs: 22 | cargoclippy: 23 | name: cargo clippy 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: actions/checkout@master 27 | - name: Install latest rust 28 | uses: actions-rs/toolchain@v1 29 | with: 30 | toolchain: nightly 31 | override: true 32 | components: rustfmt, clippy 33 | - name: Cache cargo registry 34 | uses: actions/cache@v3 35 | with: 36 | path: ~/.cargo/registry 37 | key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} 38 | - name: Cache cargo index 39 | uses: actions/cache@v3 40 | with: 41 | path: ~/.cargo/git 42 | key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }} 43 | - name: Cache cargo build 44 | uses: actions/cache@v3 45 | with: 46 | path: target 47 | key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} 48 | - name: Check workflow permissions 49 | id: check_permissions 50 | uses: scherermichael-oss/action-has-permission@1.0.6 51 | with: 52 | required-permission: write 53 | env: 54 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 55 | - if: steps.check_permissions.outputs.has-permission 56 | uses: actions-rs/clippy-check@v1 57 | with: 58 | token: ${{ secrets.GITHUB_TOKEN }} 59 | args: --all-features 60 | - name: Run clippy manually without annotations 61 | if: ${{ !steps.check_permissions.outputs.has-permission }} 62 | run: cargo clippy --all-targets -- -D warnings 63 | -------------------------------------------------------------------------------- /.github/workflows/cargo-test.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | paths: 6 | - '**.rs' 7 | - '**.rs.gen' 8 | - Cargo.toml 9 | - Cargo.lock 10 | - .github/workflows/cargo-test.yml 11 | - 'rust-toolchain' 12 | - 'rust-toolchain.toml' 13 | - 'Makefile' 14 | pull_request: 15 | paths: 16 | - '**.rs' 17 | - '**.rs.gen' 18 | - Cargo.toml 19 | - Cargo.lock 20 | - .github/workflows/cargo-build.yml 21 | - 'rust-toolchain' 22 | - 'rust-toolchain.toml' 23 | - 'Makefile' 24 | workflow_dispatch: 25 | inputs: 26 | permissions: read-all 27 | name: cargo test 28 | jobs: 29 | cargotest: 30 | name: cargo test 31 | runs-on: ubuntu-latest 32 | steps: 33 | - uses: actions/checkout@master 34 | - name: Install latest rust 35 | uses: actions-rs/toolchain@v1 36 | with: 37 | toolchain: nightly 38 | override: true 39 | components: rustfmt, clippy 40 | - name: Cache cargo registry 41 | uses: actions/cache@v3 42 | with: 43 | path: ~/.cargo/registry 44 | key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} 45 | - name: Cache cargo index 46 | uses: actions/cache@v3 47 | with: 48 | path: ~/.cargo/git 49 | key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }} 50 | - name: Cache cargo build 51 | uses: actions/cache@v3 52 | with: 53 | path: target 54 | key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} 55 | 56 | - name: Login to GitHub Container Registry 57 | uses: docker/login-action@v1 58 | with: 59 | registry: ghcr.io 60 | username: ${{ github.actor }} 61 | password: ${{ secrets.GITHUB_TOKEN }} 62 | - name: Start omicron locally 63 | shell: bash 64 | run: | 65 | make start-omicron 66 | docker logs nexus 67 | 68 | - name: Run cargo test 69 | run: | 70 | cargo test --all 71 | env: 72 | OXIDE_TEST_TOKEN: ${{secrets.OXIDE_TOKEN}} 73 | OXIDE_TEST_HOST: http://localhost:8888 74 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 75 | RUST_BACKTRACE: 1 76 | -------------------------------------------------------------------------------- /.github/workflows/check-illumos.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | set -o pipefail 4 | 5 | COMMIT=$GITHUB_SHA 6 | 7 | filebase='https://buildomat.eng.oxide.computer/public/file' 8 | commitbase="$filebase/oxidecomputer/cli/build-illumos/$COMMIT" 9 | 10 | 11 | # Wait for the binary. 12 | start=$SECONDS 13 | while :; do 14 | if (( SECONDS - start > 3600 )); then 15 | printf 'timed out waiting for artefact\n' >&2 16 | exit 1 17 | fi 18 | 19 | rm -f /tmp/oxide-x86_64-unknown-illumos.sha256 20 | if ! curl -fSsL -o /tmp/oxide-x86_64-unknown-illumos.sha256 \ 21 | "$commitbase/oxide.sha256.txt"; then 22 | sleep 5 23 | continue 24 | fi 25 | 26 | rm -f /tmp/oxide-x86_64-unknown-illumos.gz 27 | if ! curl -fSsL -o /tmp/oxide-x86_64-unknown-illumos.gz \ 28 | "$commitbase/oxide.gz"; then 29 | sleep 5 30 | continue 31 | fi 32 | 33 | rm -f /tmp/oxide-x86_64-unknown-illumos 34 | if ! gunzip /tmp/oxide-x86_64-unknown-illumos.gz; then 35 | rm -f /tmp/oxide-x86_64-unknown-illumos 36 | rm -f /tmp/oxide-x86_64-unknown-illumos.gz 37 | sleep 5 38 | continue 39 | fi 40 | 41 | exp=$( ${BUILDDIR}/${NAME}.md5; 70 | sha256sum ${BUILDDIR}/${NAME} > ${BUILDDIR}/${NAME}.sha256; 71 | echo -e "### x86_64-unknown-illumos\n\n" >> ${README}; 72 | echo -e "\`\`\`console" >> ${README}; 73 | echo -e "# Export the sha256sum for verification." >> ${README}; 74 | echo -e "\$ export OXIDE_CLI_SHA256=\"`cat ${BUILDDIR}/${NAME}.sha256 | awk '{print $1}'`\"\n\n" >> ${README}; 75 | echo -e "# Download and check the sha256sum." >> ${README}; 76 | echo -e "\$ curl -fSL \"https://dl.oxide.computer/releases/cli/v${VERSION}/${NAME}\" -o \"/usr/local/bin/oxide\" \\" >> ${README}; 77 | echo -e "\t&& echo \"\${OXIDE_CLI_SHA256} /usr/local/bin/oxide\" | sha256sum -c - \\" >> ${README}; 78 | echo -e "\t&& chmod a+x \"/usr/local/bin/oxide\"\n\n" >> ${README}; 79 | echo -e "\$ echo \"oxide cli installed!\"\n" >> ${README}; 80 | echo -e "# Run it!" >> ${README}; 81 | echo -e "\$ oxide -h" >> ${README}; 82 | echo -e "\`\`\`\n\n" >> ${README}; 83 | 84 | set -x 85 | -------------------------------------------------------------------------------- /.github/workflows/cross-deps.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | set -o pipefail 4 | 5 | # Install our deps. 6 | sudo apt update -y && sudo apt install -y \ 7 | ca-certificates \ 8 | clang \ 9 | cmake \ 10 | curl \ 11 | g++ \ 12 | gcc \ 13 | gcc-mingw-w64-i686 \ 14 | gcc-mingw-w64 \ 15 | jq \ 16 | libmpc-dev \ 17 | libmpfr-dev \ 18 | libgmp-dev \ 19 | libssl-dev \ 20 | libxml2-dev \ 21 | mingw-w64 \ 22 | wget \ 23 | zlib1g-dev 24 | 25 | # We need this for the version. 26 | cargo install toml-cli 27 | 28 | # Install cross. 29 | cargo install cross 30 | -------------------------------------------------------------------------------- /.github/workflows/make-cross.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | paths: 6 | - "**.rs" 7 | - Cargo.toml 8 | - Cargo.lock 9 | - .github/workflows/make-cross.yml 10 | - Makefile 11 | - "rust-toolchain" 12 | - "rust-toolchain.toml" 13 | pull_request: 14 | paths: 15 | - "**.rs" 16 | - Cargo.toml 17 | - Cargo.lock 18 | - .github/workflows/cargo-build.yml 19 | - "rust-toolchain" 20 | - "rust-toolchain.toml" 21 | name: make cross 22 | jobs: 23 | cross: 24 | strategy: 25 | matrix: 26 | os: [macos-latest, ubuntu-latest] 27 | name: make cross 28 | runs-on: ${{ matrix.os }} 29 | steps: 30 | - uses: actions/checkout@master 31 | - name: Install latest nightly 32 | uses: actions-rs/toolchain@v1 33 | with: 34 | toolchain: nightly 35 | override: true 36 | components: rustfmt, clippy 37 | - if: ${{ matrix.os == 'ubuntu-latest' }} 38 | name: Install deps 39 | shell: bash 40 | run: | 41 | ./.github/workflows/cross-deps.sh 42 | - if: ${{ matrix.os == 'macos-latest' }} 43 | name: Install deps 44 | shell: bash 45 | run: | 46 | brew install \ 47 | coreutils \ 48 | jq 49 | - name: Cache cargo registry 50 | uses: actions/cache@v3 51 | with: 52 | path: ~/.cargo/registry 53 | key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} 54 | - name: Cache cargo index 55 | uses: actions/cache@v3 56 | with: 57 | path: ~/.cargo/git 58 | key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }} 59 | - name: Cache cargo build 60 | uses: actions/cache@v3 61 | with: 62 | path: target 63 | key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} 64 | - name: Run make cross 65 | run: | 66 | export PATH="/usr/local/opt/coreutils/libexec/gnubin:$PATH" 67 | make release 68 | ls -la cross 69 | shell: bash 70 | -------------------------------------------------------------------------------- /.github/workflows/make-release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | tags: 4 | - v* 5 | name: make-release 6 | jobs: 7 | makerelease: 8 | strategy: 9 | matrix: 10 | os: [macos-latest, ubuntu-latest] 11 | name: make release 12 | runs-on: ${{ matrix.os }} 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: "Authenticate to Google Cloud" 16 | uses: "google-github-actions/auth@v0.8.0" 17 | with: 18 | credentials_json: "${{ secrets.GOOGLE_CLOUD_DL_SA }}" 19 | - name: Set up Cloud SDK 20 | uses: google-github-actions/setup-gcloud@v0.6.0 21 | with: 22 | project_id: oxide-downloads 23 | - name: Install latest nightly 24 | uses: actions-rs/toolchain@v1 25 | with: 26 | toolchain: nightly 27 | override: true 28 | components: rustfmt, clippy 29 | - if: ${{ matrix.os == 'ubuntu-latest' }} 30 | name: Install deps 31 | shell: bash 32 | run: | 33 | ./.github/workflows/cross-deps.sh 34 | - if: ${{ matrix.os == 'macos-latest' }} 35 | name: Install deps 36 | shell: bash 37 | run: | 38 | brew install \ 39 | coreutils \ 40 | jq 41 | 42 | cargo install toml-cli 43 | - name: Cache cargo registry 44 | uses: actions/cache@v3 45 | with: 46 | path: ~/.cargo/registry 47 | key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} 48 | - name: Cache cargo index 49 | uses: actions/cache@v3 50 | with: 51 | path: ~/.cargo/git 52 | key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }} 53 | - name: Cache cargo build 54 | uses: actions/cache@v3 55 | with: 56 | path: target 57 | key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} 58 | - name: Run make cross 59 | run: | 60 | export PATH="/usr/local/opt/coreutils/libexec/gnubin:$PATH" 61 | make release 62 | ls -la cross 63 | shell: bash 64 | - name: move files to dir for upload 65 | shell: bash 66 | run: | 67 | export VERSION=v$(toml get Cargo.toml package.version | jq -r .) 68 | mkdir -p releases/$(basename $(pwd)) 69 | cp -r cross releases/$(basename $(pwd))/${VERSION} 70 | cp cross/README.md cross/${{matrix.os}}-${{github.ref_name}}-README.md 71 | - name: "upload binary files" 72 | id: upload-files 73 | uses: google-github-actions/upload-cloud-storage@v0.10.2 74 | with: 75 | path: releases 76 | destination: dl.oxide.computer 77 | # Store the readme as an artifact so we can combine the two. 78 | - name: Archive the README.md data 79 | uses: actions/upload-artifact@v3 80 | with: 81 | name: ${{matrix.os}}-${{github.ref_name}}-README.md 82 | path: ${{github.workspace}}/cross/${{matrix.os}}-${{github.ref_name}}-README.md 83 | illumos: 84 | runs-on: ubuntu-latest 85 | name: illumos 86 | steps: 87 | - uses: actions/checkout@v2 88 | - name: "Authenticate to Google Cloud" 89 | uses: "google-github-actions/auth@v0.8.0" 90 | with: 91 | credentials_json: "${{ secrets.GOOGLE_CLOUD_DL_SA }}" 92 | - name: Set up Cloud SDK 93 | uses: google-github-actions/setup-gcloud@v0.6.0 94 | with: 95 | project_id: oxide-downloads 96 | - name: Install latest nightly 97 | uses: actions-rs/toolchain@v1 98 | with: 99 | toolchain: nightly 100 | override: true 101 | components: rustfmt, clippy 102 | - name: Install deps 103 | shell: bash 104 | run: | 105 | ./.github/workflows/cross-deps.sh 106 | - name: check illumos 107 | shell: bash 108 | run: | 109 | export PATH="/usr/local/opt/coreutils/libexec/gnubin:$PATH" 110 | ./.github/workflows/check-illumos.sh 111 | - name: move files to dir for upload 112 | shell: bash 113 | run: | 114 | cp cross/README.md cross/illumos-${{github.ref_name}}-README.md 115 | - name: "upload binary files" 116 | id: upload-files 117 | uses: google-github-actions/upload-cloud-storage@v0.10.2 118 | with: 119 | path: releases 120 | destination: dl.oxide.computer 121 | # Store the readme as an artifact so we can combine the two. 122 | - name: Archive the README.md data 123 | uses: actions/upload-artifact@v3 124 | with: 125 | name: illumos-${{github.ref_name}}-README.md 126 | path: ${{github.workspace}}/cross/illumos-${{github.ref_name}}-README.md 127 | createrelease: 128 | runs-on: ubuntu-latest 129 | needs: [illumos, makerelease] 130 | name: createrelease 131 | steps: 132 | - uses: actions/checkout@v2 133 | - name: Install latest nightly 134 | uses: actions-rs/toolchain@v1 135 | with: 136 | toolchain: nightly 137 | override: true 138 | components: rustfmt, clippy 139 | - uses: actions/download-artifact@v3 140 | with: 141 | name: ubuntu-latest-${{github.ref_name}}-README.md 142 | - uses: actions/download-artifact@v3 143 | with: 144 | name: macos-latest-${{github.ref_name}}-README.md 145 | - uses: actions/download-artifact@v3 146 | with: 147 | name: illumos-${{github.ref_name}}-README.md 148 | - name: combine readmes 149 | shell: bash 150 | run: | 151 | ls -la 152 | echo 'These instructions are meant as an easy way to install. Note: you likely need to install `coreutils` in order to have the `sha256sum` command.' > release.md 153 | echo "" >> release.md 154 | cat macos-latest-${{github.ref_name}}-README.md \ 155 | ubuntu-latest-${{github.ref_name}}-README.md \ 156 | illumos-${{github.ref_name}}-README.md \ 157 | >> release.md 158 | - name: Get if prerelease 159 | shell: bash 160 | id: extract_prerelease 161 | run: | 162 | cargo install toml-cli 163 | export VERSION=v$(toml get Cargo.toml package.version | jq -r .) 164 | if echo $VERSION | grep -q "rc"; then 165 | echo "##[set-output name=prerelease;]$(echo true)"; 166 | else 167 | if echo $VERSION | grep -q "pre"; then 168 | echo "##[set-output name=prerelease;]$(echo true)"; 169 | else 170 | echo "##[set-output name=prerelease;]$(echo false)"; 171 | fi 172 | fi 173 | - name: Create a Release 174 | uses: softprops/action-gh-release@v1 175 | with: 176 | body_path: ${{github.workspace}}/release.md 177 | prerelease: ${{steps.extract_prerelease.outputs.prerelease}} 178 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Our target directory. 2 | target 3 | # Where our cross compiled binaries will go. 4 | cross 5 | # For the ci when we cross compile. 6 | osxcross 7 | # Where our generated docs will go. 8 | generated_docs 9 | # While running release github actions. 10 | releases 11 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "oxide" 3 | version = "0.2.7" 4 | edition = "2021" 5 | build = "build.rs" 6 | 7 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 8 | 9 | [patch.crates-io] 10 | # version conflicts between hyperx and everything else 11 | # https://github.com/dekellum/hyperx/pull/40 12 | hyperx = { git = "https://github.com/lifning/hyperx" } 13 | 14 | [dependencies] 15 | ansi_term = "^0.12.1" 16 | anyhow = { version = "1", features = ["backtrace"] } 17 | async-trait = "^0.1.53" 18 | atty = "^0.2.14" 19 | base64 = "0.13" 20 | byte-unit = "4.0.14" 21 | chrono = { version = "^0.4", features = ["serde"] } 22 | chrono-humanize = "^0.2.1" 23 | clap = { version = "^3.1.8", features = ["cargo", "derive", "env", "unicode"] } 24 | clap_complete = { version = "^3.0.6" } 25 | cli-macro = { path = "cli-macro" } 26 | colored_json = "^2.1.0" 27 | data-encoding = "2" 28 | dialoguer = "^0.10.0" 29 | dirs = "4" 30 | futures = "0.3.24" 31 | git_rev = "^0.1.0" 32 | heck = "^0.4.0" 33 | http = "^0.2.6" 34 | ipnetwork = "^0.18" 35 | Inflector = "^0.11.4" 36 | libc = "0.2.133" 37 | log = "=0.4.17" 38 | regex = "1" 39 | num-traits = "^0.2.14" 40 | oauth2 = "4.1" 41 | open = "^2.1.1" 42 | oxide-api = "0.1.0-rc.41" 43 | #oxide-api = { path= "../oxide.rs/oxide" } 44 | parse-display = "^0.5.5" 45 | progenitor = { git = "https://github.com/oxidecomputer/progenitor" } 46 | pulldown-cmark = "^0.9.1" 47 | pulldown-cmark-to-cmark = "^10.0.0" 48 | rand = "0.8" 49 | regress = "0.4" 50 | reqwest = { version = "^0.11", default-features = false, features = ["json", "rustls-tls", "stream"] } 51 | ring = "^0.16.20" 52 | #roff = { version = "^0.2.1" } 53 | # Fix once https://github.com/clap-rs/clap/pull/3174 is merged. 54 | roff = { git = "https://github.com/sondr3/roff-rs", branch = "updates" } 55 | serde = { version = "1", features = ["derive"] } 56 | serde_json = "1" 57 | serde_yaml = "^0.8" 58 | sha2 = "^0.10.2" 59 | shlex = "^1.1.0" 60 | slog = "2" 61 | slog-async = "2" 62 | slog-scope = "4" 63 | slog-stdlog = "4" 64 | slog-term = "2" 65 | ssh-key = { version = "^0.4.2", features = ["encryption", "ed25519", "p256", "rsa"] } 66 | subprocess = "^0.2.9" 67 | tabwriter = "^1.2.1" 68 | tabled = { version = "^0.5.0", features = ["color"] } 69 | termbg = "^0.4.0" 70 | terminal_size = "^0.1.17" 71 | terminal-spinners = "^0.3.2" 72 | thiserror = "1" 73 | tokio = { version = "1", features = ["full"] } 74 | tokio-tungstenite = "0.17.2" 75 | toml = "^0.5.9" 76 | toml_edit = "^0.14.2" 77 | url = "2.2.2" 78 | uuid = { version = "1.0.0", features = ["serde", "v4"] } 79 | version-compare = "^0.1.0" 80 | 81 | [build-dependencies] 82 | built = "^0.5" 83 | progenitor = { git = "https://github.com/oxidecomputer/progenitor" } 84 | serde_json = "1.0" 85 | 86 | [dev-dependencies] 87 | expectorate = "^1.0.5" 88 | futures = "0.3" 89 | pretty_assertions = "1" 90 | serial_test = "^0.6.0" 91 | tempfile = "^3.3.0" 92 | test-context = "^0.1.3" 93 | 94 | [workspace] 95 | members = [ 96 | "cli-macro", 97 | "cli-macro-impl", 98 | ] 99 | 100 | [profile.release] 101 | debug = true 102 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021 The Oxide Authors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Set the shell 2 | SHELL := /bin/bash 3 | 4 | # Set an output prefix, which is the local directory if not specified 5 | PREFIX?=$(shell pwd) 6 | 7 | NAME := oxide 8 | 9 | # Set the build dir, where built cross-compiled binaries will be output 10 | BUILDDIR := ${PREFIX}/cross 11 | 12 | GENERATED_DOCS_DIR := ${PREFIX}/generated_docs 13 | 14 | UNAME := $(shell uname) 15 | 16 | # These are chosen from: https://doc.rust-lang.org/nightly/rustc/platform-support.html 17 | ifeq ($(UNAME), Darwin) 18 | CROSS_TARGETS := x86_64-apple-darwin \ 19 | aarch64-apple-darwin 20 | else 21 | CROSS_TARGETS := x86_64-pc-windows-gnu \ 22 | x86_64-unknown-linux-musl \ 23 | aarch64-unknown-linux-musl 24 | # Turn this back on when it works. 25 | # x86_64-unknown-illumos 26 | # i686-pc-windows-gnu 27 | # x86_64-unknown-freebsd 28 | endif 29 | 30 | # For this to work, you need to install toml-cli: https://github.com/gnprice/toml-cli 31 | # `cargo install toml-cli` 32 | VERSION := $(shell toml get $(CURDIR)/Cargo.toml package.version | jq -r .) 33 | 34 | GITCOMMIT := $(shell git rev-parse --short HEAD) 35 | GITUNTRACKEDCHANGES := $(shell git status --porcelain --untracked-files=no) 36 | ifneq ($(GITUNTRACKEDCHANGES),) 37 | GITCOMMIT := $(GITCOMMIT)-dirty 38 | endif 39 | ifeq ($(GITCOMMIT),) 40 | GITCOMMIT := ${GITHUB_SHA} 41 | endif 42 | 43 | define buildrelease 44 | rustup target add $(1) 45 | cargo build --release --target $(1) || cross build --release --target $(1) 46 | mv $(CURDIR)/target/$(1)/release/$(NAME) $(BUILDDIR)/$(NAME)-$(1) || mv $(CURDIR)/target/$(1)/release/$(NAME).exe $(BUILDDIR)/$(NAME)-$(1) 47 | md5sum $(BUILDDIR)/$(NAME)-$(1) > $(BUILDDIR)/$(NAME)-$(1).md5; 48 | sha256sum $(BUILDDIR)/$(NAME)-$(1) > $(BUILDDIR)/$(NAME)-$(1).sha256; 49 | echo -e "### $(1)\n\n" >> $(BUILDDIR)/README.md; 50 | echo -e "\`\`\`console" >> $(BUILDDIR)/README.md; 51 | echo -e "# Export the sha256sum for verification." >> $(BUILDDIR)/README.md; 52 | echo -e "$$ export OXIDE_CLI_SHA256=\"`cat $(BUILDDIR)/$(NAME)-$(1).sha256 | awk '{print $$1}'`\"\n\n" >> $(BUILDDIR)/README.md; 53 | echo -e "# Download and check the sha256sum." >> $(BUILDDIR)/README.md; 54 | echo -e "$$ curl -fSL \"https://dl.oxide.computer/releases/cli/v$(VERSION)/$(NAME)-$(1)\" -o \"/usr/local/bin/oxide\" \\" >> $(BUILDDIR)/README.md; 55 | echo -e "\t&& echo \"\$${OXIDE_CLI_SHA256} /usr/local/bin/oxide\" | sha256sum -c - \\" >> $(BUILDDIR)/README.md; 56 | echo -e "\t&& chmod a+x \"/usr/local/bin/oxide\"\n\n" >> $(BUILDDIR)/README.md; 57 | echo -e "$$ echo \"oxide cli installed!\"\n" >> $(BUILDDIR)/README.md; 58 | echo -e "# Run it!" >> $(BUILDDIR)/README.md; 59 | echo -e "$$ oxide -h" >> $(BUILDDIR)/README.md; 60 | echo -e "\`\`\`\n\n" >> $(BUILDDIR)/README.md; 61 | endef 62 | 63 | # If running on a Mac you will need: 64 | # brew install filosottile/musl-cross/musl-cross 65 | .PHONY: release 66 | release: src/*.rs Cargo.toml ## Builds the cross-compiled binaries, naming them in such a way for release (eg. binary-OS-ARCH). 67 | @echo "+ $@" 68 | mkdir -p $(BUILDDIR) 69 | $(foreach TARGET,$(CROSS_TARGETS), $(call buildrelease,$(TARGET))) 70 | 71 | .PHONY: tag 72 | tag: ## Create a new git tag to prepare to build a release. 73 | git tag -sa v$(VERSION) -m "v$(VERSION)" 74 | @echo "Run git push origin v$(VERSION) to push your new tag to GitHub and trigger a release." 75 | 76 | .PHONY: AUTHORS 77 | AUTHORS: 78 | @$(file >$@,# This file lists all individuals having contributed content to the repository.) 79 | @$(file >>$@,# For how it is generated, see `make AUTHORS`.) 80 | @echo "$(shell git log --format='\n%aN <%aE>' | LC_ALL=C.UTF-8 sort -uf)" >> $@ 81 | 82 | .PHONY: clean 83 | clean: ## Cleanup any build binaries or packages. 84 | @echo "+ $@" 85 | $(RM) -r $(BUILDDIR) 86 | $(RM) -r $(GENERATED_DOCS_DIR) 87 | 88 | build: Cargo.toml $(wildcard src/*.rs) ## Build the Rust crate. 89 | cargo build 90 | 91 | 92 | .PHONY: start-cockroachdb 93 | start-cockroachdb: ## Start CockroachDB. 94 | @echo "+ $@" 95 | @docker rm -f cockroachdb || true 96 | docker run -d \ 97 | --restart=always \ 98 | --name=cockroachdb \ 99 | --hostname=cockroachdb \ 100 | -p 0.0.0.0:26257:26257 \ 101 | -p 0.0.0.0:1234:8080 \ 102 | cockroachdb/cockroach:v21.2.1 start-single-node \ 103 | --insecure 104 | @echo "Waiting for CockroachDB to start..." 105 | @sleep 5 106 | 107 | OMICRON_DOCKER_VERSION:=main 108 | 109 | .PHONY: start-omicron 110 | start-omicron: start-cockroachdb ## Start Omicron. 111 | @echo "+ $@" 112 | @docker rm -f nexus || true 113 | @docker rm -f sled-agent || true 114 | @echo "Populating the database for omicron...." 115 | docker run --rm -i \ 116 | --name=bootstrap_db \ 117 | --hostname=nexus \ 118 | --net host \ 119 | --entrypoint=omicron-dev \ 120 | ghcr.io/oxidecomputer/omicron:$(OMICRON_DOCKER_VERSION) \ 121 | db-populate --database-url "postgresql://root@0.0.0.0:26257/omicron?sslmode=disable" 122 | @echo "Starting nexus..." 123 | docker run -d \ 124 | --restart=always \ 125 | --name=nexus \ 126 | --hostname=nexus \ 127 | --net host \ 128 | -v "$(CURDIR)/tests/omicron.toml:/etc/omicron/config.toml:ro" \ 129 | --entrypoint=nexus \ 130 | ghcr.io/oxidecomputer/omicron:$(OMICRON_DOCKER_VERSION) \ 131 | /etc/omicron/config.toml 132 | @echo "Starting sled-agent..." 133 | docker run -d \ 134 | --restart=always \ 135 | --name=sled-agent \ 136 | --hostname=sled-agent \ 137 | --net host \ 138 | --entrypoint=sled-agent-sim \ 139 | ghcr.io/oxidecomputer/omicron:$(OMICRON_DOCKER_VERSION) \ 140 | B100B75C-D2EF-415F-A07E-D3915470913D 0.0.0.0:12345 0.0.0.0:12221 141 | 142 | .PHONY: help 143 | help: 144 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | sed 's/^[^:]*://g' | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 145 | 146 | check_defined = \ 147 | $(strip $(foreach 1,$1, \ 148 | $(call __check_defined,$1,$(strip $(value 2))))) 149 | 150 | __check_defined = \ 151 | $(if $(value $1),, \ 152 | $(error Undefined $1$(if $2, ($2))$(if $(value @), \ 153 | required by target `$@'))) 154 | 155 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > **Warning** 2 | > 3 | > This CLI is no longer supported and will not be maintained. 4 | > Refer to https://github.com/oxidecomputer/oxide-sdk-and-cli for the current CLI and Rust client 5 | 6 | # cli 7 | 8 | The Oxide command line tool. 9 | 10 | The instructions below refer to instructions for contributing to the repo. 11 | 12 | For the CLI docs for end users refer to: https://docs.oxide.computer/cli 13 | 14 | If you are running nexus locally without `https://` make sure you denote that in 15 | the URL you pass to `OXIDE_HOST` or to `oxide auth login`. 16 | 17 | ### Authentication 18 | 19 | To authenticate today, you can use the spoof token: 20 | `oxide-spoof-001de000-05e4-4000-8000-000000004007` 21 | 22 | You can get a non-spoof access token with `oxide auth login`. 23 | That will contact `OXIDE_HOST` and attempt an OAuth 2.0 Device 24 | Authorization Grant. The CLI will attempt to open a browser window 25 | with which you can login (via SAML or other IdP method) and type in 26 | or verify the user code printed in the terminal. After a successful 27 | login and code verification, a token associated with the logged-in 28 | user will be granted and stored in the config file. 29 | 30 | ### Installing 31 | 32 | Instructions for installing are on the [latest release](https://github.com/oxidecomputer/cli/releases). 33 | 34 | ### Updating the API spec 35 | 36 | Updating the API spec is as simple as updating the [`spec.json`](spec.json) file. The macro will take it from there when 37 | you `cargo build`. It likely might need some tender love and care to make it a nice command like the other generated ones 38 | if it is out of the ordinary. 39 | 40 | **Important: Currently we are transitioning to use progenitor as a client generator instead of the current client generator.** 41 | **This means that as a temporary work around the spec.json file must be copied from oxide.rs and you must make sure all tags don't change** 42 | 43 | Only `create`, `edit`, `view/get`, `list`, `delete` commands are generated. The rest are bespoke and any generation lead to something 44 | that seemed harder to maintain over time. But if you are brave you can try. 45 | 46 | For examples of the macro formatting, checkout some of the commands under `src/` like `cmd_disk` or `cmd_org`. 47 | 48 | **Note:** If you update the API spec here, you will likely want to bump the spec for the [oxide.rs](https://github.com/oxidecomputer/oxide.rs) 49 | repo as well since that is where the API client comes from. 50 | 51 | ### Running the tests 52 | 53 | The tests require a nexus server. The tests use the `OXIDE_TEST_TOKEN` and `OXIDE_TEST_HOST` variables for knowing where to look and authenticate. 54 | 55 | For now the token for spoof is `oxide-spoof-001de000-05e4-4000-8000-000000004007`. 56 | 57 | **Note:** you DON'T want to run the tests against your production account, since it will create a bunch of stuff and then destroy what it created (and likely everything else). 58 | 59 | ### Releasing a new version 60 | 61 | 1. Make sure the `Cargo.toml` has the new version you want to release. 62 | 2. Run `make tag` this is just an easy command for making a tag formatted 63 | correctly with the version. 64 | 3. Push the tag (the result of `make tag` gives instructions for this) 65 | 4. Everything else is triggered from the tag push. Just make sure all the tests 66 | and cross compilation pass on the `main` branch before making and pushing 67 | a new tag. 68 | 69 | ### Building 70 | 71 | To build, simply run `cargo build` like usual. 72 | 73 | Make sure to update to the latest stable rustc: for example, if you use `rustup`, run `rustup update`. 74 | 75 | #### Cross compiling 76 | 77 | If you're on Debian or Ubuntu, install the required dependencies by running `.github/workflows/cross-deps.sh`. Otherwise, look there to see what packages are required. 78 | 79 | Then, simply run `make`. Binaries will be available in `cross/`. 80 | 81 | If you want to only build one of the cross targets, supply the `CROSS_TARGETS` environment variable: 82 | 83 | CROSS_TARGETS=x86_64-unknown-linux-musl make 84 | 85 | ### Docs 86 | 87 | The data powering the CLI docs at [docs.oxide.computer/cli](https://docs.oxide.computer/cli) is produced by the `oxide generate` CLI command. This command takes a positional argument specifying the output format. The options are `json`, `markdown`, and `man-pages`. `json` produces a single file, while `markdown` and `man-pages` produce a file for every command and subcommand. 88 | 89 | We version a copy of the generated JSON in this repo at [docs/oxide.json](docs/oxide.json) so any revision can be fetched by the docs site at build time. The test `test_generate_json` will fail if `docs/oxide.json` has not been updated with the CLI changes on a given branch. To update the file, run `cargo run -- generate json -D docs` or run `test_generate_json` with `EXPECTORATE=overwrite` set. 90 | -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | built::write_built_file().expect("Failed to acquire build-time information"); 3 | } 4 | -------------------------------------------------------------------------------- /cli-macro-impl/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "cli-macro-impl" 3 | description = "Macro for our CLI generation" 4 | version = "0.1.0" 5 | authors = ["Jess Frazelle "] 6 | edition = "2018" 7 | 8 | [dependencies] 9 | anyhow = "1" 10 | Inflector = "^0.11.4" 11 | openapiv3 = "1" 12 | proc-macro2 = "1" 13 | quote = "1" 14 | regex = "1.5" 15 | rustfmt-wrapper = "^0.1" 16 | serde = { version = "1.0", features = ["derive"] } 17 | serde_json = "1" 18 | serde_tokenstream = "0.1.0" 19 | syn = { version = "1.0", features = [ "derive", "parsing", "printing" ] } 20 | 21 | [dev-dependencies] 22 | expectorate = "1" 23 | -------------------------------------------------------------------------------- /cli-macro-impl/tests/gen/organizations.rs.gen: -------------------------------------------------------------------------------- 1 | use num_traits::identities::Zero; 2 | #[derive(Parser, Debug, Clone)] 3 | enum SubCommand { 4 | List(CmdOrganizationList), 5 | Create(CmdOrganizationCreate), 6 | #[clap(alias = "get")] 7 | View(CmdOrganizationView), 8 | Edit(CmdOrganizationEdit), 9 | Delete(CmdOrganizationDelete), 10 | } 11 | 12 | #[doc = "List organizations."] 13 | #[derive(clap :: Parser, Debug, Clone)] 14 | #[clap(verbatim_doc_comment)] 15 | pub struct CmdOrganizationList { 16 | #[doc = "The order in which to sort the results."] 17 | #[clap(long = "sort-by", short = 's', default_value_t)] 18 | pub sort_by: oxide_api::types::NameOrIdSortMode, 19 | #[doc = r" Maximum number of items to list."] 20 | #[clap(long, short, default_value = "30")] 21 | pub limit: u32, 22 | #[doc = r" Make additional HTTP requests to fetch all pages."] 23 | #[clap(long)] 24 | pub paginate: bool, 25 | #[doc = r" Display output in json, yaml, or table format."] 26 | #[clap(long, short)] 27 | pub format: Option, 28 | } 29 | 30 | #[async_trait::async_trait] 31 | impl crate::cmd::Command for CmdOrganizationList { 32 | async fn run(&self, ctx: &mut crate::context::Context) -> anyhow::Result<()> { 33 | if self.limit < 1 { 34 | return Err(anyhow::anyhow!("--limit must be greater than 0")); 35 | } 36 | 37 | let client = ctx.api_client("")?; 38 | let results = if self.paginate { 39 | client.organizations().get_all(self.sort_by.clone()).await? 40 | } else { 41 | client 42 | .organizations() 43 | .get_page(self.limit, "", self.sort_by.clone()) 44 | .await? 45 | }; 46 | let format = ctx.format(&self.format)?; 47 | ctx.io.write_output_for_vec(&format, &results)?; 48 | Ok(()) 49 | } 50 | } 51 | 52 | #[doc = "Create a new organization.\n\nTo create a organization interactively, use `oxide organization create` with no arguments."] 53 | #[derive(clap :: Parser, Debug, Clone)] 54 | #[clap(verbatim_doc_comment)] 55 | pub struct CmdOrganizationCreate { 56 | #[doc = "The name of the organization to create."] 57 | #[clap(name = "organization", required = true)] 58 | pub organization: String, 59 | #[doc = "The description for the organization."] 60 | #[clap(long = "description", short = 'D', default_value_t)] 61 | pub description: String, 62 | } 63 | 64 | #[async_trait::async_trait] 65 | impl crate::cmd::Command for CmdOrganizationCreate { 66 | async fn run(&self, ctx: &mut crate::context::Context) -> anyhow::Result<()> { 67 | let mut description = self.description.clone(); 68 | let mut organization = self.organization.clone(); 69 | if description.is_empty() && !ctx.io.can_prompt() { 70 | return Err(anyhow::anyhow!( 71 | "-D|--description required in non-interactive mode" 72 | )); 73 | } 74 | 75 | if organization.is_empty() && !ctx.io.can_prompt() { 76 | return Err(anyhow::anyhow!( 77 | "[organization] required in non-interactive mode" 78 | )); 79 | } 80 | 81 | let client = ctx.api_client("")?; 82 | if ctx.io.can_prompt() { 83 | if organization.is_empty() { 84 | match dialoguer::Input::::new() 85 | .with_prompt(&format!("{} name:", "organization")) 86 | .interact_text() 87 | { 88 | Ok(name) => organization = name, 89 | Err(err) => { 90 | return Err(anyhow::anyhow!("prompt failed: {}", err)); 91 | } 92 | } 93 | } 94 | if description.is_empty() { 95 | match dialoguer::Input::<_>::new() 96 | .with_prompt("organization description") 97 | .interact_text() 98 | { 99 | Ok(input) => description = input, 100 | Err(err) => { 101 | return Err(anyhow::anyhow!("prompt failed: {}", err)); 102 | } 103 | } 104 | } 105 | } 106 | 107 | client 108 | .organizations() 109 | .post(&oxide_api::types::OrganizationCreate { 110 | description: description.clone(), 111 | name: organization.clone(), 112 | }) 113 | .await?; 114 | let cs = ctx.io.color_scheme(); 115 | writeln!( 116 | ctx.io.out, 117 | "{} Created {} {}", 118 | cs.success_icon(), 119 | "organization", 120 | organization 121 | )?; 122 | Ok(()) 123 | } 124 | } 125 | 126 | #[doc = "View organization.\n\nDisplay information about an Oxide organization.\n\nWith `--web`, open the organization in a web browser instead."] 127 | #[derive(clap :: Parser, Debug, Clone)] 128 | #[clap(verbatim_doc_comment)] 129 | pub struct CmdOrganizationView { 130 | #[doc = "The organization to view. Can be an ID or name."] 131 | #[clap(name = "organization", required = true)] 132 | pub organization: String, 133 | #[doc = "Open the organization in the browser."] 134 | #[clap(short, long)] 135 | pub web: bool, 136 | #[doc = r" Display output in json, yaml, or table format."] 137 | #[clap(long, short)] 138 | pub format: Option, 139 | } 140 | 141 | #[async_trait::async_trait] 142 | impl crate::cmd::Command for CmdOrganizationView { 143 | async fn run(&self, ctx: &mut crate::context::Context) -> anyhow::Result<()> { 144 | if self.web { 145 | let url = format!( 146 | "https://{}/{}", 147 | ctx.config.default_host()?, 148 | self.organization 149 | ); 150 | ctx.browser("", &url)?; 151 | return Ok(()); 152 | } 153 | 154 | let client = ctx.api_client("")?; 155 | let result = client.organizations().get(&self.organization).await?; 156 | let format = ctx.format(&self.format)?; 157 | ctx.io.write_output(&format, &result)?; 158 | Ok(()) 159 | } 160 | } 161 | 162 | #[doc = "Edit organization settings."] 163 | #[derive(clap :: Parser, Debug, Clone)] 164 | #[clap(verbatim_doc_comment)] 165 | pub struct CmdOrganizationEdit { 166 | #[doc = "The organization to edit. Can be an ID or name."] 167 | #[clap(name = "organization", required = true)] 168 | pub organization: String, 169 | #[doc = "The new description for the organization."] 170 | #[clap(long = "description", short = 'D', required = false, default_value_t)] 171 | pub new_description: String, 172 | #[doc = "The new name for the organization."] 173 | #[clap(long = "name", short = 'n', required = false, default_value_t)] 174 | pub new_name: oxide_api::types::Name, 175 | } 176 | 177 | #[async_trait::async_trait] 178 | impl crate::cmd::Command for CmdOrganizationEdit { 179 | async fn run(&self, ctx: &mut crate::context::Context) -> anyhow::Result<()> { 180 | if self.new_description.is_empty() && self.new_name.is_empty() { 181 | return Err(anyhow::anyhow!("nothing to edit")); 182 | } 183 | 184 | let client = ctx.api_client("")?; 185 | let mut name = self.organization.clone(); 186 | if !self.new_name.is_empty() { 187 | name = self.new_name.to_string(); 188 | } 189 | 190 | let result = client 191 | .organizations() 192 | .put( 193 | &self.organization, 194 | &oxide_api::types::OrganizationUpdate { 195 | description: self.new_description.clone(), 196 | name: self.new_name.clone(), 197 | }, 198 | ) 199 | .await?; 200 | let cs = ctx.io.color_scheme(); 201 | if !self.new_name.is_empty() { 202 | writeln!( 203 | ctx.io.out, 204 | "{} Edited {} {} -> {}", 205 | cs.success_icon(), 206 | "organization", 207 | self.organization, 208 | self.new_name 209 | )?; 210 | } else { 211 | writeln!( 212 | ctx.io.out, 213 | "{} Edited {} {}", 214 | cs.success_icon_with_color(ansi_term::Color::Red), 215 | "organization", 216 | self.organization 217 | )?; 218 | } 219 | 220 | Ok(()) 221 | } 222 | } 223 | 224 | #[doc = "Delete organization."] 225 | #[derive(clap :: Parser, Debug, Clone)] 226 | #[clap(verbatim_doc_comment)] 227 | pub struct CmdOrganizationDelete { 228 | #[doc = "The organization to delete. Can be an ID or name."] 229 | #[clap(name = "organization", required = true)] 230 | pub organization: String, 231 | #[doc = r" Confirm deletion without prompting."] 232 | #[clap(long)] 233 | pub confirm: bool, 234 | } 235 | 236 | #[async_trait::async_trait] 237 | impl crate::cmd::Command for CmdOrganizationDelete { 238 | async fn run(&self, ctx: &mut crate::context::Context) -> anyhow::Result<()> { 239 | if !ctx.io.can_prompt() && !self.confirm { 240 | return Err(anyhow::anyhow!( 241 | "--confirm required when not running interactively" 242 | )); 243 | } 244 | 245 | let client = ctx.api_client("")?; 246 | if !self.confirm { 247 | if let Err(err) = dialoguer::Input::::new() 248 | .with_prompt(format!("Type {} to confirm deletion:", self.organization)) 249 | .validate_with(|input: &String| -> Result<(), &str> { 250 | if input.trim() == self.organization { 251 | Ok(()) 252 | } else { 253 | Err("mismatched confirmation") 254 | } 255 | }) 256 | .interact_text() 257 | { 258 | return Err(anyhow::anyhow!("prompt failed: {}", err)); 259 | } 260 | } 261 | 262 | client.organizations().delete(&self.organization).await?; 263 | let cs = ctx.io.color_scheme(); 264 | writeln!( 265 | ctx.io.out, 266 | "{} Deleted {} {}", 267 | cs.success_icon_with_color(ansi_term::Color::Red), 268 | "organization", 269 | self.organization 270 | )?; 271 | Ok(()) 272 | } 273 | } 274 | -------------------------------------------------------------------------------- /cli-macro-impl/tests/gen/projects.rs.gen: -------------------------------------------------------------------------------- 1 | use num_traits::identities::Zero; 2 | #[derive(Parser, Debug, Clone)] 3 | enum SubCommand { 4 | List(CmdProjectList), 5 | Create(CmdProjectCreate), 6 | #[clap(alias = "get")] 7 | View(CmdProjectView), 8 | Edit(CmdProjectEdit), 9 | Delete(CmdProjectDelete), 10 | } 11 | 12 | #[doc = "List projects."] 13 | #[derive(clap :: Parser, Debug, Clone)] 14 | #[clap(verbatim_doc_comment)] 15 | pub struct CmdProjectList { 16 | #[doc = r" The organization that holds the project."] 17 | #[clap(long, short, required = true, env = "OXIDE_ORG")] 18 | pub organization: String, 19 | #[doc = "The order in which to sort the results."] 20 | #[clap(long = "sort-by", short = 's', default_value_t)] 21 | pub sort_by: oxide_api::types::NameOrIdSortMode, 22 | #[doc = r" Maximum number of items to list."] 23 | #[clap(long, short, default_value = "30")] 24 | pub limit: u32, 25 | #[doc = r" Make additional HTTP requests to fetch all pages."] 26 | #[clap(long)] 27 | pub paginate: bool, 28 | #[doc = r" Display output in json, yaml, or table format."] 29 | #[clap(long, short)] 30 | pub format: Option, 31 | } 32 | 33 | #[async_trait::async_trait] 34 | impl crate::cmd::Command for CmdProjectList { 35 | async fn run(&self, ctx: &mut crate::context::Context) -> anyhow::Result<()> { 36 | if self.limit < 1 { 37 | return Err(anyhow::anyhow!("--limit must be greater than 0")); 38 | } 39 | 40 | let client = ctx.api_client("")?; 41 | let results = if self.paginate { 42 | client 43 | .projects() 44 | .get_all(&self.organization, self.sort_by.clone()) 45 | .await? 46 | } else { 47 | client 48 | .projects() 49 | .get_page(self.limit, &self.organization, "", self.sort_by.clone()) 50 | .await? 51 | }; 52 | let format = ctx.format(&self.format)?; 53 | ctx.io.write_output_for_vec(&format, &results)?; 54 | Ok(()) 55 | } 56 | } 57 | 58 | #[doc = "Create a new project.\n\nTo create a project interactively, use `oxide project create` with no arguments."] 59 | #[derive(clap :: Parser, Debug, Clone)] 60 | #[clap(verbatim_doc_comment)] 61 | pub struct CmdProjectCreate { 62 | #[doc = "The name of the project to create."] 63 | #[clap(name = "project", required = true)] 64 | pub project: String, 65 | #[doc = r" The organization that holds the project."] 66 | #[clap(long, short, required = true, env = "OXIDE_ORG")] 67 | pub organization: String, 68 | #[doc = "The description for the project."] 69 | #[clap(long = "description", short = 'D', default_value_t)] 70 | pub description: String, 71 | } 72 | 73 | #[async_trait::async_trait] 74 | impl crate::cmd::Command for CmdProjectCreate { 75 | async fn run(&self, ctx: &mut crate::context::Context) -> anyhow::Result<()> { 76 | let mut description = self.description.clone(); 77 | let mut project = self.project.clone(); 78 | let mut organization = self.organization.clone(); 79 | if description.is_empty() && !ctx.io.can_prompt() { 80 | return Err(anyhow::anyhow!( 81 | "-D|--description required in non-interactive mode" 82 | )); 83 | } 84 | 85 | if project.is_empty() && !ctx.io.can_prompt() { 86 | return Err(anyhow::anyhow!( 87 | "[project] required in non-interactive mode" 88 | )); 89 | } 90 | 91 | if organization.is_empty() && !ctx.io.can_prompt() { 92 | return Err(anyhow::anyhow!( 93 | "-o|--organization required in non-interactive mode" 94 | )); 95 | } 96 | 97 | let client = ctx.api_client("")?; 98 | if ctx.io.can_prompt() { 99 | if project.is_empty() { 100 | match dialoguer::Input::::new() 101 | .with_prompt(&format!("{} name:", "project")) 102 | .interact_text() 103 | { 104 | Ok(name) => project = name, 105 | Err(err) => { 106 | return Err(anyhow::anyhow!("prompt failed: {}", err)); 107 | } 108 | } 109 | } 110 | if description.is_empty() { 111 | match dialoguer::Input::<_>::new() 112 | .with_prompt("project description") 113 | .interact_text() 114 | { 115 | Ok(input) => description = input, 116 | Err(err) => { 117 | return Err(anyhow::anyhow!("prompt failed: {}", err)); 118 | } 119 | } 120 | } 121 | } 122 | 123 | client 124 | .projects() 125 | .post( 126 | &self.organization, 127 | &oxide_api::types::ProjectCreate { 128 | description: description.clone(), 129 | name: project.clone(), 130 | }, 131 | ) 132 | .await?; 133 | let cs = ctx.io.color_scheme(); 134 | let full_name = format!("{}/{}", organization, project); 135 | writeln!( 136 | ctx.io.out, 137 | "{} Created {} {}", 138 | cs.success_icon(), 139 | "project", 140 | full_name 141 | )?; 142 | Ok(()) 143 | } 144 | } 145 | 146 | #[doc = "View project.\n\nDisplay information about an Oxide project.\n\nWith `--web`, open the project in a web browser instead."] 147 | #[derive(clap :: Parser, Debug, Clone)] 148 | #[clap(verbatim_doc_comment)] 149 | pub struct CmdProjectView { 150 | #[doc = "The project to view. Can be an ID or name."] 151 | #[clap(name = "project", required = true)] 152 | pub project: String, 153 | #[doc = r" The organization that holds the project."] 154 | #[clap(long, short, required = true, env = "OXIDE_ORG")] 155 | pub organization: String, 156 | #[doc = "Open the project in the browser."] 157 | #[clap(short, long)] 158 | pub web: bool, 159 | #[doc = r" Display output in json, yaml, or table format."] 160 | #[clap(long, short)] 161 | pub format: Option, 162 | } 163 | 164 | #[async_trait::async_trait] 165 | impl crate::cmd::Command for CmdProjectView { 166 | async fn run(&self, ctx: &mut crate::context::Context) -> anyhow::Result<()> { 167 | if self.web { 168 | let url = format!("https://{}/{}", ctx.config.default_host()?, self.project); 169 | ctx.browser("", &url)?; 170 | return Ok(()); 171 | } 172 | 173 | let client = ctx.api_client("")?; 174 | let result = client 175 | .projects() 176 | .get(&self.organization, &self.project) 177 | .await?; 178 | let format = ctx.format(&self.format)?; 179 | ctx.io.write_output(&format, &result)?; 180 | Ok(()) 181 | } 182 | } 183 | 184 | #[doc = "Edit project settings."] 185 | #[derive(clap :: Parser, Debug, Clone)] 186 | #[clap(verbatim_doc_comment)] 187 | pub struct CmdProjectEdit { 188 | #[doc = "The project to edit. Can be an ID or name."] 189 | #[clap(name = "project", required = true)] 190 | pub project: String, 191 | #[doc = r" The organization that holds the project."] 192 | #[clap(long, short, required = true, env = "OXIDE_ORG")] 193 | pub organization: String, 194 | #[doc = "The new description for the project."] 195 | #[clap(long = "description", short = 'D', required = false, default_value_t)] 196 | pub new_description: String, 197 | #[doc = "The new name for the project."] 198 | #[clap(long = "name", short = 'n', required = false, default_value_t)] 199 | pub new_name: oxide_api::types::Name, 200 | } 201 | 202 | #[async_trait::async_trait] 203 | impl crate::cmd::Command for CmdProjectEdit { 204 | async fn run(&self, ctx: &mut crate::context::Context) -> anyhow::Result<()> { 205 | if self.new_description.is_empty() && self.new_name.is_empty() { 206 | return Err(anyhow::anyhow!("nothing to edit")); 207 | } 208 | 209 | let client = ctx.api_client("")?; 210 | let mut name = self.project.clone(); 211 | if !self.new_name.is_empty() { 212 | name = self.new_name.to_string(); 213 | } 214 | 215 | let result = client 216 | .projects() 217 | .put( 218 | &self.organization, 219 | &self.project, 220 | &oxide_api::types::ProjectUpdate { 221 | description: self.new_description.clone(), 222 | name: self.new_name.clone(), 223 | }, 224 | ) 225 | .await?; 226 | let cs = ctx.io.color_scheme(); 227 | let full_name = format!("{}/{}", self.organization, self.project); 228 | if !self.new_name.is_empty() { 229 | writeln!( 230 | ctx.io.out, 231 | "{} Edited {} {} -> {}/{}", 232 | cs.success_icon(), 233 | "project", 234 | full_name, 235 | self.organization, 236 | self.new_name 237 | )?; 238 | } else { 239 | writeln!( 240 | ctx.io.out, 241 | "{} Edited {} {}", 242 | cs.success_icon_with_color(ansi_term::Color::Red), 243 | "project", 244 | full_name 245 | )?; 246 | } 247 | 248 | Ok(()) 249 | } 250 | } 251 | 252 | #[doc = "Delete project."] 253 | #[derive(clap :: Parser, Debug, Clone)] 254 | #[clap(verbatim_doc_comment)] 255 | pub struct CmdProjectDelete { 256 | #[doc = "The project to delete. Can be an ID or name."] 257 | #[clap(name = "project", required = true)] 258 | pub project: String, 259 | #[doc = r" The organization that holds the project."] 260 | #[clap(long, short, required = true, env = "OXIDE_ORG")] 261 | pub organization: String, 262 | #[doc = r" Confirm deletion without prompting."] 263 | #[clap(long)] 264 | pub confirm: bool, 265 | } 266 | 267 | #[async_trait::async_trait] 268 | impl crate::cmd::Command for CmdProjectDelete { 269 | async fn run(&self, ctx: &mut crate::context::Context) -> anyhow::Result<()> { 270 | if !ctx.io.can_prompt() && !self.confirm { 271 | return Err(anyhow::anyhow!( 272 | "--confirm required when not running interactively" 273 | )); 274 | } 275 | 276 | let client = ctx.api_client("")?; 277 | if !self.confirm { 278 | if let Err(err) = dialoguer::Input::::new() 279 | .with_prompt(format!("Type {} to confirm deletion:", self.project)) 280 | .validate_with(|input: &String| -> Result<(), &str> { 281 | if input.trim() == self.project { 282 | Ok(()) 283 | } else { 284 | Err("mismatched confirmation") 285 | } 286 | }) 287 | .interact_text() 288 | { 289 | return Err(anyhow::anyhow!("prompt failed: {}", err)); 290 | } 291 | } 292 | 293 | client 294 | .projects() 295 | .delete(&self.organization, &self.project) 296 | .await?; 297 | let cs = ctx.io.color_scheme(); 298 | let full_name = format!("{}/{}", self.organization, self.project); 299 | writeln!( 300 | ctx.io.out, 301 | "{} Deleted {} {}", 302 | cs.success_icon_with_color(ansi_term::Color::Red), 303 | "project", 304 | full_name 305 | )?; 306 | Ok(()) 307 | } 308 | } 309 | -------------------------------------------------------------------------------- /cli-macro-impl/tests/gen/sleds.rs.gen: -------------------------------------------------------------------------------- 1 | use num_traits::identities::Zero; 2 | #[derive(Parser, Debug, Clone)] 3 | enum SubCommand { 4 | List(CmdSledList), 5 | #[clap(alias = "get")] 6 | View(CmdSledView), 7 | } 8 | 9 | #[doc = "List sleds."] 10 | #[derive(clap :: Parser, Debug, Clone)] 11 | #[clap(verbatim_doc_comment)] 12 | pub struct CmdSledList { 13 | #[doc = "The order in which to sort the results."] 14 | #[clap(long = "sort-by", short = 's', default_value_t)] 15 | pub sort_by: oxide_api::types::IdSortMode, 16 | #[doc = r" Maximum number of items to list."] 17 | #[clap(long, short, default_value = "30")] 18 | pub limit: u32, 19 | #[doc = r" Make additional HTTP requests to fetch all pages."] 20 | #[clap(long)] 21 | pub paginate: bool, 22 | #[doc = r" Display output in json, yaml, or table format."] 23 | #[clap(long, short)] 24 | pub format: Option, 25 | } 26 | 27 | #[async_trait::async_trait] 28 | impl crate::cmd::Command for CmdSledList { 29 | async fn run(&self, ctx: &mut crate::context::Context) -> anyhow::Result<()> { 30 | if self.limit < 1 { 31 | return Err(anyhow::anyhow!("--limit must be greater than 0")); 32 | } 33 | 34 | let client = ctx.api_client("")?; 35 | let results = if self.paginate { 36 | client.sleds().get_all(self.sort_by.clone()).await? 37 | } else { 38 | client 39 | .sleds() 40 | .get_page(self.limit, "", self.sort_by.clone()) 41 | .await? 42 | }; 43 | let format = ctx.format(&self.format)?; 44 | ctx.io.write_output_for_vec(&format, &results)?; 45 | Ok(()) 46 | } 47 | } 48 | 49 | #[doc = "View sled.\n\nDisplay information about an Oxide sled.\n\nWith `--web`, open the sled in a web browser instead."] 50 | #[derive(clap :: Parser, Debug, Clone)] 51 | #[clap(verbatim_doc_comment)] 52 | pub struct CmdSledView { 53 | #[doc = "The sled to view. Can be an ID or name."] 54 | #[clap(name = "sled", required = true)] 55 | pub sled: String, 56 | #[doc = "Open the sled in the browser."] 57 | #[clap(short, long)] 58 | pub web: bool, 59 | #[doc = r" Display output in json, yaml, or table format."] 60 | #[clap(long, short)] 61 | pub format: Option, 62 | } 63 | 64 | #[async_trait::async_trait] 65 | impl crate::cmd::Command for CmdSledView { 66 | async fn run(&self, ctx: &mut crate::context::Context) -> anyhow::Result<()> { 67 | if self.web { 68 | let url = format!("https://{}/{}", ctx.config.default_host()?, self.sled); 69 | ctx.browser("", &url)?; 70 | return Ok(()); 71 | } 72 | 73 | let client = ctx.api_client("")?; 74 | let result = client.sleds().get(&self.sled).await?; 75 | let format = ctx.format(&self.format)?; 76 | ctx.io.write_output(&format, &result)?; 77 | Ok(()) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /cli-macro-impl/tests/tests.rs: -------------------------------------------------------------------------------- 1 | use cli_macro_impl::{do_gen, get_text_fmt}; 2 | use quote::quote; 3 | 4 | #[test] 5 | fn test_do_gen() { 6 | let mut actual = do_gen( 7 | quote! { 8 | tag = "disks", 9 | }, 10 | quote! { 11 | #[derive(Parser, Debug, Clone)] 12 | enum SubCommand { 13 | Attach(CmdDiskAttach), 14 | Create(CmdDiskCreate), 15 | Detach(CmdDiskDetach), 16 | Edit(CmdDiskEdit), 17 | } 18 | }, 19 | ) 20 | .unwrap(); 21 | 22 | expectorate::assert_contents("tests/gen/disks.rs.gen", &get_text_fmt(&actual).unwrap()); 23 | 24 | actual = do_gen( 25 | quote! { 26 | tag = "organizations", 27 | }, 28 | quote! { 29 | #[derive(Parser, Debug, Clone)] 30 | enum SubCommand {} 31 | }, 32 | ) 33 | .unwrap(); 34 | 35 | expectorate::assert_contents("tests/gen/organizations.rs.gen", &get_text_fmt(&actual).unwrap()); 36 | 37 | actual = do_gen( 38 | quote! { 39 | tag = "subnets", 40 | }, 41 | quote! { 42 | #[derive(Parser, Debug, Clone)] 43 | enum SubCommand {} 44 | }, 45 | ) 46 | .unwrap(); 47 | 48 | expectorate::assert_contents("tests/gen/subnets.rs.gen", &get_text_fmt(&actual).unwrap()); 49 | 50 | actual = do_gen( 51 | quote! { 52 | tag = "routes", 53 | }, 54 | quote! { 55 | #[derive(Parser, Debug, Clone)] 56 | enum SubCommand {} 57 | }, 58 | ) 59 | .unwrap(); 60 | 61 | expectorate::assert_contents("tests/gen/routes.rs.gen", &get_text_fmt(&actual).unwrap()); 62 | 63 | actual = do_gen( 64 | quote! { 65 | tag = "sleds", 66 | }, 67 | quote! { 68 | #[derive(Parser, Debug, Clone)] 69 | enum SubCommand {} 70 | }, 71 | ) 72 | .unwrap(); 73 | 74 | expectorate::assert_contents("tests/gen/sleds.rs.gen", &get_text_fmt(&actual).unwrap()); 75 | 76 | actual = do_gen( 77 | quote! { 78 | tag = "instances", 79 | }, 80 | quote! { 81 | #[derive(Parser, Debug, Clone)] 82 | enum SubCommand {} 83 | }, 84 | ) 85 | .unwrap(); 86 | 87 | expectorate::assert_contents("tests/gen/instances.rs.gen", &get_text_fmt(&actual).unwrap()); 88 | 89 | actual = do_gen( 90 | quote! { 91 | tag = "vpcs", 92 | }, 93 | quote! { 94 | #[derive(Parser, Debug, Clone)] 95 | enum SubCommand {} 96 | }, 97 | ) 98 | .unwrap(); 99 | 100 | expectorate::assert_contents("tests/gen/vpcs.rs.gen", &get_text_fmt(&actual).unwrap()); 101 | 102 | actual = do_gen( 103 | quote! { 104 | tag = "projects", 105 | }, 106 | quote! { 107 | #[derive(Parser, Debug, Clone)] 108 | enum SubCommand {} 109 | }, 110 | ) 111 | .unwrap(); 112 | 113 | expectorate::assert_contents("tests/gen/projects.rs.gen", &get_text_fmt(&actual).unwrap()); 114 | 115 | actual = do_gen( 116 | quote! { 117 | tag = "images", 118 | }, 119 | quote! { 120 | #[derive(Parser, Debug, Clone)] 121 | enum SubCommand {} 122 | }, 123 | ) 124 | .unwrap(); 125 | 126 | expectorate::assert_contents("tests/gen/images.rs.gen", &get_text_fmt(&actual).unwrap()); 127 | 128 | actual = do_gen( 129 | quote! { 130 | tag = "images:global", 131 | }, 132 | quote! { 133 | #[derive(Parser, Debug, Clone)] 134 | enum SubCommand {} 135 | }, 136 | ) 137 | .unwrap(); 138 | 139 | expectorate::assert_contents("tests/gen/images_global.rs.gen", &get_text_fmt(&actual).unwrap()); 140 | } 141 | -------------------------------------------------------------------------------- /cli-macro/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "cli-macro" 3 | description = "Macro for our CLI generation" 4 | version = "0.1.0" 5 | authors = ["Jess Frazelle "] 6 | edition = "2018" 7 | 8 | [lib] 9 | proc-macro = true 10 | 11 | [dependencies] 12 | cli-macro-impl = { path = "../cli-macro-impl" } 13 | 14 | -------------------------------------------------------------------------------- /cli-macro/src/lib.rs: -------------------------------------------------------------------------------- 1 | extern crate proc_macro; 2 | 3 | #[proc_macro_attribute] 4 | pub fn crud_gen(attr: proc_macro::TokenStream, item: proc_macro::TokenStream) -> proc_macro::TokenStream { 5 | cli_macro_impl::do_gen(attr.into(), item.into()).unwrap().into() 6 | } 7 | -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | # TODO(https://github.com/oxidecomputer/cli/issues/205) Pinned to nightly to 3 | # avoid an OOM during tests; we should return to stable when this issue is resolved. 4 | # 5 | # See also: .github/workflows/cargo-test.yml, which also uses nightly. 6 | channel = "nightly-2022-06-26" 7 | profile = "minimal" 8 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | max_width = 120 2 | edition = "2018" 3 | format_code_in_doc_comments = true 4 | format_strings = false 5 | imports_granularity = "Crate" 6 | group_imports = "StdExternalCrate" 7 | -------------------------------------------------------------------------------- /src/cmd.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | 3 | /*pub struct Example { 4 | pub description: String, 5 | pub args: Vec, 6 | pub output: String, 7 | }*/ 8 | 9 | /// This trait describes a command. 10 | #[async_trait::async_trait] 11 | pub trait Command { 12 | async fn run(&self, ctx: &mut crate::context::Context) -> Result<()>; 13 | } 14 | 15 | /*pub trait CommandExamples { 16 | fn examples(&self) -> Vec; 17 | }*/ 18 | -------------------------------------------------------------------------------- /src/cmd_completion.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::{Command, CommandFactory, Parser}; 3 | use clap_complete::{generate, Shell}; 4 | 5 | /// Generate shell completion scripts. 6 | /// 7 | /// When installing Oxide CLI through a package manager, it's possible that 8 | /// no additional shell configuration is necessary to gain completion support. For 9 | /// Homebrew, see . 10 | /// 11 | /// If you need to set up completions manually, follow the instructions below. The exact 12 | /// config file locations might vary based on your system. Make sure to restart your 13 | /// shell before testing whether completions are working. 14 | /// 15 | /// ### bash 16 | /// 17 | /// First, ensure that you install `bash-completion` using your package manager. 18 | /// 19 | /// After, add this to your `~/.bash_profile`: 20 | /// 21 | /// eval "$(oxide completion -s bash)" 22 | /// 23 | /// ### zsh 24 | /// Generate a `_oxide` completion script and put it somewhere in your `$fpath`: 25 | /// 26 | /// oxide completion -s zsh > /usr/local/share/zsh/site-functions/_oxide 27 | /// 28 | /// Ensure that the following is present in your `~/.zshrc`: 29 | /// 30 | /// autoload -U compinit 31 | /// compinit -i 32 | /// 33 | /// Zsh version 5.7 or later is recommended. 34 | /// 35 | /// ### fish 36 | /// 37 | /// Generate a `oxide.fish` completion script: 38 | /// 39 | /// oxide completion -s fish > ~/.config/fish/completions/oxide.fish 40 | /// 41 | /// ### PowerShell 42 | /// 43 | /// Open your profile script with: 44 | /// 45 | /// mkdir -Path (Split-Path -Parent $profile) -ErrorAction SilentlyContinue 46 | /// notepad $profile 47 | /// 48 | /// Add the line and save the file: 49 | /// 50 | /// Invoke-Expression -Command $(oxide completion -s powershell | Out-String) 51 | #[derive(Parser, Debug, Clone)] 52 | #[clap(verbatim_doc_comment)] 53 | pub struct CmdCompletion { 54 | /// Shell type: {bash|zsh|fish|powershell} 55 | #[clap(short, long, default_value = "bash")] 56 | pub shell: Shell, 57 | } 58 | 59 | #[async_trait::async_trait] 60 | impl crate::cmd::Command for CmdCompletion { 61 | async fn run(&self, ctx: &mut crate::context::Context) -> Result<()> { 62 | // Convert our opts into a clap app. 63 | let mut app: Command = crate::Opts::command(); 64 | let name = app.get_name().to_string(); 65 | // Generate the completion script. 66 | generate(self.shell, &mut app, name, &mut ctx.io.out); 67 | 68 | // Add a new line. 69 | writeln!(ctx.io.out)?; 70 | 71 | Ok(()) 72 | } 73 | } 74 | 75 | #[cfg(test)] 76 | mod test { 77 | use clap::ArgEnum; 78 | use pretty_assertions::assert_eq; 79 | 80 | use crate::cmd::Command; 81 | 82 | pub struct TestItem { 83 | name: String, 84 | input: String, 85 | want_out: String, 86 | want_err: String, 87 | } 88 | 89 | #[tokio::test(flavor = "multi_thread")] 90 | async fn test_cmd_completion_get() { 91 | let tests = vec![ 92 | TestItem { 93 | name: "bash completion".to_string(), 94 | input: "bash".to_string(), 95 | want_out: "complete -F _oxide -o bashdefault -o default oxide".to_string(), 96 | want_err: "".to_string(), 97 | }, 98 | TestItem { 99 | name: "zsh completion".to_string(), 100 | input: "zsh".to_string(), 101 | want_out: "#compdef oxide".to_string(), 102 | want_err: "".to_string(), 103 | }, 104 | TestItem { 105 | name: "fish completion".to_string(), 106 | input: "fish".to_string(), 107 | want_out: "complete -c oxide ".to_string(), 108 | want_err: "".to_string(), 109 | }, 110 | TestItem { 111 | name: "PowerShell completion".to_string(), 112 | input: "powershell".to_string(), 113 | want_out: "Register-ArgumentCompleter".to_string(), 114 | want_err: "".to_string(), 115 | }, 116 | TestItem { 117 | name: "unsupported shell".to_string(), 118 | input: "csh".to_string(), 119 | want_out: "".to_string(), 120 | want_err: "Invalid variant: csh".to_string(), 121 | }, 122 | ]; 123 | 124 | for t in tests { 125 | if let Err(e) = clap_complete::Shell::from_str(&t.input, true) { 126 | assert_eq!(e.to_string(), t.want_err, "test {}", t.name); 127 | continue; 128 | } 129 | 130 | let cmd = crate::cmd_completion::CmdCompletion { 131 | shell: clap_complete::Shell::from_str(&t.input, true).unwrap(), 132 | }; 133 | 134 | let (io, stdout_path, stderr_path) = crate::iostreams::IoStreams::test(); 135 | let mut config = crate::config::new_blank_config().unwrap(); 136 | let mut c = crate::config_from_env::EnvConfig::inherit_env(&mut config); 137 | let mut ctx = crate::context::Context { 138 | config: &mut c, 139 | io, 140 | debug: false, 141 | }; 142 | 143 | cmd.run(&mut ctx).await.unwrap(); 144 | 145 | let stdout = std::fs::read_to_string(&stdout_path).unwrap(); 146 | let stderr = std::fs::read_to_string(&stderr_path).unwrap(); 147 | 148 | assert_eq!(stdout.is_empty(), t.want_out.is_empty()); 149 | assert!(stdout.contains(&t.want_out), "test {}", t.name); 150 | 151 | assert_eq!(stderr.is_empty(), t.want_err.is_empty()); 152 | assert!(stderr.contains(&t.want_err), "test {}", t.name); 153 | } 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/cmd_config.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{bail, Result}; 2 | use clap::Parser; 3 | 4 | // TODO: make this doc a function that parses from the config the options so it's not hardcoded 5 | /// Manage configuration for oxide. 6 | /// 7 | /// Current respected settings: 8 | /// - editor: the text editor program to use for authoring text 9 | /// - prompt: toggle interactive prompting in the terminal (default: "enabled") 10 | /// - browser: the web browser to use for opening URLs 11 | /// - format: the formatting style for command output 12 | #[derive(Parser, Debug, Clone)] 13 | #[clap(verbatim_doc_comment)] 14 | pub struct CmdConfig { 15 | #[clap(subcommand)] 16 | subcmd: SubCommand, 17 | } 18 | 19 | #[derive(Parser, Debug, Clone)] 20 | enum SubCommand { 21 | Set(CmdConfigSet), 22 | List(CmdConfigList), 23 | Get(CmdConfigGet), 24 | } 25 | 26 | #[async_trait::async_trait] 27 | impl crate::cmd::Command for CmdConfig { 28 | async fn run(&self, ctx: &mut crate::context::Context) -> Result<()> { 29 | match &self.subcmd { 30 | SubCommand::Get(cmd) => cmd.run(ctx).await, 31 | SubCommand::Set(cmd) => cmd.run(ctx).await, 32 | SubCommand::List(cmd) => cmd.run(ctx).await, 33 | } 34 | } 35 | } 36 | 37 | /// Print the value of a given configuration key. 38 | #[derive(Parser, Debug, Clone)] 39 | #[clap(verbatim_doc_comment)] 40 | pub struct CmdConfigGet { 41 | /// The key to get the value of. 42 | #[clap(name = "key", required = true)] 43 | pub key: String, 44 | 45 | /// Get per-host setting. 46 | #[clap(short = 'H', long, default_value = "")] 47 | pub host: String, 48 | } 49 | 50 | #[async_trait::async_trait] 51 | impl crate::cmd::Command for CmdConfigGet { 52 | async fn run(&self, ctx: &mut crate::context::Context) -> Result<()> { 53 | match ctx.config.get(&self.host, &self.key) { 54 | Ok(value) => writeln!(ctx.io.out, "{}", value)?, 55 | Err(err) => { 56 | bail!("{}", err); 57 | } 58 | } 59 | 60 | Ok(()) 61 | } 62 | } 63 | 64 | /// Update configuration with a value for the given key. 65 | #[derive(Parser, Debug, Clone)] 66 | #[clap(verbatim_doc_comment)] 67 | pub struct CmdConfigSet { 68 | /// The key to set the value of. 69 | #[clap(name = "key", required = true)] 70 | pub key: String, 71 | 72 | /// The value to set. 73 | #[clap(name = "value", required = true)] 74 | pub value: String, 75 | 76 | /// Set per-host setting. 77 | #[clap(short = 'H', long, default_value = "")] 78 | pub host: String, 79 | } 80 | 81 | #[async_trait::async_trait] 82 | impl crate::cmd::Command for CmdConfigSet { 83 | async fn run(&self, ctx: &mut crate::context::Context) -> Result<()> { 84 | let cs = ctx.io.color_scheme(); 85 | 86 | // Validate the key. 87 | match crate::config::validate_key(&self.key) { 88 | Ok(()) => (), 89 | Err(_) => { 90 | bail!( 91 | "{} warning: '{}' is not a known configuration key", 92 | cs.warning_icon(), 93 | self.key 94 | ); 95 | } 96 | } 97 | 98 | // Validate the value. 99 | if let Err(err) = crate::config::validate_value(&self.key, &self.value) { 100 | bail!("{}", err); 101 | } 102 | 103 | // Set the value. 104 | if let Err(err) = ctx.config.set(&self.host, &self.key, &self.value) { 105 | bail!("{}", err); 106 | } 107 | 108 | // Write the config file. 109 | if let Err(err) = ctx.config.write() { 110 | bail!("{}", err); 111 | } 112 | 113 | Ok(()) 114 | } 115 | } 116 | 117 | /// Print a list of configuration keys and values. 118 | #[derive(Parser, Debug, Clone)] 119 | #[clap(verbatim_doc_comment)] 120 | pub struct CmdConfigList { 121 | /// Get per-host configuration. 122 | #[clap(short = 'H', long, default_value = "")] 123 | pub host: String, 124 | } 125 | 126 | #[async_trait::async_trait] 127 | impl crate::cmd::Command for CmdConfigList { 128 | async fn run(&self, ctx: &mut crate::context::Context) -> Result<()> { 129 | let host = if self.host.is_empty() { 130 | // We don't want to do the default host here since we want to show the default's for 131 | // all hosts, even if OXIDE_HOST is set. 132 | // TODO: in this case we should print all the hosts configs, not just the default. 133 | "".to_string() 134 | } else { 135 | self.host.to_string() 136 | }; 137 | 138 | for option in crate::config::config_options() { 139 | match ctx.config.get(&host, &option.key) { 140 | Ok(value) => writeln!(ctx.io.out, "{}={}", option.key, value)?, 141 | Err(err) => { 142 | if host.is_empty() { 143 | // Only bail if the host is empty, since some hosts may not have 144 | // all the options. 145 | bail!("{}", err); 146 | } 147 | } 148 | } 149 | } 150 | 151 | Ok(()) 152 | } 153 | } 154 | 155 | #[cfg(test)] 156 | mod test { 157 | use pretty_assertions::assert_eq; 158 | 159 | use crate::cmd::Command; 160 | 161 | pub struct TestItem { 162 | name: String, 163 | cmd: crate::cmd_config::SubCommand, 164 | want_out: String, 165 | want_err: String, 166 | } 167 | 168 | #[tokio::test(flavor = "multi_thread", worker_threads = 1)] 169 | async fn test_cmd_config() { 170 | let tests: Vec = vec![ 171 | TestItem { 172 | name: "list empty".to_string(), 173 | cmd: crate::cmd_config::SubCommand::List(crate::cmd_config::CmdConfigList { host: "".to_string() }), 174 | want_out: "editor=\nprompt=enabled\nbrowser=\nformat=table\n".to_string(), 175 | want_err: "".to_string(), 176 | }, 177 | TestItem { 178 | name: "set a key unknown".to_string(), 179 | cmd: crate::cmd_config::SubCommand::Set(crate::cmd_config::CmdConfigSet { 180 | key: "foo".to_string(), 181 | value: "bar".to_string(), 182 | host: "".to_string(), 183 | }), 184 | want_out: "".to_string(), 185 | want_err: "warning: 'foo' is not a known configuration key".to_string(), 186 | }, 187 | TestItem { 188 | name: "set a key".to_string(), 189 | cmd: crate::cmd_config::SubCommand::Set(crate::cmd_config::CmdConfigSet { 190 | key: "browser".to_string(), 191 | value: "bar".to_string(), 192 | host: "".to_string(), 193 | }), 194 | want_out: "".to_string(), 195 | want_err: "".to_string(), 196 | }, 197 | TestItem { 198 | name: "set a key with host".to_string(), 199 | cmd: crate::cmd_config::SubCommand::Set(crate::cmd_config::CmdConfigSet { 200 | key: "prompt".to_string(), 201 | value: "disabled".to_string(), 202 | host: "example.org".to_string(), 203 | }), 204 | want_out: "".to_string(), 205 | want_err: "".to_string(), 206 | }, 207 | TestItem { 208 | name: "get a key we set".to_string(), 209 | cmd: crate::cmd_config::SubCommand::Get(crate::cmd_config::CmdConfigGet { 210 | key: "browser".to_string(), 211 | host: "".to_string(), 212 | }), 213 | want_out: "bar\n".to_string(), 214 | want_err: "".to_string(), 215 | }, 216 | TestItem { 217 | name: "get a key we set with host".to_string(), 218 | cmd: crate::cmd_config::SubCommand::Get(crate::cmd_config::CmdConfigGet { 219 | key: "prompt".to_string(), 220 | host: "example.org".to_string(), 221 | }), 222 | want_out: "disabled\n".to_string(), 223 | want_err: "".to_string(), 224 | }, 225 | TestItem { 226 | name: "get a non existent key".to_string(), 227 | cmd: crate::cmd_config::SubCommand::Get(crate::cmd_config::CmdConfigGet { 228 | key: "blah".to_string(), 229 | host: "".to_string(), 230 | }), 231 | want_out: "".to_string(), 232 | want_err: "Key 'blah' not found".to_string(), 233 | }, 234 | TestItem { 235 | name: "list all default".to_string(), 236 | cmd: crate::cmd_config::SubCommand::List(crate::cmd_config::CmdConfigList { host: "".to_string() }), 237 | want_out: "editor=\nprompt=enabled\nbrowser=bar\nformat=table\n".to_string(), 238 | want_err: "".to_string(), 239 | }, 240 | ]; 241 | 242 | let mut config = crate::config::new_blank_config().unwrap(); 243 | let mut c = crate::config_from_env::EnvConfig::inherit_env(&mut config); 244 | 245 | for t in tests { 246 | let (io, stdout_path, stderr_path) = crate::iostreams::IoStreams::test(); 247 | let mut ctx = crate::context::Context { 248 | config: &mut c, 249 | io, 250 | debug: false, 251 | }; 252 | 253 | let cmd_config = crate::cmd_config::CmdConfig { subcmd: t.cmd }; 254 | match cmd_config.run(&mut ctx).await { 255 | Ok(()) => { 256 | let stdout = std::fs::read_to_string(stdout_path).unwrap(); 257 | let stderr = std::fs::read_to_string(stderr_path).unwrap(); 258 | assert!(stdout.contains(&t.want_out), "test {}", t.name); 259 | assert!(stderr.is_empty(), "test {}", t.name); 260 | } 261 | Err(err) => { 262 | let stdout = std::fs::read_to_string(stdout_path).unwrap(); 263 | let stderr = std::fs::read_to_string(stderr_path).unwrap(); 264 | assert_eq!(stdout, t.want_out, "test {}", t.name); 265 | assert!(err.to_string().contains(&t.want_err), "test {}", t.name); 266 | assert!(stderr.is_empty(), "test {}", t.name); 267 | } 268 | } 269 | } 270 | } 271 | } 272 | -------------------------------------------------------------------------------- /src/cmd_image.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Parser; 3 | use cli_macro::crud_gen; 4 | 5 | /// Create, list, view, and delete images. 6 | #[derive(Parser, Debug, Clone)] 7 | #[clap(verbatim_doc_comment)] 8 | pub struct CmdImage { 9 | #[clap(subcommand)] 10 | subcmd: SubCommand, 11 | } 12 | 13 | #[crud_gen { 14 | tag = "images", 15 | }] 16 | #[derive(Parser, Debug, Clone)] 17 | enum SubCommand { 18 | Global(crate::cmd_image_global::CmdImageGlobal), 19 | } 20 | 21 | #[async_trait::async_trait] 22 | impl crate::cmd::Command for CmdImage { 23 | async fn run(&self, ctx: &mut crate::context::Context) -> Result<()> { 24 | match &self.subcmd { 25 | SubCommand::Create(cmd) => cmd.run(ctx).await, 26 | SubCommand::Delete(cmd) => cmd.run(ctx).await, 27 | SubCommand::List(cmd) => cmd.run(ctx).await, 28 | SubCommand::View(cmd) => cmd.run(ctx).await, 29 | SubCommand::Global(cmd) => cmd.run(ctx).await, 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/cmd_image_global.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Parser; 3 | use cli_macro::crud_gen; 4 | 5 | /// Create, list, view, and delete global images. 6 | #[derive(Parser, Debug, Clone)] 7 | #[clap(verbatim_doc_comment)] 8 | pub struct CmdImageGlobal { 9 | #[clap(subcommand)] 10 | subcmd: SubCommand, 11 | } 12 | 13 | #[crud_gen { 14 | tag = "images:global", 15 | }] 16 | #[derive(Parser, Debug, Clone)] 17 | enum SubCommand {} 18 | 19 | #[async_trait::async_trait] 20 | impl crate::cmd::Command for CmdImageGlobal { 21 | async fn run(&self, ctx: &mut crate::context::Context) -> Result<()> { 22 | match &self.subcmd { 23 | SubCommand::Create(cmd) => cmd.run(ctx).await, 24 | SubCommand::Delete(cmd) => cmd.run(ctx).await, 25 | SubCommand::List(cmd) => cmd.run(ctx).await, 26 | SubCommand::View(cmd) => cmd.run(ctx).await, 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/cmd_instance_serial.rs: -------------------------------------------------------------------------------- 1 | use std::{mem::swap, os::unix::io::AsRawFd, time::Duration}; 2 | 3 | use anyhow::Result; 4 | use futures::{SinkExt, StreamExt}; 5 | use http::HeaderMap; 6 | use reqwest::ClientBuilder; 7 | use tokio_tungstenite::{ 8 | tungstenite::protocol::{Message, Role}, 9 | WebSocketStream, 10 | }; 11 | 12 | mod nexus_client { 13 | progenitor::generate_api!(spec = "spec-serial.json", interface = Builder,); 14 | } 15 | 16 | impl super::cmd_instance::CmdInstanceSerial { 17 | pub(crate) async fn websock_stream_tty(&self, ctx: &mut crate::context::Context<'_>) -> Result<()> { 18 | // shenanigans to get the info we need to construct a progenitor-client 19 | let reqw = ctx 20 | .api_client("")? 21 | .request_raw(http::Method::GET, "", None) 22 | .await? 23 | .build()?; 24 | 25 | let base = reqw.url().as_str(); 26 | let mut headers = HeaderMap::new(); 27 | headers.insert( 28 | http::header::AUTHORIZATION, 29 | reqw.headers().get(http::header::AUTHORIZATION).unwrap().to_owned(), 30 | ); 31 | 32 | let reqw_client = ClientBuilder::new() 33 | .connect_timeout(Duration::new(60, 0)) 34 | .default_headers(headers) 35 | .http1_only() // HTTP2 does not support websockets 36 | .build()?; 37 | 38 | let nexus_client = nexus_client::Client::new_with_client(base, reqw_client); 39 | 40 | let upgraded = nexus_client 41 | .instance_serial_console_stream() 42 | .organization_name(self.organization.to_owned()) 43 | .project_name(self.project.to_owned()) 44 | .instance_name(self.instance.to_owned()) 45 | .send() 46 | .await 47 | .map_err(|e| anyhow::anyhow!("{}", e))? 48 | .into_inner(); 49 | 50 | let mut ws = WebSocketStream::from_raw_socket(upgraded, Role::Client, None).await; 51 | 52 | let mut stdin: Box = Box::new(std::io::empty()); 53 | let mut stdout: Box = Box::new(std::io::sink()); 54 | swap(&mut stdin, &mut ctx.io.stdin); 55 | swap(&mut stdout, &mut ctx.io.out); 56 | 57 | let _raw_guard = if ctx.io.is_stdout_tty() { 58 | Some(RawTermiosGuard::stdio_guard().expect("failed to set raw mode")) 59 | } else if cfg!(test) { 60 | None 61 | } else { 62 | return Err(anyhow::anyhow!("Stdout must be a TTY to use interactive mode.")); 63 | }; 64 | 65 | // https://docs.rs/tokio/latest/tokio/io/trait.AsyncReadExt.html#method.read_exact 66 | // is not cancel safe! Meaning reads from tokio::io::stdin are not cancel 67 | // safe. Spawn a separate task to read and put bytes onto this channel. 68 | let (stdintx, stdinrx) = tokio::sync::mpsc::channel(16); 69 | let (wstx, mut wsrx) = tokio::sync::mpsc::channel(16); 70 | 71 | tokio::spawn(async move { 72 | let mut inbuf = [0u8; 1024]; 73 | 74 | loop { 75 | let n = match tokio::task::block_in_place(|| stdin.read(&mut inbuf)) { 76 | Err(_) | Ok(0) => break, 77 | Ok(n) => n, 78 | }; 79 | 80 | stdintx.send(inbuf[0..n].to_vec()).await.unwrap(); 81 | } 82 | }); 83 | 84 | tokio::spawn(async move { stdin_to_websockets_task(stdinrx, wstx).await }); 85 | 86 | loop { 87 | tokio::select! { 88 | c = wsrx.recv() => { 89 | match c { 90 | None => { 91 | // channel is closed 92 | break; 93 | } 94 | Some(c) => { 95 | ws.send(Message::Binary(c)).await?; 96 | }, 97 | } 98 | } 99 | msg = ws.next() => { 100 | match msg { 101 | Some(Ok(Message::Binary(input))) => { 102 | tokio::task::block_in_place(|| { 103 | stdout.write_all(&input)?; 104 | stdout.flush()?; 105 | Ok::<(), std::io::Error>(()) 106 | })?; 107 | } 108 | Some(Ok(Message::Close(..))) | None => break, 109 | _ => continue, 110 | } 111 | } 112 | } 113 | } 114 | 115 | Ok(()) 116 | } 117 | } 118 | 119 | /// Guard object that will set the terminal to raw mode and restore it 120 | /// to its previous state when it's dropped 121 | struct RawTermiosGuard(libc::c_int, libc::termios); 122 | 123 | impl RawTermiosGuard { 124 | fn stdio_guard() -> Result { 125 | let fd = std::io::stdout().as_raw_fd(); 126 | let termios = unsafe { 127 | let mut curr_termios = std::mem::zeroed(); 128 | let r = libc::tcgetattr(fd, &mut curr_termios); 129 | if r == -1 { 130 | return Err(std::io::Error::last_os_error()); 131 | } 132 | curr_termios 133 | }; 134 | let guard = RawTermiosGuard(fd, termios); 135 | unsafe { 136 | let mut raw_termios = termios; 137 | libc::cfmakeraw(&mut raw_termios); 138 | let r = libc::tcsetattr(fd, libc::TCSAFLUSH, &raw_termios); 139 | if r == -1 { 140 | return Err(std::io::Error::last_os_error()); 141 | } 142 | } 143 | Ok(guard) 144 | } 145 | } 146 | 147 | impl Drop for RawTermiosGuard { 148 | fn drop(&mut self) { 149 | let r = unsafe { libc::tcsetattr(self.0, libc::TCSADRAIN, &self.1) }; 150 | if r == -1 { 151 | Err::<(), _>(std::io::Error::last_os_error()).unwrap(); 152 | } 153 | } 154 | } 155 | 156 | async fn stdin_to_websockets_task( 157 | mut stdinrx: tokio::sync::mpsc::Receiver>, 158 | wstx: tokio::sync::mpsc::Sender>, 159 | ) { 160 | // next_raw must live outside loop, because Ctrl-A should work across 161 | // multiple inbuf reads. 162 | let mut next_raw = false; 163 | 164 | loop { 165 | let inbuf = if let Some(inbuf) = stdinrx.recv().await { 166 | inbuf 167 | } else { 168 | continue; 169 | }; 170 | 171 | // Put bytes from inbuf to outbuf, but don't send Ctrl-A unless 172 | // next_raw is true. 173 | let mut outbuf = Vec::with_capacity(inbuf.len()); 174 | 175 | let mut exit = false; 176 | for c in inbuf { 177 | match c { 178 | // Ctrl-A means send next one raw 179 | b'\x01' => { 180 | if next_raw { 181 | // Ctrl-A Ctrl-A should be sent as Ctrl-A 182 | outbuf.push(c); 183 | next_raw = false; 184 | } else { 185 | next_raw = true; 186 | } 187 | } 188 | b'\x03' => { 189 | if !next_raw { 190 | // Exit on non-raw Ctrl-C 191 | exit = true; 192 | break; 193 | } else { 194 | // Otherwise send Ctrl-C 195 | outbuf.push(c); 196 | next_raw = false; 197 | } 198 | } 199 | _ => { 200 | outbuf.push(c); 201 | next_raw = false; 202 | } 203 | } 204 | } 205 | 206 | // Send what we have, even if there's a Ctrl-C at the end. 207 | if !outbuf.is_empty() { 208 | wstx.send(outbuf).await.unwrap(); 209 | } 210 | 211 | if exit { 212 | break; 213 | } 214 | } 215 | } 216 | 217 | #[cfg(test)] 218 | mod test { 219 | use pretty_assertions::assert_eq; 220 | use test_context::{test_context, AsyncTestContext}; 221 | 222 | use crate::cmd::Command; 223 | 224 | struct TContext { 225 | orig_oxide_host: Result, 226 | orig_oxide_token: Result, 227 | } 228 | 229 | #[async_trait::async_trait] 230 | impl AsyncTestContext for TContext { 231 | async fn setup() -> TContext { 232 | let orig = TContext { 233 | orig_oxide_host: std::env::var("OXIDE_HOST"), 234 | orig_oxide_token: std::env::var("OXIDE_TOKEN"), 235 | }; 236 | 237 | // Set our test values. 238 | let test_host = 239 | std::env::var("OXIDE_TEST_HOST").expect("you need to set OXIDE_TEST_HOST to where the api is running"); 240 | 241 | let test_token = std::env::var("OXIDE_TEST_TOKEN").expect("OXIDE_TEST_TOKEN is required"); 242 | std::env::set_var("OXIDE_HOST", test_host); 243 | std::env::set_var("OXIDE_TOKEN", test_token); 244 | 245 | orig 246 | } 247 | 248 | async fn teardown(self) { 249 | // Put the original env var back. 250 | if let Ok(ref val) = self.orig_oxide_host { 251 | std::env::set_var("OXIDE_HOST", val); 252 | } else { 253 | std::env::remove_var("OXIDE_HOST"); 254 | } 255 | 256 | if let Ok(ref val) = self.orig_oxide_token { 257 | std::env::set_var("OXIDE_TOKEN", val); 258 | } else { 259 | std::env::remove_var("OXIDE_TOKEN"); 260 | } 261 | } 262 | } 263 | 264 | // TODO: Auth is shaky with current docker container CI implementation. 265 | // remove ignore tag once tests run against mock API server 266 | #[ignore] 267 | #[test_context(TContext)] 268 | #[tokio::test(flavor = "multi_thread", worker_threads = 1)] 269 | async fn test_cmd_instance_serial_interactive() { 270 | let cmd = crate::cmd_instance::CmdInstanceSerial { 271 | instance: "things".to_string(), 272 | project: "bar".to_string(), 273 | organization: "foo".to_string(), 274 | max_bytes: None, 275 | byte_offset: None, 276 | continuous: false, 277 | interactive: true, 278 | }; 279 | let mut config = crate::config::new_blank_config().unwrap(); 280 | let mut c = crate::config_from_env::EnvConfig::inherit_env(&mut config); 281 | let (mut io, stdout_path, stderr_path) = crate::iostreams::IoStreams::test(); 282 | io.stdin = Box::new(std::io::Cursor::new("")); 283 | let mut ctx = crate::context::Context { 284 | config: &mut c, 285 | io, 286 | debug: false, 287 | }; 288 | cmd.run(&mut ctx).await.unwrap(); 289 | 290 | let stdout = std::fs::read_to_string(stdout_path).unwrap(); 291 | let stderr = std::fs::read_to_string(stderr_path).unwrap(); 292 | assert!(stderr.is_empty()); 293 | assert_eq!(stdout, ""); 294 | } 295 | } 296 | -------------------------------------------------------------------------------- /src/cmd_open.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Parser; 3 | use parse_display::{Display, FromStr}; 4 | 5 | /// Shortcut to open the Oxide documentation or Console in your browser. 6 | /// 7 | /// If no arguments are given, the default is to open the Oxide documentation. 8 | #[derive(Parser, Debug, Clone)] 9 | #[clap(verbatim_doc_comment)] 10 | pub struct CmdOpen { 11 | #[clap(name = "shortcut", default_value_t)] 12 | shortcut: OpenShortcut, 13 | } 14 | 15 | /// The type of shortcut to open. 16 | #[derive(PartialEq, Eq, Debug, Clone, FromStr, Display)] 17 | #[display(style = "kebab-case")] 18 | pub enum OpenShortcut { 19 | /// Open the Oxide documentation in your browser. 20 | Docs, 21 | /// Open the Oxide API reference in your browser. 22 | ApiRef, 23 | /// Open the Oxide CLI reference in your browser. 24 | CliRef, 25 | /// Open the Oxide Console in your browser. 26 | Console, 27 | } 28 | 29 | impl Default for OpenShortcut { 30 | fn default() -> Self { 31 | OpenShortcut::Docs 32 | } 33 | } 34 | 35 | impl OpenShortcut { 36 | fn get_url(&self) -> String { 37 | match self { 38 | OpenShortcut::Docs => "https://docs.oxide.computer".to_string(), 39 | OpenShortcut::ApiRef => "https://docs.oxide.computer/api".to_string(), 40 | OpenShortcut::CliRef => "https://docs.oxide.computer/cli".to_string(), 41 | OpenShortcut::Console => "".to_string(), 42 | } 43 | } 44 | } 45 | 46 | #[async_trait::async_trait] 47 | impl crate::cmd::Command for CmdOpen { 48 | async fn run(&self, ctx: &mut crate::context::Context) -> Result<()> { 49 | if self.shortcut != OpenShortcut::Console { 50 | return ctx.browser("", &self.shortcut.get_url()); 51 | } 52 | 53 | // If they want to open the console, we need to get their default host. 54 | let mut host = ctx.config.default_host()?; 55 | 56 | if !host.starts_with("http") { 57 | // Default to https:// 58 | host = format!("https://{}", host); 59 | } 60 | 61 | // TODO: check this works once we have a proper console. 62 | ctx.browser("", &host)?; 63 | 64 | Ok(()) 65 | } 66 | } 67 | 68 | /// Returns the URL to the changelog for the given version. 69 | pub fn changelog_url(version: &str) -> String { 70 | format!("https://github.com/oxidecomputer/cli/releases/tag/v{}", version) 71 | } 72 | -------------------------------------------------------------------------------- /src/cmd_org.rs: -------------------------------------------------------------------------------- 1 | use std::io::Write; 2 | 3 | use anyhow::Result; 4 | use clap::Parser; 5 | use cli_macro::crud_gen; 6 | 7 | /// Create, list, edit, view, and delete organizations. 8 | #[derive(Parser, Debug, Clone)] 9 | #[clap(verbatim_doc_comment)] 10 | pub struct CmdOrganization { 11 | #[clap(subcommand)] 12 | subcmd: SubCommand, 13 | } 14 | 15 | #[crud_gen { 16 | tag = "organizations", 17 | }] 18 | #[derive(Parser, Debug, Clone)] 19 | enum SubCommand {} 20 | 21 | #[async_trait::async_trait] 22 | impl crate::cmd::Command for CmdOrganization { 23 | async fn run(&self, ctx: &mut crate::context::Context) -> Result<()> { 24 | match &self.subcmd { 25 | SubCommand::Create(cmd) => cmd.run(ctx).await, 26 | SubCommand::Delete(cmd) => cmd.run(ctx).await, 27 | SubCommand::Edit(cmd) => cmd.run(ctx).await, 28 | SubCommand::List(cmd) => cmd.run(ctx).await, 29 | SubCommand::View(cmd) => cmd.run(ctx).await, 30 | } 31 | } 32 | } 33 | 34 | #[cfg(test)] 35 | mod test { 36 | use pretty_assertions::assert_eq; 37 | use test_context::{test_context, AsyncTestContext}; 38 | 39 | use crate::cmd::Command; 40 | 41 | pub struct TestItem { 42 | name: String, 43 | cmd: crate::cmd_org::SubCommand, 44 | stdin: String, 45 | want_out: String, 46 | want_err: String, 47 | } 48 | 49 | struct TContext { 50 | orig_oxide_host: Result, 51 | orig_oxide_token: Result, 52 | } 53 | 54 | #[async_trait::async_trait] 55 | impl AsyncTestContext for TContext { 56 | async fn setup() -> TContext { 57 | let orig = TContext { 58 | orig_oxide_host: std::env::var("OXIDE_HOST"), 59 | orig_oxide_token: std::env::var("OXIDE_TOKEN"), 60 | }; 61 | 62 | // Set our test values. 63 | let test_host = 64 | std::env::var("OXIDE_TEST_HOST").expect("you need to set OXIDE_TEST_HOST to where the api is running"); 65 | 66 | let test_token = std::env::var("OXIDE_TEST_TOKEN").expect("OXIDE_TEST_TOKEN is required"); 67 | std::env::set_var("OXIDE_HOST", test_host); 68 | std::env::set_var("OXIDE_TOKEN", test_token); 69 | 70 | orig 71 | } 72 | 73 | async fn teardown(self) { 74 | // Put the original env var back. 75 | if let Ok(ref val) = self.orig_oxide_host { 76 | std::env::set_var("OXIDE_HOST", val); 77 | } else { 78 | std::env::remove_var("OXIDE_HOST"); 79 | } 80 | 81 | if let Ok(ref val) = self.orig_oxide_token { 82 | std::env::set_var("OXIDE_TOKEN", val); 83 | } else { 84 | std::env::remove_var("OXIDE_TOKEN"); 85 | } 86 | } 87 | } 88 | 89 | #[test_context(TContext)] 90 | #[tokio::test(flavor = "multi_thread", worker_threads = 1)] 91 | #[serial_test::serial] 92 | async fn test_cmd_org(_ctx: &mut TContext) { 93 | let tests: Vec = vec![ 94 | TestItem { 95 | name: "create no name".to_string(), 96 | cmd: crate::cmd_org::SubCommand::Create(crate::cmd_org::CmdOrganizationCreate { 97 | organization: "".to_string(), 98 | description: "hi hi".to_string(), 99 | }), 100 | 101 | stdin: "".to_string(), 102 | want_out: "".to_string(), 103 | want_err: "[organization] required in non-interactive mode".to_string(), 104 | }, 105 | TestItem { 106 | name: "create no description".to_string(), 107 | cmd: crate::cmd_org::SubCommand::Create(crate::cmd_org::CmdOrganizationCreate { 108 | organization: "".to_string(), 109 | description: "".to_string(), 110 | }), 111 | 112 | stdin: "".to_string(), 113 | want_out: "".to_string(), 114 | want_err: "-D|--description required in non-interactive mode".to_string(), 115 | }, 116 | TestItem { 117 | name: "delete no --confirm non-interactive".to_string(), 118 | cmd: crate::cmd_org::SubCommand::Delete(crate::cmd_org::CmdOrganizationDelete { 119 | organization: "things".to_string(), 120 | confirm: false, 121 | }), 122 | 123 | stdin: "".to_string(), 124 | want_out: "".to_string(), 125 | want_err: "--confirm required when not running interactively".to_string(), 126 | }, 127 | TestItem { 128 | name: "list zero limit".to_string(), 129 | cmd: crate::cmd_org::SubCommand::List(crate::cmd_org::CmdOrganizationList { 130 | sort_by: Default::default(), 131 | limit: 0, 132 | paginate: false, 133 | format: None, 134 | }), 135 | 136 | stdin: "".to_string(), 137 | want_out: "".to_string(), 138 | want_err: "--limit must be greater than 0".to_string(), 139 | }, 140 | TestItem { 141 | name: "list --json --paginate".to_string(), 142 | cmd: crate::cmd_org::SubCommand::List(crate::cmd_org::CmdOrganizationList { 143 | sort_by: Default::default(), 144 | limit: 30, 145 | paginate: true, 146 | format: Some(crate::types::FormatOutput::Json), 147 | }), 148 | 149 | stdin: "".to_string(), 150 | want_out: "".to_string(), 151 | want_err: "".to_string(), 152 | }, 153 | ]; 154 | 155 | let mut config = crate::config::new_blank_config().unwrap(); 156 | let mut c = crate::config_from_env::EnvConfig::inherit_env(&mut config); 157 | 158 | for t in tests { 159 | let (mut io, stdout_path, stderr_path) = crate::iostreams::IoStreams::test(); 160 | if !t.stdin.is_empty() { 161 | io.stdin = Box::new(std::io::Cursor::new(t.stdin)); 162 | } 163 | // We need to also turn off the fancy terminal colors. 164 | // This ensures it also works in GitHub actions/any CI. 165 | io.set_color_enabled(false); 166 | io.set_never_prompt(true); 167 | let mut ctx = crate::context::Context { 168 | config: &mut c, 169 | io, 170 | debug: false, 171 | }; 172 | 173 | let cmd_org = crate::cmd_org::CmdOrganization { subcmd: t.cmd }; 174 | match cmd_org.run(&mut ctx).await { 175 | Ok(()) => { 176 | let stdout = std::fs::read_to_string(stdout_path).unwrap(); 177 | let stderr = std::fs::read_to_string(stderr_path).unwrap(); 178 | assert!(stderr.is_empty(), "test {}: {}", t.name, stderr); 179 | if !stdout.contains(&t.want_out) { 180 | assert_eq!(stdout, t.want_out, "test {}: stdout mismatch", t.name); 181 | } 182 | } 183 | Err(err) => { 184 | let stdout = std::fs::read_to_string(stdout_path).unwrap(); 185 | let stderr = std::fs::read_to_string(stderr_path).unwrap(); 186 | assert_eq!(stdout, t.want_out, "test {}", t.name); 187 | if !err.to_string().contains(&t.want_err) { 188 | assert_eq!(err.to_string(), t.want_err, "test {}: err mismatch", t.name); 189 | } 190 | assert!(stderr.is_empty(), "test {}: {}", t.name, stderr); 191 | } 192 | } 193 | } 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /src/cmd_project.rs: -------------------------------------------------------------------------------- 1 | use std::io::Write; 2 | 3 | use anyhow::Result; 4 | use clap::Parser; 5 | use cli_macro::crud_gen; 6 | 7 | /// Create, list, edit, view, and delete projects. 8 | #[derive(Parser, Debug, Clone)] 9 | #[clap(verbatim_doc_comment)] 10 | pub struct CmdProject { 11 | #[clap(subcommand)] 12 | subcmd: SubCommand, 13 | } 14 | 15 | #[crud_gen { 16 | tag = "projects", 17 | }] 18 | #[derive(Parser, Debug, Clone)] 19 | enum SubCommand {} 20 | 21 | #[async_trait::async_trait] 22 | impl crate::cmd::Command for CmdProject { 23 | async fn run(&self, ctx: &mut crate::context::Context) -> Result<()> { 24 | match &self.subcmd { 25 | SubCommand::Create(cmd) => cmd.run(ctx).await, 26 | SubCommand::Delete(cmd) => cmd.run(ctx).await, 27 | SubCommand::Edit(cmd) => cmd.run(ctx).await, 28 | SubCommand::List(cmd) => cmd.run(ctx).await, 29 | SubCommand::View(cmd) => cmd.run(ctx).await, 30 | } 31 | } 32 | } 33 | 34 | #[cfg(test)] 35 | mod test { 36 | use pretty_assertions::assert_eq; 37 | 38 | use crate::cmd::Command; 39 | 40 | pub struct TestItem { 41 | name: String, 42 | cmd: crate::cmd_project::SubCommand, 43 | stdin: String, 44 | want_out: String, 45 | want_err: String, 46 | } 47 | 48 | #[tokio::test(flavor = "multi_thread", worker_threads = 1)] 49 | async fn test_cmd_project() { 50 | let tests: Vec = vec![ 51 | TestItem { 52 | name: "create no name".to_string(), 53 | cmd: crate::cmd_project::SubCommand::Create(crate::cmd_project::CmdProjectCreate { 54 | project: "".to_string(), 55 | organization: "".to_string(), 56 | description: "hello".to_string(), 57 | }), 58 | 59 | stdin: "".to_string(), 60 | want_out: "".to_string(), 61 | want_err: "[project] required in non-interactive mode".to_string(), 62 | }, 63 | TestItem { 64 | name: "create no organization".to_string(), 65 | cmd: crate::cmd_project::SubCommand::Create(crate::cmd_project::CmdProjectCreate { 66 | project: "things".to_string(), 67 | organization: "".to_string(), 68 | description: "foo".to_string(), 69 | }), 70 | 71 | stdin: "".to_string(), 72 | want_out: "".to_string(), 73 | want_err: "-o|--organization required in non-interactive mode".to_string(), 74 | }, 75 | TestItem { 76 | name: "create no description".to_string(), 77 | cmd: crate::cmd_project::SubCommand::Create(crate::cmd_project::CmdProjectCreate { 78 | project: "things".to_string(), 79 | organization: "foo".to_string(), 80 | description: "".to_string(), 81 | }), 82 | 83 | stdin: "".to_string(), 84 | want_out: "".to_string(), 85 | want_err: "-D|--description required in non-interactive mode".to_string(), 86 | }, 87 | TestItem { 88 | name: "delete no --confirm non-interactive".to_string(), 89 | cmd: crate::cmd_project::SubCommand::Delete(crate::cmd_project::CmdProjectDelete { 90 | project: "things".to_string(), 91 | organization: "".to_string(), 92 | confirm: false, 93 | }), 94 | 95 | stdin: "".to_string(), 96 | want_out: "".to_string(), 97 | want_err: "--confirm required when not running interactively".to_string(), 98 | }, 99 | TestItem { 100 | name: "list zero limit".to_string(), 101 | cmd: crate::cmd_project::SubCommand::List(crate::cmd_project::CmdProjectList { 102 | sort_by: Default::default(), 103 | limit: 0, 104 | organization: "".to_string(), 105 | paginate: false, 106 | format: None, 107 | }), 108 | 109 | stdin: "".to_string(), 110 | want_out: "".to_string(), 111 | want_err: "--limit must be greater than 0".to_string(), 112 | }, 113 | ]; 114 | 115 | let mut config = crate::config::new_blank_config().unwrap(); 116 | let mut c = crate::config_from_env::EnvConfig::inherit_env(&mut config); 117 | 118 | for t in tests { 119 | let (mut io, stdout_path, stderr_path) = crate::iostreams::IoStreams::test(); 120 | if !t.stdin.is_empty() { 121 | io.stdin = Box::new(std::io::Cursor::new(t.stdin)); 122 | } 123 | // We need to also turn off the fancy terminal colors. 124 | // This ensures it also works in GitHub actions/any CI. 125 | io.set_color_enabled(false); 126 | io.set_never_prompt(true); 127 | let mut ctx = crate::context::Context { 128 | config: &mut c, 129 | io, 130 | debug: false, 131 | }; 132 | 133 | let cmd_project = crate::cmd_project::CmdProject { subcmd: t.cmd }; 134 | match cmd_project.run(&mut ctx).await { 135 | Ok(()) => { 136 | let stdout = std::fs::read_to_string(stdout_path).unwrap(); 137 | let stderr = std::fs::read_to_string(stderr_path).unwrap(); 138 | assert!(stderr.is_empty(), "test {}: {}", t.name, stderr); 139 | if !stdout.contains(&t.want_out) { 140 | assert_eq!(stdout, t.want_out, "test {}: stdout mismatch", t.name); 141 | } 142 | } 143 | Err(err) => { 144 | let stdout = std::fs::read_to_string(stdout_path).unwrap(); 145 | let stderr = std::fs::read_to_string(stderr_path).unwrap(); 146 | assert_eq!(stdout, t.want_out, "test {}", t.name); 147 | if !err.to_string().contains(&t.want_err) { 148 | assert_eq!(err.to_string(), t.want_err, "test {}: err mismatch", t.name); 149 | } 150 | assert!(stderr.is_empty(), "test {}: {}", t.name, stderr); 151 | } 152 | } 153 | } 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/cmd_rack.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Parser; 3 | use cli_macro::crud_gen; 4 | 5 | /// Manage racks. 6 | #[derive(Parser, Debug, Clone)] 7 | #[clap(verbatim_doc_comment)] 8 | pub struct CmdRack { 9 | #[clap(subcommand)] 10 | subcmd: SubCommand, 11 | } 12 | 13 | #[crud_gen { 14 | tag = "racks", 15 | }] 16 | #[derive(Parser, Debug, Clone)] 17 | enum SubCommand {} 18 | 19 | #[async_trait::async_trait] 20 | impl crate::cmd::Command for CmdRack { 21 | async fn run(&self, ctx: &mut crate::context::Context) -> Result<()> { 22 | match &self.subcmd { 23 | SubCommand::List(cmd) => cmd.run(ctx).await, 24 | SubCommand::View(cmd) => cmd.run(ctx).await, 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/cmd_role.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Parser; 3 | use cli_macro::crud_gen; 4 | 5 | /// Manage built-in roles. 6 | #[derive(Parser, Debug, Clone)] 7 | #[clap(verbatim_doc_comment)] 8 | pub struct CmdRole { 9 | #[clap(subcommand)] 10 | subcmd: SubCommand, 11 | } 12 | 13 | #[crud_gen { 14 | tag = "roles", 15 | }] 16 | #[derive(Parser, Debug, Clone)] 17 | enum SubCommand {} 18 | 19 | #[async_trait::async_trait] 20 | impl crate::cmd::Command for CmdRole { 21 | async fn run(&self, ctx: &mut crate::context::Context) -> Result<()> { 22 | match &self.subcmd { 23 | SubCommand::List(cmd) => cmd.run(ctx).await, 24 | SubCommand::View(cmd) => cmd.run(ctx).await, 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/cmd_route.rs: -------------------------------------------------------------------------------- 1 | use std::io::Write; 2 | 3 | use anyhow::Result; 4 | use clap::Parser; 5 | use cli_macro::crud_gen; 6 | 7 | /// Create, list, edit, view, and delete routes. 8 | #[derive(Parser, Debug, Clone)] 9 | #[clap(verbatim_doc_comment)] 10 | pub struct CmdRoute { 11 | #[clap(subcommand)] 12 | subcmd: SubCommand, 13 | } 14 | 15 | #[crud_gen { 16 | tag = "routes", 17 | }] 18 | #[derive(Parser, Debug, Clone)] 19 | enum SubCommand {} 20 | 21 | #[async_trait::async_trait] 22 | impl crate::cmd::Command for CmdRoute { 23 | async fn run(&self, ctx: &mut crate::context::Context) -> Result<()> { 24 | match &self.subcmd { 25 | SubCommand::Create(cmd) => cmd.run(ctx).await, 26 | SubCommand::Delete(cmd) => cmd.run(ctx).await, 27 | SubCommand::Edit(cmd) => cmd.run(ctx).await, 28 | SubCommand::List(cmd) => cmd.run(ctx).await, 29 | SubCommand::View(cmd) => cmd.run(ctx).await, 30 | } 31 | } 32 | } 33 | 34 | #[cfg(test)] 35 | mod test { 36 | use pretty_assertions::assert_eq; 37 | 38 | use crate::cmd::Command; 39 | 40 | pub struct TestItem { 41 | name: String, 42 | cmd: crate::cmd_route::SubCommand, 43 | stdin: String, 44 | want_out: String, 45 | want_err: String, 46 | } 47 | 48 | #[tokio::test(flavor = "multi_thread", worker_threads = 1)] 49 | async fn test_cmd_route() { 50 | let destination = Some(oxide_api::types::RouteDestination::Ip("127.0.0.1".to_string())); 51 | let target = Some(oxide_api::types::RouteTarget::Ip("192.1.16.2".to_string())); 52 | 53 | let tests: Vec = vec![ 54 | TestItem { 55 | name: "create no description".to_string(), 56 | cmd: crate::cmd_route::SubCommand::Create(crate::cmd_route::CmdRouteCreate { 57 | route: "things".to_string(), 58 | organization: "foo".to_string(), 59 | project: "bar".to_string(), 60 | description: "".to_string(), 61 | vpc: "".to_string(), 62 | router: "".to_string(), 63 | target: target.clone(), 64 | destination: destination.clone(), 65 | }), 66 | 67 | stdin: "".to_string(), 68 | want_out: "".to_string(), 69 | want_err: "description required in non-interactive mode".to_string(), 70 | }, 71 | TestItem { 72 | name: "create no name".to_string(), 73 | cmd: crate::cmd_route::SubCommand::Create(crate::cmd_route::CmdRouteCreate { 74 | route: "".to_string(), 75 | organization: "".to_string(), 76 | project: "".to_string(), 77 | description: "blah blah".to_string(), 78 | vpc: "foo bar".to_string(), 79 | router: "".to_string(), 80 | target: target.clone(), 81 | destination: destination.clone(), 82 | }), 83 | 84 | stdin: "".to_string(), 85 | want_out: "".to_string(), 86 | want_err: "[route] required in non-interactive mode".to_string(), 87 | }, 88 | TestItem { 89 | name: "create no organization".to_string(), 90 | cmd: crate::cmd_route::SubCommand::Create(crate::cmd_route::CmdRouteCreate { 91 | route: "things".to_string(), 92 | organization: "".to_string(), 93 | project: "".to_string(), 94 | description: "blah blah".to_string(), 95 | vpc: "blah".to_string(), 96 | router: "".to_string(), 97 | target: target.clone(), 98 | destination: destination.clone(), 99 | }), 100 | 101 | stdin: "".to_string(), 102 | want_out: "".to_string(), 103 | want_err: "organization required in non-interactive mode".to_string(), 104 | }, 105 | TestItem { 106 | name: "create no project".to_string(), 107 | cmd: crate::cmd_route::SubCommand::Create(crate::cmd_route::CmdRouteCreate { 108 | route: "things".to_string(), 109 | organization: "foo".to_string(), 110 | project: "".to_string(), 111 | description: "blah blah".to_string(), 112 | vpc: "blah".to_string(), 113 | router: "".to_string(), 114 | target: target.clone(), 115 | destination: destination.clone(), 116 | }), 117 | 118 | stdin: "".to_string(), 119 | want_out: "".to_string(), 120 | want_err: "project required in non-interactive mode".to_string(), 121 | }, 122 | TestItem { 123 | name: "create no vpc".to_string(), 124 | cmd: crate::cmd_route::SubCommand::Create(crate::cmd_route::CmdRouteCreate { 125 | route: "things".to_string(), 126 | organization: "foo".to_string(), 127 | project: "bar".to_string(), 128 | description: "blah blah".to_string(), 129 | vpc: "".to_string(), 130 | router: "testing".to_string(), 131 | target: target.clone(), 132 | destination: destination.clone(), 133 | }), 134 | 135 | stdin: "".to_string(), 136 | want_out: "".to_string(), 137 | want_err: "-v|--vpc required in non-interactive mode".to_string(), 138 | }, 139 | TestItem { 140 | name: "create no router".to_string(), 141 | cmd: crate::cmd_route::SubCommand::Create(crate::cmd_route::CmdRouteCreate { 142 | route: "things".to_string(), 143 | organization: "foo".to_string(), 144 | project: "bar".to_string(), 145 | description: "blah blah".to_string(), 146 | vpc: "".to_string(), 147 | router: "".to_string(), 148 | target: target.clone(), 149 | destination: destination.clone(), 150 | }), 151 | 152 | stdin: "".to_string(), 153 | want_out: "".to_string(), 154 | want_err: "-r|--router required in non-interactive mode".to_string(), 155 | }, 156 | TestItem { 157 | name: "create no target".to_string(), 158 | cmd: crate::cmd_route::SubCommand::Create(crate::cmd_route::CmdRouteCreate { 159 | route: "things".to_string(), 160 | organization: "foo".to_string(), 161 | project: "bar".to_string(), 162 | description: "blah blah".to_string(), 163 | vpc: "".to_string(), 164 | router: "testing".to_string(), 165 | target: Default::default(), 166 | destination: destination.clone(), 167 | }), 168 | 169 | stdin: "".to_string(), 170 | want_out: "".to_string(), 171 | want_err: "-t|--target required in non-interactive mode".to_string(), 172 | }, 173 | TestItem { 174 | name: "create no destination".to_string(), 175 | cmd: crate::cmd_route::SubCommand::Create(crate::cmd_route::CmdRouteCreate { 176 | route: "things".to_string(), 177 | organization: "foo".to_string(), 178 | project: "bar".to_string(), 179 | description: "blah blah".to_string(), 180 | vpc: "".to_string(), 181 | router: "testing".to_string(), 182 | target: target.clone(), 183 | destination: Default::default(), 184 | }), 185 | 186 | stdin: "".to_string(), 187 | want_out: "".to_string(), 188 | want_err: "--destination required in non-interactive mode".to_string(), 189 | }, 190 | TestItem { 191 | name: "delete no --confirm non-interactive".to_string(), 192 | cmd: crate::cmd_route::SubCommand::Delete(crate::cmd_route::CmdRouteDelete { 193 | route: "things".to_string(), 194 | organization: "".to_string(), 195 | project: "".to_string(), 196 | vpc: "things".to_string(), 197 | router: "blah".to_string(), 198 | confirm: false, 199 | }), 200 | 201 | stdin: "".to_string(), 202 | want_out: "".to_string(), 203 | want_err: "--confirm required when not running interactively".to_string(), 204 | }, 205 | TestItem { 206 | name: "list zero limit".to_string(), 207 | cmd: crate::cmd_route::SubCommand::List(crate::cmd_route::CmdRouteList { 208 | sort_by: Default::default(), 209 | limit: 0, 210 | organization: "".to_string(), 211 | vpc: "things".to_string(), 212 | project: "".to_string(), 213 | router: "blah".to_string(), 214 | paginate: false, 215 | format: None, 216 | }), 217 | 218 | stdin: "".to_string(), 219 | want_out: "".to_string(), 220 | want_err: "--limit must be greater than 0".to_string(), 221 | }, 222 | ]; 223 | 224 | let mut config = crate::config::new_blank_config().unwrap(); 225 | let mut c = crate::config_from_env::EnvConfig::inherit_env(&mut config); 226 | 227 | for t in tests { 228 | let (mut io, stdout_path, stderr_path) = crate::iostreams::IoStreams::test(); 229 | if !t.stdin.is_empty() { 230 | io.stdin = Box::new(std::io::Cursor::new(t.stdin)); 231 | } 232 | // We need to also turn off the fancy terminal colors. 233 | // This ensures it also works in GitHub actions/any CI. 234 | io.set_color_enabled(false); 235 | io.set_never_prompt(true); 236 | let mut ctx = crate::context::Context { 237 | config: &mut c, 238 | io, 239 | debug: false, 240 | }; 241 | 242 | let cmd_route = crate::cmd_route::CmdRoute { subcmd: t.cmd }; 243 | match cmd_route.run(&mut ctx).await { 244 | Ok(()) => { 245 | let stdout = std::fs::read_to_string(stdout_path).unwrap(); 246 | let stderr = std::fs::read_to_string(stderr_path).unwrap(); 247 | assert!(stderr.is_empty(), "test {}: {}", t.name, stderr); 248 | if !stdout.contains(&t.want_out) { 249 | assert_eq!(stdout, t.want_out, "test {}: stdout mismatch", t.name); 250 | } 251 | } 252 | Err(err) => { 253 | let stdout = std::fs::read_to_string(stdout_path).unwrap(); 254 | let stderr = std::fs::read_to_string(stderr_path).unwrap(); 255 | assert_eq!(stdout, t.want_out, "test {}", t.name); 256 | if !err.to_string().contains(&t.want_err) { 257 | assert_eq!(err.to_string(), t.want_err, "test {}: err mismatch", t.name); 258 | } 259 | assert!(stderr.is_empty(), "test {}: {}", t.name, stderr); 260 | } 261 | } 262 | } 263 | } 264 | } 265 | -------------------------------------------------------------------------------- /src/cmd_router.rs: -------------------------------------------------------------------------------- 1 | use std::io::Write; 2 | 3 | use anyhow::Result; 4 | use clap::Parser; 5 | use cli_macro::crud_gen; 6 | 7 | /// Create, list, edit, view, and delete routers. 8 | #[derive(Parser, Debug, Clone)] 9 | #[clap(verbatim_doc_comment)] 10 | pub struct CmdRouter { 11 | #[clap(subcommand)] 12 | subcmd: SubCommand, 13 | } 14 | 15 | #[crud_gen { 16 | tag = "routers", 17 | }] 18 | #[derive(Parser, Debug, Clone)] 19 | enum SubCommand {} 20 | 21 | #[async_trait::async_trait] 22 | impl crate::cmd::Command for CmdRouter { 23 | async fn run(&self, ctx: &mut crate::context::Context) -> Result<()> { 24 | match &self.subcmd { 25 | SubCommand::Create(cmd) => cmd.run(ctx).await, 26 | SubCommand::Delete(cmd) => cmd.run(ctx).await, 27 | SubCommand::Edit(cmd) => cmd.run(ctx).await, 28 | SubCommand::List(cmd) => cmd.run(ctx).await, 29 | SubCommand::View(cmd) => cmd.run(ctx).await, 30 | } 31 | } 32 | } 33 | 34 | #[cfg(test)] 35 | mod test { 36 | use pretty_assertions::assert_eq; 37 | 38 | use crate::cmd::Command; 39 | 40 | pub struct TestItem { 41 | name: String, 42 | cmd: crate::cmd_router::SubCommand, 43 | stdin: String, 44 | want_out: String, 45 | want_err: String, 46 | } 47 | 48 | #[tokio::test(flavor = "multi_thread", worker_threads = 1)] 49 | async fn test_cmd_router() { 50 | let tests: Vec = vec![ 51 | TestItem { 52 | name: "create no description".to_string(), 53 | cmd: crate::cmd_router::SubCommand::Create(crate::cmd_router::CmdRouterCreate { 54 | router: "things".to_string(), 55 | organization: "foo".to_string(), 56 | project: "bar".to_string(), 57 | description: "".to_string(), 58 | vpc: "".to_string(), 59 | }), 60 | 61 | stdin: "".to_string(), 62 | want_out: "".to_string(), 63 | want_err: "description required in non-interactive mode".to_string(), 64 | }, 65 | TestItem { 66 | name: "create no name".to_string(), 67 | cmd: crate::cmd_router::SubCommand::Create(crate::cmd_router::CmdRouterCreate { 68 | router: "".to_string(), 69 | organization: "".to_string(), 70 | project: "".to_string(), 71 | description: "blah blah".to_string(), 72 | vpc: "foo bar".to_string(), 73 | }), 74 | 75 | stdin: "".to_string(), 76 | want_out: "".to_string(), 77 | want_err: "[router] required in non-interactive mode".to_string(), 78 | }, 79 | TestItem { 80 | name: "create no organization".to_string(), 81 | cmd: crate::cmd_router::SubCommand::Create(crate::cmd_router::CmdRouterCreate { 82 | router: "things".to_string(), 83 | organization: "".to_string(), 84 | project: "".to_string(), 85 | description: "blah blah".to_string(), 86 | vpc: "blah".to_string(), 87 | }), 88 | 89 | stdin: "".to_string(), 90 | want_out: "".to_string(), 91 | want_err: "organization required in non-interactive mode".to_string(), 92 | }, 93 | TestItem { 94 | name: "create no project".to_string(), 95 | cmd: crate::cmd_router::SubCommand::Create(crate::cmd_router::CmdRouterCreate { 96 | router: "things".to_string(), 97 | organization: "foo".to_string(), 98 | project: "".to_string(), 99 | description: "blah blah".to_string(), 100 | vpc: "blah".to_string(), 101 | }), 102 | 103 | stdin: "".to_string(), 104 | want_out: "".to_string(), 105 | want_err: "project required in non-interactive mode".to_string(), 106 | }, 107 | TestItem { 108 | name: "create no vpc".to_string(), 109 | cmd: crate::cmd_router::SubCommand::Create(crate::cmd_router::CmdRouterCreate { 110 | router: "things".to_string(), 111 | organization: "foo".to_string(), 112 | project: "bar".to_string(), 113 | description: "blah blah".to_string(), 114 | vpc: "".to_string(), 115 | }), 116 | 117 | stdin: "".to_string(), 118 | want_out: "".to_string(), 119 | want_err: "-v|--vpc required in non-interactive mode".to_string(), 120 | }, 121 | TestItem { 122 | name: "delete no --confirm non-interactive".to_string(), 123 | cmd: crate::cmd_router::SubCommand::Delete(crate::cmd_router::CmdRouterDelete { 124 | router: "things".to_string(), 125 | organization: "".to_string(), 126 | project: "".to_string(), 127 | vpc: "things".to_string(), 128 | confirm: false, 129 | }), 130 | 131 | stdin: "".to_string(), 132 | want_out: "".to_string(), 133 | want_err: "--confirm required when not running interactively".to_string(), 134 | }, 135 | TestItem { 136 | name: "list zero limit".to_string(), 137 | cmd: crate::cmd_router::SubCommand::List(crate::cmd_router::CmdRouterList { 138 | sort_by: Default::default(), 139 | limit: 0, 140 | organization: "".to_string(), 141 | vpc: "things".to_string(), 142 | project: "".to_string(), 143 | paginate: false, 144 | format: None, 145 | }), 146 | 147 | stdin: "".to_string(), 148 | want_out: "".to_string(), 149 | want_err: "--limit must be greater than 0".to_string(), 150 | }, 151 | ]; 152 | 153 | let mut config = crate::config::new_blank_config().unwrap(); 154 | let mut c = crate::config_from_env::EnvConfig::inherit_env(&mut config); 155 | 156 | for t in tests { 157 | let (mut io, stdout_path, stderr_path) = crate::iostreams::IoStreams::test(); 158 | if !t.stdin.is_empty() { 159 | io.stdin = Box::new(std::io::Cursor::new(t.stdin)); 160 | } 161 | // We need to also turn off the fancy terminal colors. 162 | // This ensures it also works in GitHub actions/any CI. 163 | io.set_color_enabled(false); 164 | io.set_never_prompt(true); 165 | let mut ctx = crate::context::Context { 166 | config: &mut c, 167 | io, 168 | debug: false, 169 | }; 170 | 171 | let cmd_router = crate::cmd_router::CmdRouter { subcmd: t.cmd }; 172 | match cmd_router.run(&mut ctx).await { 173 | Ok(()) => { 174 | let stdout = std::fs::read_to_string(stdout_path).unwrap(); 175 | let stderr = std::fs::read_to_string(stderr_path).unwrap(); 176 | assert!(stderr.is_empty(), "test {}: {}", t.name, stderr); 177 | if !stdout.contains(&t.want_out) { 178 | assert_eq!(stdout, t.want_out, "test {}: stdout mismatch", t.name); 179 | } 180 | } 181 | Err(err) => { 182 | let stdout = std::fs::read_to_string(stdout_path).unwrap(); 183 | let stderr = std::fs::read_to_string(stderr_path).unwrap(); 184 | assert_eq!(stdout, t.want_out, "test {}", t.name); 185 | if !err.to_string().contains(&t.want_err) { 186 | assert_eq!(err.to_string(), t.want_err, "test {}: err mismatch", t.name); 187 | } 188 | assert!(stderr.is_empty(), "test {}: {}", t.name, stderr); 189 | } 190 | } 191 | } 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /src/cmd_sled.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Parser; 3 | use cli_macro::crud_gen; 4 | 5 | /// Manage sleds. 6 | #[derive(Parser, Debug, Clone)] 7 | #[clap(verbatim_doc_comment)] 8 | pub struct CmdSled { 9 | #[clap(subcommand)] 10 | subcmd: SubCommand, 11 | } 12 | 13 | #[crud_gen { 14 | tag = "sleds", 15 | }] 16 | #[derive(Parser, Debug, Clone)] 17 | enum SubCommand {} 18 | 19 | #[async_trait::async_trait] 20 | impl crate::cmd::Command for CmdSled { 21 | async fn run(&self, ctx: &mut crate::context::Context) -> Result<()> { 22 | match &self.subcmd { 23 | SubCommand::List(cmd) => cmd.run(ctx).await, 24 | SubCommand::View(cmd) => cmd.run(ctx).await, 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/cmd_snapshot.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Parser; 3 | use cli_macro::crud_gen; 4 | 5 | /// Create, list, view, and delete snapshots. 6 | #[derive(Parser, Debug, Clone)] 7 | #[clap(verbatim_doc_comment)] 8 | pub struct CmdSnapshot { 9 | #[clap(subcommand)] 10 | subcmd: SubCommand, 11 | } 12 | 13 | #[crud_gen { 14 | tag = "snapshots", 15 | }] 16 | #[derive(Parser, Debug, Clone)] 17 | enum SubCommand {} 18 | 19 | #[async_trait::async_trait] 20 | impl crate::cmd::Command for CmdSnapshot { 21 | async fn run(&self, ctx: &mut crate::context::Context) -> Result<()> { 22 | match &self.subcmd { 23 | SubCommand::Create(cmd) => cmd.run(ctx).await, 24 | SubCommand::Delete(cmd) => cmd.run(ctx).await, 25 | SubCommand::List(cmd) => cmd.run(ctx).await, 26 | SubCommand::View(cmd) => cmd.run(ctx).await, 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/cmd_subnet.rs: -------------------------------------------------------------------------------- 1 | use std::io::Write; 2 | 3 | use anyhow::Result; 4 | use clap::Parser; 5 | use cli_macro::crud_gen; 6 | 7 | /// Create, list, edit, view, and delete subnets. 8 | #[derive(Parser, Debug, Clone)] 9 | #[clap(verbatim_doc_comment)] 10 | pub struct CmdSubnet { 11 | #[clap(subcommand)] 12 | subcmd: SubCommand, 13 | } 14 | 15 | #[crud_gen { 16 | tag = "subnets", 17 | }] 18 | #[derive(Parser, Debug, Clone)] 19 | enum SubCommand {} 20 | 21 | #[async_trait::async_trait] 22 | impl crate::cmd::Command for CmdSubnet { 23 | async fn run(&self, ctx: &mut crate::context::Context) -> Result<()> { 24 | match &self.subcmd { 25 | SubCommand::Create(cmd) => cmd.run(ctx).await, 26 | SubCommand::Delete(cmd) => cmd.run(ctx).await, 27 | SubCommand::Edit(cmd) => cmd.run(ctx).await, 28 | SubCommand::List(cmd) => cmd.run(ctx).await, 29 | SubCommand::View(cmd) => cmd.run(ctx).await, 30 | } 31 | } 32 | } 33 | 34 | #[cfg(test)] 35 | mod test { 36 | use pretty_assertions::assert_eq; 37 | 38 | use crate::cmd::Command; 39 | 40 | pub struct TestItem { 41 | name: String, 42 | cmd: crate::cmd_subnet::SubCommand, 43 | stdin: String, 44 | want_out: String, 45 | want_err: String, 46 | } 47 | 48 | #[tokio::test(flavor = "multi_thread", worker_threads = 1)] 49 | async fn test_cmd_subnet() { 50 | let ipv4_block = 51 | oxide_api::types::Ipv4Net(ipnetwork::Ipv4Network::new(std::net::Ipv4Addr::new(172, 30, 0, 0), 22).unwrap()); 52 | 53 | let tests: Vec = vec![ 54 | TestItem { 55 | name: "create no description".to_string(), 56 | cmd: crate::cmd_subnet::SubCommand::Create(crate::cmd_subnet::CmdSubnetCreate { 57 | subnet: "things".to_string(), 58 | organization: "foo".to_string(), 59 | project: "bar".to_string(), 60 | description: "".to_string(), 61 | vpc: "".to_string(), 62 | ipv4_block: Some(ipv4_block), 63 | ipv6_block: Default::default(), 64 | }), 65 | 66 | stdin: "".to_string(), 67 | want_out: "".to_string(), 68 | want_err: "description required in non-interactive mode".to_string(), 69 | }, 70 | TestItem { 71 | name: "create no name".to_string(), 72 | cmd: crate::cmd_subnet::SubCommand::Create(crate::cmd_subnet::CmdSubnetCreate { 73 | subnet: "".to_string(), 74 | organization: "".to_string(), 75 | project: "".to_string(), 76 | description: "blah blah".to_string(), 77 | vpc: "foo bar".to_string(), 78 | ipv4_block: Some(ipv4_block), 79 | ipv6_block: Default::default(), 80 | }), 81 | 82 | stdin: "".to_string(), 83 | want_out: "".to_string(), 84 | want_err: "[subnet] required in non-interactive mode".to_string(), 85 | }, 86 | TestItem { 87 | name: "create no organization".to_string(), 88 | cmd: crate::cmd_subnet::SubCommand::Create(crate::cmd_subnet::CmdSubnetCreate { 89 | subnet: "things".to_string(), 90 | organization: "".to_string(), 91 | project: "".to_string(), 92 | description: "blah blah".to_string(), 93 | vpc: "blah".to_string(), 94 | ipv4_block: Some(ipv4_block), 95 | ipv6_block: Default::default(), 96 | }), 97 | 98 | stdin: "".to_string(), 99 | want_out: "".to_string(), 100 | want_err: "organization required in non-interactive mode".to_string(), 101 | }, 102 | TestItem { 103 | name: "create no project".to_string(), 104 | cmd: crate::cmd_subnet::SubCommand::Create(crate::cmd_subnet::CmdSubnetCreate { 105 | subnet: "things".to_string(), 106 | organization: "foo".to_string(), 107 | project: "".to_string(), 108 | description: "blah blah".to_string(), 109 | vpc: "blah".to_string(), 110 | ipv4_block: Some(ipv4_block), 111 | ipv6_block: Default::default(), 112 | }), 113 | 114 | stdin: "".to_string(), 115 | want_out: "".to_string(), 116 | want_err: "project required in non-interactive mode".to_string(), 117 | }, 118 | TestItem { 119 | name: "create no vpc".to_string(), 120 | cmd: crate::cmd_subnet::SubCommand::Create(crate::cmd_subnet::CmdSubnetCreate { 121 | subnet: "things".to_string(), 122 | organization: "foo".to_string(), 123 | project: "bar".to_string(), 124 | description: "blah blah".to_string(), 125 | vpc: "".to_string(), 126 | ipv4_block: Some(ipv4_block), 127 | ipv6_block: Default::default(), 128 | }), 129 | 130 | stdin: "".to_string(), 131 | want_out: "".to_string(), 132 | want_err: "-v|--vpc required in non-interactive mode".to_string(), 133 | }, 134 | TestItem { 135 | name: "delete no --confirm non-interactive".to_string(), 136 | cmd: crate::cmd_subnet::SubCommand::Delete(crate::cmd_subnet::CmdSubnetDelete { 137 | subnet: "things".to_string(), 138 | organization: "".to_string(), 139 | project: "".to_string(), 140 | vpc: "things".to_string(), 141 | confirm: false, 142 | }), 143 | 144 | stdin: "".to_string(), 145 | want_out: "".to_string(), 146 | want_err: "--confirm required when not running interactively".to_string(), 147 | }, 148 | TestItem { 149 | name: "list zero limit".to_string(), 150 | cmd: crate::cmd_subnet::SubCommand::List(crate::cmd_subnet::CmdSubnetList { 151 | sort_by: Default::default(), 152 | limit: 0, 153 | organization: "".to_string(), 154 | vpc: "things".to_string(), 155 | project: "".to_string(), 156 | paginate: false, 157 | format: None, 158 | }), 159 | 160 | stdin: "".to_string(), 161 | want_out: "".to_string(), 162 | want_err: "--limit must be greater than 0".to_string(), 163 | }, 164 | ]; 165 | 166 | let mut config = crate::config::new_blank_config().unwrap(); 167 | let mut c = crate::config_from_env::EnvConfig::inherit_env(&mut config); 168 | 169 | for t in tests { 170 | let (mut io, stdout_path, stderr_path) = crate::iostreams::IoStreams::test(); 171 | if !t.stdin.is_empty() { 172 | io.stdin = Box::new(std::io::Cursor::new(t.stdin)); 173 | } 174 | // We need to also turn off the fancy terminal colors. 175 | // This ensures it also works in GitHub actions/any CI. 176 | io.set_color_enabled(false); 177 | io.set_never_prompt(true); 178 | let mut ctx = crate::context::Context { 179 | config: &mut c, 180 | io, 181 | debug: false, 182 | }; 183 | 184 | let cmd_subnet = crate::cmd_subnet::CmdSubnet { subcmd: t.cmd }; 185 | match cmd_subnet.run(&mut ctx).await { 186 | Ok(()) => { 187 | let stdout = std::fs::read_to_string(stdout_path).unwrap(); 188 | let stderr = std::fs::read_to_string(stderr_path).unwrap(); 189 | assert!(stderr.is_empty(), "test {}: {}", t.name, stderr); 190 | if !stdout.contains(&t.want_out) { 191 | assert_eq!(stdout, t.want_out, "test {}: stdout mismatch", t.name); 192 | } 193 | } 194 | Err(err) => { 195 | let stdout = std::fs::read_to_string(stdout_path).unwrap(); 196 | let stderr = std::fs::read_to_string(stderr_path).unwrap(); 197 | assert_eq!(stdout, t.want_out, "test {}", t.name); 198 | if !err.to_string().contains(&t.want_err) { 199 | assert_eq!(err.to_string(), t.want_err, "test {}: err mismatch", t.name); 200 | } 201 | assert!(stderr.is_empty(), "test {}: {}", t.name, stderr); 202 | } 203 | } 204 | } 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /src/cmd_update.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Parser; 3 | 4 | /// Update the current running binary to the latest version. 5 | /// 6 | /// This function will return an error if the current binary is under Homebrew or if 7 | /// the running version is already the latest version. 8 | #[derive(Parser, Debug, Clone)] 9 | #[clap(verbatim_doc_comment)] 10 | pub struct CmdUpdate {} 11 | 12 | #[async_trait::async_trait] 13 | impl crate::cmd::Command for CmdUpdate { 14 | async fn run(&self, ctx: &mut crate::context::Context) -> Result<()> { 15 | if crate::update::is_under_homebrew()? { 16 | anyhow::bail!("You are running under Homebrew. Please run `brew update && brew upgrade oxide` instead."); 17 | } 18 | 19 | // Get the latest release. 20 | let latest_release = crate::update::get_latest_release_info().await?; 21 | let current_version = clap::crate_version!(); 22 | 23 | if !crate::update::version_greater_then(&latest_release.version, current_version)? { 24 | anyhow::bail!( 25 | "You are already running the latest version ({}) of `oxide`.", 26 | current_version 27 | ); 28 | } 29 | 30 | let current_binary_path = std::env::current_exe()?; 31 | 32 | let cs = ctx.io.color_scheme(); 33 | 34 | writeln!( 35 | ctx.io.out, 36 | "Updating from v{} to {}...", 37 | current_version, latest_release.version 38 | )?; 39 | 40 | // Download the latest release. 41 | let temp_latest_binary_path = crate::update::download_binary_to_temp_file(&latest_release.version).await?; 42 | 43 | // Rename the file to that of the current running exe. 44 | std::fs::rename(temp_latest_binary_path, current_binary_path)?; 45 | 46 | writeln!( 47 | ctx.io.out, 48 | "{} Updated to v{}!", 49 | cs.success_icon(), 50 | latest_release.version 51 | )?; 52 | 53 | Ok(()) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/cmd_version.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Parser; 3 | 4 | /// Prints the version of the program. 5 | #[derive(Parser, Debug, Clone)] 6 | #[clap(verbatim_doc_comment)] 7 | pub struct CmdVersion { 8 | #[doc = "Open the version in the browser."] 9 | #[clap(short, long)] 10 | pub web: bool, 11 | } 12 | 13 | #[async_trait::async_trait] 14 | impl crate::cmd::Command for CmdVersion { 15 | async fn run(&self, ctx: &mut crate::context::Context) -> Result<()> { 16 | let version = clap::crate_version!(); 17 | let git_hash = git_rev::try_revision_string!(); 18 | let url = changelog_url(version); 19 | 20 | if let Some(gh) = git_hash { 21 | writeln!(ctx.io.out, "oxide {} ({})", version, gh)?; 22 | } else { 23 | writeln!(ctx.io.out, "oxide {}", version)?; 24 | } 25 | 26 | writeln!(ctx.io.out, "{}", url)?; 27 | 28 | if self.web { 29 | ctx.browser("", &url)?; 30 | } 31 | 32 | Ok(()) 33 | } 34 | } 35 | 36 | /// Returns the URL to the changelog for the given version. 37 | pub fn changelog_url(version: &str) -> String { 38 | format!("https://github.com/oxidecomputer/cli/releases/tag/v{}", version) 39 | } 40 | -------------------------------------------------------------------------------- /src/cmd_vpc.rs: -------------------------------------------------------------------------------- 1 | use std::io::Write; 2 | 3 | use anyhow::Result; 4 | use clap::Parser; 5 | use cli_macro::crud_gen; 6 | 7 | /// Create, list, edit, view, and delete VPCs. 8 | #[derive(Parser, Debug, Clone)] 9 | #[clap(verbatim_doc_comment)] 10 | pub struct CmdVpc { 11 | #[clap(subcommand)] 12 | subcmd: SubCommand, 13 | } 14 | 15 | #[crud_gen { 16 | tag = "vpcs", 17 | }] 18 | #[derive(Parser, Debug, Clone)] 19 | enum SubCommand {} 20 | 21 | #[async_trait::async_trait] 22 | impl crate::cmd::Command for CmdVpc { 23 | async fn run(&self, ctx: &mut crate::context::Context) -> Result<()> { 24 | match &self.subcmd { 25 | SubCommand::Create(cmd) => cmd.run(ctx).await, 26 | SubCommand::Delete(cmd) => cmd.run(ctx).await, 27 | SubCommand::Edit(cmd) => cmd.run(ctx).await, 28 | SubCommand::List(cmd) => cmd.run(ctx).await, 29 | SubCommand::View(cmd) => cmd.run(ctx).await, 30 | } 31 | } 32 | } 33 | 34 | #[cfg(test)] 35 | mod test { 36 | use pretty_assertions::assert_eq; 37 | 38 | use crate::cmd::Command; 39 | 40 | pub struct TestItem { 41 | name: String, 42 | cmd: crate::cmd_vpc::SubCommand, 43 | stdin: String, 44 | want_out: String, 45 | want_err: String, 46 | } 47 | 48 | #[tokio::test(flavor = "multi_thread", worker_threads = 1)] 49 | async fn test_cmd_vpc() { 50 | let tests: Vec = vec![ 51 | TestItem { 52 | name: "create no description".to_string(), 53 | cmd: crate::cmd_vpc::SubCommand::Create(crate::cmd_vpc::CmdVpcCreate { 54 | vpc: "things".to_string(), 55 | organization: "foo".to_string(), 56 | project: "bar".to_string(), 57 | description: "".to_string(), 58 | dns_name: "".to_string(), 59 | ipv6_prefix: Default::default(), 60 | }), 61 | 62 | stdin: "".to_string(), 63 | want_out: "".to_string(), 64 | want_err: "description required in non-interactive mode".to_string(), 65 | }, 66 | TestItem { 67 | name: "create no name".to_string(), 68 | cmd: crate::cmd_vpc::SubCommand::Create(crate::cmd_vpc::CmdVpcCreate { 69 | vpc: "".to_string(), 70 | organization: "".to_string(), 71 | project: "".to_string(), 72 | description: "blah blah".to_string(), 73 | dns_name: "foo bar".to_string(), 74 | ipv6_prefix: Default::default(), 75 | }), 76 | 77 | stdin: "".to_string(), 78 | want_out: "".to_string(), 79 | want_err: "[vpc] required in non-interactive mode".to_string(), 80 | }, 81 | TestItem { 82 | name: "create no organization".to_string(), 83 | cmd: crate::cmd_vpc::SubCommand::Create(crate::cmd_vpc::CmdVpcCreate { 84 | vpc: "things".to_string(), 85 | organization: "".to_string(), 86 | project: "".to_string(), 87 | description: "blah blah".to_string(), 88 | dns_name: "blah".to_string(), 89 | ipv6_prefix: Default::default(), 90 | }), 91 | 92 | stdin: "".to_string(), 93 | want_out: "".to_string(), 94 | want_err: "organization required in non-interactive mode".to_string(), 95 | }, 96 | TestItem { 97 | name: "create no project".to_string(), 98 | cmd: crate::cmd_vpc::SubCommand::Create(crate::cmd_vpc::CmdVpcCreate { 99 | vpc: "things".to_string(), 100 | organization: "foo".to_string(), 101 | project: "".to_string(), 102 | description: "blah blah".to_string(), 103 | dns_name: "blah".to_string(), 104 | ipv6_prefix: Default::default(), 105 | }), 106 | 107 | stdin: "".to_string(), 108 | want_out: "".to_string(), 109 | want_err: "project required in non-interactive mode".to_string(), 110 | }, 111 | TestItem { 112 | name: "create no dns_name".to_string(), 113 | cmd: crate::cmd_vpc::SubCommand::Create(crate::cmd_vpc::CmdVpcCreate { 114 | vpc: "things".to_string(), 115 | organization: "foo".to_string(), 116 | project: "bar".to_string(), 117 | description: "blah blah".to_string(), 118 | dns_name: "".to_string(), 119 | ipv6_prefix: Default::default(), 120 | }), 121 | 122 | stdin: "".to_string(), 123 | want_out: "".to_string(), 124 | want_err: "--dns-name required in non-interactive mode".to_string(), 125 | }, 126 | TestItem { 127 | name: "delete no --confirm non-interactive".to_string(), 128 | cmd: crate::cmd_vpc::SubCommand::Delete(crate::cmd_vpc::CmdVpcDelete { 129 | vpc: "things".to_string(), 130 | organization: "".to_string(), 131 | project: "".to_string(), 132 | confirm: false, 133 | }), 134 | 135 | stdin: "".to_string(), 136 | want_out: "".to_string(), 137 | want_err: "--confirm required when not running interactively".to_string(), 138 | }, 139 | TestItem { 140 | name: "list zero limit".to_string(), 141 | cmd: crate::cmd_vpc::SubCommand::List(crate::cmd_vpc::CmdVpcList { 142 | sort_by: Default::default(), 143 | limit: 0, 144 | organization: "".to_string(), 145 | project: "".to_string(), 146 | paginate: false, 147 | format: None, 148 | }), 149 | 150 | stdin: "".to_string(), 151 | want_out: "".to_string(), 152 | want_err: "--limit must be greater than 0".to_string(), 153 | }, 154 | ]; 155 | 156 | let mut config = crate::config::new_blank_config().unwrap(); 157 | let mut c = crate::config_from_env::EnvConfig::inherit_env(&mut config); 158 | 159 | for t in tests { 160 | let (mut io, stdout_path, stderr_path) = crate::iostreams::IoStreams::test(); 161 | if !t.stdin.is_empty() { 162 | io.stdin = Box::new(std::io::Cursor::new(t.stdin)); 163 | } 164 | // We need to also turn off the fancy terminal colors. 165 | // This ensures it also works in GitHub actions/any CI. 166 | io.set_color_enabled(false); 167 | io.set_never_prompt(true); 168 | let mut ctx = crate::context::Context { 169 | config: &mut c, 170 | io, 171 | debug: false, 172 | }; 173 | 174 | let cmd_vpc = crate::cmd_vpc::CmdVpc { subcmd: t.cmd }; 175 | match cmd_vpc.run(&mut ctx).await { 176 | Ok(()) => { 177 | let stdout = std::fs::read_to_string(stdout_path).unwrap(); 178 | let stderr = std::fs::read_to_string(stderr_path).unwrap(); 179 | assert!(stderr.is_empty(), "test {}: {}", t.name, stderr); 180 | if !stdout.contains(&t.want_out) { 181 | assert_eq!(stdout, t.want_out, "test {}: stdout mismatch", t.name); 182 | } 183 | } 184 | Err(err) => { 185 | let stdout = std::fs::read_to_string(stdout_path).unwrap(); 186 | let stderr = std::fs::read_to_string(stderr_path).unwrap(); 187 | assert_eq!(stdout, t.want_out, "test {}", t.name); 188 | if !err.to_string().contains(&t.want_err) { 189 | assert_eq!(err.to_string(), t.want_err, "test {}: err mismatch", t.name); 190 | } 191 | assert!(stderr.is_empty(), "test {}: {}", t.name, stderr); 192 | } 193 | } 194 | } 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /src/colors.rs: -------------------------------------------------------------------------------- 1 | use crate::config_file::get_env_var; 2 | 3 | pub fn env_color_disabled() -> bool { 4 | !get_env_var("NO_COLOR").is_empty() || get_env_var("CLICOLOR") == "0" 5 | } 6 | 7 | pub fn env_color_forced() -> bool { 8 | !get_env_var("CLICOLOR_FORCE").is_empty() && get_env_var("CLICOLOR_FORCE") != "0" 9 | } 10 | 11 | pub fn is_true_color_supported() -> bool { 12 | let term = get_env_var("TERM"); 13 | let color_term = get_env_var("COLORTERM"); 14 | 15 | term.contains("24bit") 16 | || term.contains("truecolor") 17 | || color_term.contains("24bit") 18 | || color_term.contains("truecolor") 19 | } 20 | 21 | pub fn is_256_color_supported() -> bool { 22 | let term = get_env_var("TERM"); 23 | let color_term = get_env_var("COLORTERM"); 24 | 25 | is_true_color_supported() || term.contains("256") || color_term.contains("256") 26 | } 27 | 28 | #[allow(dead_code)] 29 | pub struct ColorScheme { 30 | enabled: bool, 31 | is_256_enabled: bool, 32 | has_true_color: bool, 33 | } 34 | 35 | impl ColorScheme { 36 | pub fn new(enabled: bool, is_256_enabled: bool, has_true_color: bool) -> Self { 37 | ColorScheme { 38 | enabled, 39 | is_256_enabled, 40 | has_true_color, 41 | } 42 | } 43 | 44 | pub fn bold(&self, t: &str) -> String { 45 | if !self.enabled { 46 | return t.to_string(); 47 | } 48 | 49 | ansi_term::Style::new().bold().paint(t).to_string() 50 | } 51 | 52 | pub fn red(&self, t: &str) -> String { 53 | if !self.enabled { 54 | return t.to_string(); 55 | } 56 | 57 | ansi_term::Colour::Red.paint(t).to_string() 58 | } 59 | 60 | pub fn yellow(&self, t: &str) -> String { 61 | if !self.enabled { 62 | return t.to_string(); 63 | } 64 | 65 | ansi_term::Colour::Yellow.paint(t).to_string() 66 | } 67 | 68 | pub fn green(&self, t: &str) -> String { 69 | if !self.enabled { 70 | return t.to_string(); 71 | } 72 | 73 | ansi_term::Colour::Green.paint(t).to_string() 74 | } 75 | 76 | #[allow(dead_code)] 77 | pub fn gray(&self, t: &str) -> String { 78 | if !self.enabled { 79 | return t.to_string(); 80 | } 81 | 82 | if self.is_256_enabled { 83 | ansi_term::Colour::Fixed(242).paint(t).to_string() 84 | } else { 85 | t.to_string() 86 | } 87 | } 88 | 89 | pub fn purple(&self, t: &str) -> String { 90 | if !self.enabled { 91 | return t.to_string(); 92 | } 93 | 94 | ansi_term::Colour::Purple.paint(t).to_string() 95 | } 96 | 97 | #[allow(dead_code)] 98 | pub fn blue(&self, t: &str) -> String { 99 | if !self.enabled { 100 | return t.to_string(); 101 | } 102 | 103 | ansi_term::Colour::Blue.paint(t).to_string() 104 | } 105 | 106 | pub fn cyan(&self, t: &str) -> String { 107 | if !self.enabled { 108 | return t.to_string(); 109 | } 110 | 111 | ansi_term::Colour::Cyan.paint(t).to_string() 112 | } 113 | 114 | pub fn success_icon(&self) -> String { 115 | self.green("✔") 116 | } 117 | 118 | pub fn success_icon_with_color(&self, color: ansi_term::Colour) -> String { 119 | if self.enabled { 120 | return color.paint("✔").to_string(); 121 | } 122 | 123 | "✔".to_string() 124 | } 125 | 126 | pub fn warning_icon(&self) -> String { 127 | self.yellow("!") 128 | } 129 | 130 | #[allow(dead_code)] 131 | pub fn failure_icon(&self) -> String { 132 | self.red("✘") 133 | } 134 | 135 | pub fn failure_icon_with_color(&self, color: ansi_term::Colour) -> String { 136 | if self.enabled { 137 | return color.paint("✘").to_string(); 138 | } 139 | 140 | "✘".to_string() 141 | } 142 | } 143 | 144 | #[cfg(test)] 145 | mod test { 146 | use pretty_assertions::assert_eq; 147 | use serial_test::serial; 148 | use test_context::{test_context, TestContext}; 149 | 150 | use super::*; 151 | 152 | struct Context { 153 | orig_no_color_env: Result, 154 | orig_clicolor_env: Result, 155 | orig_clicolor_force_env: Result, 156 | } 157 | 158 | impl TestContext for Context { 159 | fn setup() -> Context { 160 | Context { 161 | orig_no_color_env: std::env::var("NO_COLOR"), 162 | orig_clicolor_env: std::env::var("CLICOLOR"), 163 | orig_clicolor_force_env: std::env::var("CLICOLOR_FORCE"), 164 | } 165 | } 166 | 167 | fn teardown(self) { 168 | // Put the original env var back. 169 | if let Ok(ref val) = self.orig_no_color_env { 170 | std::env::set_var("NO_COLOR", val); 171 | } else { 172 | std::env::remove_var("NO_COLOR"); 173 | } 174 | 175 | if let Ok(ref val) = self.orig_clicolor_env { 176 | std::env::set_var("CLICOLOR", val); 177 | } else { 178 | std::env::remove_var("CLICOLOR"); 179 | } 180 | 181 | if let Ok(ref val) = self.orig_clicolor_force_env { 182 | std::env::set_var("CLICOLOR_FORCE", val); 183 | } else { 184 | std::env::remove_var("CLICOLOR_FORCE"); 185 | } 186 | } 187 | } 188 | 189 | pub struct TestItem { 190 | name: String, 191 | no_color_env: String, 192 | clicolor_env: String, 193 | clicolor_force_env: String, 194 | want: bool, 195 | } 196 | 197 | #[test_context(Context)] 198 | #[test] 199 | #[serial] 200 | fn test_env_color_disabled(_ctx: &mut Context) { 201 | let tests = vec![ 202 | TestItem { 203 | name: "pristine env".to_string(), 204 | no_color_env: "".to_string(), 205 | clicolor_env: "".to_string(), 206 | clicolor_force_env: "".to_string(), 207 | want: false, 208 | }, 209 | TestItem { 210 | name: "NO_COLOR enabled".to_string(), 211 | no_color_env: "1".to_string(), 212 | clicolor_env: "".to_string(), 213 | clicolor_force_env: "".to_string(), 214 | want: true, 215 | }, 216 | TestItem { 217 | name: "CLICOLOR disabled".to_string(), 218 | no_color_env: "".to_string(), 219 | clicolor_env: "0".to_string(), 220 | clicolor_force_env: "".to_string(), 221 | want: true, 222 | }, 223 | TestItem { 224 | name: "CLICOLOR enabled".to_string(), 225 | no_color_env: "".to_string(), 226 | clicolor_env: "1".to_string(), 227 | clicolor_force_env: "".to_string(), 228 | want: false, 229 | }, 230 | TestItem { 231 | name: "CLICOLOR_FORCE has no effect".to_string(), 232 | no_color_env: "".to_string(), 233 | clicolor_env: "".to_string(), 234 | clicolor_force_env: "1".to_string(), 235 | want: false, 236 | }, 237 | ]; 238 | 239 | for t in tests { 240 | std::env::set_var("NO_COLOR", t.no_color_env); 241 | std::env::set_var("CLICOLOR", t.clicolor_env); 242 | std::env::set_var("CLICOLOR_FORCE", t.clicolor_force_env); 243 | 244 | let got = env_color_disabled(); 245 | assert_eq!(got, t.want, "test {}", t.name); 246 | } 247 | } 248 | 249 | #[test_context(Context)] 250 | #[test] 251 | #[serial] 252 | fn test_env_color_forced(_ctx: &mut Context) { 253 | let tests = vec![ 254 | TestItem { 255 | name: "pristine env".to_string(), 256 | no_color_env: "".to_string(), 257 | clicolor_env: "".to_string(), 258 | clicolor_force_env: "".to_string(), 259 | want: false, 260 | }, 261 | TestItem { 262 | name: "NO_COLOR enabled".to_string(), 263 | no_color_env: "1".to_string(), 264 | clicolor_env: "".to_string(), 265 | clicolor_force_env: "".to_string(), 266 | want: false, 267 | }, 268 | TestItem { 269 | name: "CLICOLOR disabled".to_string(), 270 | no_color_env: "".to_string(), 271 | clicolor_env: "0".to_string(), 272 | clicolor_force_env: "".to_string(), 273 | want: false, 274 | }, 275 | TestItem { 276 | name: "CLICOLOR enabled".to_string(), 277 | no_color_env: "".to_string(), 278 | clicolor_env: "1".to_string(), 279 | clicolor_force_env: "".to_string(), 280 | want: false, 281 | }, 282 | TestItem { 283 | name: "CLICOLOR_FORCE enabled".to_string(), 284 | no_color_env: "".to_string(), 285 | clicolor_env: "".to_string(), 286 | clicolor_force_env: "1".to_string(), 287 | want: true, 288 | }, 289 | TestItem { 290 | name: "CLICOLOR_FORCE disabled".to_string(), 291 | no_color_env: "".to_string(), 292 | clicolor_env: "".to_string(), 293 | clicolor_force_env: "0".to_string(), 294 | want: false, 295 | }, 296 | ]; 297 | 298 | for t in tests { 299 | std::env::set_var("NO_COLOR", t.no_color_env); 300 | std::env::set_var("CLICOLOR", t.clicolor_env); 301 | std::env::set_var("CLICOLOR_FORCE", t.clicolor_force_env); 302 | 303 | let got = env_color_forced(); 304 | 305 | assert_eq!(got, t.want, "test {}", t.name); 306 | } 307 | } 308 | } 309 | -------------------------------------------------------------------------------- /src/config_alias.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use anyhow::Result; 4 | 5 | pub struct AliasConfig<'a> { 6 | pub map: crate::config_map::ConfigMap, 7 | pub parent: &'a mut (dyn crate::config::Config + 'a), 8 | } 9 | 10 | impl AliasConfig<'_> { 11 | pub fn get(&self, alias: &str) -> (String, bool) { 12 | if self.map.is_empty() { 13 | return ("".to_string(), false); 14 | } 15 | 16 | let value = match self.map.get_string_value(alias) { 17 | Ok(value) => value, 18 | Err(_) => "".to_string(), 19 | }; 20 | 21 | (value.to_string(), !value.is_empty()) 22 | } 23 | 24 | pub fn add(&mut self, alias: &str, expansion: &str) -> Result<()> { 25 | self.map.set_string_value(alias, expansion)?; 26 | 27 | self.parent.save_aliases(&self.map)?; 28 | 29 | // Update the parent config. 30 | self.parent.write() 31 | } 32 | 33 | pub fn delete(&mut self, alias: &str) -> Result<()> { 34 | self.map.remove_entry(alias)?; 35 | 36 | self.parent.save_aliases(&self.map)?; 37 | 38 | // Update the parent config. 39 | self.parent.write() 40 | } 41 | 42 | pub fn list(&self) -> HashMap { 43 | let mut list: HashMap = HashMap::new(); 44 | 45 | for (key, value) in self.map.root.iter() { 46 | list.insert(key.to_string(), value.to_string()); 47 | } 48 | 49 | list 50 | } 51 | } 52 | 53 | #[cfg(test)] 54 | mod test { 55 | use pretty_assertions::assert_eq; 56 | 57 | use crate::config::Config; 58 | 59 | #[test] 60 | fn test_aliases() { 61 | let mut c = crate::config::new_blank_config().unwrap(); 62 | 63 | let mut aliases = c.aliases().unwrap(); 64 | let alias_list = aliases.list(); 65 | 66 | assert!(alias_list.is_empty()); 67 | 68 | assert_eq!(aliases.get("empty"), ("".to_string(), false)); 69 | 70 | // Add some aliases. 71 | aliases.add("alias1", "value1 thing foo").unwrap(); 72 | aliases.add("alias2", "value2 single").unwrap(); 73 | 74 | let alias_list = aliases.list(); 75 | assert_eq!(alias_list.len(), 2); 76 | 77 | assert_eq!(aliases.get("alias1"), ("value1 thing foo".to_string(), true)); 78 | assert_eq!(aliases.get("alias2"), ("value2 single".to_string(), true)); 79 | 80 | assert_eq!(aliases.get("not_existing"), ("".to_string(), false)); 81 | 82 | aliases.add("alias_3", "things hi there").unwrap(); 83 | assert_eq!(aliases.get("alias_3"), ("things hi there".to_string(), true)); 84 | 85 | assert_eq!(aliases.list().len(), 3); 86 | 87 | aliases.delete("alias_3").unwrap(); 88 | assert_eq!(aliases.get("alias_3"), ("".to_string(), false)); 89 | 90 | // Print the config. 91 | let expected = r#"[aliases] 92 | alias1 = "value1 thing foo" 93 | alias2 = "value2 single""#; 94 | assert!(c.config_to_string().unwrap().contains(expected)); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/config_file.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | env, fs, 3 | io::Write, 4 | path::{Path, PathBuf}, 5 | }; 6 | 7 | use anyhow::{anyhow, Context, Result}; 8 | 9 | const OXIDE_CONFIG_DIR: &str = "OXIDE_CONFIG_DIR"; 10 | const XDG_CONFIG_HOME: &str = "XDG_CONFIG_HOME"; 11 | const XDG_STATE_HOME: &str = "XDG_STATE_HOME"; 12 | #[allow(dead_code)] 13 | const XDG_DATA_HOME: &str = "XDG_DATA_HOME"; 14 | const APP_DATA: &str = "CommandData"; 15 | const LOCAL_APP_DATA: &str = "LocalCommandData"; 16 | 17 | // Config path precedence 18 | // 1. OXIDE_CONFIG_DIR 19 | // 2. XDG_CONFIG_HOME 20 | // 3. CommandData (windows only) 21 | // 4. HOME 22 | pub fn config_dir() -> Result { 23 | let path: PathBuf; 24 | 25 | let oxide_config_dir = get_env_var(OXIDE_CONFIG_DIR); 26 | let xdg_config_home = get_env_var(XDG_CONFIG_HOME); 27 | let app_data = get_env_var(APP_DATA); 28 | 29 | if !oxide_config_dir.is_empty() { 30 | path = Path::new(&oxide_config_dir).to_path_buf(); 31 | } else if !xdg_config_home.is_empty() { 32 | path = Path::new(&xdg_config_home).join("oxide"); 33 | } else if !app_data.is_empty() && std::env::consts::OS == "windows" { 34 | path = Path::new(&app_data).join("Oxide CLI"); 35 | } else { 36 | match dirs::home_dir() { 37 | Some(home) => { 38 | path = home.join(".config").join("oxide"); 39 | } 40 | None => { 41 | return Err(anyhow!("could not find home directory")); 42 | } 43 | } 44 | } 45 | 46 | // Convert the path into a string slice 47 | match path.to_str() { 48 | None => Err(anyhow!("path is not a valid UTF-8 sequence")), 49 | Some(s) => Ok(s.to_string()), 50 | } 51 | } 52 | 53 | // State path precedence 54 | // 2. XDG_STATE_HOME 55 | // 3. LocalCommandData (windows only) 56 | // 4. HOME 57 | pub fn state_dir() -> Result { 58 | let path: PathBuf; 59 | 60 | let xdg_state_home = get_env_var(XDG_STATE_HOME); 61 | let local_app_data = get_env_var(LOCAL_APP_DATA); 62 | 63 | if !xdg_state_home.is_empty() { 64 | path = Path::new(&xdg_state_home).join("oxide"); 65 | } else if !local_app_data.is_empty() && std::env::consts::OS == "windows" { 66 | path = Path::new(&local_app_data).join("Oxide CLI"); 67 | } else { 68 | match dirs::home_dir() { 69 | Some(home) => { 70 | path = home.join(".local").join("state").join("oxide"); 71 | } 72 | None => { 73 | return Err(anyhow!("could not find home directory")); 74 | } 75 | } 76 | } 77 | 78 | // Convert the path into a string slice 79 | match path.to_str() { 80 | None => Err(anyhow!("path is not a valid UTF-8 sequence")), 81 | Some(s) => Ok(s.to_string()), 82 | } 83 | } 84 | 85 | // Data path precedence 86 | // 2. XDG_DATA_HOME 87 | // 3. LocalCommandData (windows only) 88 | // 4. HOME 89 | #[allow(dead_code)] 90 | pub fn data_dir() -> Result { 91 | let path: PathBuf; 92 | 93 | let xdg_data_home = get_env_var(XDG_DATA_HOME); 94 | let local_app_data = get_env_var(LOCAL_APP_DATA); 95 | 96 | if !xdg_data_home.is_empty() { 97 | path = Path::new(&xdg_data_home).join("oxide"); 98 | } else if !local_app_data.is_empty() && std::env::consts::OS == "windows" { 99 | path = Path::new(&local_app_data).join("Oxide CLI"); 100 | } else { 101 | match dirs::home_dir() { 102 | Some(home) => { 103 | path = home.join(".local").join("share").join("oxide"); 104 | } 105 | None => { 106 | return Err(anyhow!("could not find home directory")); 107 | } 108 | } 109 | } 110 | 111 | // Convert the path into a string slice 112 | match path.to_str() { 113 | None => Err(anyhow!("path is not a valid UTF-8 sequence")), 114 | Some(s) => Ok(s.to_string()), 115 | } 116 | } 117 | 118 | pub fn config_file() -> Result { 119 | let config_dir = config_dir()?; 120 | let path = Path::new(&config_dir).join("config.toml"); 121 | 122 | // Convert the path into a string slice 123 | match path.to_str() { 124 | None => Err(anyhow!("path is not a valid UTF-8 sequence")), 125 | Some(s) => Ok(s.to_string()), 126 | } 127 | } 128 | 129 | pub fn hosts_file() -> Result { 130 | let config_dir = config_dir()?; 131 | let path = Path::new(&config_dir).join("hosts.toml"); 132 | 133 | // Convert the path into a string slice 134 | match path.to_str() { 135 | None => Err(anyhow!("path is not a valid UTF-8 sequence")), 136 | Some(s) => Ok(s.to_string()), 137 | } 138 | } 139 | 140 | pub fn state_file() -> Result { 141 | let state_dir = state_dir()?; 142 | let path = Path::new(&state_dir).join("state.toml"); 143 | 144 | // Convert the path into a string slice 145 | match path.to_str() { 146 | None => Err(anyhow!("path is not a valid UTF-8 sequence")), 147 | Some(s) => Ok(s.to_string()), 148 | } 149 | } 150 | 151 | pub fn parse_default_config() -> Result { 152 | let config_file_path = config_file()?; 153 | 154 | // If the config file does not exist, create it. 155 | let path = Path::new(&config_file_path); 156 | let mut root = if !path.exists() { 157 | // Get the default config from a blank. 158 | crate::config::new_blank_root()? 159 | } else { 160 | // Get the default config from the file. 161 | let contents = read_config_file(&config_file_path)?; 162 | contents.parse::()? 163 | }; 164 | 165 | // Parse the hosts file. 166 | let hosts_file_path = hosts_file()?; 167 | let path = Path::new(&hosts_file_path); 168 | if path.exists() { 169 | let contents = read_config_file(&hosts_file_path)?; 170 | let doc = contents.parse::()?; 171 | let hosts = doc.as_table().clone(); 172 | root.insert("hosts", toml_edit::Item::Table(hosts)); 173 | } 174 | 175 | Ok(crate::config::new_config(root)) 176 | } 177 | 178 | fn read_config_file(filename: &str) -> Result { 179 | fs::read_to_string(filename).with_context(|| format!("failed to read from {}", filename)) 180 | } 181 | 182 | pub fn write_config_file(filename: &str, data: &str) -> Result<()> { 183 | let path = Path::new(filename); 184 | let parent = path.parent().unwrap(); 185 | fs::create_dir_all(parent).with_context(|| format!("failed to create directory {}", parent.display()))?; 186 | 187 | let mut file = fs::File::create(filename)?; 188 | file.write_all(data.as_bytes()) 189 | .with_context(|| format!("failed to write to {}", filename)) 190 | } 191 | 192 | #[allow(dead_code)] 193 | fn backup_config_file(filename: String) -> Result<()> { 194 | fs::rename(&filename, &format!("{}.bak", filename)).with_context(|| format!("failed to backup {}", filename)) 195 | } 196 | 197 | pub fn get_env_var(key: &str) -> String { 198 | match env::var(key) { 199 | Ok(val) => val, 200 | Err(_) => "".to_string(), 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /src/config_from_env.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | 3 | use anyhow::Result; 4 | use thiserror::Error; 5 | 6 | use crate::cmd_auth::parse_host; 7 | use crate::config_file::get_env_var; 8 | 9 | const OXIDE_HOST: &str = "OXIDE_HOST"; 10 | const OXIDE_TOKEN: &str = "OXIDE_TOKEN"; 11 | 12 | pub struct EnvConfig<'a> { 13 | pub config: &'a mut (dyn crate::config::Config + 'a), 14 | } 15 | 16 | impl EnvConfig<'_> { 17 | pub fn inherit_env(config: &mut dyn crate::config::Config) -> EnvConfig { 18 | EnvConfig { config } 19 | } 20 | } 21 | 22 | #[derive(Error, Debug)] 23 | pub enum ReadOnlyEnvVarError { 24 | #[error("read-only value in: {0}")] 25 | Variable(String), 26 | } 27 | 28 | unsafe impl Send for EnvConfig<'_> {} 29 | unsafe impl Sync for EnvConfig<'_> {} 30 | 31 | impl crate::config::Config for EnvConfig<'_> { 32 | fn get(&self, hostname: &str, key: &str) -> Result { 33 | let (val, _) = self.get_with_source(hostname, key)?; 34 | Ok(val) 35 | } 36 | 37 | fn get_with_source(&self, hostname: &str, key: &str) -> Result<(String, String)> { 38 | // If they are asking specifically for the token, return the value. 39 | if key == "token" { 40 | let token = get_env_var(OXIDE_TOKEN); 41 | if !token.is_empty() { 42 | return Ok((token, OXIDE_TOKEN.to_string())); 43 | } 44 | } else { 45 | let var = format!("OXIDE_{}", heck::AsShoutySnakeCase(key)); 46 | let val = get_env_var(&var); 47 | if !val.is_empty() { 48 | return Ok((val, var)); 49 | } 50 | } 51 | 52 | self.config.get_with_source(hostname, key) 53 | } 54 | 55 | fn set(&mut self, hostname: &str, key: &str, value: &str) -> Result<()> { 56 | self.config.set(hostname, key, value) 57 | } 58 | 59 | fn unset_host(&mut self, key: &str) -> Result<()> { 60 | self.config.unset_host(key) 61 | } 62 | 63 | fn hosts(&self) -> Result> { 64 | self.config.hosts() 65 | } 66 | 67 | fn default_host(&self) -> Result { 68 | let (host, _) = self.default_host_with_source()?; 69 | Ok(host) 70 | } 71 | 72 | fn default_host_with_source(&self) -> Result<(String, String)> { 73 | if let Ok(host) = env::var(OXIDE_HOST) { 74 | let host = parse_host(&host)?; 75 | Ok((host.to_string(), OXIDE_HOST.to_string())) 76 | } else { 77 | self.config.default_host_with_source() 78 | } 79 | } 80 | 81 | fn aliases(&mut self) -> Result { 82 | self.config.aliases() 83 | } 84 | 85 | fn save_aliases(&mut self, aliases: &crate::config_map::ConfigMap) -> Result<()> { 86 | self.config.save_aliases(aliases) 87 | } 88 | 89 | fn expand_alias(&mut self, args: Vec) -> Result<(Vec, bool)> { 90 | self.config.expand_alias(args) 91 | } 92 | 93 | fn check_writable(&self, hostname: &str, key: &str) -> Result<()> { 94 | // If they are asking specifically for the token, return the value. 95 | if key == "token" { 96 | let token = get_env_var(OXIDE_TOKEN); 97 | if !token.is_empty() { 98 | return Err(ReadOnlyEnvVarError::Variable(OXIDE_TOKEN.to_string()).into()); 99 | } 100 | } 101 | 102 | self.config.check_writable(hostname, key) 103 | } 104 | 105 | fn write(&self) -> Result<()> { 106 | self.config.write() 107 | } 108 | 109 | fn config_to_string(&self) -> Result { 110 | self.config.config_to_string() 111 | } 112 | 113 | fn hosts_to_string(&self) -> Result { 114 | self.config.hosts_to_string() 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/config_map.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{anyhow, Result}; 2 | 3 | // ConfigMap implements a low-level get/set config that is backed by an in-memory tree of toml 4 | // nodes. It allows us to interact with a toml-based config programmatically, preserving any 5 | // comments that were present when the toml was parsed. 6 | #[derive(Clone, Debug)] 7 | pub struct ConfigMap { 8 | pub root: toml_edit::Table, 9 | } 10 | 11 | impl ConfigMap { 12 | pub fn is_empty(&self) -> bool { 13 | self.root.is_empty() 14 | } 15 | 16 | pub fn get_string_value(&self, key: &str) -> Result { 17 | match self.root.get(key) { 18 | Some(toml_edit::Item::Value(toml_edit::Value::String(s))) => Ok(s.value().to_string()), 19 | Some(v) => Err(anyhow!("Expected string value for key '{}', found '{:?}'", key, v)), 20 | None => Err(anyhow!("Key '{}' not found", key)), 21 | } 22 | } 23 | 24 | pub fn get_bool_value(&self, key: &str) -> Result { 25 | match self.root.get(key) { 26 | Some(toml_edit::Item::Value(toml_edit::Value::Boolean(s))) => Ok(*s.value()), 27 | Some(v) => Err(anyhow!("Expected bool value for key '{}', found '{:?}'", key, v)), 28 | None => Ok(false), 29 | } 30 | } 31 | 32 | pub fn set_string_value(&mut self, key: &str, value: &str) -> Result<()> { 33 | if key == "default" && (value == "true" || value == "false") { 34 | // Add this as a bool. 35 | self.root.insert(key, toml_edit::value(value == "true")); 36 | return Ok(()); 37 | } 38 | 39 | self.root.insert(key, toml_edit::value(value)); 40 | Ok(()) 41 | } 42 | 43 | pub fn find_entry(&self, key: &str) -> Result { 44 | match self.root.get(key) { 45 | Some(v) => Ok(v.clone()), 46 | None => Err(anyhow!("Key '{}' not found", key)), 47 | } 48 | } 49 | 50 | pub fn remove_entry(&mut self, key: &str) -> Result<()> { 51 | self.root.remove_entry(key); 52 | Ok(()) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/context.rs: -------------------------------------------------------------------------------- 1 | use std::str::FromStr; 2 | 3 | use anyhow::{anyhow, Result}; 4 | 5 | use crate::{config::Config, config_file::get_env_var, types::FormatOutput}; 6 | 7 | pub struct Context<'a> { 8 | pub config: &'a mut (dyn Config + Send + Sync + 'a), 9 | pub io: crate::iostreams::IoStreams, 10 | pub debug: bool, 11 | } 12 | 13 | impl Context<'_> { 14 | pub fn new(config: &mut (dyn Config + Send + Sync)) -> Context { 15 | // Let's get our IO streams. 16 | let mut io = crate::iostreams::IoStreams::system(); 17 | 18 | // Set the prompt. 19 | let prompt = config.get("", "prompt").unwrap(); 20 | if prompt == "disabled" { 21 | io.set_never_prompt(true) 22 | } 23 | 24 | // Check if we should force use the tty. 25 | if let Ok(oxide_force_tty) = std::env::var("OXIDE_FORCE_TTY") { 26 | if !oxide_force_tty.is_empty() { 27 | io.force_terminal(&oxide_force_tty); 28 | } 29 | } 30 | 31 | Context { 32 | config, 33 | io, 34 | debug: false, 35 | } 36 | } 37 | 38 | /// This function returns an API client for Oxide that is based on the configured 39 | /// user. 40 | pub fn api_client(&self, hostname: &str) -> Result { 41 | // Use the host passed in if it's set. 42 | // Otherwise, use the default host. 43 | let host = if hostname.is_empty() { 44 | self.config.default_host()? 45 | } else { 46 | hostname.to_string() 47 | }; 48 | 49 | // Change the baseURL to the one we want. 50 | let mut baseurl = host.to_string(); 51 | if !host.starts_with("http://") && !host.starts_with("https://") { 52 | baseurl = format!("https://{}", host); 53 | if host.starts_with("localhost") { 54 | baseurl = format!("http://{}", host) 55 | } 56 | } 57 | 58 | // Get the token for that host. 59 | let token = self.config.get(&host, "token")?; 60 | 61 | // Create the client. 62 | let client = oxide_api::Client::new(&token, &baseurl); 63 | 64 | Ok(client) 65 | } 66 | 67 | /// This function opens a browser that is based on the configured 68 | /// environment to the specified path. 69 | /// 70 | /// Browser precedence: 71 | /// 1. OXIDE_BROWSER 72 | /// 2. BROWSER 73 | /// 3. browser from config 74 | pub fn browser(&self, hostname: &str, url: &str) -> Result<()> { 75 | let source: String; 76 | let browser = if !get_env_var("OXIDE_BROWSER").is_empty() { 77 | source = "OXIDE_BROWSER".to_string(); 78 | get_env_var("OXIDE_BROWSER") 79 | } else if !get_env_var("BROWSER").is_empty() { 80 | source = "BROWSER".to_string(); 81 | get_env_var("BROWSER") 82 | } else { 83 | source = crate::config_file::config_file()?; 84 | self.config.get(hostname, "browser").unwrap_or_else(|_| "".to_string()) 85 | }; 86 | 87 | if browser.is_empty() { 88 | if let Err(err) = open::that(url) { 89 | return Err(anyhow!("An error occurred when opening '{}': {}", url, err)); 90 | } 91 | } else if let Err(err) = open::with(url, &browser) { 92 | return Err(anyhow!( 93 | "An error occurred when opening '{}' with browser '{}' configured from '{}': {}", 94 | url, 95 | browser, 96 | source, 97 | err 98 | )); 99 | } 100 | 101 | Ok(()) 102 | } 103 | 104 | /// Return the configured output format or override the default with the value passed in, 105 | /// if it is some. 106 | pub fn format(&self, format: &Option) -> Result { 107 | if let Some(format) = format { 108 | Ok(format.clone()) 109 | } else { 110 | let value = self.config.get("", "format")?; 111 | Ok(FormatOutput::from_str(&value).unwrap_or_default()) 112 | } 113 | } 114 | } 115 | 116 | #[cfg(test)] 117 | mod test { 118 | use pretty_assertions::assert_eq; 119 | use test_context::{test_context, TestContext}; 120 | 121 | use super::*; 122 | 123 | struct TContext { 124 | orig_oxide_force_tty_env: Result, 125 | } 126 | 127 | impl TestContext for TContext { 128 | fn setup() -> TContext { 129 | TContext { 130 | orig_oxide_force_tty_env: std::env::var("OXIDE_FORCE_TTY"), 131 | } 132 | } 133 | 134 | fn teardown(self) { 135 | if let Ok(ref val) = self.orig_oxide_force_tty_env { 136 | std::env::set_var("OXIDE_FORCE_TTY", val); 137 | } else { 138 | std::env::remove_var("OXIDE_FORCE_TTY"); 139 | } 140 | } 141 | } 142 | 143 | pub struct TestItem { 144 | name: String, 145 | oxide_force_tty_env: String, 146 | prompt: String, 147 | want_prompt: String, 148 | want_terminal_width_override: i32, 149 | } 150 | 151 | #[test_context(TContext)] 152 | #[test] 153 | #[serial_test::serial] 154 | fn test_context(_ctx: &mut TContext) { 155 | let tests = vec![ 156 | TestItem { 157 | name: "config prompt".to_string(), 158 | oxide_force_tty_env: "".to_string(), 159 | prompt: "disabled".to_string(), 160 | want_prompt: "disabled".to_string(), 161 | want_terminal_width_override: 0, 162 | }, 163 | TestItem { 164 | name: "OXIDE_FORCE_TTY env".to_string(), 165 | oxide_force_tty_env: "120".to_string(), 166 | prompt: "disabled".to_string(), 167 | want_prompt: "disabled".to_string(), 168 | want_terminal_width_override: 120, 169 | }, 170 | ]; 171 | 172 | for t in tests { 173 | let mut config = crate::config::new_blank_config().unwrap(); 174 | let mut c = crate::config_from_env::EnvConfig::inherit_env(&mut config); 175 | 176 | if !t.prompt.is_empty() { 177 | c.set("", "prompt", &t.prompt).unwrap(); 178 | } 179 | 180 | if !t.oxide_force_tty_env.is_empty() { 181 | std::env::set_var("OXIDE_FORCE_TTY", t.oxide_force_tty_env.clone()); 182 | } else { 183 | std::env::remove_var("OXIDE_FORCE_TTY"); 184 | } 185 | 186 | let ctx = Context::new(&mut c); 187 | 188 | assert_eq!( 189 | ctx.io.get_never_prompt(), 190 | t.want_prompt == "disabled", 191 | "test {}", 192 | t.name 193 | ); 194 | 195 | assert_eq!(ctx.config.get("", "prompt").unwrap(), t.want_prompt, "test: {}", t.name); 196 | 197 | if t.want_terminal_width_override > 0 { 198 | assert_eq!( 199 | ctx.io.terminal_width(), 200 | t.want_terminal_width_override, 201 | "test: {}", 202 | t.name 203 | ); 204 | } 205 | } 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /src/docs_man.rs: -------------------------------------------------------------------------------- 1 | use std::io::Write; 2 | 3 | use roff::{bold, escape, italic, list, paragraph, ManSection, Roff, Troffable}; 4 | 5 | /// Man page generator 6 | pub struct Man { 7 | section: Option, 8 | manual: Option, 9 | sections: Vec<(String, String)>, 10 | } 11 | 12 | impl Default for Man { 13 | fn default() -> Self { 14 | Self { 15 | section: Some(ManSection::Executable), 16 | manual: Some("General Commands Manual".to_string()), 17 | sections: Vec::new(), 18 | } 19 | } 20 | } 21 | 22 | /// Generate manpage for your application using the most common default values. 23 | pub fn generate_manpage(app: &clap::Command, buf: &mut dyn Write, title: &str, root: &clap::Command) { 24 | let man = Man::default(); 25 | man.render(app, buf, title, root); 26 | } 27 | 28 | impl Man { 29 | /// Write the manpage to a buffer. 30 | pub fn render(self, app: &clap::Command, buf: &mut dyn std::io::Write, title: &str, root: &clap::Command) { 31 | let mut page = Roff::new(root.get_name(), self.get_section()) 32 | .source(&format!( 33 | "{} {}", 34 | root.get_name(), 35 | root.get_version().unwrap_or_default() 36 | )) 37 | .section("Name", [&about(app, title)]) 38 | .section("Synopsis", [&synopsis(app, title)]) 39 | .section("Description", &description(app)); 40 | 41 | if let Some(manual) = &self.manual { 42 | page = page.manual(manual); 43 | } 44 | 45 | if app_has_arguments(app) { 46 | page = page.section("Options", &options(app)); 47 | } 48 | 49 | if app_has_subcommands(app) { 50 | page = page.section( 51 | &subcommand_heading(app), 52 | &subcommands(app, self.get_section().value(), title), 53 | ) 54 | } 55 | 56 | if app.get_after_long_help().is_some() || app.get_after_help().is_some() { 57 | page = page.section("Extra", &after_help(app)) 58 | } 59 | 60 | for (title, section) in self.sections { 61 | page = page.section(&title, &[section]); 62 | } 63 | 64 | // Check if the command has a parent, for the see also section. 65 | let mut split = title.split(' ').collect::>(); 66 | if title != root.get_name() { 67 | // Get the parent command. 68 | // Iterate if more than one, thats why we have a list. 69 | if split.len() > 1 { 70 | // Remove the last element, since that is the command name. 71 | split.pop(); 72 | 73 | page = page.section("See also", &see_also(split)); 74 | } 75 | } 76 | 77 | if app_has_version(root) { 78 | page = page.section("Version", &[version(root)]); 79 | } 80 | 81 | if root.get_author().is_some() { 82 | page = page.section("Author(s)", &[root.get_author().unwrap_or_default()]); 83 | } 84 | 85 | buf.write_all(page.render().as_bytes()).unwrap(); 86 | } 87 | 88 | fn get_section(&self) -> ManSection { 89 | self.section.unwrap_or(ManSection::Executable) 90 | } 91 | } 92 | 93 | fn app_has_version(app: &clap::Command) -> bool { 94 | app.get_long_version().or_else(|| app.get_version()).is_some() 95 | } 96 | 97 | fn app_has_arguments(app: &clap::Command) -> bool { 98 | app.get_arguments().any(|i| !i.is_hide_set()) 99 | } 100 | 101 | fn app_has_subcommands(app: &clap::Command) -> bool { 102 | app.get_subcommands().any(|i| !i.is_hide_set()) 103 | } 104 | 105 | fn subcommand_heading(app: &clap::Command) -> String { 106 | match app.get_subcommand_help_heading() { 107 | Some(title) => title.to_string(), 108 | None => "Subcommands".to_string(), 109 | } 110 | } 111 | 112 | fn about(app: &clap::Command, title: &str) -> String { 113 | let t = title.replace(' ', "-"); 114 | match app.get_about().or_else(|| app.get_long_about()) { 115 | Some(about) => format!("{} - {}", t, about), 116 | None => t, 117 | } 118 | } 119 | 120 | fn description(app: &clap::Command) -> Vec { 121 | match app.get_long_about().or_else(|| app.get_about()) { 122 | Some(about) => about 123 | .lines() 124 | .filter_map(|l| (!l.trim().is_empty()).then(|| paragraph(l.trim()))) 125 | .collect(), 126 | None => Vec::new(), 127 | } 128 | } 129 | 130 | fn synopsis(app: &clap::Command, title: &str) -> String { 131 | let mut res = String::new(); 132 | 133 | res.push_str(&italic(title)); 134 | res.push(' '); 135 | 136 | for opt in app.get_arguments() { 137 | let (lhs, rhs) = option_markers(opt); 138 | res.push_str(&match (opt.get_short(), opt.get_long()) { 139 | (Some(short), Some(long)) => format!("{}-{}|--{}{} ", lhs, short, long, rhs), 140 | (Some(short), None) => format!("{}-{}{} ", lhs, short, rhs), 141 | (None, Some(long)) => format!("{}--{}{} ", lhs, long, rhs), 142 | (None, None) => "".to_string(), 143 | }); 144 | } 145 | 146 | for arg in app.get_positionals() { 147 | let (lhs, rhs) = option_markers(arg); 148 | res.push_str(&format!("{}{}{} ", lhs, arg.get_id(), rhs)); 149 | } 150 | 151 | if app.has_subcommands() { 152 | let (lhs, rhs) = subcommand_markers(app); 153 | res.push_str(&format!( 154 | "{}{}{} ", 155 | lhs, 156 | escape( 157 | &app.get_subcommand_value_name() 158 | .unwrap_or(&subcommand_heading(app)) 159 | .to_lowercase() 160 | ), 161 | rhs 162 | )); 163 | } 164 | 165 | res.trim().to_string() 166 | } 167 | 168 | fn options(app: &clap::Command) -> Vec { 169 | let mut res = Vec::new(); 170 | let items: Vec<_> = app.get_arguments().filter(|i| !i.is_hide_set()).collect(); 171 | 172 | for opt in items.iter().filter(|a| !a.is_positional()) { 173 | let mut body = Vec::new(); 174 | 175 | let mut header = match (opt.get_short(), opt.get_long()) { 176 | (Some(short), Some(long)) => { 177 | vec![short_option(short), ", ".to_string(), long_option(long)] 178 | } 179 | (Some(short), None) => vec![short_option(short)], 180 | (None, Some(long)) => vec![long_option(long)], 181 | (None, None) => vec![], 182 | }; 183 | 184 | if let Some(value) = &opt.get_value_names() { 185 | header.push(format!("={}", italic(&value.join(" ")))); 186 | } 187 | 188 | if let Some(defs) = option_default_values(opt) { 189 | header.push(format!(" {}", defs)); 190 | } 191 | 192 | if let Some(help) = opt.get_long_help().or_else(|| opt.get_help()) { 193 | body.push(help.to_string()); 194 | } 195 | 196 | if let Some(env) = option_environment(opt) { 197 | body.push(env); 198 | } 199 | 200 | body.push("\n".to_string()); 201 | 202 | res.push(list(&header, &body)); 203 | } 204 | 205 | for pos in items.iter().filter(|a| a.is_positional()) { 206 | let (lhs, rhs) = option_markers(pos); 207 | let name = format!("{}{}{}", lhs, pos.get_id(), rhs); 208 | 209 | let mut header = vec![bold(&name)]; 210 | 211 | let mut body = Vec::new(); 212 | 213 | if let Some(defs) = option_default_values(pos) { 214 | header.push(format!(" {}", defs)); 215 | } 216 | 217 | if let Some(help) = pos.get_long_help().or_else(|| pos.get_help()) { 218 | body.push(help.to_string()); 219 | } 220 | 221 | if let Some(env) = option_environment(pos) { 222 | body.push(env); 223 | } 224 | 225 | res.push(list(&header, &body)) 226 | } 227 | 228 | res 229 | } 230 | 231 | fn subcommands(app: &clap::Command, section: i8, title: &str) -> Vec { 232 | app.get_subcommands() 233 | .filter(|s| !s.is_hide_set()) 234 | .map(|command| { 235 | let name = format!("{}-{}({})", title.replace(' ', "-"), command.get_name(), section); 236 | 237 | let mut body = match command.get_about().or_else(|| command.get_long_about()) { 238 | Some(about) => about 239 | .lines() 240 | .filter_map(|l| (!l.trim().is_empty()).then(|| l.trim())) 241 | .collect(), 242 | None => Vec::new(), 243 | }; 244 | 245 | body.push("\n"); 246 | 247 | list(&[bold(&name)], &body) 248 | }) 249 | .collect() 250 | } 251 | 252 | fn version(app: &clap::Command) -> String { 253 | format!("v{}", app.get_long_version().or_else(|| app.get_version()).unwrap()) 254 | } 255 | 256 | fn see_also(split: Vec<&str>) -> Vec { 257 | let mut result: Vec = vec![]; 258 | for (i, _) in split.iter().enumerate() { 259 | let mut p = split.clone(); 260 | p.truncate(i + 1); 261 | let parent = p.join("-"); 262 | 263 | // TODO: we could print the description here as well, instead of empty. 264 | let empty: Vec = vec![]; 265 | 266 | result.push(list(&[bold(&format!("{}(1)", parent))], &empty)); 267 | } 268 | 269 | result 270 | } 271 | 272 | fn after_help(app: &clap::Command) -> Vec { 273 | match app.get_after_long_help().or_else(|| app.get_after_help()) { 274 | Some(about) => about 275 | .lines() 276 | .filter_map(|l| (!l.trim().is_empty()).then(|| paragraph(l.trim()))) 277 | .collect(), 278 | None => Vec::new(), 279 | } 280 | } 281 | 282 | fn subcommand_markers(cmd: &clap::Command) -> (&'static str, &'static str) { 283 | markers(cmd.is_subcommand_required_set() || cmd.is_arg_required_else_help_set()) 284 | } 285 | 286 | fn option_markers(opt: &clap::Arg) -> (&'static str, &'static str) { 287 | markers(opt.is_required_set()) 288 | } 289 | 290 | fn markers(required: bool) -> (&'static str, &'static str) { 291 | if required { 292 | ("<", ">") 293 | } else { 294 | ("[", "]") 295 | } 296 | } 297 | 298 | fn short_option(opt: char) -> String { 299 | format!("-{}", bold(&opt.to_string())) 300 | } 301 | 302 | fn long_option(opt: &str) -> String { 303 | format!("--{}", bold(opt)) 304 | } 305 | 306 | fn option_environment(opt: &clap::Arg) -> Option { 307 | if opt.is_hide_env_set() { 308 | return None; 309 | } else if let Some(env) = opt.get_env() { 310 | return Some(paragraph(&format!( 311 | "May also be specified with the {} environment variable. ", 312 | bold(&env.to_string_lossy()) 313 | ))); 314 | } 315 | 316 | None 317 | } 318 | 319 | fn option_default_values(opt: &clap::Arg) -> Option { 320 | if !opt.get_default_values().is_empty() { 321 | let values = opt 322 | .get_default_values() 323 | .iter() 324 | .map(|s| s.to_string_lossy()) 325 | .collect::>() 326 | .join(","); 327 | 328 | return Some(format!("[default: {}]", values)); 329 | } 330 | 331 | None 332 | } 333 | -------------------------------------------------------------------------------- /src/docs_markdown.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Command; 3 | use pulldown_cmark_to_cmark::cmark; 4 | 5 | struct MarkdownDocument<'a>(Vec>); 6 | 7 | impl MarkdownDocument<'_> { 8 | fn header(&mut self, text: String, level: pulldown_cmark::HeadingLevel) { 9 | self.0.push(pulldown_cmark::Event::Start(pulldown_cmark::Tag::Heading( 10 | level, 11 | None, 12 | vec![], 13 | ))); 14 | self.0.push(pulldown_cmark::Event::Text(text.into())); 15 | self.0.push(pulldown_cmark::Event::End(pulldown_cmark::Tag::Heading( 16 | level, 17 | None, 18 | vec![], 19 | ))); 20 | } 21 | 22 | fn paragraph(&mut self, text: String) { 23 | self.0 24 | .push(pulldown_cmark::Event::Start(pulldown_cmark::Tag::Paragraph)); 25 | self.0.push(pulldown_cmark::Event::Text(text.into())); 26 | self.0.push(pulldown_cmark::Event::End(pulldown_cmark::Tag::Paragraph)); 27 | } 28 | 29 | fn link_in_list(&mut self, text: String, url: String) { 30 | let link = pulldown_cmark::Tag::Link(pulldown_cmark::LinkType::Inline, url.into(), "".into()); 31 | 32 | self.0.push(pulldown_cmark::Event::Start(pulldown_cmark::Tag::Item)); 33 | self.0.push(pulldown_cmark::Event::Start(link.clone())); 34 | self.0.push(pulldown_cmark::Event::Text(text.into())); 35 | self.0.push(pulldown_cmark::Event::End(link)); 36 | self.0.push(pulldown_cmark::Event::End(pulldown_cmark::Tag::Item)); 37 | } 38 | } 39 | 40 | fn do_markdown(doc: &mut MarkdownDocument, app: &Command, title: &str) { 41 | // We don't need the header since our renderer will do that for us. 42 | //doc.header(app.get_name().to_string(), pulldown_cmark::HeadingLevel::H2); 43 | 44 | if let Some(about) = app.get_about() { 45 | doc.paragraph(about.to_string()); 46 | } 47 | 48 | if app.has_subcommands() { 49 | doc.header("Subcommands".to_string(), pulldown_cmark::HeadingLevel::H3); 50 | 51 | doc.0 52 | .push(pulldown_cmark::Event::Start(pulldown_cmark::Tag::List(None))); 53 | 54 | for cmd in app.get_subcommands() { 55 | doc.link_in_list( 56 | format!("{} {}", title, cmd.get_name()), 57 | format!("./{}_{}", title.replace(' ', "_"), cmd.get_name()), 58 | ); 59 | } 60 | 61 | doc.0.push(pulldown_cmark::Event::End(pulldown_cmark::Tag::List(None))); 62 | } 63 | 64 | let args = app.get_arguments().collect::>(); 65 | if !args.is_empty() { 66 | doc.header("Options".to_string(), pulldown_cmark::HeadingLevel::H3); 67 | 68 | let mut html = "
\n".to_string(); 69 | 70 | for (i, arg) in args.iter().enumerate() { 71 | if i > 0 { 72 | html.push('\n'); 73 | } 74 | let mut def = String::new(); 75 | 76 | if let Some(short) = arg.get_short() { 77 | def.push('-'); 78 | def.push(short); 79 | } 80 | 81 | if let Some(long) = arg.get_long() { 82 | if arg.get_short().is_some() { 83 | def.push('/'); 84 | } 85 | def.push_str("--"); 86 | def.push_str(long); 87 | } 88 | 89 | html.push_str(&format!( 90 | r#"
{}
91 |
{}
92 | "#, 93 | def, 94 | arg.get_help().unwrap_or_default() 95 | )); 96 | } 97 | 98 | html.push_str("
\n\n"); 99 | 100 | doc.0.push(pulldown_cmark::Event::Html(html.into())); 101 | } 102 | 103 | // TODO: add examples 104 | 105 | if let Some(about) = app.get_long_about() { 106 | doc.header("About".to_string(), pulldown_cmark::HeadingLevel::H3); 107 | 108 | doc.paragraph( 109 | about 110 | .to_string() 111 | .trim_start_matches(app.get_about().unwrap_or_default()) 112 | .trim_start_matches('.') 113 | .trim() 114 | .to_string(), 115 | ); 116 | } 117 | 118 | // Check if the command has a parent. 119 | let mut split = title.split(' ').collect::>(); 120 | let first = format!("{} ", split.first().unwrap()); 121 | if !(title == app.get_name() || title.trim_start_matches(&first) == app.get_name()) { 122 | doc.header("See also".to_string(), pulldown_cmark::HeadingLevel::H3); 123 | 124 | doc.0 125 | .push(pulldown_cmark::Event::Start(pulldown_cmark::Tag::List(None))); 126 | 127 | // Get the parent command. 128 | // Iterate if more than one, thats why we have a list. 129 | if split.len() > 2 { 130 | // Remove the last element, since that is the command name. 131 | split.pop(); 132 | 133 | for (i, _) in split.iter().enumerate() { 134 | if i < 1 { 135 | // We don't care about the first command. 136 | continue; 137 | } 138 | 139 | let mut p = split.clone(); 140 | p.truncate(i + 1); 141 | let parent = p.join(" "); 142 | doc.link_in_list(parent.to_string(), format!("./{}", parent.replace(' ', "_"))); 143 | } 144 | } 145 | 146 | doc.0.push(pulldown_cmark::Event::End(pulldown_cmark::Tag::List(None))); 147 | } 148 | } 149 | 150 | /// Convert a clap Command to markdown documentation. 151 | pub fn app_to_markdown(app: &Command, title: &str) -> Result { 152 | let mut document = MarkdownDocument(Vec::new()); 153 | 154 | do_markdown(&mut document, app, title); 155 | 156 | let mut result = String::new(); 157 | cmark(document.0.iter(), &mut result)?; 158 | 159 | Ok(result) 160 | } 161 | -------------------------------------------------------------------------------- /src/prompt_ext.rs: -------------------------------------------------------------------------------- 1 | use std::str::FromStr; 2 | 3 | use anyhow::Result; 4 | 5 | pub trait PromptExt { 6 | fn prompt(base: &str) -> Result 7 | where 8 | Self: Sized; 9 | } 10 | 11 | impl PromptExt for oxide_api::types::RouteDestination { 12 | fn prompt(base: &str) -> Result { 13 | let route_destination_type = oxide_api::types::RouteDestinationType::prompt(base)?; 14 | 15 | let value: String = match dialoguer::Input::::new() 16 | .with_prompt(&format!("{} value?", route_destination_type)) 17 | .interact_text() 18 | { 19 | Ok(i) => i, 20 | Err(err) => { 21 | anyhow::bail!("prompt failed: {}", err); 22 | } 23 | }; 24 | 25 | Ok(match route_destination_type { 26 | oxide_api::types::RouteDestinationType::Ip => oxide_api::types::RouteDestination::Ip(value), 27 | oxide_api::types::RouteDestinationType::IpNet => { 28 | let ipnet = oxide_api::types::IpNet::from_str(&value) 29 | .map_err(|e| anyhow::anyhow!("invalid ipnet {}: {}", value, e)); 30 | 31 | oxide_api::types::RouteDestination::IpNet(ipnet?) 32 | } 33 | oxide_api::types::RouteDestinationType::Vpc => oxide_api::types::RouteDestination::Vpc(value), 34 | oxide_api::types::RouteDestinationType::Subnet => oxide_api::types::RouteDestination::Subnet(value), 35 | }) 36 | } 37 | } 38 | 39 | impl PromptExt for oxide_api::types::RouteDestinationType { 40 | fn prompt(base: &str) -> Result { 41 | let items = oxide_api::types::RouteDestination::variants(); 42 | 43 | let index = dialoguer::Select::new().with_prompt(base).items(&items[..]).interact(); 44 | 45 | let item = match index { 46 | Ok(i) => items[i].to_string(), 47 | Err(err) => { 48 | anyhow::bail!("prompt failed: {}", err); 49 | } 50 | }; 51 | 52 | oxide_api::types::RouteDestinationType::from_str(&item) 53 | } 54 | } 55 | 56 | impl PromptExt for oxide_api::types::RouteTarget { 57 | fn prompt(base: &str) -> Result { 58 | let route_target_type = oxide_api::types::RouteTargetType::prompt(base)?; 59 | 60 | let value: String = match dialoguer::Input::::new() 61 | .with_prompt(&format!("{} value?", route_target_type)) 62 | .interact_text() 63 | { 64 | Ok(i) => i, 65 | Err(err) => { 66 | anyhow::bail!("prompt failed: {}", err); 67 | } 68 | }; 69 | 70 | Ok(match route_target_type { 71 | oxide_api::types::RouteTargetType::Ip => oxide_api::types::RouteTarget::Ip(value), 72 | oxide_api::types::RouteTargetType::Vpc => oxide_api::types::RouteTarget::Vpc(value), 73 | oxide_api::types::RouteTargetType::Subnet => oxide_api::types::RouteTarget::Subnet(value), 74 | oxide_api::types::RouteTargetType::Instance => oxide_api::types::RouteTarget::Instance(value), 75 | oxide_api::types::RouteTargetType::InternetGateway => oxide_api::types::RouteTarget::InternetGateway(value), 76 | }) 77 | } 78 | } 79 | 80 | impl PromptExt for oxide_api::types::RouteTargetType { 81 | fn prompt(base: &str) -> Result { 82 | let items = oxide_api::types::RouteTarget::variants(); 83 | 84 | let index = dialoguer::Select::new().with_prompt(base).items(&items[..]).interact(); 85 | 86 | let item = match index { 87 | Ok(i) => items[i].to_string(), 88 | Err(err) => { 89 | anyhow::bail!("prompt failed: {}", err); 90 | } 91 | }; 92 | 93 | oxide_api::types::RouteTargetType::from_str(&item) 94 | } 95 | } 96 | 97 | impl PromptExt for oxide_api::types::Ipv4Net { 98 | fn prompt(base: &str) -> Result { 99 | let input = dialoguer::Input::::new() 100 | .with_prompt(base) 101 | .validate_with(|input: &String| -> Result<(), &str> { 102 | let ipnet = oxide_api::types::Ipv4Net::from_str(input); 103 | 104 | if ipnet.is_err() { 105 | Err("invalid IPv4 network") 106 | } else { 107 | Ok(()) 108 | } 109 | }) 110 | .interact_text()?; 111 | 112 | oxide_api::types::Ipv4Net::from_str(&input).map_err(|e| anyhow::anyhow!("invalid ipv4net `{}`: {}", input, e)) 113 | } 114 | } 115 | 116 | impl PromptExt for oxide_api::types::Ipv6Net { 117 | fn prompt(base: &str) -> Result { 118 | let input = dialoguer::Input::::new() 119 | .with_prompt(base) 120 | .validate_with(|input: &String| -> Result<(), &str> { 121 | let ipnet = oxide_api::types::Ipv6Net::from_str(input); 122 | 123 | if ipnet.is_err() { 124 | Err("invalid IPv6 network") 125 | } else { 126 | Ok(()) 127 | } 128 | }) 129 | .interact_text()?; 130 | 131 | oxide_api::types::Ipv6Net::from_str(&input).map_err(|e| anyhow::anyhow!("invalid ipv6net `{}`: {}", input, e)) 132 | } 133 | } 134 | 135 | impl PromptExt for oxide_api::types::ByteCount { 136 | fn prompt(base: &str) -> Result { 137 | let input = dialoguer::Input::::new().with_prompt(base).interact_text()?; 138 | // Echo the user's input, and print in a normalized base-2 form, 139 | // to give them the chance to verify their input. 140 | let bytes = input.parse::<::byte_unit::Byte>()?; 141 | println!("Using {} bytes ({})", bytes, bytes.get_appropriate_unit(true)); 142 | Ok(oxide_api::types::ByteCount::try_from(bytes.get_bytes())?) 143 | } 144 | } 145 | 146 | impl PromptExt for oxide_api::types::ImageSource { 147 | fn prompt(base: &str) -> Result { 148 | let input = dialoguer::Input::::new().with_prompt(base).interact_text()?; 149 | 150 | oxide_api::types::ImageSource::from_str(&input) 151 | } 152 | } 153 | 154 | impl PromptExt for oxide_api::types::DiskSource { 155 | fn prompt(base: &str) -> Result { 156 | let disk_source_type = oxide_api::types::DiskSourceType::prompt(base)?; 157 | 158 | let mut value = String::new(); 159 | if disk_source_type != oxide_api::types::DiskSourceType::Blank { 160 | value = match dialoguer::Input::::new() 161 | .with_prompt(&format!("{} value?", disk_source_type)) 162 | .interact_text() 163 | { 164 | Ok(i) => i, 165 | Err(err) => { 166 | anyhow::bail!("prompt failed: {}", err); 167 | } 168 | }; 169 | } 170 | 171 | Ok(match disk_source_type { 172 | oxide_api::types::DiskSourceType::Blank => { 173 | let value: i64 = match dialoguer::Input::::new() 174 | .with_prompt(&format!("{} value?", disk_source_type)) 175 | .interact_text() 176 | { 177 | Ok(i) => i, 178 | Err(err) => { 179 | anyhow::bail!("prompt failed: {}", err); 180 | } 181 | }; 182 | oxide_api::types::DiskSource::Blank { block_size: value } 183 | } 184 | oxide_api::types::DiskSourceType::GlobalImage => { 185 | oxide_api::types::DiskSource::GlobalImage { image_id: value } 186 | } 187 | oxide_api::types::DiskSourceType::Image => oxide_api::types::DiskSource::Image { image_id: value }, 188 | oxide_api::types::DiskSourceType::Snapshot => oxide_api::types::DiskSource::Snapshot { snapshot_id: value }, 189 | }) 190 | } 191 | } 192 | 193 | impl PromptExt for oxide_api::types::DiskSourceType { 194 | fn prompt(base: &str) -> Result { 195 | let items = oxide_api::types::DiskSource::variants(); 196 | 197 | let index = dialoguer::Select::new().with_prompt(base).items(&items[..]).interact(); 198 | 199 | let item = match index { 200 | Ok(i) => items[i].to_string(), 201 | Err(err) => { 202 | anyhow::bail!("prompt failed: {}", err); 203 | } 204 | }; 205 | 206 | oxide_api::types::DiskSourceType::from_str(&item) 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /src/types.rs: -------------------------------------------------------------------------------- 1 | use parse_display::{Display, FromStr}; 2 | 3 | #[derive(Debug, Clone, PartialEq, Eq, FromStr, Display)] 4 | #[display(style = "kebab-case")] 5 | pub enum FormatOutput { 6 | Json, 7 | Yaml, 8 | Table, 9 | } 10 | 11 | impl Default for FormatOutput { 12 | fn default() -> FormatOutput { 13 | FormatOutput::Table 14 | } 15 | } 16 | 17 | impl FormatOutput { 18 | pub fn variants() -> Vec { 19 | vec!["table".to_string(), "json".to_string(), "yaml".to_string()] 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tests/omicron.toml: -------------------------------------------------------------------------------- 1 | # 2 | # Oxide API: example configuration file 3 | # 4 | 5 | [console] 6 | # Directory for static assets. Absolute path or relative to CWD. 7 | static_dir = "nexus/static" # TODO: figure out value 8 | cache_control_max_age_minutes = 10 9 | session_idle_timeout_minutes = 60 10 | session_absolute_timeout_minutes = 480 11 | 12 | # List of authentication schemes to support. 13 | # 14 | # This is not fleshed out yet and the only reason to change it now is for 15 | # working on authentication or authorization. Neither is really implemented 16 | # yet. 17 | [authn] 18 | # TODO(https://github.com/oxidecomputer/omicron/issues/372): Remove "spoof". 19 | schemes_external = ["spoof", "session_cookie"] 20 | 21 | [deployment] 22 | # Identifier for this instance of Nexus 23 | id = "e6bff1ff-24fb-49dc-a54e-c6a350cd4d6c" 24 | rack_id = "c19a698f-c6f9-4a17-ae30-20d711b8f7dc" 25 | 26 | [deployment.database] 27 | # URL for connecting to the database 28 | type = "from_url" 29 | url = "postgresql://root@0.0.0.0:26257/omicron?sslmode=disable" 30 | 31 | [deployment.dropshot_external] 32 | # IP address and TCP port on which to listen for the external API 33 | bind_address = "0.0.0.0:8888" 34 | # Allow larger request bodies (1MiB) to accomodate firewall endpoints (one 35 | # rule is ~500 bytes) 36 | request_body_max_bytes = 1048576 37 | 38 | [deployment.dropshot_internal] 39 | # IP address and TCP port on which to listen for the internal API 40 | bind_address = "0.0.0.0:12221" 41 | 42 | [deployment.subnet] 43 | net = "fd00:1122:3344:0100::/56" 44 | 45 | [log] 46 | # Show log messages of this level and more severe 47 | level = "info" 48 | 49 | # Example output to a terminal (with colors) 50 | mode = "stderr-terminal" 51 | 52 | # Example output to a file, appending if it already exists. 53 | #mode = "file" 54 | #path = "logs/server.log" 55 | #if_exists = "append" 56 | 57 | # Configuration for interacting with the timeseries database 58 | [timeseries_db] 59 | address = "[::1]:8123" 60 | --------------------------------------------------------------------------------